import { decoderDiagnostics } from "../src/decoder_diagnostics.js"; import test from "node:test"; import assert from "node:assert/strict"; import { connectDecoderHelper, processDecoderMessageHelper } from "../src/decoder_runtime.js"; import { ensureSessionResult } from "../src/timing_logic.js"; function createHarness(options = {}) { const session = { id: "session_1", eventId: "event_1", mode: "race", type: options.sessionType || "team_race", status: "running", startMode: options.startMode || "mass", startedAt: 1_000, }; const state = { events: [{ id: "event_1", mode: "race", teams: [{ id: "team_1", name: "Team One", driverIds: ["driver_1", "driver_2"], carIds: [] }], }], drivers: [ { id: "driver_1", name: "Driver One", transponder: "123" }, { id: "driver_2", name: "Driver Two", transponder: "456" }, ], cars: [], resultsBySession: {}, }; let saveCount = 0; const overlayEvents = []; const audioEvents = []; const deps = { state, t: (key) => key, getActiveSession: () => session, ensureSessionResult: (sessionId) => ensureSessionResult(sessionId, { state }), normalizeStartMode: () => options.startMode || "mass", getSessionLapWindow: () => ({ minLapMs: Number(options.minLapMs || 0), maxLapMs: options.maxLapMs == null ? Number.POSITIVE_INFINITY : Number(options.maxLapMs), }), findEventTeamForPassing: (event, driverId) => event.teams.find((team) => team.driverIds.includes(driverId)) || null, persistPassingToBackend: () => {}, pushOverlayEvent: (type, label) => overlayEvents.push({ type, label }), buildLeaderboard: () => [], formatLap: (ms) => `${ms}ms`, announcePassing: () => {}, triggerRaceAudio: (key) => audioEvents.push(key), saveState: () => { saveCount += 1; }, lastOverlayLeaderKeyBySession: {}, lastOverlayTop3BySession: {}, lastOverlayBestLapByKey: {}, }; const passing = (timestamp, role, transponder = "123") => processDecoderMessageHelper({ msg: "PASSING", transponder, rtc_time: new Date(timestamp).toISOString(), }, deps, role); return { state, session, passing, overlayEvents, audioEvents, getSaveCount: () => saveCount }; } test("five controlled practice laps trigger one stable-pace voice cue", () => { const harness = createHarness({ sessionType: "open_practice" }); for (let lap = 1; lap <= 5; lap += 1) harness.passing(1_000 + lap * 20_000, "main"); assert.equal(harness.audioEvents.filter((event) => event === "training_stable_pace").length, 1); }); test("stable-pace training cue is never emitted during a race", () => { const harness = createHarness({ sessionType: "team_race" }); for (let lap = 1; lap <= 5; lap += 1) harness.passing(1_000 + lap * 20_000, "main"); assert.equal(harness.audioEvents.includes("training_stable_pace"), false); }); test("split decoder records intermediate time without changing main lap state", () => { const harness = createHarness(); assert.equal(harness.passing(17_000, "main"), true); const result = harness.state.resultsBySession.session_1; const entry = result.competitors.team_team_1; assert.equal(entry.laps, 1); assert.equal(entry.lastTimestamp, 17_000); harness.passing(25_000, "split"); assert.equal(entry.laps, 1); assert.equal(entry.lastTimestamp, 17_000); assert.equal(entry.lastSplitMs, 8_000); assert.equal(entry.lastSplitLap, 2); assert.equal(result.splits.length, 1); assert.equal(result.passings.length, 1); }); test("minimum lap time rejects a shortcut without adding a lap", () => { const harness = createHarness({ minLapMs: 10_000, maxLapMs: 120_000 }); harness.passing(6_000, "main"); const result = harness.state.resultsBySession.session_1; assert.equal(result.competitors.team_team_1.laps, 0); assert.equal(result.passings[0].validLap, false); assert.equal(result.passings[0].invalidReason, "below_min"); }); test("maximum lap time starts a statistics stint but still counts the race lap", () => { const harness = createHarness({ minLapMs: 10_000, maxLapMs: 60_000 }); harness.passing(121_001, "main"); const result = harness.state.resultsBySession.session_1; assert.equal(result.competitors.team_team_1.laps, 1); assert.equal(result.passings[0].validLap, true); assert.equal(result.passings[0].validationWarning, "above_max_stint"); assert.equal(result.passings[0].excludedFromPace, true); 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("personal best is per session and track layout - not per stint, and an old layout's time doesn't count", () => { const harness = createHarness({ sessionType: "open_practice" }); harness.session.trackLayoutId = "layout_a"; const entry = () => Object.values(harness.state.resultsBySession.session_1.competitors)[0]; const pbs = () => harness.audioEvents.filter((event) => event === "personal_best").length; harness.passing(21_000, "main"); // 20 s harness.passing(39_000, "main"); // 18 s -> PB assert.equal(pbs(), 1); harness.passing(200_000, "main"); // long break: a new stint, but the 18 s best still stands harness.passing(219_000, "main"); // 19 s - no PB assert.equal(pbs(), 1, "a new stint doesn't reset the personal best"); assert.equal(entry().bestLapMs, 18_000); harness.session.trackLayoutId = "layout_b"; // the track was rebuilt mid-practice harness.passing(244_000, "main"); // 25 s on the new layout - first lap there, no PB call assert.equal(entry().bestLapMs, 25_000, "the old layout's 18 s no longer counts"); harness.passing(268_000, "main"); // 24 s -> PB on the new layout assert.equal(pbs(), 2); assert.equal(harness.state.resultsBySession.session_1.passings.at(-1).trackLayoutId, "layout_b", "each lap remembers its layout"); }); test("pit entry and exit create one timed stop without adding laps", () => { const harness = createHarness(); harness.passing(17_000, "main"); harness.passing(30_000, "pit_entry"); harness.passing(31_000, "pit_entry"); harness.passing(42_500, "pit_exit", "456"); const result = harness.state.resultsBySession.session_1; const entry = result.competitors.team_team_1; assert.equal(entry.laps, 1); assert.equal(entry.pitActive, false); assert.equal(entry.pitStops, 1); assert.equal(entry.lastPitStopMs, 12_500); assert.equal(entry.totalPitMs, 12_500); assert.equal(result.pitEvents.length, 2); assert.equal(result.pitStops.length, 1); assert.equal(result.pitStops[0].driverId, "driver_1"); assert.equal(result.pitStops[0].driverName, "Driver One"); assert.equal(result.pitStops[0].transponder, "123"); assert.equal(result.pitStops[0].exitTransponder, "456"); assert.equal(result.passings.length, 1); assert.deepEqual(harness.audioEvents, ["entering_pits", "leaving_pits"]); }); test("connect decoder opens one websocket per enabled role", () => { const OriginalWebSocket = globalThis.WebSocket; class FakeWebSocket { constructor(url) { this.url = url; } close() {} } globalThis.WebSocket = FakeWebSocket; try { const clients = {}; const timers = {}; let disconnected = 0; let renderCount = 0; const state = { settings: { wsUrl: "ws://server:9000", splitDecoderEnabled: true, splitWsUrl: "ws://server:9001", pitDecoderEnabled: true, pitEntryWsUrl: "ws://server:9002", pitExitWsUrl: "ws://server:9003", }, decoder: {}, }; connectDecoderHelper({ state, t: (key) => key, saveState: () => {}, getWsClient: (role) => clients[role] || null, setWsClient: (role, client) => { clients[role] = client; }, getReconnectTimer: (role) => timers[role] || null, setReconnectTimer: (role, timer) => { timers[role] = timer; }, getCurrentView: () => "settings", renderView: () => { renderCount += 1; }, updateConnectionBadge: () => {}, processDecoderMessage: () => {}, disconnectDecoder: () => { disconnected += 1; }, }); assert.equal(disconnected, 1); assert.deepEqual(Object.fromEntries(Object.entries(clients).map(([role, client]) => [role, client.url])), { main: "ws://server:9000", split: "ws://server:9001", pit_entry: "ws://server:9002", pit_exit: "ws://server:9003", }); decoderDiagnostics.clear("main"); clients.main.onopen(); clients.main.onmessage({ data: "not json" }); clients.main.onmessage({ data: JSON.stringify({ msg: "STATUS" }) }); assert.equal(decoderDiagnostics.snapshot()[0].frames, 2); assert.equal(decoderDiagnostics.snapshot()[0].parseErrors, 1); clients.main.onerror(); clients.main.onclose(); assert.equal(decoderDiagnostics.snapshot()[0].status, "disconnected"); clients.main.onopen(); assert.equal(decoderDiagnostics.snapshot()[0].status, "connected"); assert.equal(renderCount, 0); } finally { globalThis.WebSocket = OriginalWebSocket; } }); test("non-passing decoder messages do not persist race state", () => { const harness = createHarness(); const changed = processDecoderMessageHelper({ msg: "STATUS", decoder: "online" }, { state: harness.state, getActiveSession: () => harness.session, }); assert.equal(Boolean(changed), false); assert.equal(harness.getSaveCount(), 0); });