feat(race-day): start heats with an EFRA/IFMAR count-down and flying start

Race Day Start now runs a count-down (Go, 30 s - 5 min, default per
event) with calls every minute, 30 s and 10 s, grid call and 9..3
count for grid starts, hold/resume/+1 min/to 30 s/to 10 s/Go now, and
starts the heat by itself when it runs out. The transponder check runs
during the count-down, replacing the separate warm-up.

Adds a flying start mode (horn + clock is running, own clock per car)
as the qualifying default, and the latest personal clock start rules
from EFRA 9.3 / IFMAR 4.2.3 for flying and staggered starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-30 21:27:20 +02:00
parent 1bc75f8ec1
commit 5bfb67ea02
27 changed files with 884 additions and 334 deletions

View File

@@ -21,7 +21,6 @@ const SILENCED_EVENTS = Object.fromEntries(RACE_AUDIO_EVENT_DEFINITIONS.map((def
export async function runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs = [] }) {
const click = (id) => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
const buttonDisabled = async (id) => await evaluate(`document.getElementById(${JSON.stringify(id)})?.disabled`);
const buttonText = async (id) => await evaluate(`document.getElementById(${JSON.stringify(id)})?.textContent`);
const savedState = async () => (await (await fetch(base + "/api/state")).json()).state;
const patchState = async (patch) => {
const current = await savedState();
@@ -82,23 +81,29 @@ export async function runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFo
},
});
// --- Heat: full-length countdown, proving the countdown display itself ticks down
// (not just that the session eventually starts) for a competitive session type. ---
// --- Heat: a real count-down on Race Day (the prologue), proving the clock ticks down
// and the heat only goes running once the 10..3 count and the lights are done. The
// global start count-down (startCountdownSeconds) is replaced by the prologue here. ---
await patchState({ activeSessionId: sessions[0].id, settings: { ...(await savedState()).settings, raceAudio: { ...(await savedState()).settings.raceAudio, startCountdownSeconds: 10 } } });
await navigate("race_day", "raceDayStart");
await waitFor(async () => (await buttonDisabled("raceDayStart")) === false, "heat preflight ready (class-based participants accepted)");
await evaluate(`document.getElementById("raceDayPrologueLength").value = "30"`);
await click("raceDayStart");
await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-prologue="to10"]')`)), "count-down started");
await evaluate(`document.querySelector('[data-prologue="to10"]').click()`);
assert.equal((await findSession(sessions[0].id)).status, "ready", "heat does not jump straight to running like a practice session");
const observedCountdown = new Set();
const countdownDeadline = Date.now() + 4000;
while (Date.now() < countdownDeadline) {
observedCountdown.add(await buttonText("raceDayStart"));
observedCountdown.add(await evaluate(`document.querySelector(".race-day-control-panel [data-prologue-clock]")?.textContent || null`));
await pause(300);
}
assert.ok([...observedCountdown].some((text) => /^\d+$/.test(String(text || ""))), "start button showed a ticking countdown digit: " + JSON.stringify([...observedCountdown]));
await waitFor(async () => (await findSession(sessions[0].id)).status === "running", "heat running after its full countdown+light sequence", 20000);
assert.deepEqual(await recentAudioEventKeys(2), ["start_sequence", "drivers_ready"], "heat's sequence announced drivers_ready then start_sequence, no qualifying_start");
console.log("PASS heat start runs a real countdown+light sequence instead of starting instantly");
assert.ok([...observedCountdown].filter((text) => /^0:(0\d|10)$/.test(String(text || ""))).length >= 3, "count-down clock ticked down from 10 s: " + JSON.stringify([...observedCountdown]));
await waitFor(async () => (await findSession(sessions[0].id)).status === "running", "heat running after its count-down + light sequence", 20000);
const heatLog = (await savedState()).audioEventLog.slice(0, 12).map((entry) => entry.event);
assert.ok(heatLog.includes("prologue_count") && heatLog.includes("race_10_seconds"), "the count-down called 10 s and 9..3: " + JSON.stringify(heatLog));
assert.ok(!heatLog.includes("drivers_ready") && !heatLog.includes("race_30_seconds"), "no second count-down after the prologue: " + JSON.stringify(heatLog));
console.log("PASS heat start runs a real count-down + light sequence instead of starting instantly");
await click("raceDayPause");
await waitFor(async () => (await findSession(sessions[0].id)).status === "paused", "heat paused");
@@ -121,6 +126,8 @@ export async function runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFo
await patchState({ activeSessionId: session.id });
await navigate("race_day", "raceDayStart");
await waitFor(async () => (await buttonDisabled("raceDayStart")) === false, `${label} preflight ready`);
// "Go - start now": no count-down, but the start procedure itself still runs.
await evaluate(`(() => { const select = document.getElementById("raceDayPrologueLength"); if (select) select.value = "0"; })()`);
await click("raceDayStart");
await waitFor(async () => (await findSession(session.id)).status === "running", `${label} running after its sequence`, 10000);
assert.deepEqual(await recentAudioEventKeys(expectedRecentKeys.length), expectedRecentKeys, `${label} sequence announced the right events`);