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>
This commit is contained in:
larssand
2026-10-07 19:53:23 +02:00
parent 3cc4fa0845
commit 8601954ea2
24 changed files with 556 additions and 60 deletions

View File

@@ -40,8 +40,9 @@ export async function startFakeDecoder() {
// listening so the frontend's own reconnect logic has somewhere to reconnect to.
dropConnections: () => { for (const socket of sockets) socket.destroy(); },
// Sends an AMMC-style PASSING message as an unmasked server->client WebSocket text frame.
sendPassing: (transponder) => {
const payload = Buffer.from(JSON.stringify({ msg: "PASSING", transponder: String(transponder), rtc_time: new Date().toISOString() }));
// `at` (ms) sets the decoder's own passing time, for exact lap times in a test.
sendPassing: (transponder, at = Date.now()) => {
const payload = Buffer.from(JSON.stringify({ msg: "PASSING", transponder: String(transponder), rtc_time: new Date(at).toISOString() }));
const header = payload.length < 126
? Buffer.from([0x81, payload.length])
: Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff]);

View File

@@ -12,6 +12,7 @@ import { runTtsSettingsSmoke } from "./browser_tts_settings.js";
import { runTrackLayoutsSmoke } from "./browser_track_layouts.js";
import { runPrologueSmoke } from "./browser_prologue.js";
import { runPracticeResetSmoke } from "./browser_practice_reset.js";
import { runSuspectLapsSmoke } from "./browser_suspect_laps.js";
import { runObsPopupSmoke } from "./browser_obs_popup.js";
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
import assert from "node:assert/strict";
@@ -154,6 +155,7 @@ try {
await runTrackLayoutsSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs });
await runPrologueSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs });
await runPracticeResetSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs });
await runSuspectLapsSmoke({ evaluate, navigate, savedState, waitFor, 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 });

View File

@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { startFakeDecoder } from "./browser_fake_decoder.js";
// Open Practice: a driver who usually laps around 20 s suddenly does one lap of 14 s (cut
// across the track). A real decoder passing must flag it: the "suspiciously short laps" panel
// on Timing, a ⚠ next to the driver in the leaderboard and the overlay, not used as the best
// lap - and one click on Remove takes it away.
export async function runSuspectLapsSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }) {
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 results = async () => (await savedState()).resultsBySession?.sl_practice;
const dialogsAtStart = dialogs.length;
const decoder = await startFakeDecoder();
const seeded = await savedState();
const event = { id: "sl_event", name: "SL Practice", classId: "sl_class", mode: "race", raceConfig: { practiceOnly: true, minLapMs: 3000, maxLapMs: 60000 } };
// Six normal laps around 20 s; the decoder then reports the next crossing 14 s later.
const lastTs = Date.now() - 1000;
const laps = [20400, 19800, 20200, 20600, 20100, 20300];
let ts = lastTs - laps.reduce((sum, value) => sum + value, 0);
const startedAt = ts;
const passings = laps.map((lapMs) => { ts += lapMs; return { driverId: "sl_driver", driverName: "SL Kid", displayName: "SL Kid", competitorKey: "driver_sl_driver", transponder: "8330001", lapMs, timestamp: ts, validLap: true }; });
const practice = { id: "sl_practice", eventId: event.id, name: "SL Open Practice", type: "open_practice", durationMin: 600, status: "running", startedAt };
await patchState({
classes: [...seeded.classes, { id: "sl_class", name: "SL Class" }],
drivers: [...seeded.drivers, { id: "sl_driver", name: "SL Kid", classId: "sl_class", transponder: "8330001" }],
events: [...seeded.events, event],
sessions: [...seeded.sessions.map((session) => (["running", "paused"].includes(session.status) ? { ...session, status: "finished", endedAt: Date.now() } : session)), practice],
resultsBySession: {
...seeded.resultsBySession,
[practice.id]: { passings, competitors: { driver_sl_driver: { key: "driver_sl_driver", driverId: "sl_driver", driverName: "SL Kid", displayName: "SL Kid", transponder: "8330001", laps: 6, lastLapMs: 20300, bestLapMs: 19800, startTimestamp: startedAt, lastTimestamp: lastTs } } },
},
activeSessionId: practice.id,
settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}` },
});
try {
await navigate("timing", "resetSession");
await waitFor(async () => evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected");
decoder.sendPassing("8330001", lastTs + 14000);
await waitFor(async () => (await results())?.passings?.length === 7, "the short lap arrived");
const shortLap = (await results()).passings.at(-1);
assert.equal(shortLap.lapMs, 14000);
assert.equal(shortLap.suspect, true, "flagged as suspect");
assert.equal((await results()).competitors.driver_sl_driver.bestLapMs, 19800, "not used as the best lap");
await waitFor(async () => Boolean(await evaluate(`document.getElementById("suspectLapsPanel")`)), "suspect panel on Timing");
assert.match(await evaluate(`document.getElementById("suspectLapsPanel").innerText`), /SL Kid/);
assert.equal(await evaluate(`Boolean([...document.querySelectorAll(".table-primary")].find((node) => node.textContent.includes("SL Kid"))?.querySelector(".suspect-flag"))`), true, "⚠ in the leaderboard");
console.log("PASS a lap far quicker than the driver's own pace is flagged on Timing and not used as the best lap");
await navigate("overlay", "view");
await waitFor(async () => evaluate(`document.body.innerText.includes("SL Kid")`), "overlay shows the driver");
assert.equal(await evaluate(`[...document.querySelectorAll(".suspect-flag")].length > 0`), true, "⚠ in the overlay");
console.log("PASS the overlay marks the driver with a suspect lap");
await navigate("race_day", "raceDayStart");
await waitFor(async () => Boolean(await evaluate(`document.querySelector('#suspectLapsPanel [data-lap-action="approve"]')`)), "the same panel on Race Day");
console.log("PASS Race Day shows the suspect lap with Approve / Remove");
await navigate("timing", "resetSession");
await waitFor(async () => Boolean(await evaluate(`document.querySelector('#suspectLapsPanel [data-lap-action="remove"]')`)), "Remove button");
await evaluate(`document.querySelector('#suspectLapsPanel [data-lap-action="remove"]').click()`);
await waitFor(async () => (await results())?.passings?.at(-1)?.validLap === false, "the short lap removed");
const after = await results();
assert.equal(after.passings.at(-1).invalidReason, "manual_invalid");
assert.equal(after.competitors.driver_sl_driver.laps, 6, "no longer counted");
assert.ok(after.adjustments?.some((adjustment) => adjustment.category === "invalid"), "logged as a judge correction (undoable)");
await waitFor(async () => !(await evaluate(`Boolean(document.getElementById("suspectLapsPanel"))`)), "panel gone once nothing is pending");
console.log("PASS one click removes the suspect lap and it is logged as an undoable correction");
} finally {
dialogs.splice(dialogsAtStart);
const current = await savedState();
await patchState({
settings: { ...current.settings, autoReconnect: false },
sessions: current.sessions.map((session) => (session.id === practice.id ? { ...session, status: "finished", endedAt: Date.now() } : session)),
activeSessionId: "",
});
await decoder.close();
}
}

View File

@@ -157,6 +157,33 @@ test("personal best is per session and track layout - not per stint, and an old
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");

View File

@@ -9,6 +9,7 @@ import {
getManualCorrectionSummary,
invalidateCompetitorLastLap,
recalculateCompetitorFromPassings,
setCompetitorLapStatus,
restoreCompetitorLastInvalidLap,
undoJudgingAdjustment,
} from "../src/judging_logic.js";
@@ -86,6 +87,34 @@ test("recalculating after a judge correction keeps the best lap on the current t
assert.equal(harness.row.bestLapMs, 16000);
});
test("any lap can be removed, restored or (if suspect) approved - each logged and undoable", () => {
const harness = createHarness();
const act = (timestamp, action) => setCompetitorLapStatus(harness.session, "driver_1", timestamp, action, harness.deps);
// Remove the FIRST lap, not just the last one.
assert.equal(act(17000, "remove"), true);
assert.equal(harness.row.laps, 2);
assert.equal(harness.row.bestLapMs, 16500);
assert.equal(act(17000, "remove"), false, "already removed");
assert.equal(act(17000, "restore"), true);
assert.equal(harness.row.laps, 3);
assert.equal(harness.row.bestLapMs, 16000);
// A suspect lap doesn't count for best lap until approved; undo makes it suspect again.
harness.result.passings[2].lapMs = 12000;
harness.result.passings[2].suspect = true;
harness.deps.recalculateCompetitorFromPassings(harness.session, "driver_1");
assert.equal(harness.row.bestLapMs, 16000);
assert.equal(act(50500, "approve"), true);
assert.equal(harness.row.bestLapMs, 12000);
const approval = harness.result.adjustments.at(-1);
assert.equal(approval.category, "invalid");
assert.equal(undoJudgingAdjustment(harness.session, approval.id, { ...harness.deps, findPassingByUndoMarker: (session, key, ts) => harness.result.passings.find((passing) => passing.competitorKey === key && passing.timestamp === ts) }), true);
assert.equal(harness.result.passings[2].suspect, true);
assert.equal(harness.row.bestLapMs, 16000);
assert.equal(act(99999, "remove"), false, "unknown lap");
assert.equal(act(17000, "explode"), false, "unknown action");
});
test("manual corrections accumulate, reset, and undo to the prior state", () => {
const harness = createHarness();

View File

@@ -1,5 +1,6 @@
import "./auth.test.js";
import "./prologue_logic.test.js";
import "./suspect_laps_logic.test.js";
import "./tts.test.js";
import "./piper_speech.test.js";
import "./driver_profile_logic.test.js";

View File

@@ -388,6 +388,17 @@ test("a stale full-state save from a second browser cannot revert a session's ju
// ...but the live race result survived the stale overwrite instead of reverting to empty.
assert.deepEqual(afterStaleSave.state.resultsBySession.session_1, liveSessionResult);
// A judge removing that lap moments later must not be held back by the same protection:
// the corrected result goes through /api/session-results and wins straight away.
const correctedResult = {
...liveSessionResult,
competitors: { driver_1: { ...liveSessionResult.competitors.driver_1, laps: 0, bestLapMs: null } },
passings: [{ ...liveSessionResult.passings[0], validLap: false, invalidReason: "manual_invalid" }],
};
assert.equal((await fetch(`${baseUrl}/api/session-results`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: "session_1", sessionResult: correctedResult }) })).status, 200);
assert.deepEqual((await (await fetch(`${baseUrl}/api/state`)).json()).state.resultsBySession.session_1, correctedResult);
assert.equal((await fetch(`${baseUrl}/api/session-results`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: "session_1" }) })).status, 400);
// The protection is time-bounded, not permanent: once the window passes, a save is trusted
// again, so a legitimate later change to that same session's results (e.g. an admin
// deleting the session) still takes effect.

View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isSuspectLap, getSuspectReferenceLaps, countsForBestLap, listPendingSuspectLaps, SUSPECT_LAP_RATIO, MIN_REFERENCE_LAPS } from "../src/suspect_laps_logic.js";
import { syncTrainingLedger } from "../src/training_logic.js";
const lap = (lapMs, extra = {}) => ({ competitorKey: "d1", lapMs, timestamp: lapMs, validLap: true, ...extra });
test("a lap well under 85 % of the driver's own median is suspect - only once there are 5 normal laps", () => {
assert.equal(SUSPECT_LAP_RATIO, 0.85);
assert.equal(MIN_REFERENCE_LAPS, 5);
const reference = [20000, 20500, 19800, 20200, 21000]; // median 20 200 -> limit 17 170
assert.equal(isSuspectLap(17000, reference), true, "a shortcut");
assert.equal(isSuspectLap(17500, reference), false, "a genuinely good lap");
assert.equal(isSuspectLap(12000, reference.slice(0, 4)), false, "too little to compare with yet");
});
test("the reference is the driver's own recent normal laps on the same layout", () => {
const passings = [
lap(20000), lap(20000), lap(30000, { stintBoundary: true }), lap(9000, { validLap: false }),
lap(15000, { suspect: true }), lap(25000, { competitorKey: "other" }), lap(40000, { trackLayoutId: "old" }), lap(21000),
];
assert.deepEqual(getSuspectReferenceLaps(passings, "d1", ""), [20000, 20000, 21000]);
});
test("suspect and removed laps never count for best lap; pending ones are listed newest first", () => {
assert.equal(countsForBestLap(lap(15000, { suspect: true })), false);
assert.equal(countsForBestLap(lap(15000, { validLap: false })), false);
assert.equal(countsForBestLap(lap(15000, { suspectApproved: true })), true);
const result = { passings: [lap(15000, { suspect: true, timestamp: 1 }), lap(16000, { suspect: true, validLap: false, timestamp: 2 }), lap(14000, { suspect: true, timestamp: 3 })] };
assert.deepEqual(listPendingSuspectLaps(result).map((passing) => passing.lapMs), [14000, 15000], "a removed suspect lap is no longer pending");
});
test("a suspect lap stays out of the training stats until it is approved", () => {
let n = 0;
const uid = (prefix) => `${prefix}_${++n}`;
const state = {
sessions: [{ id: "op", name: "Open Practice", eventId: "e" }],
events: [{ id: "e", name: "Club" }],
resultsBySession: { op: { passings: [{ ...lap(20000), driverId: "d1", timestamp: 1000 }, { ...lap(15000, { suspect: true }), driverId: "d1", timestamp: 2000 }] } },
};
assert.deepEqual(syncTrainingLedger({}, state, { uid }).ledger.map((entry) => entry.lapMs), [20000]);
Object.assign(state.resultsBySession.op.passings[1], { suspect: false, suspectApproved: true });
assert.deepEqual(syncTrainingLedger({}, state, { uid }).ledger.map((entry) => entry.lapMs), [20000, 15000]);
});