Add browser coverage for heat/qualification/final/team_race start sequences

Unlike Open Practice, these session types run the real countdown/light/
audio sequence before a session transitions to "running" - this was
untested beyond Open Practice's instant-start shortcut. Confirms the
Start button shows a real ticking countdown, the right audio events
fire per type (qualifying_start only for qualification), and pause/
resume/finish behave the same as they already do for Open Practice.
All audio effects/voice are silenced via raceAudio.events so the test
depends only on the sequence's own timers, not on real audio/TTS
playback completing in a headless browser with no sound hardware.

Also logs a new roadmap item: indoor clubs that reconfigure the track
layout in the same venue have no way to tell the app a layout change
happened, so PBs/track records/pace baselines silently keep comparing
lap times across incomparable layouts. No design decided yet - open
questions recorded for a future dedicated conversation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-10 13:53:08 +02:00
parent 4c463485e0
commit 90df3378a6
3 changed files with 155 additions and 1 deletions

View File

@@ -26,7 +26,7 @@ Acceptance: real create/edit/save flows persist after reload; background updates
- [x] Cover team creation, membership changes, editing and deletion. - [x] Cover team creation, membership changes, editing and deletion.
- [x] Cover Open Practice to team-race conversion, duration/follow-up edits and persisted lap limits. - [x] Cover Open Practice to team-race conversion, duration/follow-up edits and persisted lap limits.
- [x] Cover Race Day start/pause/resume/finish controls and switching to the next queued heat, with a simulated decoder socket (no real AMMC hardware). - [x] Cover Race Day start/pause/resume/finish controls and switching to the next queued heat, with a simulated decoder socket (no real AMMC hardware).
- [ ] Cover session-type-specific start sequences (heat/qualification/race/team_race countdown and audio) and other transitions beyond Open Practice. - [x] Cover session-type-specific start sequences (heat/qualification/race/team_race countdown and audio) and other transitions beyond Open Practice.
- [x] Verify leaderboard/TV/team sorting heading alignment at 390/900/1600 px and preserved race positions. - [x] Verify leaderboard/TV/team sorting heading alignment at 390/900/1600 px and preserved race positions.
- [x] Verify OBS views have no interactive sorting controls. - [x] Verify OBS views have no interactive sorting controls.
- [x] Cover actual popup launching and compact OBS layout automatically. - [x] Cover actual popup launching and compact OBS layout automatically.
@@ -36,6 +36,8 @@ Verified 2026-09-09: the OBS overlay popup opens via a real click on the admin "
Verified 2026-09-08: Race Day start/pause/resume/finish and next-heat selection pass end to end (`tests/browser_race_day.js`) against a simulated decoder socket, with native-dialog auto-accept added to the browser harness so an unexpected `alert()`/`confirm()` fails the test instead of hanging it. Found and fixed two real bugs in the process: Open Practice sessions on a track-mode (sponsor) event showed a ready/green preflight but were silently blocked from starting by a stricter assignment check; and the Race Day view never refreshed when the decoder connected, disconnected, or errored, so its Start button and preflight checklist could stay stale after a page load. Verified 2026-09-08: Race Day start/pause/resume/finish and next-heat selection pass end to end (`tests/browser_race_day.js`) against a simulated decoder socket, with native-dialog auto-accept added to the browser harness so an unexpected `alert()`/`confirm()` fails the test instead of hanging it. Found and fixed two real bugs in the process: Open Practice sessions on a track-mode (sponsor) event showed a ready/green preflight but were silently blocked from starting by a stricter assignment check; and the Race Day view never refreshed when the decoder connected, disconnected, or errored, so its Start button and preflight checklist could stay stale after a page load.
Verified 2026-09-10 (`tests/browser_race_day_start_sequences.js`): unlike Open Practice, heat/qualification/final/team_race sessions run the real countdown-light-audio sequence in `src/race_audio.js` before a session is allowed to transition to "running" - confirmed a heat session stays `status: "ready"` immediately after clicking Start, that the Start button visibly ticks down through real countdown digits, and that it only becomes `running` once the full sequence (countdown, 5-light step, start delay) completes. For all four types, confirmed the announced-event log shows `drivers_ready` then `start_sequence` (plus `qualifying_start` in between for qualification only, nowhere else), and that pause/resume/finish behave the same as they already do for Open Practice. Every effect/voice was silenced via `raceAudio.events` so the test depends only on the sequence's own timers, not on real audio/TTS playback completing in a headless browser with no sound hardware - kept the test both fast and independent of any audio asset actually loading.
## 2. Connection and save status ## 2. Connection and save status
Acceptance: users can distinguish server contact, pending edits, an active save and a failed save; a health check never falsely confirms a failed write. Acceptance: users can distinguish server contact, pending edits, an active save and a failed save; a health check never falsely confirms a failed write.
@@ -159,6 +161,18 @@ Deferred 2026-09-09 at the user's request - the largest remaining item, on par w
- [ ] Build compact mobile controls for start/stop, track call, flags and next heat. - [ ] Build compact mobile controls for start/stop, track call, flags and next heat.
- [ ] Test concurrent control and accidental repeated commands. - [ ] Test concurrent control and accidental repeated commands.
## 7. Track layout changes and stat continuity
Reported 2026-09-10 by the user: an indoor club running in the same physical building often reconfigures the track layout (different wall/gate placement) while everything else - the venue, the class, the drivers - stays the same. Nothing in the app currently knows a layout change happened, so every lap-time comparison silently keeps treating before-the-change and after-the-change laps as if they were run on the same track: personal bests and the "track record" audio cue (`RACE_AUDIO_EVENT_DEFINITIONS` in `src/race_audio.js`) would announce a "record" that's actually just a different, possibly easier, layout; the training pace baseline and "stable pace" cue would judge consistency against a lap time that no longer means anything; and the Results Archive would let someone compare two sessions across a layout change as if they were comparable, with no indication they aren't.
No design decided yet - open questions before this can be built:
- Scope: is a layout global to the installation, or per class (a club might run different classes on different sub-layouts at once)?
- How a change is recorded: an explicit admin action ("mark a new layout as of now", maybe with a name/date) versus something automatic - automatic detection isn't realistic, lap times alone don't reveal *why* they changed.
- What happens to stats spanning the change: hide cross-layout comparisons entirely, keep them but label which layout each figure belongs to, or reset PBs/records at the boundary while keeping session history intact for the record?
- Whether this becomes a new stored entity (a `trackLayouts` list with an id/name/startedAt) or a simpler incrementing counter on club settings that every PB/record/baseline calculation is scoped by.
Revisit as its own design conversation once the scope question above is answered.
## PDF export character encoding ## PDF export character encoding
Fixed 2026-09-09, reported while exporting an old race from the new Results archive - correctly suspected as a general PDF export bug, not specific to that one feature. `buildSimplePdf` in `server.js` (the shared renderer behind every PDF export: race results, start lists, heat sheets, team reports, and the archive) declared its font with no `/Encoding` at all, so viewers fell back to the standard font's built-in encoding, which has no å/ä/ö glyphs - and separately, the content stream was written as UTF-8, so even a correctly-encoded font would have received the wrong bytes (a multi-byte UTF-8 sequence where a single WinAnsi byte was expected), the classic "åäö" mojibake pattern. Fixed by declaring `/Encoding /WinAnsiEncoding` and writing the content stream through a proper WinAnsi (~CP1252) byte mapper instead of UTF-8 - Swedish å/ä/ö/Å/Ä/Ö map directly to their Latin-1 byte values, with the handful of CP1252 "smart punctuation" code points (curly quotes, en/em dash, €, etc.) mapped explicitly; anything else falls back to `?` rather than corrupting the byte stream. Fixed 2026-09-09, reported while exporting an old race from the new Results archive - correctly suspected as a general PDF export bug, not specific to that one feature. `buildSimplePdf` in `server.js` (the shared renderer behind every PDF export: race results, start lists, heat sheets, team reports, and the archive) declared its font with no `/Encoding` at all, so viewers fell back to the standard font's built-in encoding, which has no å/ä/ö glyphs - and separately, the content stream was written as UTF-8, so even a correctly-encoded font would have received the wrong bytes (a multi-byte UTF-8 sequence where a single WinAnsi byte was expected), the classic "åäö" mojibake pattern. Fixed by declaring `/Encoding /WinAnsiEncoding` and writing the content stream through a proper WinAnsi (~CP1252) byte mapper instead of UTF-8 - Swedish å/ä/ö/Å/Ä/Ö map directly to their Latin-1 byte values, with the handful of CP1252 "smart punctuation" code points (curly quotes, en/em dash, €, etc.) mapped explicitly; anything else falls back to `?` rather than corrupting the byte stream.

View File

@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { startFakeDecoder } from "./browser_fake_decoder.js";
import { RACE_AUDIO_EVENT_DEFINITIONS } from "../src/race_audio.js";
// Practice types skip the countdown/light/audio sequence entirely (see
// startSessionWithAudio in src/app.js) - already covered by browser_race_day.js.
// heat/qualification/final/team_race all run the real sequence in src/race_audio.js's
// runStartSequence, gating the session's transition to "running" until it completes -
// this file exercises that for each of those four types, plus pause/resume/finish.
//
// Every effect/voice is silenced via raceAudio.events so the sequence's own audio
// playback (real <audio> elements, speechSynthesis) never has to actually complete in a
// headless browser with no sound hardware - only the sequence's own timers (countdown,
// light stepping, start delay) gate progress, which this file sets to near-zero except
// for one full-length run (the heat scenario) that keeps a real countdown to prove the
// countdown display itself updates.
const SILENCED_EVENTS = Object.fromEntries(RACE_AUDIO_EVENT_DEFINITIONS.map((definition) => [definition.key, { effect: false, voice: false }]));
export async function runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs = [] }) {
const click = (id) => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
const buttonDisabled = async (id) => await evaluate(`document.getElementById(${JSON.stringify(id)})?.disabled`);
const 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();
Object.assign(current, patch);
const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) });
assert.equal(response.ok, true);
};
const findSession = async (id) => (await savedState()).sessions.find((session) => session.id === id);
const recentAudioEventKeys = async (count) => (await savedState()).audioEventLog.slice(0, count).map((entry) => entry.event);
const decoder = await startFakeDecoder();
const seeded = await savedState();
const classId = "rdts_class";
const driverA = { id: "rdts_driver_a", name: "RDTS Driver A", classId, transponder: "7710001" };
const driverB = { id: "rdts_driver_b", name: "RDTS Driver B", classId, transponder: "7710002" };
const event = {
id: "rdts_event",
name: "RDTS Event",
classId,
raceConfig: {
teams: [
{ id: "rdts_team_a", name: "RDTS Team A", driverIds: [driverA.id], carIds: [] },
{ id: "rdts_team_b", name: "RDTS Team B", driverIds: [driverB.id], carIds: [] },
],
},
};
const sessions = [
{ id: "rdts_heat", eventId: event.id, name: "RDTS Heat", type: "heat", durationMin: 5, status: "ready" },
{ id: "rdts_quali", eventId: event.id, name: "RDTS Qualification", type: "qualification", durationMin: 5, status: "ready" },
{ id: "rdts_final", eventId: event.id, name: "RDTS Final", type: "final", durationMin: 5, status: "ready" },
{ id: "rdts_team_race", eventId: event.id, name: "RDTS Team Race", type: "team_race", durationMin: 5, status: "ready" },
];
try {
await patchState({
classes: [...seeded.classes, { id: classId, name: "RDTS Class" }],
drivers: [...seeded.drivers, driverA, driverB],
events: [...seeded.events, event],
sessions: [...seeded.sessions, ...sessions],
settings: {
...seeded.settings,
autoReconnect: true,
wsUrl: `ws://127.0.0.1:${decoder.port}`,
raceAudio: {
...seeded.settings.raceAudio,
enabled: true,
muted: false,
startLights: true,
lightIntervalMs: 100,
randomStartDelay: false,
minStartDelay: 0,
maxStartDelay: 0,
startCountdownSeconds: 0,
events: SILENCED_EVENTS,
},
},
});
// --- Heat: full-length countdown, proving the countdown display itself ticks down
// (not just that the session eventually starts) for a competitive session type. ---
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 click("raceDayStart");
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"));
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");
await click("raceDayPause");
await waitFor(async () => (await findSession(sessions[0].id)).status === "paused", "heat paused");
await click("raceDayResume");
await waitFor(async () => (await findSession(sessions[0].id)).status === "running", "heat resumed");
await click("raceDayFinish");
await waitFor(async () => (await findSession(sessions[0].id)).status === "finished", "heat finished");
console.log("PASS heat pause/resume/finish transitions work the same as open practice");
// --- Qualification, final, team_race: countdownSeconds back to 0 to keep the run
// fast, but the light-stepping/audio-log sequence still runs (not skipped). ---
await patchState({ settings: { ...(await savedState()).settings, raceAudio: { ...(await savedState()).settings.raceAudio, startCountdownSeconds: 0 } } });
for (const [session, expectedRecentKeys, label] of [
[sessions[1], ["start_sequence", "qualifying_start", "drivers_ready"], "qualification"],
[sessions[2], ["start_sequence", "drivers_ready"], "final"],
[sessions[3], ["start_sequence", "drivers_ready"], "team_race"],
]) {
await patchState({ activeSessionId: session.id });
await navigate("race_day", "raceDayStart");
await waitFor(async () => (await buttonDisabled("raceDayStart")) === false, `${label} preflight ready`);
await click("raceDayStart");
await waitFor(async () => (await findSession(session.id)).status === "running", `${label} running after its sequence`, 10000);
assert.deepEqual(await recentAudioEventKeys(expectedRecentKeys.length), expectedRecentKeys, `${label} sequence announced the right events`);
await click("raceDayPause");
await waitFor(async () => (await findSession(session.id)).status === "paused", `${label} paused`);
await click("raceDayResume");
await waitFor(async () => (await findSession(session.id)).status === "running", `${label} resumed`);
await click("raceDayFinish");
await waitFor(async () => (await findSession(session.id)).status === "finished", `${label} finished`);
console.log(`PASS ${label} runs the real start sequence (not the practice shortcut) and supports pause/resume/finish`);
}
assert.deepEqual(dialogs, [], "no unexpected native dialog during the start-sequence flow");
} finally {
const cleanup = await savedState();
await patchState({ settings: { ...cleanup.settings, autoReconnect: false, raceAudio: seeded.settings.raceAudio } });
await decoder.close();
}
}

View File

@@ -1,6 +1,7 @@
import { runOverlaySmoke } from "./browser_overlays.js"; import { runOverlaySmoke } from "./browser_overlays.js";
import { runRaceSetupSmoke } from "./browser_race_setup.js"; import { runRaceSetupSmoke } from "./browser_race_setup.js";
import { runRaceDaySmoke } from "./browser_race_day.js"; import { runRaceDaySmoke } from "./browser_race_day.js";
import { runRaceDayStartSequencesSmoke } from "./browser_race_day_start_sequences.js";
import { runObsPopupSmoke } from "./browser_obs_popup.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js";
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js"; import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
@@ -134,6 +135,7 @@ try {
} }
await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause }); await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause });
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, 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 }); await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick });
await runDecoderReconnectSmoke({ evaluate, navigate, savedState, waitFor, pause, base }); await runDecoderReconnectSmoke({ evaluate, navigate, savedState, waitFor, pause, base });
await navigate("classes", "classForm"); await navigate("classes", "classForm");