Files
Live_RC/tests/decoder_runtime.test.js
larssand 8601954ea2 feat(practice): flag suspiciously short laps and remove any lap in one click
Practice laps under 85% of the driver's own median (a likely shortcut)
are flagged: counted, but never a best lap, record or training stat
until approved. Timing and Race Day get a one-click Approve/Remove
panel, overlays show a warning mark, and any lap can be removed or
restored from Timing and Judging with undo.

Also sends judge corrections straight to the server so its hot-results
protection can no longer hold back a correction made right after a
passing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:53:23 +02:00

281 lines
12 KiB
JavaScript

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("practice: a shortcut lap is flagged suspect - counted, but no best lap, personal best or record", () => {
const harness = createHarness({ sessionType: "open_practice" });
const entry = () => Object.values(harness.state.resultsBySession.session_1.competitors)[0];
let ts = 1_000;
[20_000, 20_400, 19_800, 20_200, 20_600, 20_100].forEach((lapMs) => { ts += lapMs; harness.passing(ts, "main"); });
assert.equal(entry().bestLapMs, 19_800);
const pbsBefore = harness.audioEvents.filter((event) => event === "personal_best").length;
ts += 15_000; // usually ~20 s, then one lap of 15 s: cut across the track
harness.passing(ts, "main");
const last = harness.state.resultsBySession.session_1.passings.at(-1);
assert.equal(last.suspect, true);
assert.equal(entry().laps, 7, "still counted as a lap");
assert.equal(entry().bestLapMs, 19_800, "not a best lap");
assert.equal(harness.audioEvents.filter((event) => event === "personal_best").length, pbsBefore, "no personal best call for it");
ts += 15_100; // a second shortcut in a row is flagged too (suspect laps aren't the reference)
harness.passing(ts, "main");
assert.equal(harness.state.resultsBySession.session_1.passings.at(-1).suspect, true);
assert.ok(harness.overlayEvents.some((event) => event.type === "suspect"), "the overlay event feed shows it");
});
test("races never flag suspect laps - that's what the minimum lap time is for", () => {
const harness = createHarness({ sessionType: "heat" });
let ts = 1_000;
[20_000, 20_400, 19_800, 20_200, 20_600, 12_000].forEach((lapMs) => { ts += lapMs; harness.passing(ts, "main"); });
assert.equal(harness.state.resultsBySession.session_1.passings.some((passing) => passing.suspect), false);
});
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);
});