diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eea92e..3b52193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.14.0 - 2026-09-10 + +### Added + +- A staggered-start qualifying heat now announces each driver's car (transponder) number in grid order, about 3 seconds apart, right after the official start - a mass/position-start session is unaffected and still starts everyone together with the usual countdown and start sound. +- The Timing leaderboard and every overlay/leaderboard display now show a green "Done" flag next to a driver's name once their own personal timing window has elapsed in a staggered-start heat, even while the session is still running for other drivers who started their own window later. + ## 1.13.0 - 2026-09-10 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 4ede934..2744d76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -331,6 +331,25 @@ Found a real gap while investigating, since the scenario as described (the OTHER Verified 2026-09-10: new unit tests (`tests/driver_profile_logic.test.js`) cover the conflict/no-conflict/self-edit/empty-transponder cases for `findTransponderConflict` directly. A full headless-browser run against a real, isolated server reproduced the exact reported scenario end to end through the real Drivers UI: created a 4WD driver with a transponder, then created a 2WD driver with the same transponder through the real create form - got the warning dialog naming the correct driver and class, confirmed through it, and confirmed the same warning fires from the driver edit form too when changing an existing driver's transponder into a collision. Also confirmed no false-positive warning when the same person's own second class entry legitimately reuses their own transponder. +## Staggered start announcer, practice countdown cues, and per-driver "done" flag + +Asked 2026-09-10 by the user, three related verification requests about start procedure and per-driver status: + +1. In a staggered-start qualifying heat, the app should announce each car's number in start order (~3s apart); a final should always start everyone together with the usual countdown and start sound. +2. Free practice should announce "2 min left", "1 min left", and at the end "session ended, finish the lap you're on". +3. In a staggered-start qualifying heat (individual ~5-minute windows per driver), the overlay/leaderboard should mark a driver "done" (green status or finish flag) once their own window has elapsed, even while the session is still running for others. + +Investigated first, confirmed already correct, no changes needed: +- Finals already always run the shared countdown → lights → start-tone sequence for everyone (`runStartSequence` in `src/race_audio.js` never varied by start mode before this work) - point 1's second half. +- Free practice already gets "One minute remaining" / "Thirty seconds remaining" / "Ten seconds remaining" cues, and at time-up the voice line is literally "Time has expired. Finish the lap you are on." (`time_expired` in `rc-race-sound-pack/sound_manifest.json`) - already exactly what was asked for in point 2, except there is no "2 minutes remaining" cue (only 1 min/30s/10s exist). Flagged back to the user; not added since it wasn't confirmed as wanted over the existing ladder. + +Found two genuine gaps and, after confirming the desired behavior with the user, built both: + +- [x] **Staggered release announcer** (point 1's first half): `runStartSequence` (`src/race_audio.js`) now, immediately after the official GO/start-tone (only when `session.startMode === "staggered"`), calls out each car's transponder number in grid order (`getSessionGridOrder`, resolved to transponders in `startSessionWithAudio` in `src/app.js` since the audio engine itself has no access to `state.drivers`), waiting a real 3 seconds between each. A new `staggered_release` race-audio event was added (`"Car {tp}."` / `"Bil {tp}."` in the sound manifest) alongside the existing catalog. The session is already live and recording throughout - each driver's own personal timing clock already starts at their own first crossing regardless (pre-existing behavior) - this is purely the announcer telling marshals who goes next. +- [x] **Per-driver "done" indicator** (point 3): `buildLeaderboard` (`src/timing_logic.js`) now computes `personalTimeUp` per row - true once `now - row.startTimestamp - totalPausedMs >= targetMs` for a staggered-mode session with a real personal start reference (paused time excluded the same way the shared clock already excludes it). Rendered as a green "🏁 Done" pill next to the driver's name in the Timing leaderboard (`src/timing_views.js`), the standalone leaderboard/results/TV/speaker overlay (`renderOverlayLeaderboard` in `src/overlays.js`), and a plain 🏁 marker in both compact OBS layouts (lower-third and tower). Never flagged for mass/position starts, which already share one clock shown at the session level. + +Verified 2026-09-10: new unit tests cover both - `tests/race_audio.test.js` (the staggered release sequence fires in grid order with real ~3s gaps only when `startMode === "staggered"`, and never for a mass start even when a grid order is passed) and `tests/race_timing_logic.test.js` (`personalTimeUp` true/false cases, paused-time exclusion, never flagged outside staggered mode). A full headless-browser run against a real, isolated server reproduced the exact scenario end to end: started a real 3-driver staggered heat through the real Race Day "Start" button and confirmed all 3 transponders were announced in the session's actual grid order (not driver-creation order) with the real ~3-second gaps elapsing (not fired instantly), confirmed a mass-start final's sequence never calls out individual cars, then seeded one driver 6 minutes into their own 5-minute staggered window and another only 2 minutes in and confirmed the green "Done" pill appeared next to the first driver's name (and only that driver's) in both the real Timing leaderboard and the real standalone overlay page. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 91a7f37..a59d3bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.13.0", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.13.0", + "version": "1.14.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 8fb28a2..801fcdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.13.0", + "version": "1.14.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/rc-race-sound-pack/sound_manifest.json b/rc-race-sound-pack/sound_manifest.json index 2dc4dee..0478287 100644 --- a/rc-race-sound-pack/sound_manifest.json +++ b/rc-race-sound-pack/sound_manifest.json @@ -46,6 +46,7 @@ "grid_open": "The starting grid is open.", "grid_closed": "The starting grid is closed.", "race_1_minute": "Race starts in one minute.", "race_30_seconds": "Thirty seconds to start.", "race_10_seconds": "Ten seconds to start.", "drivers_ready": "Drivers, stand by.", "start_sequence": "Starting on the sound of the tone.", "false_start": "False start.", "restart_race": "Prepare to restart the race.", + "staggered_release": "Car {tp}.", "personal_best": "Personal best. {driverName}. {lapTime}.", "fastest_lap": "Fastest lap. {driverName}. {lapTime}.", "training_stable_pace": "{driverName}, good pace and consistent driving.", "track_record": "New track record. {driverName}. {lapTime}.", "new_leader": "New race leader. {driverName}.", @@ -67,6 +68,7 @@ "grid_open": "Startfältet är öppet.", "grid_closed": "Startfältet är stängt.", "race_1_minute": "Start om en minut.", "race_30_seconds": "Trettio sekunder till start.", "race_10_seconds": "Tio sekunder till start.", "drivers_ready": "Förare, gör er redo.", "start_sequence": "Start på signalen.", "false_start": "Felaktig start.", "restart_race": "Förbered omstart.", + "staggered_release": "Bil {tp}.", "personal_best": "Personbästa. {driverName}. {lapTime}.", "fastest_lap": "Snabbaste varv. {driverName}. {lapTime}.", "training_stable_pace": "{driverName}, bra tempo och stabil körning.", "track_record": "Nytt banrekord. {driverName}. {lapTime}.", "new_leader": "Ny ledare. {driverName}.", diff --git a/src/app.js b/src/app.js index dfd7d25..66bea52 100644 --- a/src/app.js +++ b/src/app.js @@ -199,7 +199,10 @@ const handleSessionTimerTick = () => handleSessionTimerTickHelper({ getActiveSes const cancelRaceStart = () => raceAudioEngine.cancelStartSequence(); const startSessionWithAudio = (session, callbacks) => { const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || "")); - return raceAudioEngine.runStartSequence(session, { ...callbacks, skipAudio: practice }); + const staggerOrder = getSessionGridOrder(session) + .map((driverId) => state.drivers.find((driver) => driver.id === driverId)?.transponder) + .filter(Boolean); + return raceAudioEngine.runStartSequence(session, { ...callbacks, skipAudio: practice, staggerOrder }); }; const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, refreshTimingDynamics, updateHeaderState }); const buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog, renderSessionChannelAnalysis }); @@ -1252,6 +1255,7 @@ const TRANSLATIONS = { "timing.pit_entry_decoder": "Depå in", "timing.pit_exit_decoder": "Depå ut", "timing.pit_active": "I DEPÅ", + "timing.personal_time_up": "Klar", "timing.channel_analysis": "Mellantider och depåstopp", "timing.channel_analysis_hint": "Mellantider räknar inte varv. Depåtiden mäts från Depå in till Depå ut och kopplas till föraren och bilen vid infarten.", "timing.channel_analysis_race_hint": "Sammanställning och full depålogg per heat. Använd detta exempelvis för att granska alla stopp i ett 45-minutersrace.", @@ -2768,6 +2772,7 @@ const TRANSLATIONS = { "timing.pit_entry_decoder": "Pit entry", "timing.pit_exit_decoder": "Pit exit", "timing.pit_active": "IN PIT", + "timing.personal_time_up": "Done", "timing.channel_analysis": "Split timing and pit stops", "timing.channel_analysis_hint": "Split timing does not count laps. Pit time is measured from Pit entry to Pit exit and attributed to the driver and car at entry.", "timing.channel_analysis_race_hint": "Summary and complete pit log per heat. Use it, for example, to review every stop in a 45-minute race.", diff --git a/src/overlays.js b/src/overlays.js index 82a45cf..4f4db13 100644 --- a/src/overlays.js +++ b/src/overlays.js @@ -76,7 +76,7 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive ${idx + 1}
- ${escapeHtml(row.displayName || row.driverName)} + ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? ` 🏁 ${t("timing.personal_time_up")}` : ""} ${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")} ${row.lastSplitMs || row.pitActive || row.pitStops ? `${row.lastSplitMs ? `${t("table.split")}: ${formatLap(row.lastSplitMs)}` : ""}${row.lastSplitMs && (row.pitActive || row.pitStops) ? " • " : ""}${row.pitActive ? t("timing.pit_active") : row.pitStops ? `${t("table.pit")}: ${row.pitStops} / ${formatLap(row.totalPitMs)}` : ""}` : ""} ${row.invalidPending ? `${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}` : ""} @@ -261,7 +261,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra return `
${idx + 1} - ${escapeHtml(row.displayName || row.driverName)} + ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? " 🏁" : ""} ${obsConfig.showLaps ? `${t("table.laps")}: ${row.laps ?? 0}` : ""} ${obsConfig.showResult ? `${t("table.result")}: ${escapeHtml(row.resultDisplay)}` : ""} ${obsConfig.showGap ? `${t("table.gap")}: ${escapeHtml(row.gapDisplay || row.gapAhead || "-")}` : ""} @@ -304,7 +304,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
${idx + 1}
- ${escapeHtml(row.displayName || row.driverName)} + ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? " 🏁" : ""} ${escapeHtml(row.teamId ? formatTeamActiveMemberLabel(row) : row.subLabel || row.transponder || "-")}
${trail ? `
${trail}
` : ""} diff --git a/src/race_audio.js b/src/race_audio.js index 7d2afad..110b9ee 100644 --- a/src/race_audio.js +++ b/src/race_audio.js @@ -9,6 +9,7 @@ export const RACE_AUDIO_EVENT_DEFINITIONS = Object.freeze([ event("drivers_ready", "start", "high"), event("start_sequence", "start", "high"), 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("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), @@ -277,6 +278,18 @@ export function createRaceAudioEngine(options = {}) { await playEffect("start_tone", { force: true }); if (cancelled()) return false; callbacks.onTone?.(); + // Staggered heats don't release every car at once - the session is already live and + // recording by this point (each driver's own personal clock starts at their own first + // crossing regardless), this is purely the announcer calling cars out in grid order so + // marshals know who goes next, ~3s apart. + if (session?.startMode === "staggered" && Array.isArray(callbacks.staggerOrder) && callbacks.staggerOrder.length) { + for (let index = 0; index < callbacks.staggerOrder.length; index += 1) { + if (cancelled()) return false; + await trigger("staggered_release", { variables: { tp: callbacks.staggerOrder[index] }, scope: callbacks.staggerOrder[index], logData: { sessionId: session?.id, transponder: callbacks.staggerOrder[index] } }).voiceDone; + if (cancelled()) return false; + if (index < callbacks.staggerOrder.length - 1) { await wait(3000); if (cancelled()) return false; } + } + } return true; } diff --git a/src/timing_logic.js b/src/timing_logic.js index d864b38..bcd6c9b 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -221,8 +221,19 @@ export function buildLeaderboard(session, { ensureSessionResult, getSessionTarge ? "warn" : "late"; const invalidPending = latestAnyPassing?.validLap === false; + // In staggered mode, each competitor's own window runs from their own first crossing + // (row.startTimestamp), independent of the session's shared clock - so one driver can + // be done for the day while the session itself is still "running" for others who + // started their own window later. Only meaningful in staggered mode: mass/position + // 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 + && (nowTs - personalStartTs - totalPausedMs) >= targetMs + ); return { ...row, + personalTimeUp, laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment), lastLapMs, bestLapMs, diff --git a/src/timing_views.js b/src/timing_views.js index f382afa..ec622cc 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -291,7 +291,7 @@ function renderLeaderboard(rows, deps) { ${idx + 1} -
${escapeHtml(row.displayName || row.driverName)}
+
${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? ` 🏁 ${t("timing.personal_time_up")}` : ""}
${row.teamId ? `
${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
` : ""} ${getManualCorrectionSummary(row) ? `
${escapeHtml(getManualCorrectionSummary(row))}
` : ""} ${row.invalidPending ? `
${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}
` : ""} diff --git a/tests/race_audio.test.js b/tests/race_audio.test.js index 96840e7..5cd3277 100644 --- a/tests/race_audio.test.js +++ b/tests/race_audio.test.js @@ -66,6 +66,43 @@ test("start lights, random delay and tone precede the official start callback", assert.deepEqual(calls.slice(-3), [["wait", 2500], ["effect", "/race-sounds/sounds/effects/start_beep.wav"], ["timing-start"]]); }); +test("staggered start mode calls out each car in grid order, 3s apart, after the official start", async () => { + const calls = []; + const engine = createRaceAudioEngine({ + fetchImpl: async () => ({ ok: true, json: async () => ({ ...manifest, voice_events: { sv: { ...manifest.voice_events.sv, staggered_release: "Bil {tp}." } } }) }), + audioFactory: (src) => ({ play: () => { calls.push(["effect", src]); 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: 0, maxStartDelay: 0 }), + }); + await engine.runStartSequence( + { id: "q1h1", type: "qualification", startMode: "staggered" }, + { onTone: () => calls.push(["timing-start"]), staggerOrder: ["111", "222", "333"] } + ); + const afterStart = calls.slice(calls.findIndex((call) => call[0] === "timing-start") + 1); + assert.deepEqual(afterStart, [ + ["voice", "Bil 111."], ["wait", 3000], + ["voice", "Bil 222."], ["wait", 3000], + ["voice", "Bil 333."], + ]); +}); + +test("a mass-start session never calls out individual cars, even if a grid order is passed", async () => { + const calls = []; + const engine = createRaceAudioEngine({ + fetchImpl: async () => ({ ok: true, json: async () => manifest }), + audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }), + speak: async (text) => { calls.push(text); return true; }, + wait: async () => {}, + getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, startLights: false, minStartDelay: 0, maxStartDelay: 0 }), + }); + await engine.runStartSequence( + { id: "final", type: "final", startMode: "mass" }, + { staggerOrder: ["111", "222"] } + ); + assert.ok(!calls.some((text) => text.startsWith("Bil") || text.startsWith("Car")), "no per-car call-out for a mass start: " + JSON.stringify(calls)); +}); + test("cancelling a pending start prevents the official start callback", async () => { let releaseDelay; let started = false; @@ -87,7 +124,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", + "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", "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/race_timing_logic.test.js b/tests/race_timing_logic.test.js index e1eb3e9..b0632e6 100644 --- a/tests/race_timing_logic.test.js +++ b/tests/race_timing_logic.test.js @@ -546,6 +546,70 @@ test("buildLeaderboard excludes paused wall-clock time from a competitor's elaps assert.equal(rows[0].comparisonMs, 15000); }); +// Regression coverage for the user's report: in a staggered-start qualifying heat, each +// driver's own personal 5-minute window runs from their own first crossing, independent of +// the shared session clock - so one driver can be "done" while the session is still +// running for others. Nothing surfaced that distinction anywhere in the UI before this. +test("buildLeaderboard flags a staggered-mode competitor whose own personal window has elapsed, and only in staggered mode", () => { + const buildRows = (session, competitors) => buildLeaderboard(session, { + ensureSessionResult: () => ({ competitors }), + getSessionTargetMs: (s) => getSessionTargetMs(s, { isUntimedSession }), + getCompetitorPassings: () => [], + isCountedPassing: () => true, + getCompetitorElapsedMs, + getCompetitorSeedMetric: () => null, + getSessionLapWindow: () => ({ minLapMs: 0, maxLapMs: Infinity }), + getPassingValidationLabel: () => "", + formatSeedMetric: () => "", + formatRaceClock: (ms) => `${ms}`, + formatLeaderboardGap: () => "", + formatLapDelta: () => "", + formatLap: (ms) => `${ms}`, + t: (key) => key, + }); + + const now = Date.now(); + const competitors = { + driver_done: { key: "driver_done", driverName: "Done", startTimestamp: now - 6 * 60 * 1000, laps: 10, lastLapMs: 17000, lastTimestamp: now - 60000, bestLapMs: 16500, manualLapAdjustment: 0, manualTimeAdjustmentMs: 0 }, + driver_still_going: { key: "driver_still_going", driverName: "Still going", startTimestamp: now - 2 * 60 * 1000, laps: 4, lastLapMs: 17000, lastTimestamp: now - 5000, bestLapMs: 16800, manualLapAdjustment: 0, manualTimeAdjustmentMs: 0 }, + }; + + const staggered = buildRows({ id: "q1h1", type: "qualification", startMode: "staggered", durationMin: 5, totalPausedMs: 0 }, competitors); + assert.equal(staggered.find((row) => row.key === "driver_done").personalTimeUp, true, "6 minutes into their own 5-minute window - done"); + assert.equal(staggered.find((row) => row.key === "driver_still_going").personalTimeUp, false, "only 2 minutes into their own window - still going"); + + const mass = buildRows({ id: "final", type: "final", startMode: "mass", durationMin: 5, totalPausedMs: 0 }, competitors); + assert.ok(mass.every((row) => row.personalTimeUp === false), "mass/position starts share one clock already shown at the session level - never flagged per-row"); +}); + +test("buildLeaderboard's personalTimeUp excludes paused time from a staggered competitor's own window too", () => { + const now = Date.now(); + const competitors = { + driver_1: { key: "driver_1", driverName: "One", startTimestamp: now - 5 * 60 * 1000 - 30000, laps: 10, lastLapMs: 17000, lastTimestamp: now - 60000, bestLapMs: 16500, manualLapAdjustment: 0, manualTimeAdjustmentMs: 0 }, + }; + const deps = { + ensureSessionResult: () => ({ competitors }), + getSessionTargetMs: (s) => getSessionTargetMs(s, { isUntimedSession }), + getCompetitorPassings: () => [], + isCountedPassing: () => true, + getCompetitorElapsedMs, + getCompetitorSeedMetric: () => null, + getSessionLapWindow: () => ({ minLapMs: 0, maxLapMs: Infinity }), + getPassingValidationLabel: () => "", + formatSeedMetric: () => "", + formatRaceClock: (ms) => `${ms}`, + formatLeaderboardGap: () => "", + formatLapDelta: () => "", + formatLap: (ms) => `${ms}`, + t: (key) => key, + }; + // 5:30 has passed since this driver's own first crossing, but a 1-minute track call is + // excluded from their personal window the same way it already is from the shared clock - + // net real driving time is only 4:30 of their 5:00, so they are not done yet. + const paused = buildLeaderboard({ id: "q1h1", type: "qualification", startMode: "staggered", durationMin: 5, totalPausedMs: 60000 }, deps); + assert.equal(paused[0].personalTimeUp, false); +}); + test("race lap window and seed metrics enforce timing rules", () => { const state = { events: [