diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b278e..90c2663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ The application version is maintained in `package.json` and displayed in the adm ### Added - Personal mobile profiles show last successful data retrieval, stale-data and failed-update warnings, and a Retry button without replacing loaded statistics or active forms. Requests time out after 15 seconds and cannot overlap. +- Browser coverage for Race Day start/pause/resume/finish and switching to the next queued heat, against a simulated decoder socket. + +### Fixed + +- Open Practice sessions on a sponsor (track-mode) event could show a ready/green Race Day preflight yet refuse to start, blocked by a stricter assignment check the preflight never warned about. Open Practice is now exempt from that check everywhere, matching the preflight. +- The Race Day view did not refresh when the decoder connected, disconnected, or errored, so its Start button and preflight checklist could stay stale after a page load until something else triggered a re-render. ## 1.1.2 - 2026-09-08 diff --git a/ROADMAP.md b/ROADMAP.md index fa6c215..8989353 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,12 +23,15 @@ Acceptance: real create/edit/save flows persist after reload; background updates - [x] Cover sponsor-event creation/editing and endurance race wizard/name edits with reload verification. - [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. -- [ ] Cover other session-type transitions and start/finish controls. +- [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] 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. - [ ] Cover actual popup launching and compact OBS layout automatically. - [ ] Add Windows and Linux CI runs for supported runtimes. +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. + ## 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. @@ -95,3 +98,11 @@ Acceptance: a driver receives a short, encouraging summary based on their own cl - [ ] Update the architecture's module/dependency inventory as modules change. - [ ] Complete state/settings/SQLite contracts and critical form-to-controller mappings. - [ ] Consolidate older future-work notes so this file remains the primary roadmap. + + +# Träningsstat +lägga till mera skoj utvecklande tränings format +- [ ] +2. "Pyramiden" (För extrem mental uthållighet)I racing tappar man ofta koncentrationen i mitten av ett lopp. Detta upplägg tvingar dig att hålla fokus när hjärnan börjar bli trött.Fas 1: 5 varv på 70% fart (Hitta linjerna och värm upp)Fas 2: 5 varv på 90% fart (Skruva upp tempot)Fas 3: 5 varv på 100% fart (Maxattack)Fas 4: 5 varv på 90% fart (Håll farten trots trötthet)Fas 5: 5 varv på 70% fart (Nedvarvning och analys av bilen) +- [ ] +3. "Konsistens-jakten" (F1-förarnas favorit)F1-team bryr sig ofta mer om konstanta varvtider än ett enda supersnabbt varv.Bestäm dig för en måltid (t.ex. 85% av din maxfart).Kör 15 varv på rad där målet är att alla varv ska ligga inom (konfigurerbart) 0,2 sekunder från varandra.Om du kör ett varv som är för snabbt eller för långsamt, "stryks" serien och du får börja om. Detta bygger ett otroligt muskelminne för bromspunkter och kurvhastighet. \ No newline at end of file diff --git a/src/app.js b/src/app.js index 822c26d..fcd6759 100644 --- a/src/app.js +++ b/src/app.js @@ -5391,6 +5391,11 @@ function shuffle(items) { } function validateTrackSessionForStart(session) { + // Open Practice never requires pre-assignments here or in the Race Day preflight + // (src/race_day_logic.js): anonymous transponders register as drivers on passing. + if (session.type === "open_practice") { + return { ok: true, message: "" }; + } const assignments = session.assignments || []; if (!assignments.length) { return { ok: false, message: t("validation.no_assignments") }; diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index 12bfd39..cf46079 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -184,7 +184,7 @@ function connectDecoderRole(role, deps) { state.decoder[lastErrorKey] = ""; saveState(); updateConnectionBadge(); - if (["timing", "overlay"].includes(getCurrentView())) { + if (["timing", "overlay", "race_day"].includes(getCurrentView())) { renderView(); } }; @@ -228,7 +228,7 @@ function connectDecoderRole(role, deps) { }, 2000)); } - if (["timing", "overlay"].includes(getCurrentView())) { + if (["timing", "overlay", "race_day"].includes(getCurrentView())) { renderView(); } }; @@ -238,7 +238,7 @@ function connectDecoderRole(role, deps) { state.decoder[lastErrorKey] = t("error.decoder_connection"); saveState(); updateConnectionBadge(); - if (["timing", "overlay"].includes(getCurrentView())) { + if (["timing", "overlay", "race_day"].includes(getCurrentView())) { renderView(); } }; diff --git a/tests/browser_race_day.js b/tests/browser_race_day.js new file mode 100644 index 0000000..ebaa3da --- /dev/null +++ b/tests/browser_race_day.js @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { createServer as createNetServer } from "node:net"; + +async function freePort() { + const server = createNetServer(); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} + +// Accepts the WebSocket upgrade handshake so the frontend's real decoder-connect path +// (src/decoder_runtime.js) reaches `connected: true` without a physical AMMC decoder. +// No frames are sent or parsed: this scenario only needs an established socket, not lap data. +async function startFakeDecoder() { + const port = await freePort(); + const server = createServer(); + // The upgraded sockets are intentionally left open (no decoder frames needed for this + // scenario), so server.close() alone would wait forever for them to end; track and + // destroy them explicitly instead. closeAllConnections() does not reach upgraded sockets. + const sockets = new Set(); + server.on("upgrade", (req, socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + const key = req.headers["sec-websocket-key"]; + if (!key) { socket.destroy(); return; } + const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64"); + socket.write(["HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${accept}`, "", ""].join("\r\n")); + }); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", resolve); }); + return { + port, + close: () => new Promise(resolve => { + for (const socket of sockets) socket.destroy(); + server.close(() => resolve()); + }), + }; +} + +export async function runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs = [] }) { + const click = id => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`); + const submit = id => evaluate(`document.getElementById(${JSON.stringify(id)}).requestSubmit()`); + const findEvent = async name => (await savedState()).events.find(event => event.name === name); + const findSession = async name => (await savedState()).sessions.find(session => session.name === name); + 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 buttonDisabled = async id => await evaluate(`document.getElementById(${JSON.stringify(id)})?.disabled`); + + const decoder = await startFakeDecoder(); + try { + // Reliability tests must not depend on real AMMC hardware; a socket that only completes + // the WebSocket handshake is enough to satisfy Race Day's decoder-connected preflight check. + const seeded = await savedState(); + await patchState({ settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}` } }); + + await navigate("events", "eventForm"); + // Decoder connection state is deliberately excluded from buildPersistableState() in + // src/app.js (it's per-tab, not shared/persisted), so check the live connection badge + // in the page instead of polling backend state. + await waitFor(async () => await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "fake decoder connected"); + await fill("eventForm", { name: "Race Day Event", date: "2026-09-08", classId: "test_class" }); + await submit("eventForm"); + await waitFor(async () => Boolean(await findEvent("Race Day Event")), "create race day event"); + const event = await findEvent("Race Day Event"); + await click("event-manage-" + event.id); + + await fill("sessionForm", { name: "Race Day Heat 1", type: "open_practice", durationMin: "5" }); + await submit("sessionForm"); + await waitFor(async () => Boolean(await findSession("Race Day Heat 1")), "create heat 1"); + await fill("sessionForm", { name: "Race Day Heat 2", type: "open_practice", durationMin: "5" }); + await submit("sessionForm"); + await waitFor(async () => Boolean(await findSession("Race Day Heat 2")), "create heat 2"); + const heat1 = await findSession("Race Day Heat 1"); + const heat2 = await findSession("Race Day Heat 2"); + // Pin the active session explicitly: other scenarios may have left earlier "ready" sessions + // that would otherwise win the Race Day queue's global fallback lookup. + await patchState({ activeSessionId: heat1.id }); + + await navigate("race_day", "raceDayStart"); + await waitFor(async () => (await buttonDisabled("raceDayStart")) === false, "start enabled once preflight is ready"); + assert.equal(await buttonDisabled("raceDayPause"), true); + assert.equal(await buttonDisabled("raceDayFinish"), true); + + await click("raceDayStart"); + await waitFor(async () => (await findSession("Race Day Heat 1")).status === "running", "heat 1 running"); + const started = await findSession("Race Day Heat 1"); + assert.ok(started.startedAt, "startedAt recorded"); + // normalizeSession() in src/app.js stores "not paused" as 0, not null (Number(null) === 0 + // passes its Number.isFinite check) - every real usage treats pausedAt as falsy, so match that. + assert.ok(!started.pausedAt, "not paused after start"); + await waitFor(async () => (await buttonDisabled("raceDayPause")) === false, "pause enabled after start"); + assert.equal(await buttonDisabled("raceDayFinish"), false); + assert.equal(await buttonDisabled("raceDayResume"), true); + + await click("raceDayPause"); + await waitFor(async () => (await findSession("Race Day Heat 1")).status === "paused", "heat 1 paused"); + assert.ok((await findSession("Race Day Heat 1")).pausedAt, "pausedAt recorded"); + await waitFor(async () => (await buttonDisabled("raceDayResume")) === false, "resume enabled after pause"); + assert.equal(await buttonDisabled("raceDayPause"), true); + await pause(1200); + + await click("raceDayResume"); + await waitFor(async () => (await findSession("Race Day Heat 1")).status === "running", "heat 1 resumed"); + const resumed = await findSession("Race Day Heat 1"); + assert.ok(!resumed.pausedAt, "not paused after resume"); + assert.equal(resumed.startedAt, started.startedAt, "resume does not reset the original start time"); + assert.ok(resumed.totalPausedMs >= 1000, "paused duration accumulated in totalPausedMs"); + await waitFor(async () => (await buttonDisabled("raceDayPause")) === false, "pause enabled after resume"); + + await click("raceDayFinish"); + await waitFor(async () => (await findSession("Race Day Heat 1")).status === "finished", "heat 1 finished"); + const finished = await findSession("Race Day Heat 1"); + assert.ok(finished.endedAt, "endedAt recorded"); + assert.ok(!finished.pausedAt, "not paused after finish"); + console.log("PASS race day heat 1 start/pause/resume/finish preserves session timing fields"); + + await waitFor(async () => await evaluate(`Boolean(document.querySelector('[data-race-day-select="${heat2.id}"]'))`), "heat 2 listed as next in queue"); + await evaluate(`document.querySelector('[data-race-day-select="${heat2.id}"]').click()`); + await waitFor(async () => (await savedState()).activeSessionId === heat2.id, "heat 2 selected as active session"); + await waitFor(async () => (await evaluate('document.querySelector(".race-day-hero h2")?.textContent')) === "Race Day Heat 2", "heat 2 shown as current"); + assert.equal(await buttonDisabled("raceDayStart"), false, "next heat is startable"); + + await click("raceDayStart"); + await waitFor(async () => (await findSession("Race Day Heat 2")).status === "running", "heat 2 running"); + await click("raceDayFinish"); + await waitFor(async () => (await findSession("Race Day Heat 2")).status === "finished", "heat 2 finished"); + + const heat1After = await findSession("Race Day Heat 1"); + assert.equal(heat1After.status, "finished"); + assert.equal(heat1After.startedAt, started.startedAt, "heat 1 timing untouched by heat 2's lifecycle"); + assert.ok(heat1After.totalPausedMs >= 1000); + console.log("PASS switching to next heat keeps the previous heat's recorded timing intact"); + + // A native alert()/confirm() anywhere in this flow (e.g. a start validation surprise) + // would otherwise pass silently: it freezes the page rather than failing an assertion. + assert.deepEqual(dialogs, [], "no unexpected native dialog during the race day flow"); + } finally { + // Leave autoReconnect off again so later scenarios in the shared harness don't retry + // against a decoder socket that no longer exists. + const cleanup = await savedState(); + await patchState({ settings: { ...cleanup.settings, autoReconnect: false } }); + await decoder.close(); + } +} diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 697569f..6d07fd9 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -1,5 +1,6 @@ import { runOverlaySmoke } from "./browser_overlays.js"; import { runRaceSetupSmoke } from "./browser_race_setup.js"; +import { runRaceDaySmoke } from "./browser_race_day.js"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; @@ -42,7 +43,7 @@ try { const base = `http://127.0.0.1:${port}`; launch(process.execPath, ["server.js"], { env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: join(temporary, "data"), RC_TIMING_PID_FILE: "" } }); await waitFor(async () => (await fetch(base + "/api/health")).ok, "isolated server"); - const initial = { classes: [{ id: "test_class", name: "Test Class" }], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {}, settings: { language: "en", autoReconnect: false, backendUrl: base }, training: {} }; + const initial = { classes: [{ id: "test_class", name: "Test Class" }], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {}, settings: { language: "en", autoReconnect: false, backendUrl: base, raceAudio: { enabled: false } }, training: {} }; const seedResponse = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial) }); assert.equal(seedResponse.ok, true); launch(chrome, ["--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", ...(process.env.RC_BROWSER_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), `--user-data-dir=${join(temporary, "browser")}`, `--remote-debugging-port=${debugPort}`, "about:blank"]); @@ -53,6 +54,7 @@ try { let nextId = 0; const requests = new Map(); const errors = []; + const dialogs = []; socket.addEventListener("message", event => { const message = JSON.parse(event.data); if (message.id) { @@ -60,6 +62,13 @@ try { requests.delete(message.id); if (message.error) request?.reject(new Error(message.error.message)); else request?.resolve(message.result); } else if (message.method === "Runtime.exceptionThrown") errors.push(message.params.exceptionDetails.text + ": " + (message.params.exceptionDetails.exception?.description || "")); + // A native confirm()/alert() blocks the page's JS thread until dismissed. Nothing in a + // headless run can click it, so every scenario would hang forever on the first one; + // auto-accept and record it so an unexpected dialog still surfaces as a finding. + else if (message.method === "Page.javascriptDialogOpening") { + dialogs.push({ type: message.params.type, message: message.params.message }); + send("Page.handleJavaScriptDialog", { accept: true }); + } }); function send(method, params = {}) { return new Promise((resolve, reject) => { const id = ++nextId; requests.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); }); @@ -108,6 +117,7 @@ try { console.log("PASS create/edit/reload and draft retention: " + collection); } await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause }); + await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs }); await navigate("classes", "classForm"); await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); await fill("classForm", { name: "Offline Draft" }); @@ -150,6 +160,7 @@ try { await waitFor(async () => await evaluate('document.getElementById("publicTrainingConnection")?.dataset.state === "current"'), "mobile retry recovered"); console.log("PASS mobile offline status preserves profile nodes and retry recovers"); assert.deepEqual(errors, []); + if (dialogs.length) console.log("NOTE auto-accepted native dialog(s):", JSON.stringify(dialogs)); console.log("PASS offline save failure, local draft retention, retry and confirmed saved status"); } finally { socket?.close();