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:
110
tests/browser_prologue.js
Normal file
110
tests/browser_prologue.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { startFakeDecoder } from "./browser_fake_decoder.js";
|
||||
|
||||
// Race Day start through the prologue (count-down), driven by real decoder passings: Start
|
||||
// begins the count-down, drivers go green as their transponder crosses, a driver registered
|
||||
// with the wrong number gets the unknown transponder linked from the panel, the count-down
|
||||
// can be held and resumed, "Go now" warns about anyone missing, and a count-down that runs
|
||||
// out starts the heat on its own.
|
||||
export async function runPrologueSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }) {
|
||||
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 statuses = () => evaluate(`Object.fromEntries([...document.querySelectorAll("[data-warmup-driver]")].map((row) => [row.querySelector("strong").textContent, row.querySelector(".warmup-status").textContent.trim()]))`);
|
||||
const clockText = () => evaluate(`document.querySelector(".race-day-control-panel [data-prologue-clock]")?.textContent || ""`);
|
||||
const sessionStatus = async (id) => (await savedState()).sessions.find((session) => session.id === id)?.status;
|
||||
|
||||
const dialogsAtStart = dialogs.length;
|
||||
const decoder = await startFakeDecoder();
|
||||
const classId = "wu_class";
|
||||
const drivers = [
|
||||
{ id: "wu_a", name: "WU Anna", classId, transponder: "8110001" },
|
||||
{ id: "wu_b", name: "WU Bo", classId, transponder: "8110002" },
|
||||
{ id: "wu_c", name: "WU Cia", classId, transponder: "8110003" }, // registered with the wrong number
|
||||
];
|
||||
const event = { id: "wu_event", name: "WU Race", classId, mode: "race", raceConfig: { minLapMs: 3000, maxLapMs: 60000, finalPrologueSec: 90 } };
|
||||
const heat = { id: "wu_heat", eventId: event.id, name: "WU Heat 1", type: "heat", durationMin: 5, status: "ready" };
|
||||
const heat2 = { id: "wu_heat2", eventId: event.id, name: "WU Heat 2", type: "heat", durationMin: 5, status: "ready" };
|
||||
const seeded = await savedState();
|
||||
await patchState({
|
||||
classes: [...seeded.classes, { id: classId, name: "WU Class" }],
|
||||
drivers: [...seeded.drivers, ...drivers],
|
||||
events: [...seeded.events, event],
|
||||
sessions: [...seeded.sessions.map((session) => (["running", "paused"].includes(session.status) ? { ...session, status: "finished" } : session)), heat, heat2],
|
||||
activeSessionId: heat.id,
|
||||
settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}`, raceAudio: { ...seeded.settings.raceAudio, enabled: false } },
|
||||
});
|
||||
|
||||
try {
|
||||
await navigate("race_day", "raceDayStart");
|
||||
await waitFor(async () => evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected");
|
||||
await waitFor(async () => Boolean(await evaluate(`document.querySelector("[data-warmup-driver]")`)), "transponder check shown for a ready heat");
|
||||
assert.equal(await evaluate(`document.getElementById("raceDayPrologueLength").value`), "90", "count-down length defaults to the event's setting");
|
||||
assert.deepEqual(await evaluate(`[...document.getElementById("raceDayPrologueLength").options].map((option) => option.value)`), ["0", "30", "60", "90", "120", "180", "300"]);
|
||||
|
||||
decoder.sendPassing("8110001"); // before the count-down starts - must not count
|
||||
// Wait for the frame to actually reach the page (decoder diagnostics count it) before
|
||||
// starting, otherwise it could arrive after the start and rightly count.
|
||||
await waitFor(async () => evaluate(`import("/src/decoder_diagnostics.js").then(({ decoderDiagnostics }) => JSON.stringify(decoderDiagnostics.snapshot?.() || decoderDiagnostics).includes("8110001"))`), "pre-count-down passing received");
|
||||
// Rare flake seen twice in ~10 suite runs (not reproducible with extra logging): the
|
||||
// passing counted as after the start. A short settle keeps this scenario about the UI;
|
||||
// the before/after rule itself is covered deterministically in prologue_logic.test.js.
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
await evaluate(`(() => { document.getElementById("raceDayPrologueLength").value = "120"; document.getElementById("raceDayStart").click(); })()`);
|
||||
await waitFor(async () => /^[12]:\d\d$/.test(await clockText()), "count-down running");
|
||||
assert.deepEqual(await statuses(), { "WU Anna": "⏳", "WU Bo": "⏳", "WU Cia": "⏳" }, "a passing from before the count-down doesn't count");
|
||||
assert.equal(await sessionStatus(heat.id), "ready", "the heat itself hasn't started");
|
||||
|
||||
decoder.sendPassing("8110001");
|
||||
decoder.sendPassing("8119999"); // Cia's real transponder; Bo never shows up
|
||||
await waitFor(async () => JSON.stringify(await statuses()) === JSON.stringify({ "WU Anna": "✅", "WU Bo": "⏳", "WU Cia": "⏳" }), "Anna goes green, Bo and Cia still missing");
|
||||
console.log("PASS drivers go green as their transponder crosses during the count-down");
|
||||
|
||||
await evaluate(`(() => { const select = document.querySelector('[data-warmup-driver="wu_c"] [data-warmup-assign]'); select.value = "8119999"; select.dispatchEvent(new Event("change")); })()`);
|
||||
await waitFor(async () => (await statuses())["WU Cia"] === "✅", "Cia green after linking the unknown transponder");
|
||||
await waitFor(async () => (await savedState()).drivers.find((driver) => driver.id === "wu_c").transponder === "8119999", "Cia's transponder saved");
|
||||
assert.match(await evaluate(`document.body.innerText`), /Transponder check[\s\S]{0,40}2\/3/, "preflight shows how many have been seen");
|
||||
console.log("PASS an unknown transponder seen during the count-down can be linked to a driver in one step");
|
||||
|
||||
await evaluate(`document.querySelector('[data-prologue="hold"]').click()`);
|
||||
await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-prologue="resume"]')`)), "count-down on hold");
|
||||
const held = await clockText();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
assert.equal(await clockText(), held, "a held count-down doesn't move");
|
||||
await evaluate(`document.querySelector('[data-prologue="resume"]').click()`);
|
||||
await waitFor(async () => (await clockText()) !== held, "count-down moves again after resume");
|
||||
await evaluate(`document.querySelector('[data-prologue="plus"]').click()`);
|
||||
await waitFor(async () => /^[23]:\d\d$/.test(await clockText()), "+1 min adds a minute");
|
||||
console.log("PASS the count-down can be held (cars on the grid), resumed and extended");
|
||||
|
||||
const before = dialogs.length;
|
||||
await evaluate(`document.querySelector('[data-prologue="go"]').click()`);
|
||||
await waitFor(async () => dialogs.slice(before).some((dialog) => dialog.type === "confirm" && /WU Bo/.test(dialog.message) && !/WU Anna/.test(dialog.message)), "Go now warns about the driver not seen");
|
||||
await waitFor(async () => (await sessionStatus(heat.id)) === "running", "confirming starts the heat anyway");
|
||||
console.log("PASS Go now warns about drivers not seen during the count-down but can still start the heat");
|
||||
|
||||
// A count-down that runs out starts the next heat by itself - no click, no dialog.
|
||||
await patchState({ sessions: (await savedState()).sessions.map((session) => (session.id === heat.id ? { ...session, status: "finished", endedAt: Date.now() } : session)), activeSessionId: heat2.id });
|
||||
await navigate("race_day", "raceDayStart");
|
||||
await waitFor(async () => (await evaluate(`document.querySelector(".race-day-hero h2")?.textContent`)) === heat2.name, "next heat selected");
|
||||
const dialogsBefore = dialogs.length;
|
||||
await evaluate(`(() => { document.getElementById("raceDayPrologueLength").value = "30"; document.getElementById("raceDayStart").click(); })()`);
|
||||
await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-prologue="to10"]')`)), "count-down running");
|
||||
await evaluate(`document.querySelector('[data-prologue="to10"]').click()`);
|
||||
await waitFor(async () => (await sessionStatus(heat2.id)) === "running", "heat starts on its own when the count-down runs out", 15000);
|
||||
assert.equal(dialogs.length, dialogsBefore, "no confirm when the count-down runs out");
|
||||
console.log("PASS the heat starts on its own when the count-down runs out");
|
||||
} finally {
|
||||
dialogs.splice(dialogsAtStart);
|
||||
const current = await savedState();
|
||||
await patchState({
|
||||
settings: { ...current.settings, autoReconnect: false },
|
||||
sessions: current.sessions.map((session) => ([heat.id, heat2.id].includes(session.id) ? { ...session, status: "finished", endedAt: Date.now() } : session)),
|
||||
activeSessionId: "",
|
||||
});
|
||||
await decoder.close();
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { runGuideTabsSmoke } from "./browser_guide_tabs.js";
|
||||
import { runAuthSmoke } from "./browser_auth.js";
|
||||
import { runTtsSettingsSmoke } from "./browser_tts_settings.js";
|
||||
import { runTrackLayoutsSmoke } from "./browser_track_layouts.js";
|
||||
import { runWarmupSmoke } from "./browser_warmup.js";
|
||||
import { runPrologueSmoke } from "./browser_prologue.js";
|
||||
import { runObsPopupSmoke } from "./browser_obs_popup.js";
|
||||
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -151,7 +151,7 @@ try {
|
||||
await runAuthSmoke({ evaluate, navigate, fill, savedState, waitFor });
|
||||
await runTtsSettingsSmoke({ evaluate, navigate, savedState, waitFor });
|
||||
await runTrackLayoutsSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs });
|
||||
await runWarmupSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs });
|
||||
await runPrologueSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs });
|
||||
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs });
|
||||
await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs });
|
||||
await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick });
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { startFakeDecoder } from "./browser_fake_decoder.js";
|
||||
|
||||
// Pre-heat warm-up / transponder check, driven through real decoder passings: drivers go
|
||||
// green as their transponder crosses, a driver registered with the wrong number gets the
|
||||
// unknown transponder linked straight from the panel, and Start warns about anyone missing.
|
||||
export async function runWarmupSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }) {
|
||||
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 statuses = () => evaluate(`Object.fromEntries([...document.querySelectorAll("[data-warmup-driver]")].map((row) => [row.querySelector("strong").textContent, row.querySelector(".warmup-status").textContent.trim()]))`);
|
||||
|
||||
const dialogsAtStart = dialogs.length;
|
||||
const decoder = await startFakeDecoder();
|
||||
const classId = "wu_class";
|
||||
const drivers = [
|
||||
{ id: "wu_a", name: "WU Anna", classId, transponder: "8110001" },
|
||||
{ id: "wu_b", name: "WU Bo", classId, transponder: "8110002" },
|
||||
{ id: "wu_c", name: "WU Cia", classId, transponder: "8110003" }, // registered with the wrong number
|
||||
];
|
||||
const event = { id: "wu_event", name: "WU Race", classId, mode: "race", raceConfig: { minLapMs: 3000, maxLapMs: 60000 } };
|
||||
const heat = { id: "wu_heat", eventId: event.id, name: "WU Heat 1", type: "heat", durationMin: 5, status: "ready" };
|
||||
const seeded = await savedState();
|
||||
await patchState({
|
||||
classes: [...seeded.classes, { id: classId, name: "WU Class" }],
|
||||
drivers: [...seeded.drivers, ...drivers],
|
||||
events: [...seeded.events, event],
|
||||
sessions: [...seeded.sessions.map((session) => (["running", "paused"].includes(session.status) ? { ...session, status: "finished" } : session)), heat],
|
||||
activeSessionId: heat.id,
|
||||
settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}`, raceAudio: { ...seeded.settings.raceAudio, enabled: false } },
|
||||
});
|
||||
|
||||
try {
|
||||
await navigate("race_day", "raceDayStart");
|
||||
await waitFor(async () => evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected");
|
||||
await waitFor(async () => Boolean(await evaluate(`document.querySelector("[data-warmup-start]")`)), "warm-up panel shown for a ready heat");
|
||||
|
||||
decoder.sendPassing("8110001"); // before the warm-up starts - must not count
|
||||
// Wait for the frame to actually reach the page (decoder diagnostics count it) before
|
||||
// starting the warm-up, otherwise it could arrive after the start and rightly count.
|
||||
await waitFor(async () => evaluate(`import("/src/decoder_diagnostics.js").then(({ decoderDiagnostics }) => JSON.stringify(decoderDiagnostics.snapshot?.() || decoderDiagnostics).includes("8110001"))`), "pre-warm-up passing received");
|
||||
// Rare flake seen twice in ~10 suite runs (not reproducible with extra logging): the
|
||||
// passing counted as after the start. A short settle keeps this scenario about the UI;
|
||||
// the before/after rule itself is covered deterministically in warmup_logic.test.js.
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
await evaluate(`(() => { document.querySelector("[data-warmup-minutes]").value = "1"; document.querySelector("[data-warmup-start]").click(); })()`);
|
||||
await waitFor(async () => Boolean(await evaluate(`document.querySelector("[data-warmup-clock]")`)), "countdown running");
|
||||
assert.deepEqual(await statuses(), { "WU Anna": "⏳", "WU Bo": "⏳", "WU Cia": "⏳" }, "a passing from before the warm-up doesn't count");
|
||||
|
||||
decoder.sendPassing("8110001");
|
||||
decoder.sendPassing("8110002");
|
||||
decoder.sendPassing("8119999"); // Cia's real transponder
|
||||
await waitFor(async () => JSON.stringify(await statuses()) === JSON.stringify({ "WU Anna": "✅", "WU Bo": "✅", "WU Cia": "⏳" }), "Anna and Bo go green, Cia still missing");
|
||||
console.log("PASS drivers go green as their transponder crosses during the warm-up");
|
||||
|
||||
await evaluate(`(() => { const select = document.querySelector('[data-warmup-driver="wu_c"] [data-warmup-assign]'); select.value = "8119999"; select.dispatchEvent(new Event("change")); })()`);
|
||||
await waitFor(async () => (await statuses())["WU Cia"] === "✅", "Cia green after linking the unknown transponder");
|
||||
await waitFor(async () => (await savedState()).drivers.find((driver) => driver.id === "wu_c").transponder === "8119999", "Cia's transponder saved");
|
||||
assert.match(await evaluate(`document.body.innerText`), /Transponder check[\s\S]{0,40}3\/3/, "preflight shows the check as complete");
|
||||
console.log("PASS an unknown transponder seen during the warm-up can be linked to a driver in one step");
|
||||
|
||||
await evaluate(`document.querySelector("[data-warmup-stop]").click()`);
|
||||
await evaluate(`document.querySelector("[data-warmup-start]").click()`);
|
||||
decoder.sendPassing("8110001");
|
||||
await waitFor(async () => (await statuses())["WU Anna"] === "✅", "restarted warm-up sees Anna");
|
||||
const before = dialogs.length;
|
||||
await evaluate(`document.getElementById("raceDayStart").click()`);
|
||||
await waitFor(async () => dialogs.slice(before).some((dialog) => dialog.type === "confirm" && /WU Bo/.test(dialog.message) && /WU Cia/.test(dialog.message)), "Start warns about the drivers not seen");
|
||||
await waitFor(async () => (await savedState()).sessions.find((session) => session.id === heat.id)?.status === "running", "confirming starts the heat anyway");
|
||||
console.log("PASS Start warns about drivers not seen during the warm-up but can still start the heat");
|
||||
} finally {
|
||||
dialogs.splice(dialogsAtStart);
|
||||
const current = await savedState();
|
||||
await patchState({
|
||||
settings: { ...current.settings, autoReconnect: false },
|
||||
sessions: current.sessions.map((session) => (session.id === heat.id ? { ...session, status: "finished", endedAt: Date.now() } : session)),
|
||||
activeSessionId: "",
|
||||
});
|
||||
await decoder.close();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ function createHarness(options = {}) {
|
||||
mode: "race",
|
||||
type: options.sessionType || "team_race",
|
||||
status: "running",
|
||||
startMode: "mass",
|
||||
startMode: options.startMode || "mass",
|
||||
startedAt: 1_000,
|
||||
};
|
||||
const state = {
|
||||
@@ -36,7 +36,7 @@ function createHarness(options = {}) {
|
||||
t: (key) => key,
|
||||
getActiveSession: () => session,
|
||||
ensureSessionResult: (sessionId) => ensureSessionResult(sessionId, { state }),
|
||||
normalizeStartMode: () => "mass",
|
||||
normalizeStartMode: () => options.startMode || "mass",
|
||||
getSessionLapWindow: () => ({
|
||||
minLapMs: Number(options.minLapMs || 0),
|
||||
maxLapMs: options.maxLapMs == null ? Number.POSITIVE_INFINITY : Number(options.maxLapMs),
|
||||
@@ -112,6 +112,30 @@ test("maximum lap time starts a statistics stint but still counts the race lap",
|
||||
assert.equal(result.passings[0].stintBoundary, true);
|
||||
});
|
||||
|
||||
test("flying start: each car's clock starts at its own first crossing, a late car's at the latest 150 % of a lap (EFRA 9.3)", () => {
|
||||
const harness = createHarness({ sessionType: "qualification", startMode: "flying" });
|
||||
const byDriver = (driverId) => Object.values(harness.state.resultsBySession.session_1.competitors).find((entry) => entry.driverId === driverId);
|
||||
harness.passing(4_000, "main", "123"); // Driver One's clock starts - not a lap
|
||||
assert.equal(byDriver("driver_1").startTimestamp, 4_000);
|
||||
assert.equal(byDriver("driver_1").laps, 0);
|
||||
harness.passing(24_000, "main", "123"); // first lap anyone completes: 20 s -> latest start 1 000 + 30 000
|
||||
assert.equal(byDriver("driver_1").laps, 1);
|
||||
harness.passing(29_000, "main", "456"); // Driver Two just in time: own clock
|
||||
assert.equal(byDriver("driver_2").startTimestamp, 29_000);
|
||||
assert.equal(byDriver("driver_2").laps, 0);
|
||||
});
|
||||
|
||||
test("flying start: a car first seen after the latest clock start gets that clock and its crossing counts as a lap", () => {
|
||||
const harness = createHarness({ sessionType: "qualification", startMode: "flying" });
|
||||
const byDriver = (driverId) => Object.values(harness.state.resultsBySession.session_1.competitors).find((entry) => entry.driverId === driverId);
|
||||
harness.passing(4_000, "main", "123");
|
||||
harness.passing(24_000, "main", "123"); // latest start = 31 000
|
||||
harness.passing(45_000, "main", "456");
|
||||
assert.equal(byDriver("driver_2").startTimestamp, 31_000);
|
||||
assert.equal(byDriver("driver_2").laps, 1);
|
||||
assert.equal(byDriver("driver_2").lastLapMs, 14_000);
|
||||
});
|
||||
|
||||
test("pit entry and exit create one timed stop without adding laps", () => {
|
||||
const harness = createHarness();
|
||||
harness.passing(17_000, "main");
|
||||
|
||||
121
tests/prologue_logic.test.js
Normal file
121
tests/prologue_logic.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
recordTransponderSighting, buildWarmupCheck, createPrologue, getPrologueRemainingMs, holdPrologue, resumePrologue,
|
||||
setPrologueRemaining, takeDuePrologueCall, getPrologueHandoverMs, normalizePrologueSec, formatPrologueTime,
|
||||
PROLOGUE_OPTIONS_SEC, getLatestClockStartTs, usesPersonalClock,
|
||||
} from "../src/prologue_logic.js";
|
||||
|
||||
const entrants = [
|
||||
{ id: "a", name: "Anna", transponder: "1001" },
|
||||
{ id: "b", name: "Bo", transponder: "1002" },
|
||||
{ id: "c", name: "Cia", transponder: "" },
|
||||
];
|
||||
|
||||
test("a driver is green only once their transponder crosses after the warm-up started", () => {
|
||||
const sightings = new Map();
|
||||
recordTransponderSighting(sightings, "1001", 500); // before the warm-up - must not count
|
||||
recordTransponderSighting(sightings, "1002", 1500);
|
||||
let check = buildWarmupCheck({ entrants, sightings, since: 1000 });
|
||||
assert.deepEqual(check.rows.map((row) => row.seen), [false, true, false]);
|
||||
assert.equal(check.seenCount, 1);
|
||||
assert.equal(check.total, 3);
|
||||
assert.deepEqual(check.missing.map((row) => row.name), ["Anna", "Cia"], "a driver with no transponder is never green");
|
||||
recordTransponderSighting(sightings, "1001", 2000);
|
||||
check = buildWarmupCheck({ entrants, sightings, since: 1000 });
|
||||
assert.equal(check.rows[0].seen, true);
|
||||
assert.equal(check.rows[0].lastSeenAt, 2000);
|
||||
});
|
||||
|
||||
test("unknown transponders are listed newest first, naming the owner if it belongs to another driver", () => {
|
||||
const sightings = new Map();
|
||||
recordTransponderSighting(sightings, "9999", 1100);
|
||||
recordTransponderSighting(sightings, "7777", 1200);
|
||||
recordTransponderSighting(sightings, "1001", 1300);
|
||||
recordTransponderSighting(sightings, "5555", 900); // before the warm-up
|
||||
const check = buildWarmupCheck({ entrants, drivers: [...entrants, { id: "x", name: "Xerxes", transponder: "7777" }], sightings, since: 1000 });
|
||||
assert.deepEqual(check.unknownTransponders.map((item) => [item.transponder, item.ownerName]), [["7777", "Xerxes"], ["9999", ""]]);
|
||||
});
|
||||
|
||||
test("sightings count repeat passings and stay bounded", () => {
|
||||
const sightings = new Map();
|
||||
recordTransponderSighting(sightings, " 42 ", 1);
|
||||
recordTransponderSighting(sightings, "42", 2);
|
||||
assert.equal(sightings.get("42").count, 2);
|
||||
recordTransponderSighting(sightings, "", 3);
|
||||
assert.equal(sightings.size, 1);
|
||||
for (let i = 0; i < 600; i += 1) recordTransponderSighting(sightings, `t${i}`, 10 + i);
|
||||
assert.equal(sightings.size, 500);
|
||||
assert.equal(sightings.has("t599"), true, "the newest are kept");
|
||||
});
|
||||
|
||||
test("prologue counts down, holds and resumes without losing time", () => {
|
||||
let prologue = createPrologue("s1", 120000, 0);
|
||||
assert.equal(getPrologueRemainingMs(prologue, 30000), 90000);
|
||||
prologue = holdPrologue(prologue, 30000);
|
||||
assert.equal(getPrologueRemainingMs(prologue, 500000), 90000, "a held count-down doesn't move");
|
||||
prologue = resumePrologue(prologue, 500000);
|
||||
assert.equal(getPrologueRemainingMs(prologue, 510000), 80000);
|
||||
assert.equal(getPrologueRemainingMs(prologue, 999999), 0);
|
||||
assert.equal(getPrologueRemainingMs(null), 0);
|
||||
});
|
||||
|
||||
// Runs a prologue second by second and collects what the announcer says.
|
||||
function collectCalls(totalMs, startMode) {
|
||||
let prologue = createPrologue("s1", totalMs, 0);
|
||||
const calls = [];
|
||||
for (let now = 0; now <= totalMs; now += 250) {
|
||||
const next = takeDuePrologueCall(prologue, startMode, now);
|
||||
prologue = next.prologue;
|
||||
if (next.call) calls.push(next.call.variables?.seconds ?? next.call.variables?.minutes ?? next.call.event);
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
test("flying start calls the EFRA 9.2b marks: 1 min, 30 s, 10 s (2 min is the opening call)", () => {
|
||||
assert.deepEqual(collectCalls(120000, "flying"), ["race_1_minute", "race_30_seconds", "race_10_seconds"]);
|
||||
assert.deepEqual(collectCalls(300000, "flying"), [4, 3, 2, "race_1_minute", "race_30_seconds", "race_10_seconds"]);
|
||||
assert.deepEqual(collectCalls(30000, "flying"), ["race_10_seconds"]);
|
||||
});
|
||||
|
||||
test("grid start calls cars to the grid at 30 s and counts 10..3 second by second (IFMAR 4.2.5-4.2.6)", () => {
|
||||
assert.deepEqual(collectCalls(90000, "position"), ["race_1_minute", "prologue_grid", "race_10_seconds", 9, 8, 7, 6, 5, 4, 3]);
|
||||
assert.equal(getPrologueHandoverMs("position"), 3000, "the start signal follows 0-5 s after the 3 s mark");
|
||||
assert.equal(getPrologueHandoverMs("flying"), 0);
|
||||
});
|
||||
|
||||
test("jumping to 10 s marks the skipped calls done and says only the latest", () => {
|
||||
let prologue = createPrologue("s1", 120000, 0);
|
||||
prologue = setPrologueRemaining(prologue, 10000, 1000);
|
||||
const { prologue: after, call } = takeDuePrologueCall(prologue, "flying", 1000);
|
||||
assert.equal(call.event, "race_10_seconds");
|
||||
assert.deepEqual(after.announced, ["m1", "s30", "s10"]);
|
||||
// +1 min from there brings the 1 min / 30 s calls back.
|
||||
const back = setPrologueRemaining(after, 70000, 1000);
|
||||
assert.deepEqual(back.announced, []);
|
||||
assert.equal(getPrologueRemainingMs(back, 1000), 70000);
|
||||
});
|
||||
|
||||
test("prologue lengths snap to the offered choices", () => {
|
||||
assert.deepEqual(PROLOGUE_OPTIONS_SEC, [0, 30, 60, 90, 120, 180, 300]);
|
||||
assert.equal(normalizePrologueSec(90), 90);
|
||||
assert.equal(normalizePrologueSec("0"), 0);
|
||||
assert.equal(normalizePrologueSec(45), 120);
|
||||
assert.equal(normalizePrologueSec(undefined), 120);
|
||||
assert.equal(formatPrologueTime(90000, "sv"), "1 minut och 30 sekunder");
|
||||
assert.equal(formatPrologueTime(120000, "en"), "2 minutes");
|
||||
assert.equal(formatPrologueTime(30000, "sv"), "30 sekunder");
|
||||
});
|
||||
|
||||
test("latest personal clock start follows EFRA 9.3 / IFMAR 4.2.3", () => {
|
||||
const passings = [{ timestamp: 30000, lapMs: 20000, validLap: true }, { timestamp: 25000, lapMs: 3000, validLap: false }];
|
||||
// Flying: 150 % of the first completed lap after the horn.
|
||||
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "flying" }, { passings }), 10000 + 30000);
|
||||
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "flying" }, { passings: [] }), null, "unknown until someone completes a lap");
|
||||
// Staggered: when the first car completes a lap, or 1 minute after the start.
|
||||
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "staggered" }, { passings }), 30000);
|
||||
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "staggered" }, { passings: [] }), 70000);
|
||||
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "mass" }, { passings }), null);
|
||||
assert.equal(usesPersonalClock("flying"), true);
|
||||
assert.equal(usesPersonalClock("position"), false);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudio
|
||||
|
||||
const manifest = {
|
||||
events: { transponder_pass: { effect: "effects/transponder_pass.wav" }, start_tone: { effect: "effects/start_beep.wav" } },
|
||||
voice_events: { sv: { drivers_ready: "Redo", start_sequence: "Start på signalen", track_call: "Stopp på banan" }, en: { drivers_ready: "Ready" } },
|
||||
voice_events: { sv: { drivers_ready: "Redo", start_sequence: "Start på signalen", track_call: "Stopp på banan", clock_running: "Klockan går." }, en: { drivers_ready: "Ready" } },
|
||||
};
|
||||
|
||||
test("race audio settings normalize volume, delay, language and switches", () => {
|
||||
@@ -87,6 +87,34 @@ test("staggered start mode calls out each car in grid order, 3s apart, after the
|
||||
]);
|
||||
});
|
||||
|
||||
function createRecordingEngine(calls, settings = {}) {
|
||||
return createRaceAudioEngine({
|
||||
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
|
||||
audioFactory: (src) => ({ play: () => { calls.push(["effect", src.split("/").pop()]); return Promise.resolve(); }, volume: 1 }),
|
||||
speak: async (text) => { calls.push(["voice", text]); return true; },
|
||||
wait: async (ms) => calls.push(["wait", ms]),
|
||||
getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, startLights: false, minStartDelay: 2, maxStartDelay: 2, randomStartDelay: false, ...settings }),
|
||||
});
|
||||
}
|
||||
|
||||
test("a flying start has no start procedure: horn, then 'clock is running' (EFRA 9.2b)", async () => {
|
||||
const calls = [];
|
||||
const engine = createRecordingEngine(calls, { startLights: true });
|
||||
const states = [];
|
||||
engine.subscribeStartLights((state) => states.push(state));
|
||||
await engine.runStartSequence({ id: "q1", type: "qualification", startMode: "flying" }, { onTone: () => calls.push(["timing-start"]), skipCountdown: true });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(states, ["IDLE", "GRID", "GO"], "no lights and no random delay");
|
||||
assert.deepEqual(calls, [["effect", "start_beep.wav"], ["timing-start"], ["voice", "Klockan går."]]);
|
||||
});
|
||||
|
||||
test("after the prologue a grid start skips its own count-down and intro calls", async () => {
|
||||
const calls = [];
|
||||
const engine = createRecordingEngine(calls, { startCountdownSeconds: 30 });
|
||||
await engine.runStartSequence({ id: "f1", type: "final", startMode: "position" }, { onTone: () => calls.push(["timing-start"]), skipCountdown: true, skipIntro: true });
|
||||
assert.deepEqual(calls, [["wait", 2000], ["effect", "start_beep.wav"], ["timing-start"]], "only the 0-5 s delay and the horn are left");
|
||||
});
|
||||
|
||||
test("a mass-start session never calls out individual cars, even if a grid order is passed", async () => {
|
||||
const calls = [];
|
||||
const engine = createRaceAudioEngine({
|
||||
@@ -124,7 +152,7 @@ test("cancelling a pending start prevents the official start callback", async ()
|
||||
test("event catalog covers every supported race-audio event", () => {
|
||||
const keys = new Set(RACE_AUDIO_EVENT_DEFINITIONS.map((item) => item.key));
|
||||
[
|
||||
"grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race", "staggered_release", "warmup_start", "warmup_1_minute", "warmup_end",
|
||||
"grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race", "staggered_release", "prologue_start", "prologue_minutes", "prologue_grid", "prologue_count", "prologue_hold", "prologue_resumed", "prologue_cancelled", "clock_running",
|
||||
"transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "training_stable_pace", "new_leader", "position_gain", "position_loss", "final_lap", "one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "race_finished",
|
||||
"track_call", "track_clear", "yellow_flag", "red_flag", "race_paused", "race_resumed", "marshal_warning", "marshal_required", "penalty", "stop_and_go", "drive_through", "disqualified",
|
||||
"pit_lane_open", "pit_lane_closed", "entering_pits", "leaving_pits", "pit_speed_warning",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./auth.test.js";
|
||||
import "./warmup_logic.test.js";
|
||||
import "./prologue_logic.test.js";
|
||||
import "./tts.test.js";
|
||||
import "./piper_speech.test.js";
|
||||
import "./driver_profile_logic.test.js";
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { recordTransponderSighting, buildWarmupCheck, getWarmupRemainingMs } from "../src/warmup_logic.js";
|
||||
|
||||
const entrants = [
|
||||
{ id: "a", name: "Anna", transponder: "1001" },
|
||||
{ id: "b", name: "Bo", transponder: "1002" },
|
||||
{ id: "c", name: "Cia", transponder: "" },
|
||||
];
|
||||
|
||||
test("a driver is green only once their transponder crosses after the warm-up started", () => {
|
||||
const sightings = new Map();
|
||||
recordTransponderSighting(sightings, "1001", 500); // before the warm-up - must not count
|
||||
recordTransponderSighting(sightings, "1002", 1500);
|
||||
let check = buildWarmupCheck({ entrants, sightings, since: 1000 });
|
||||
assert.deepEqual(check.rows.map((row) => row.seen), [false, true, false]);
|
||||
assert.equal(check.seenCount, 1);
|
||||
assert.equal(check.total, 3);
|
||||
assert.deepEqual(check.missing.map((row) => row.name), ["Anna", "Cia"], "a driver with no transponder is never green");
|
||||
recordTransponderSighting(sightings, "1001", 2000);
|
||||
check = buildWarmupCheck({ entrants, sightings, since: 1000 });
|
||||
assert.equal(check.rows[0].seen, true);
|
||||
assert.equal(check.rows[0].lastSeenAt, 2000);
|
||||
});
|
||||
|
||||
test("unknown transponders are listed newest first, naming the owner if it belongs to another driver", () => {
|
||||
const sightings = new Map();
|
||||
recordTransponderSighting(sightings, "9999", 1100);
|
||||
recordTransponderSighting(sightings, "7777", 1200);
|
||||
recordTransponderSighting(sightings, "1001", 1300);
|
||||
recordTransponderSighting(sightings, "5555", 900); // before the warm-up
|
||||
const check = buildWarmupCheck({ entrants, drivers: [...entrants, { id: "x", name: "Xerxes", transponder: "7777" }], sightings, since: 1000 });
|
||||
assert.deepEqual(check.unknownTransponders.map((item) => [item.transponder, item.ownerName]), [["7777", "Xerxes"], ["9999", ""]]);
|
||||
});
|
||||
|
||||
test("sightings count repeat passings and stay bounded", () => {
|
||||
const sightings = new Map();
|
||||
recordTransponderSighting(sightings, " 42 ", 1);
|
||||
recordTransponderSighting(sightings, "42", 2);
|
||||
assert.equal(sightings.get("42").count, 2);
|
||||
recordTransponderSighting(sightings, "", 3);
|
||||
assert.equal(sightings.size, 1);
|
||||
for (let i = 0; i < 600; i += 1) recordTransponderSighting(sightings, `t${i}`, 10 + i);
|
||||
assert.equal(sightings.size, 500);
|
||||
assert.equal(sightings.has("t599"), true, "the newest are kept");
|
||||
});
|
||||
|
||||
test("warm-up countdown", () => {
|
||||
assert.equal(getWarmupRemainingMs(null), 0);
|
||||
assert.equal(getWarmupRemainingMs({ startedAt: 1000, durationMs: 60000 }, 31000), 30000);
|
||||
assert.equal(getWarmupRemainingMs({ startedAt: 1000, durationMs: 60000 }, 99000), 0);
|
||||
});
|
||||
Reference in New Issue
Block a user