From 5bfb67ea02b630c701eb6dbf0a30d12569ff066a Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 30 Sep 2026 21:27:20 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 11 ++ ROADMAP.md | 41 +++++ docs/javascript-architecture.md | 10 +- package-lock.json | 4 +- package.json | 2 +- rc-race-sound-pack/sound_manifest.json | 22 ++- src/app.js | 207 +++++++++++++++++----- src/decoder_runtime.js | 12 +- src/event_common.js | 2 +- src/event_manager_controller.js | 2 + src/event_manager_view.js | 15 ++ src/event_race_logic.js | 17 +- src/prologue_logic.js | 166 +++++++++++++++++ src/race_audio.js | 38 +++- src/race_day_view.js | 140 +++++++++------ src/styles.css | 20 ++- src/timing_logic.js | 8 +- src/warmup_logic.js | 45 ----- tests/browser_prologue.js | 110 ++++++++++++ tests/browser_race_day_start_sequences.js | 23 ++- tests/browser_smoke.js | 4 +- tests/browser_warmup.js | 84 --------- tests/decoder_runtime.test.js | 28 ++- tests/prologue_logic.test.js | 121 +++++++++++++ tests/race_audio.test.js | 32 +++- tests/run.js | 2 +- tests/warmup_logic.test.js | 52 ------ 27 files changed, 884 insertions(+), 334 deletions(-) create mode 100644 src/prologue_logic.js delete mode 100644 src/warmup_logic.js create mode 100644 tests/browser_prologue.js delete mode 100644 tests/browser_warmup.js create mode 100644 tests/prologue_logic.test.js delete mode 100644 tests/warmup_logic.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c225d18..a4b5747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.27.0 - 2026-09-30 + +### Changed + +- Race Day starts a race-format heat the EFRA/IFMAR way: Start begins a count-down (Go, 30 s, 1, 1:30, 2, 3 or 5 min; the default is set per event for qualifying and finals in Race Setup) instead of the separate warm-up button from 1.26.0. The track is open and the announcer calls "Heat 3 starts in 2 minutes", every minute, 30 s and 10 s (EFRA 9.2b). For a grid start it also calls "cars to the grid" at 30 s and counts 9..3 second by second, then the start lights and a random 0-5 s signal follow (IFMAR 4.2.5-4.2.8). The count-down can be held while cars are placed on the grid, resumed, extended by a minute, jumped to 30 s or 10 s, started at once ("Go now", which warns about drivers not seen) or cancelled. When it runs out the heat starts by itself. The transponder check (green ticks, quick transponder fix) now runs during the count-down. A reload keeps the count-down but puts it on hold, and it only runs in the browser tab that started it. + +### Added + +- Flying start mode for qualifying (EFRA 9.2b), now the default for new events and the built-in presets: no start procedure, the start signal is followed by "Clock is running", and each car's clock starts at its own first crossing. +- Latest personal clock start (EFRA 9.3, IFMAR 4.2.3): a car that hasn't crossed in time gets its clock started anyway. For a flying start this happens at the latest 150 % of the first lap anyone completes; for a staggered start, when the first car completes a lap or at the latest 1 minute after the start. That car's first crossing then counts as a lap. + ## 1.26.0 - 2026-09-30 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index fc41b29..ff5f885 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -573,6 +573,47 @@ Asked by the user 2026-09-30: what happens if a driver in a running heat registe Verified 2026-09-30: unit tests (`tests/warmup_logic.test.js`) cover seen-since-start, a passing before the warm-up not counting, drivers without a transponder, unknown transponders newest-first with owner names, repeat counting and the size bound, and the countdown. `tests/browser_warmup.js` drives it with real decoder frames: Anna and Bo go green, Cia (registered wrong) stays missing until the unknown transponder is linked from the panel - then green and saved - preflight shows 3/3, and after a restarted warm-up Start warns naming the two not seen and starts the heat on confirm. While writing that test it first failed because a passing sent "before" the warm-up reached the page after the click and was (correctly) counted - a test timing issue, fixed by waiting for the frame to arrive. After that fix the same assertion still failed rarely (twice in about ten full-suite runs). Investigated with temporary logging: every run had exactly one decoder connection and the passing was processed before the start (`no_running_session` in the diagnostics), so it is not a duplicate-connection bug - but the flake could not be reproduced while logging (4/4 passed), so the root cause is unknown. The browser test now waits 750 ms between that passing and the start; the before/after rule itself is covered deterministically by the unit tests. 230 unit/integration tests pass, and the 46 browser scenarios passed three runs in a row. +### Follow-up: the warm-up becomes the start count-down (EFRA / IFMAR) + +The user's feedback on the same day: the warm-up should not be a separate part. It belongs in the heat start itself, as "heat starts in 2 min, 1 min". It must be possible to hold the start so the cars can be put on the grid. Qualifying uses a flying start, where each driver's 5 minutes start at their own crossing. Lengths should be 30 s, 1, 1:30 and 2 min. The goal is to follow EFRA and IFMAR. Checked first: +- EVERLAPS (formerly RCM), which does exactly this: a "prologue" after Start, with calls every minute and at 30/20/10 s, and green ticks for drivers who have crossed. It also offers 30 s / 10 s shortcuts and Go. +- EFRA General Rules 2024 §9.2-9.3. +- IFMAR 1:10 IC Track 2024 §4.2. + +- [x] `src/warmup_logic.js` became `src/prologue_logic.js`. It adds the prologue state (hold/resume/set remaining) and the call schedule: + - EFRA 9.2b for a flying start: 1 min, 30 s, 10 s; with a longer count-down, every whole minute ("N minutes to the start"). + - IFMAR 4.2.4-4.2.6 for grid and staggered starts: "30 seconds, cars to the grid", then 9..3 second by second. + - The start procedure takes over at 3 s (IFMAR 4.2.8: the signal comes 0-5 s later, which is our random delay). A flying start takes over at 0. +- [x] Race Day's Start on a race-format heat now starts the count-down. Choices are Go, 30 s, 1, 1:30, 2, 3 and 5 min. The default is set per event (`qualPrologueSec` / `finalPrologueSec`, 2 min) and comes from new fields in Race Setup. + - While counting down: Hold / Resume, +1 min, To 30 s (IFMAR 4.2.8 grid restart from 30 s), To 10 s, Go now (warns about drivers not seen) and Cancel. + - The hero clock shows the time to the start. + - When the count-down runs out the heat starts by itself, with no dialog. + - The global 10/30/60 s start count-down in Settings is skipped on Race Day. It still applies to Start on the Timing page. +- [x] The count-down lives only in the browser tab that started it (module state plus `sessionStorage`). A second browser on the same event therefore never runs its own count-down and doubles the calls. A reload restores it on hold, so a start is never fired by a reload. +- [x] `runStartSequence` takes `skipCountdown` and `skipIntro`: + - "Go" still says "drivers, stand by" and runs the lights, random delay and horn. + - After a full count-down only the lights, random delay and horn are left. +- [x] New start mode `flying` (EFRA 9.2b): no start procedure, just the horn and "Clock is running" (new voice event `clock_running`). Each car gets its own clock from its first crossing, like staggered. + - It is the default for qualifying in new events and in the three built-in presets. Existing events keep the mode they already have saved. + - The shared session clock gets one extra minute, so the last car to start still has its full window. +- [x] Latest personal clock start (EFRA 9.3, IFMAR 4.2.3) in `decoder_runtime.js`. A car first seen after the latest start gets that moment as its start, and its first crossing counts as a lap. + - Flying start: 150 % of a standard lap after the horn. We take the first lap anyone completes in the heat as the standard lap, since there is no configured standard lap time. + - Staggered start: when the first car completes a lap, or 1 minute after the start, whichever comes first. +- [x] Voice events `warmup_*` were replaced by `prologue_start`, `prologue_minutes`, `prologue_grid`, `prologue_count`, `prologue_hold`, `prologue_resumed`, `prologue_cancelled` and `clock_running`, in sv and en. The existing `race_1_minute`, `race_30_seconds` and `race_10_seconds` are reused. + +Verified 2026-09-30: +- 239 unit/integration tests pass. They include new tests for the call schedule per start mode, hold/resume, jumping to 10 s (skipped calls marked done, only the latest one spoken, calls come back after +1 min), length choices, the latest clock start rules, a flying start in the decoder (own clock, and a late car getting the latest start with a counted lap), and the audio sequence for flying and post-count-down grid starts. +- `tests/browser_prologue.js` drives real decoder frames through the whole flow: + - the default length comes from the event, and a passing before the count-down doesn't count; + - Anna goes green and Cia is fixed by linking her unknown transponder; + - hold keeps the clock still for 1.5 s, and it moves again after resume; +1 min works; + - "Go now" warns about Bo only and starts on confirm; + - a second heat started with 30 s and jumped to 10 s starts by itself with no dialog. +- `tests/browser_race_day_start_sequences.js` was updated to the new flow: the heat counts down and never gets a second count-down, and the other types use Go. +- The 48 browser scenarios passed three runs in a row. + +Not done: a configurable standard lap time per event (EVERLAPS has one). Until then, the first completed lap stands in for it. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 15956e5..c496a91 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -576,13 +576,17 @@ Type: Owns: - "Track layout: @@ -525,8 +527,14 @@ export function renderEventManagerMarkup(context) { + ` ) : ""} + ${showBasicQualifyingFields ? renderRaceFormatFieldView( + "events.qual_prologue", + "events.prologue_hint", + `` + ) : ""} ${raceFormatAdvanced ? renderRaceFormatFieldView( "events.qual_seed_laps", @@ -610,8 +618,14 @@ export function renderEventManagerMarkup(context) { + ` ) : ""} + ${showBasicFinalFields ? renderRaceFormatFieldView( + "events.final_prologue", + "events.prologue_hint", + `` + ) : ""} ${showBasicFinalFields ? renderRaceFormatFieldView( "events.bump_count", "events.bump_count_hint", @@ -915,6 +929,7 @@ export function renderEventManagerMarkup(context) { + ${t("events.session_start_mode_hint")} diff --git a/src/event_race_logic.js b/src/event_race_logic.js index 24cf529..c9872c7 100644 --- a/src/event_race_logic.js +++ b/src/event_race_logic.js @@ -1,3 +1,4 @@ +import { normalizePrologueSec } from "./prologue_logic.js"; export function normalizeRaceTeam(team, { uid }) { return { id: String(team?.id || uid("team")), @@ -34,7 +35,7 @@ export function getRaceFormatPresets({ state, t, uid, normalizeStoredRacePreset qualifyingRounds: 3, carsPerHeat: 6, qualDurationMin: 5, - qualStartMode: "staggered", + qualStartMode: "flying", qualSeedLapCount: 3, qualSeedMethod: "best_sum", countedQualRounds: 2, @@ -59,7 +60,7 @@ export function getRaceFormatPresets({ state, t, uid, normalizeStoredRacePreset qualifyingRounds: 4, carsPerHeat: 8, qualDurationMin: 5, - qualStartMode: "staggered", + qualStartMode: "flying", qualSeedLapCount: 3, qualSeedMethod: "best_sum", countedQualRounds: 2, @@ -84,7 +85,7 @@ export function getRaceFormatPresets({ state, t, uid, normalizeStoredRacePreset qualifyingRounds: 5, carsPerHeat: 10, qualDurationMin: 5, - qualStartMode: "staggered", + qualStartMode: "flying", qualSeedLapCount: 3, qualSeedMethod: "best_sum", countedQualRounds: 3, @@ -162,7 +163,7 @@ export function buildRaceFormatConfigFromForm(form, event, { normalizeStartMode, qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3), carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8), qualDurationMin: Math.max(1, Number(form.get("qualDurationMin") || 5) || 5), - qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "staggered")), + qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "flying")), qualSeedLapCount: Math.max(0, Number(form.get("qualSeedLapCount") || 2) || 0), qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(form.get("qualSeedMethod") || "").toLowerCase()) ? String(form.get("qualSeedMethod")).toLowerCase() @@ -179,6 +180,8 @@ export function buildRaceFormatConfigFromForm(form, event, { normalizeStartMode, countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1), finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5), finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")), + qualPrologueSec: normalizePrologueSec(form.get("qualPrologueSec")), + finalPrologueSec: normalizePrologueSec(form.get("finalPrologueSec")), followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0), minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)), maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)), @@ -204,7 +207,7 @@ export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartM qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3), carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8), qualDurationMin: Math.max(1, Number(event?.raceConfig?.qualDurationMin || 5) || 5), - qualStartMode: normalizeStartMode(event?.raceConfig?.qualStartMode || "staggered"), + qualStartMode: normalizeStartMode(event?.raceConfig?.qualStartMode || "flying"), qualSeedLapCount: Math.max(0, Number(event?.raceConfig?.qualSeedLapCount || 2) || 2), qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(event?.raceConfig?.qualSeedMethod || "").toLowerCase()) ? String(event.raceConfig.qualSeedMethod).toLowerCase() @@ -221,6 +224,8 @@ export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartM countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1), finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5), finalStartMode: normalizeStartMode(event?.raceConfig?.finalStartMode || "position"), + qualPrologueSec: normalizePrologueSec(event?.raceConfig?.qualPrologueSec), + finalPrologueSec: normalizePrologueSec(event?.raceConfig?.finalPrologueSec), followUpSec: Math.max(0, Number(event?.raceConfig?.followUpSec || 0) || 0), minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0), maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000), @@ -637,7 +642,7 @@ export function generateQualifyingForRace(event, { buildPracticeStandings, getEv const qualifyingRounds = Math.max(1, Number(event.raceConfig?.qualifyingRounds || 1) || 1); const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8); const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5); - const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered"); + const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "flying"); const qualSeedLapCount = Math.max(0, Number(event.raceConfig?.qualSeedLapCount || 2) || 0); const qualSeedMethod = ["best_sum", "average", "consecutive"].includes(String(event.raceConfig?.qualSeedMethod || "").toLowerCase()) ? String(event.raceConfig.qualSeedMethod).toLowerCase() diff --git a/src/prologue_logic.js b/src/prologue_logic.js new file mode 100644 index 0000000..3633e5e --- /dev/null +++ b/src/prologue_logic.js @@ -0,0 +1,166 @@ +// Start of a heat per EFRA / IFMAR: the count-down before it ("prologue"), the transponder +// check during that count-down, and when a car's own clock starts at the latest. +// +// Transponder check: every passing the main decoder sees is remembered (transponder -> when), +// independent of whether a session is running, so the Race Day panel can show which drivers +// in the selected heat have been seen since the count-down began and which unknown +// transponders crossed the line. +const MAX_SIGHTINGS = 500; + +export function recordTransponderSighting(sightings, transponder, at = Date.now()) { + const key = String(transponder || "").trim(); + if (!key) return sightings; + const previous = sightings.get(key); + sightings.delete(key); // re-insert so Map order stays oldest -> newest + sightings.set(key, { transponder: key, lastSeenAt: at, count: (previous?.count || 0) + 1 }); + while (sightings.size > MAX_SIGHTINGS) sightings.delete(sightings.keys().next().value); + return sightings; +} + +// entrants: drivers in the heat, in start order. drivers: every registered driver, used to +// name the owner of a transponder that crossed the line but doesn't belong to this heat. +export function buildWarmupCheck({ entrants = [], drivers = [], sightings = new Map(), since = 0 }) { + const seenSince = (transponder) => { + const sighting = transponder ? sightings.get(String(transponder).trim()) : null; + return sighting && sighting.lastSeenAt >= since ? sighting : null; + }; + const rows = entrants.map((driver) => { + const sighting = seenSince(driver.transponder); + return { driverId: driver.id, name: driver.name, transponder: String(driver.transponder || ""), seen: Boolean(sighting), lastSeenAt: sighting?.lastSeenAt || null }; + }); + const entrantTransponders = new Set(rows.map((row) => row.transponder).filter(Boolean)); + const unknownTransponders = [...sightings.values()] + .filter((sighting) => sighting.lastSeenAt >= since && !entrantTransponders.has(sighting.transponder)) + .map((sighting) => ({ ...sighting, ownerName: drivers.find((driver) => String(driver.transponder || "") === sighting.transponder)?.name || "" })) + .sort((a, b) => b.lastSeenAt - a.lastSeenAt); + return { + rows, + total: rows.length, + seenCount: rows.filter((row) => row.seen).length, + missing: rows.filter((row) => !row.seen), + unknownTransponders, + }; +} + +// --- Prologue: the count-down before a heat -------------------------------------------- +// Race Day's Start runs a prologue first (EVERLAPS' term): the track is open, the announcer +// counts down (EFRA 9.2b: 2 min, 1 min, 30 s, 10 s) and the warm-up check above shows who +// has crossed the line. The race director can hold it (cars being put on the grid), add time +// or jump ahead. State is a plain object stored on the session so a reload keeps it. +export const PROLOGUE_OPTIONS_SEC = Object.freeze([0, 30, 60, 90, 120, 180, 300]); +export const DEFAULT_PROLOGUE_SEC = 120; +// Grid starts (mass/position) and staggered releases hand over to the start procedure at +// 3 s: IFMAR 4.2.8 - "from 3 seconds the counting stops and the start signal is given +// between 0 and 5 seconds" (our random delay). A flying start ends at 0 with the horn. +const GRID_HANDOVER_MS = 3000; + +export function normalizePrologueSec(value, fallback = DEFAULT_PROLOGUE_SEC) { + const seconds = Number(value); + return PROLOGUE_OPTIONS_SEC.includes(seconds) ? seconds : fallback; +} + +// Staggered and flying starts give every car its own clock from its own first crossing. +export function usesPersonalClock(startMode) { + return startMode === "staggered" || startMode === "flying"; +} + +export function getPrologueHandoverMs(startMode) { + return startMode === "flying" ? 0 : GRID_HANDOVER_MS; +} + +export function createPrologue(sessionId, totalMs, now = Date.now()) { + return { sessionId, totalMs, startedAt: now, endsAt: now + totalMs, heldRemainingMs: null, announced: [] }; +} + +export function getPrologueRemainingMs(prologue, now = Date.now()) { + if (!prologue) return 0; + if (prologue.heldRemainingMs !== null && prologue.heldRemainingMs !== undefined) return Math.max(0, prologue.heldRemainingMs); + return Math.max(0, Number(prologue.endsAt || 0) - now); +} + +export function holdPrologue(prologue, now = Date.now()) { + if (!prologue || prologue.heldRemainingMs !== null) return prologue; + return { ...prologue, heldRemainingMs: getPrologueRemainingMs(prologue, now) }; +} + +export function resumePrologue(prologue, now = Date.now()) { + if (!prologue || prologue.heldRemainingMs === null) return prologue; + return { ...prologue, endsAt: now + prologue.heldRemainingMs, heldRemainingMs: null }; +} + +// Sets a new remaining time (+1 min, "to 10 s", IFMAR 4.2.8 "re-start from 30 s"). Calls +// for marks above the new time are forgotten so they are made again on the way down. +export function setPrologueRemaining(prologue, remainingMs, now = Date.now()) { + if (!prologue) return prologue; + const value = Math.max(0, Math.round(remainingMs)); + const announced = prologue.announced.filter((marker) => getMarkMs(marker) > value); + const totalMs = Math.max(prologue.totalMs, value); + return prologue.heldRemainingMs !== null + ? { ...prologue, totalMs, announced, heldRemainingMs: value } + : { ...prologue, totalMs, announced, endsAt: now + value }; +} + +function getMarkMs(marker) { + const [, unit, amount] = /^([ms])(\d+)$/.exec(marker) || []; + return unit === "m" ? Number(amount) * 60000 : Number(amount) * 1000; +} + +function getPrologueMarks(startMode) { + const marks = [5, 4, 3, 2].map((minutes) => ({ marker: `m${minutes}`, event: "prologue_minutes", variables: { minutes } })); + marks.push({ marker: "m1", event: "race_1_minute" }); + // IFMAR 4.2.5: from 30 s the cars may be placed in the starting boxes. + marks.push({ marker: "s30", event: startMode === "flying" ? "race_30_seconds" : "prologue_grid" }); + marks.push({ marker: "s10", event: "race_10_seconds" }); + // IFMAR 4.2.6: from 10 s to 3 s the time is counted down second by second. + if (startMode !== "flying") [9, 8, 7, 6, 5, 4, 3].forEach((seconds) => marks.push({ marker: `s${seconds}`, event: "prologue_count", variables: { seconds } })); + return marks.map((mark) => ({ ...mark, ms: getMarkMs(mark.marker) })); +} + +// Which call is due now. Marks at or above the prologue's own length are skipped (the +// opening call already says the time), and when several are passed at once (a jump to +// 10 s) they are all marked done but only the latest one is spoken. +export function takeDuePrologueCall(prologue, startMode, now = Date.now()) { + if (!prologue) return { prologue, call: null }; + const remaining = getPrologueRemainingMs(prologue, now); + const due = getPrologueMarks(startMode) + .filter((mark) => mark.ms < prologue.totalMs - 500 && remaining <= mark.ms && !prologue.announced.includes(mark.marker)); + if (!due.length) return { prologue, call: null }; + return { + prologue: { ...prologue, announced: [...prologue.announced, ...due.map((mark) => mark.marker)] }, + call: due[due.length - 1], + }; +} + +// Label for a length choice in the event form and on Race Day: "Go", "30 s", "1:30 min". +export function formatPrologueOption(seconds, t) { + if (!seconds) return t("prologue.option_go"); + if (seconds < 60) return `${seconds} s`; + return seconds % 60 ? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} min` : `${seconds / 60} min`; +} + +export function formatPrologueTime(ms, language = "sv") { + const total = Math.round(ms / 1000); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + const sv = language === "sv"; + const minuteText = minutes ? (sv ? `${minutes} ${minutes === 1 ? "minut" : "minuter"}` : `${minutes} ${minutes === 1 ? "minute" : "minutes"}`) : ""; + const secondText = seconds || !minutes ? (sv ? `${seconds} sekunder` : `${seconds} seconds`) : ""; + return [minuteText, secondText].filter(Boolean).join(sv ? " och " : " "); +} + +// --- Latest personal clock start ------------------------------------------------------- +// A car that doesn't cross the line in time doesn't get a later clock of its own: +// - staggered (EFRA 9.3, IFMAR 4.2.3): the clock starts at the latest when any car completes +// a full lap, or 1 minute after the start, whichever comes first; +// - flying (EFRA 9.3): at the latest 150 % of a standard lap after "clock is running". The +// standard lap is the first lap anyone completes in the heat. +// Returns null while that moment isn't known yet. +export function getLatestClockStartTs(session, result) { + const goTs = Number(session?.startedAt || 0); + if (!goTs || !usesPersonalClock(session?.startMode)) return null; + const firstLap = (result?.passings || []) + .filter((passing) => passing.validLap !== false && Number(passing.lapMs) > 0) + .sort((a, b) => a.timestamp - b.timestamp)[0] || null; + if (session.startMode === "staggered") return Math.min(goTs + 60000, firstLap ? firstLap.timestamp : Number.POSITIVE_INFINITY); + return firstLap ? goTs + Math.round(firstLap.lapMs * 1.5) : null; +} diff --git a/src/race_audio.js b/src/race_audio.js index 5450355..fb4bf23 100644 --- a/src/race_audio.js +++ b/src/race_audio.js @@ -10,7 +10,10 @@ export const RACE_AUDIO_EVENT_DEFINITIONS = Object.freeze([ event("start_tone", "start", "high", false, "effects/start_beep.wav"), event("false_start", "start", "critical", true, "effects/warning.wav", 1000), event("restart_race", "start", "high"), event("staggered_release", "start", "normal"), - event("warmup_start", "start", "normal"), event("warmup_1_minute", "start", "normal"), event("warmup_end", "start", "high"), + event("prologue_start", "start", "normal"), event("prologue_minutes", "start", "normal"), + event("prologue_grid", "start", "high"), event("prologue_count", "start", "high"), + event("prologue_hold", "start", "high", true, "effects/warning.wav"), event("prologue_resumed", "start", "normal"), + event("prologue_cancelled", "start", "normal"), event("clock_running", "start", "high"), event("transponder_pass", "race", "pass", false, "effects/transponder_pass.wav"), event("lap_completed", "race", "pass", false), event("personal_best", "race", "low", true, "effects/personal_best.wav", 1500), event("fastest_lap", "race", "normal", true, "effects/fastest_lap_trophy.wav", 1500), @@ -251,16 +254,33 @@ export function createRaceAudioEngine(options = {}) { return; } emitLightState("GRID", { sessionId: session?.id }); - if (settings.startCountdownSeconds >= 60) { trigger("race_1_minute", { interrupt: true }); callbacks.onCountdown?.(60); await wait(30000); if (cancelled()) return false; } - if (settings.startCountdownSeconds >= 30) { trigger("race_30_seconds", { interrupt: true }); callbacks.onCountdown?.(30); await wait(20000); if (cancelled()) return false; } - if (settings.startCountdownSeconds >= 10) { - trigger("race_10_seconds", { interrupt: true }); - for (let seconds = 10; seconds > 0; seconds -= 1) { callbacks.onCountdown?.(seconds); await wait(1000); if (cancelled()) return false; } + const flying = session?.startMode === "flying"; + // Race Day's prologue replaces this count-down (skipCountdown); when the prologue ran all + // the way down it has also called 10..3, so the intro calls are skipped too (skipIntro). + if (!callbacks.skipCountdown) { + if (settings.startCountdownSeconds >= 60) { trigger("race_1_minute", { interrupt: true }); callbacks.onCountdown?.(60); await wait(30000); if (cancelled()) return false; } + if (settings.startCountdownSeconds >= 30) { trigger("race_30_seconds", { interrupt: true }); callbacks.onCountdown?.(30); await wait(20000); if (cancelled()) return false; } + if (settings.startCountdownSeconds >= 10) { + trigger("race_10_seconds", { interrupt: true }); + for (let seconds = 10; seconds > 0; seconds -= 1) { callbacks.onCountdown?.(seconds); await wait(1000); if (cancelled()) return false; } + } + } + // A flying start has no start procedure (EFRA 9.2b): the horn and "clock is running" - + // each car's own clock then starts at its own first crossing. + if (flying) { + emitLightState("GO", { sessionId: session?.id }); + await playEffect("start_tone", { force: true }); + if (cancelled()) return false; + callbacks.onTone?.(); + trigger("clock_running", { interrupt: true, logData: { sessionId: session?.id } }); + return true; } emitLightState("READY", { sessionId: session?.id }); - await trigger("drivers_ready", { interrupt: true }).voiceDone; if (cancelled()) return false; - if (session?.type === "qualification") await trigger("qualifying_start").voiceDone; if (cancelled()) return false; - await trigger("start_sequence").voiceDone; if (cancelled()) return false; + if (!callbacks.skipIntro) { + await trigger("drivers_ready", { interrupt: true }).voiceDone; if (cancelled()) return false; + if (session?.type === "qualification") await trigger("qualifying_start").voiceDone; if (cancelled()) return false; + await trigger("start_sequence").voiceDone; if (cancelled()) return false; + } if (settings.startLights) { for (let light = 1; light <= 5; light += 1) { emitLightState(`LIGHT_${light}`, { sessionId: session?.id, lights: light }); diff --git a/src/race_day_view.js b/src/race_day_view.js index 534b208..c6f2e73 100644 --- a/src/race_day_view.js +++ b/src/race_day_view.js @@ -1,5 +1,6 @@ import { isPracticeSessionType } from "./track_layouts_logic.js"; import { renderTrackLayoutBar, bindTrackLayoutBar } from "./track_layout_bar.js"; +import { PROLOGUE_OPTIONS_SEC, formatPrologueOption } from "./prologue_logic.js"; export function getRaceDayCheckAction(item, session) { if (item.level === "ok") return null; if (["backend", "decoder", "split_decoder", "pit_decoders", "audio"].includes(item.id)) { @@ -54,11 +55,11 @@ function renderQueueSession(session, positionKey, deps) { } -// --- Warm-up / transponder check ------------------------------------------------------- -// Shown for a selected, not-yet-started race-format heat with driver transponders. The panel -// re-renders on its own (refreshRaceDayWarmup) on every passing, without rebuilding the page. +// --- Prologue (count-down) and transponder check --------------------------------------- +// A race-format heat is started through a prologue (see prologue_logic.js): Start begins the +// count-down, and the check panel shows who has crossed the line since. The panel re-renders +// on its own (refreshRaceDayWarmup) on every passing, without rebuilding the page. let lastWarmupDeps = null; -let warmupClockTimer = null; function showsWarmupPanel(session) { return Boolean(session && session.status === "ready" && session.mode !== "track" && session.type !== "team_race" && !isPracticeSessionType(session.type)); @@ -69,10 +70,31 @@ function formatMmSs(ms) { return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; } +function renderPrologueControls({ t, escapeHtml, getDefaultPrologueSec }, session, check, canStart) { + const prologue = check?.prologue; + if (!prologue) { + const defaultSec = getDefaultPrologueSec(session); + return ` + + `; + } + const held = prologue.heldRemainingMs !== null; + return ` + ${formatMmSs(check.remainingMs)} + ${t(held ? "prologue.held" : "prologue.running")} + + + + + + `; +} + function renderWarmupPanelInner({ t, escapeHtml, getRaceDayWarmupCheck }, session) { const check = getRaceDayWarmupCheck(session); - const warmup = check.warmup; - const remaining = warmup && !warmup.stopped ? Math.max(0, warmup.startedAt + warmup.durationMs - Date.now()) : 0; + const warmup = check.prologue; const unknownOptions = check.unknownTransponders.map((item) => ``).join(""); const status = (row) => (!warmup ? "–" : row.seen ? "✅" : "⏳"); return ` @@ -81,14 +103,7 @@ function renderWarmupPanelInner({ t, escapeHtml, getRaceDayWarmupCheck }, sessio ${warmup ? `${t("warmup.seen_count", { seen: check.seenCount, total: check.total })}` : ""}
-
- ${warmup && remaining > 0 - ? `${formatMmSs(remaining)}` - : ` - - ${warmup ? `${t("warmup.ended")}` : ""}`} -
- +
${check.rows.map((row) => ` @@ -106,15 +121,7 @@ function renderWarmupPanelInner({ t, escapeHtml, getRaceDayWarmupCheck }, sessio } function bindWarmupPanel(panel, deps, session) { - const { startRaceDayWarmup, stopRaceDayWarmup, setDriverTransponder } = deps; - panel.querySelector("[data-warmup-start]")?.addEventListener("click", () => { - startRaceDayWarmup(session, Number(panel.querySelector("[data-warmup-minutes]")?.value || 2) * 60000); - refreshRaceDayWarmup(true); - }); - panel.querySelector("[data-warmup-stop]")?.addEventListener("click", () => { - stopRaceDayWarmup(); - refreshRaceDayWarmup(true); - }); + const { setDriverTransponder } = deps; panel.querySelectorAll("[data-warmup-driver]").forEach((row) => { const driverId = row.dataset.warmupDriver; row.querySelector("[data-warmup-assign]")?.addEventListener("change", (event) => { @@ -124,16 +131,20 @@ function bindWarmupPanel(panel, deps, session) { if (setDriverTransponder(driverId, row.querySelector("[data-warmup-transponder]")?.value)) refreshRaceDayWarmup(true); }); }); - clearInterval(warmupClockTimer); - warmupClockTimer = setInterval(() => { - if (!panel.isConnected) { clearInterval(warmupClockTimer); return; } - const clock = panel.querySelector("[data-warmup-clock]"); - if (!clock) return; - const warmup = deps.getRaceDayWarmupCheck(session).warmup; - const remaining = warmup ? Math.max(0, warmup.startedAt + warmup.durationMs - Date.now()) : 0; - clock.textContent = formatMmSs(remaining); - if (remaining <= 0) refreshRaceDayWarmup(true); - }, 500); +} + +// Called by the prologue timer in app.js (5x a second) - touches only the clock text. +export function refreshRaceDayPrologueClock(remainingMs) { + document.querySelectorAll("[data-prologue-clock]").forEach((node) => { node.textContent = formatMmSs(remainingMs); }); +} + +export function updateRaceDayStartDisplay(label, lights = 0) { + const button = document.getElementById("raceDayStart"); + if (button && label) button.textContent = label; + const panel = document.getElementById("raceDayStartLights"); + if (!panel) return; + panel.querySelector("strong")?.replaceChildren(document.createTextNode(label)); + panel.querySelectorAll("[data-start-light]").forEach((node) => node.classList.toggle("is-on", Number(node.dataset.startLight) <= lights)); } function paintWarmupPanel() { @@ -161,15 +172,19 @@ export function renderRaceDayView(deps) { getSessionTiming, getSessionGridEntries, buildRaceDayQueue, buildRaceDayPreflight, backendAvailable, selectSession, startSession, finishSession, pauseSession, resumeSession, saveLapWindow, navigateTo, getStartLightState, addTrackLayout, renderView, - getRaceDayWarmupCheck, startRaceDayWarmup, stopRaceDayWarmup, setDriverTransponder, + getRaceDayWarmupCheck, setDriverTransponder, getDefaultPrologueSec, + startRacePrologue, holdRacePrologue, resumeRacePrologue, setRacePrologueRemaining, cancelRacePrologue, goRacePrologue, + isStartPending, } = deps; const queue = buildRaceDayQueue(state); const session = queue.current; const preflight = buildRaceDayPreflight(state, session, { backendAvailable }); - const warmupForChecks = showsWarmupPanel(session) ? getRaceDayWarmupCheck(session) : null; + const usesPrologue = showsWarmupPanel(session); + const warmupForChecks = usesPrologue ? getRaceDayWarmupCheck(session) : null; + const prologue = warmupForChecks?.prologue || null; const checks = sortRaceDayChecks([ ...preflight.checks, - ...(warmupForChecks?.warmup ? [{ id: "transponder_check", level: warmupForChecks.missing.length ? "warning" : "ok", seen: warmupForChecks.seenCount, total: warmupForChecks.total }] : []), + ...(prologue ? [{ id: "transponder_check", level: warmupForChecks.missing.length ? "warning" : "ok", seen: warmupForChecks.seenCount, total: warmupForChecks.total }] : []), ]); const blockers = checks.filter((item) => item.level === "error").length; const warnings = checks.filter((item) => item.level === "warning").length; @@ -180,7 +195,7 @@ export function renderRaceDayView(deps) { : timing.untimed ? formatElapsedClock(timing.elapsedMs) : formatCountdown(timing.remainingMs); const grid = session ? getSessionGridEntries(session) : []; const isSelected = Boolean(session && state.activeSessionId === session.id); - const canStart = Boolean(session && preflight.ready && !["running", "paused"].includes(session.status)); + const canStart = Boolean(session && preflight.ready && !["running", "paused"].includes(session.status) && !isStartPending?.(session)); dom.view.innerHTML = `
@@ -190,7 +205,9 @@ export function renderRaceDayView(deps) {

${session ? `${escapeHtml(getEventName(session.eventId))} · ${escapeHtml(getSessionTypeLabel(session.type))} · ${escapeHtml(getStartModeLabel(session.startMode))}` : t("race_day.no_session_hint")}

${renderTrackLayoutBar({ state, t, escapeHtml, showNewButton: !session || isPracticeSessionType(session.type) })} -
${clock}${session ? escapeHtml(getStatusLabel(session.status)) : t("race_day.none")}
+ ${prologue + ? `
${formatMmSs(warmupForChecks.remainingMs)}${t("prologue.to_start")}
` + : `
${clock}${session ? escapeHtml(getStatusLabel(session.status)) : t("race_day.none")}
`}
@@ -203,10 +220,13 @@ export function renderRaceDayView(deps) {
${session && !isSelected ? `` : ""}
- + ${usesPrologue + ? renderPrologueControls({ t, escapeHtml, getDefaultPrologueSec }, session, warmupForChecks, canStart) + : ``} + ${prologue ? "" : ` - + `}
${[1, 2, 3, 4, 5].map((light) => ``).join("")} @@ -294,33 +314,41 @@ export function renderRaceDayView(deps) { saveLapWindow(session, minLapSec, maxLapSec); }); bindTrackLayoutBar(dom.view, { state, t, addTrackLayout, renderView }); - lastWarmupDeps = { t, escapeHtml, renderView, getRaceDayWarmupCheck, startRaceDayWarmup, stopRaceDayWarmup, setDriverTransponder, getSession: () => buildRaceDayQueue(state).current }; + lastWarmupDeps = { t, escapeHtml, renderView, getRaceDayWarmupCheck, setDriverTransponder, getSession: () => buildRaceDayQueue(state).current }; paintWarmupPanel(); document.getElementById("raceDayStart")?.addEventListener("click", async () => { if (!session || !preflight.ready) return; - // Warns (never blocks - a driver may not show up) when the warm-up ran and someone in - // the heat still hasn't crossed the line. - const warmupCheck = showsWarmupPanel(session) ? getRaceDayWarmupCheck(session) : null; - if (warmupCheck?.warmup && warmupCheck.missing.length && !window.confirm(t("warmup.start_missing", { names: warmupCheck.missing.map((row) => row.name).join(", ") }))) return; - stopRaceDayWarmup(); if (!isSelected) selectSession(session.id, { render: false }); + if (usesPrologue) { + startRacePrologue(session, Number(document.getElementById("raceDayPrologueLength")?.value || 0)); + return; + } const button = document.getElementById("raceDayStart"); if (button) button.disabled = true; - const updateStartDisplay = (label, lights = 0) => { - if (button && label) button.textContent = label; - const panel = document.getElementById("raceDayStartLights"); - if (!panel) return; - panel.querySelector("strong")?.replaceChildren(document.createTextNode(label)); - panel.querySelectorAll("[data-start-light]").forEach((node) => node.classList.toggle("is-on", Number(node.dataset.startLight) <= lights)); - }; const result = await startSession(session, { - onCountdown: (seconds) => updateStartDisplay(String(seconds), 0), - onLight: (light) => updateStartDisplay(`LIGHT_${light}`, light), - onArmed: () => updateStartDisplay("WAIT_RANDOM", 5), + onCountdown: (seconds) => updateRaceDayStartDisplay(String(seconds), 0), + onLight: (light) => updateRaceDayStartDisplay(`LIGHT_${light}`, light), + onArmed: () => updateRaceDayStartDisplay("WAIT_RANDOM", 5), }); if (!result.ok && result.message) alert(result.message); if (!result.ok && button) button.disabled = false; }); + dom.view.querySelectorAll("[data-prologue]").forEach((button) => button.addEventListener("click", () => { + const action = button.dataset.prologue; + if (action === "hold") holdRacePrologue(); + if (action === "resume") resumeRacePrologue(); + if (action === "plus") setRacePrologueRemaining(getRaceDayWarmupCheck(session).remainingMs + 60000); + if (action === "to30") setRacePrologueRemaining(30000); + if (action === "to10") setRacePrologueRemaining(10000); + if (action === "cancel") cancelRacePrologue(); + if (action === "go") { + // Warns (never blocks - a driver may not show up) when someone in the heat still + // hasn't crossed the line since the count-down began. + const check = getRaceDayWarmupCheck(session); + if (check.missing.length && !window.confirm(t("warmup.start_missing", { names: check.missing.map((row) => row.name).join(", ") }))) return; + goRacePrologue(); + } + })); document.getElementById("raceDayPause")?.addEventListener("click", () => pauseSession(session)); document.getElementById("raceDayResume")?.addEventListener("click", () => resumeSession(session)); document.getElementById("raceDayFinish")?.addEventListener("click", () => finishSession(session)); diff --git a/src/styles.css b/src/styles.css index 4cdd830..d07f9cc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4448,10 +4448,24 @@ select:focus { color: var(--muted, inherit); } -.warmup-clock { - font-size: 1.6rem; +.prologue-clock { + font: 700 1.6rem/1 Orbitron, sans-serif; font-variant-numeric: tabular-nums; - margin-right: 8px; + align-self: center; + margin-right: 4px; +} + +.prologue-clock.is-held, +.race-day-clock.is-prologue strong { + color: #f5a623; +} + +.prologue-length { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.75rem; + color: var(--muted); } .warmup-table { diff --git a/src/timing_logic.js b/src/timing_logic.js index 0c2e34d..4710901 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -89,7 +89,11 @@ export function getSessionTiming(session, nowTs = Date.now(), { getSessionTarget const gridCount = Array.isArray(session?.manualGridIds) && session.manualGridIds.length ? session.manualGridIds.length : Array.isArray(session?.driverIds) ? session.driverIds.length : 0; - const staggerExtensionMs = session?.startMode === "staggered" && baseTargetMs !== null && gridCount > 1 + // A flying start's last car gets its clock at the latest ~1.5 laps after the horn (EFRA + // 9.3), so the shared clock gets one extra minute to cover that. + const staggerExtensionMs = baseTargetMs === null ? 0 + : session?.startMode === "flying" ? 60000 + : session?.startMode === "staggered" && gridCount > 1 ? (gridCount - 1) * Math.max(0, Number(session?.staggerGapSec || 3) || 3) * 1000 : 0; const targetMs = baseTargetMs === null ? null : baseTargetMs + staggerExtensionMs; @@ -241,7 +245,7 @@ export function buildLeaderboard(session, { ensureSessionResult, getSessionTarge // starts already share one clock, which the session-level countdown already covers. const personalStartTs = Number(row.startTimestamp || 0) || 0; const personalTimeUp = Boolean( - session.startMode === "staggered" && targetMs !== null && personalStartTs + (session.startMode === "staggered" || session.startMode === "flying") && targetMs !== null && personalStartTs && (nowTs - personalStartTs - totalPausedMs) >= targetMs ); return { diff --git a/src/warmup_logic.js b/src/warmup_logic.js deleted file mode 100644 index f2b905e..0000000 --- a/src/warmup_logic.js +++ /dev/null @@ -1,45 +0,0 @@ -// Warm-up / transponder check before a heat: every passing the main decoder sees is -// remembered (transponder -> when), independent of whether a session is running, so the -// Race Day panel can show which drivers in the selected heat have been seen since the -// warm-up started and which unknown transponders crossed the line. -const MAX_SIGHTINGS = 500; - -export function recordTransponderSighting(sightings, transponder, at = Date.now()) { - const key = String(transponder || "").trim(); - if (!key) return sightings; - const previous = sightings.get(key); - sightings.delete(key); // re-insert so Map order stays oldest -> newest - sightings.set(key, { transponder: key, lastSeenAt: at, count: (previous?.count || 0) + 1 }); - while (sightings.size > MAX_SIGHTINGS) sightings.delete(sightings.keys().next().value); - return sightings; -} - -// entrants: drivers in the heat, in start order. drivers: every registered driver, used to -// name the owner of a transponder that crossed the line but doesn't belong to this heat. -export function buildWarmupCheck({ entrants = [], drivers = [], sightings = new Map(), since = 0 }) { - const seenSince = (transponder) => { - const sighting = transponder ? sightings.get(String(transponder).trim()) : null; - return sighting && sighting.lastSeenAt >= since ? sighting : null; - }; - const rows = entrants.map((driver) => { - const sighting = seenSince(driver.transponder); - return { driverId: driver.id, name: driver.name, transponder: String(driver.transponder || ""), seen: Boolean(sighting), lastSeenAt: sighting?.lastSeenAt || null }; - }); - const entrantTransponders = new Set(rows.map((row) => row.transponder).filter(Boolean)); - const unknownTransponders = [...sightings.values()] - .filter((sighting) => sighting.lastSeenAt >= since && !entrantTransponders.has(sighting.transponder)) - .map((sighting) => ({ ...sighting, ownerName: drivers.find((driver) => String(driver.transponder || "") === sighting.transponder)?.name || "" })) - .sort((a, b) => b.lastSeenAt - a.lastSeenAt); - return { - rows, - total: rows.length, - seenCount: rows.filter((row) => row.seen).length, - missing: rows.filter((row) => !row.seen), - unknownTransponders, - }; -} - -export function getWarmupRemainingMs(warmup, now = Date.now()) { - if (!warmup?.startedAt) return 0; - return Math.max(0, warmup.startedAt + warmup.durationMs - now); -} diff --git a/tests/browser_prologue.js b/tests/browser_prologue.js new file mode 100644 index 0000000..154e8de --- /dev/null +++ b/tests/browser_prologue.js @@ -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(); + } +} diff --git a/tests/browser_race_day_start_sequences.js b/tests/browser_race_day_start_sequences.js index 6b12e0c..4cbac8e 100644 --- a/tests/browser_race_day_start_sequences.js +++ b/tests/browser_race_day_start_sequences.js @@ -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`); diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 0da9c08..f6e4ce1 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -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 }); diff --git a/tests/browser_warmup.js b/tests/browser_warmup.js deleted file mode 100644 index 82e5a91..0000000 --- a/tests/browser_warmup.js +++ /dev/null @@ -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(); - } -} diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js index ce6e347..5bfbad1 100644 --- a/tests/decoder_runtime.test.js +++ b/tests/decoder_runtime.test.js @@ -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"); diff --git a/tests/prologue_logic.test.js b/tests/prologue_logic.test.js new file mode 100644 index 0000000..73e27e8 --- /dev/null +++ b/tests/prologue_logic.test.js @@ -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); +}); diff --git a/tests/race_audio.test.js b/tests/race_audio.test.js index e78ba8b..001e860 100644 --- a/tests/race_audio.test.js +++ b/tests/race_audio.test.js @@ -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", diff --git a/tests/run.js b/tests/run.js index d5b469a..682b89f 100644 --- a/tests/run.js +++ b/tests/run.js @@ -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"; diff --git a/tests/warmup_logic.test.js b/tests/warmup_logic.test.js deleted file mode 100644 index 4c5a025..0000000 --- a/tests/warmup_logic.test.js +++ /dev/null @@ -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); -});