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>
165 lines
11 KiB
JavaScript
165 lines
11 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { startFakeDecoder } from "./browser_fake_decoder.js";
|
|
import { RACE_AUDIO_EVENT_DEFINITIONS } from "../src/race_audio.js";
|
|
|
|
// Only the casual, come-and-go practice modes (open_practice - the standalone "Quick Open
|
|
// Practice" shortcut - and free_practice) skip the countdown/light/audio sequence entirely
|
|
// (see startSessionWithAudio in src/app.js). "practice" is a proper scheduled slot in a
|
|
// structured event's session plan and, like heat/qualification/final/team_race, runs the
|
|
// real sequence in src/race_audio.js's runStartSequence, gating the session's transition
|
|
// to "running" until it completes - this file exercises that for each of those five types,
|
|
// plus pause/resume/finish, and separately confirms open_practice still skips it.
|
|
//
|
|
// Every effect/voice is silenced via raceAudio.events so the sequence's own audio
|
|
// playback (real <audio> elements, speechSynthesis) never has to actually complete in a
|
|
// headless browser with no sound hardware - only the sequence's own timers (countdown,
|
|
// light stepping, start delay) gate progress, which this file sets to near-zero except
|
|
// for one full-length run (the heat scenario) that keeps a real countdown to prove the
|
|
// countdown display itself updates.
|
|
const SILENCED_EVENTS = Object.fromEntries(RACE_AUDIO_EVENT_DEFINITIONS.map((definition) => [definition.key, { effect: false, voice: false }]));
|
|
|
|
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 savedState = async () => (await (await fetch(base + "/api/state")).json()).state;
|
|
const patchState = async (patch) => {
|
|
const current = await savedState();
|
|
Object.assign(current, patch);
|
|
const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) });
|
|
assert.equal(response.ok, true);
|
|
};
|
|
const findSession = async (id) => (await savedState()).sessions.find((session) => session.id === id);
|
|
const recentAudioEventKeys = async (count) => (await savedState()).audioEventLog.slice(0, count).map((entry) => entry.event);
|
|
|
|
const decoder = await startFakeDecoder();
|
|
const seeded = await savedState();
|
|
const classId = "rdts_class";
|
|
const driverA = { id: "rdts_driver_a", name: "RDTS Driver A", classId, transponder: "7710001" };
|
|
const driverB = { id: "rdts_driver_b", name: "RDTS Driver B", classId, transponder: "7710002" };
|
|
const event = {
|
|
id: "rdts_event",
|
|
name: "RDTS Event",
|
|
classId,
|
|
raceConfig: {
|
|
teams: [
|
|
{ id: "rdts_team_a", name: "RDTS Team A", driverIds: [driverA.id], carIds: [] },
|
|
{ id: "rdts_team_b", name: "RDTS Team B", driverIds: [driverB.id], carIds: [] },
|
|
],
|
|
},
|
|
};
|
|
const sessions = [
|
|
{ id: "rdts_heat", eventId: event.id, name: "RDTS Heat", type: "heat", durationMin: 5, status: "ready" },
|
|
{ id: "rdts_quali", eventId: event.id, name: "RDTS Qualification", type: "qualification", durationMin: 5, status: "ready" },
|
|
{ id: "rdts_final", eventId: event.id, name: "RDTS Final", type: "final", durationMin: 5, status: "ready" },
|
|
{ id: "rdts_team_race", eventId: event.id, name: "RDTS Team Race", type: "team_race", durationMin: 5, status: "ready" },
|
|
{ id: "rdts_practice", eventId: event.id, name: "RDTS Practice", type: "practice", durationMin: 5, status: "ready" },
|
|
{ id: "rdts_open_practice", eventId: event.id, name: "RDTS Open Practice", type: "open_practice", durationMin: 5, status: "ready" },
|
|
];
|
|
|
|
try {
|
|
await patchState({
|
|
classes: [...seeded.classes, { id: classId, name: "RDTS Class" }],
|
|
drivers: [...seeded.drivers, driverA, driverB],
|
|
events: [...seeded.events, event],
|
|
sessions: [...seeded.sessions, ...sessions],
|
|
settings: {
|
|
...seeded.settings,
|
|
autoReconnect: true,
|
|
wsUrl: `ws://127.0.0.1:${decoder.port}`,
|
|
raceAudio: {
|
|
...seeded.settings.raceAudio,
|
|
enabled: true,
|
|
muted: false,
|
|
startLights: true,
|
|
lightIntervalMs: 100,
|
|
randomStartDelay: false,
|
|
minStartDelay: 0,
|
|
maxStartDelay: 0,
|
|
startCountdownSeconds: 0,
|
|
events: SILENCED_EVENTS,
|
|
},
|
|
},
|
|
});
|
|
|
|
// --- 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 evaluate(`document.querySelector(".race-day-control-panel [data-prologue-clock]")?.textContent || null`));
|
|
await pause(300);
|
|
}
|
|
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");
|
|
await click("raceDayResume");
|
|
await waitFor(async () => (await findSession(sessions[0].id)).status === "running", "heat resumed");
|
|
await click("raceDayFinish");
|
|
await waitFor(async () => (await findSession(sessions[0].id)).status === "finished", "heat finished");
|
|
console.log("PASS heat pause/resume/finish transitions work the same as open practice");
|
|
|
|
// --- Qualification, final, team_race: countdownSeconds back to 0 to keep the run
|
|
// fast, but the light-stepping/audio-log sequence still runs (not skipped). ---
|
|
await patchState({ settings: { ...(await savedState()).settings, raceAudio: { ...(await savedState()).settings.raceAudio, startCountdownSeconds: 0 } } });
|
|
|
|
for (const [session, expectedRecentKeys, label] of [
|
|
[sessions[1], ["start_sequence", "qualifying_start", "drivers_ready"], "qualification"],
|
|
[sessions[2], ["start_sequence", "drivers_ready"], "final"],
|
|
[sessions[3], ["start_sequence", "drivers_ready"], "team_race"],
|
|
[sessions[4], ["start_sequence", "drivers_ready"], "practice"],
|
|
]) {
|
|
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`);
|
|
|
|
await click("raceDayPause");
|
|
await waitFor(async () => (await findSession(session.id)).status === "paused", `${label} paused`);
|
|
await click("raceDayResume");
|
|
await waitFor(async () => (await findSession(session.id)).status === "running", `${label} resumed`);
|
|
await click("raceDayFinish");
|
|
await waitFor(async () => (await findSession(session.id)).status === "finished", `${label} finished`);
|
|
console.log(`PASS ${label} runs the real start sequence (not the practice shortcut) and supports pause/resume/finish`);
|
|
}
|
|
|
|
// --- open_practice: the standalone "Quick Open Practice" shortcut still skips the
|
|
// ceremony entirely and jumps straight to running, unlike the scheduled "practice"
|
|
// type just proven above. ---
|
|
const beforeOpenPracticeLog = (await savedState()).audioEventLog.length;
|
|
await patchState({ activeSessionId: sessions[5].id });
|
|
await navigate("race_day", "raceDayStart");
|
|
await waitFor(async () => (await buttonDisabled("raceDayStart")) === false, "open_practice preflight ready");
|
|
await click("raceDayStart");
|
|
await waitFor(async () => (await findSession(sessions[5].id)).status === "running", "open_practice jumps straight to running", 5000);
|
|
const afterOpenPracticeLog = (await savedState()).audioEventLog;
|
|
const newOpenPracticeEntries = afterOpenPracticeLog.slice(0, afterOpenPracticeLog.length - beforeOpenPracticeLog);
|
|
assert.deepEqual(newOpenPracticeEntries.map((entry) => entry.event), [], "open_practice still skips the whole ceremony - no new audio events logged: " + JSON.stringify(newOpenPracticeEntries.map((e) => e.event)));
|
|
console.log("PASS open_practice (Quick Open Practice shortcut) still skips the countdown/lights/audio ceremony entirely");
|
|
|
|
assert.deepEqual(dialogs, [], "no unexpected native dialog during the start-sequence flow");
|
|
} finally {
|
|
const cleanup = await savedState();
|
|
await patchState({ settings: { ...cleanup.settings, autoReconnect: false, raceAudio: seeded.settings.raceAudio } });
|
|
await decoder.close();
|
|
}
|
|
}
|