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

@@ -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();
}
}