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:
@@ -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]);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
85
tests/browser_suspect_laps.js
Normal file
85
tests/browser_suspect_laps.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
44
tests/suspect_laps_logic.test.js
Normal file
44
tests/suspect_laps_logic.test.js
Normal 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]);
|
||||
});
|
||||
Reference in New Issue
Block a user