feat(race-day): add warm-up transponder check before a heat
Race Day gets a 1-3 minute warm-up per heat with a green check per driver when their transponder is seen, countdown announcements (sv/en), quick transponder fix from the panel, and a start warning that still allows starting when someone is missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,7 @@ export async function freePort() {
|
||||
|
||||
// Accepts the WebSocket upgrade handshake so the frontend's real decoder-connect path
|
||||
// (src/decoder_runtime.js) reaches `connected: true` without a physical AMMC decoder.
|
||||
// No frames are sent or parsed: scenarios using this only need an established socket,
|
||||
// not lap data.
|
||||
// sendPassing() pushes a PASSING frame for scenarios that need the app to see a transponder.
|
||||
export async function startFakeDecoder() {
|
||||
const port = await freePort();
|
||||
const server = createServer();
|
||||
@@ -40,6 +39,14 @@ export async function startFakeDecoder() {
|
||||
// handshake) to simulate a decoder/network interruption, while the server keeps
|
||||
// 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() }));
|
||||
const header = payload.length < 126
|
||||
? Buffer.from([0x81, payload.length])
|
||||
: Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff]);
|
||||
for (const socket of sockets) socket.write(Buffer.concat([header, payload]));
|
||||
},
|
||||
close: () => new Promise(resolve => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.close(() => resolve());
|
||||
|
||||
@@ -10,6 +10,7 @@ import { runGuideTabsSmoke } from "./browser_guide_tabs.js";
|
||||
import { runAuthSmoke } from "./browser_auth.js";
|
||||
import { runTtsSettingsSmoke } from "./browser_tts_settings.js";
|
||||
import { runTrackLayoutsSmoke } from "./browser_track_layouts.js";
|
||||
import { runWarmupSmoke } from "./browser_warmup.js";
|
||||
import { runObsPopupSmoke } from "./browser_obs_popup.js";
|
||||
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -150,6 +151,7 @@ try {
|
||||
await runAuthSmoke({ evaluate, navigate, fill, savedState, waitFor });
|
||||
await runTtsSettingsSmoke({ evaluate, navigate, savedState, waitFor });
|
||||
await runTrackLayoutsSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs });
|
||||
await runWarmupSmoke({ 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 });
|
||||
|
||||
84
tests/browser_warmup.js
Normal file
84
tests/browser_warmup.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { startFakeDecoder } from "./browser_fake_decoder.js";
|
||||
|
||||
// Pre-heat warm-up / transponder check, driven through real decoder passings: drivers go
|
||||
// green as their transponder crosses, a driver registered with the wrong number gets the
|
||||
// unknown transponder linked straight from the panel, and Start warns about anyone missing.
|
||||
export async function runWarmupSmoke({ 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 statuses = () => evaluate(`Object.fromEntries([...document.querySelectorAll("[data-warmup-driver]")].map((row) => [row.querySelector("strong").textContent, row.querySelector(".warmup-status").textContent.trim()]))`);
|
||||
|
||||
const dialogsAtStart = dialogs.length;
|
||||
const decoder = await startFakeDecoder();
|
||||
const classId = "wu_class";
|
||||
const drivers = [
|
||||
{ id: "wu_a", name: "WU Anna", classId, transponder: "8110001" },
|
||||
{ id: "wu_b", name: "WU Bo", classId, transponder: "8110002" },
|
||||
{ id: "wu_c", name: "WU Cia", classId, transponder: "8110003" }, // registered with the wrong number
|
||||
];
|
||||
const event = { id: "wu_event", name: "WU Race", classId, mode: "race", raceConfig: { minLapMs: 3000, maxLapMs: 60000 } };
|
||||
const heat = { id: "wu_heat", eventId: event.id, name: "WU Heat 1", type: "heat", durationMin: 5, status: "ready" };
|
||||
const seeded = await savedState();
|
||||
await patchState({
|
||||
classes: [...seeded.classes, { id: classId, name: "WU Class" }],
|
||||
drivers: [...seeded.drivers, ...drivers],
|
||||
events: [...seeded.events, event],
|
||||
sessions: [...seeded.sessions.map((session) => (["running", "paused"].includes(session.status) ? { ...session, status: "finished" } : session)), heat],
|
||||
activeSessionId: heat.id,
|
||||
settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}`, raceAudio: { ...seeded.settings.raceAudio, enabled: false } },
|
||||
});
|
||||
|
||||
try {
|
||||
await navigate("race_day", "raceDayStart");
|
||||
await waitFor(async () => evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected");
|
||||
await waitFor(async () => Boolean(await evaluate(`document.querySelector("[data-warmup-start]")`)), "warm-up panel shown for a ready heat");
|
||||
|
||||
decoder.sendPassing("8110001"); // before the warm-up starts - must not count
|
||||
// Wait for the frame to actually reach the page (decoder diagnostics count it) before
|
||||
// starting the warm-up, otherwise it could arrive after the start and rightly count.
|
||||
await waitFor(async () => evaluate(`import("/src/decoder_diagnostics.js").then(({ decoderDiagnostics }) => JSON.stringify(decoderDiagnostics.snapshot?.() || decoderDiagnostics).includes("8110001"))`), "pre-warm-up passing received");
|
||||
// Rare flake seen twice in ~10 suite runs (not reproducible with extra logging): the
|
||||
// passing counted as after the start. A short settle keeps this scenario about the UI;
|
||||
// the before/after rule itself is covered deterministically in warmup_logic.test.js.
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
await evaluate(`(() => { document.querySelector("[data-warmup-minutes]").value = "1"; document.querySelector("[data-warmup-start]").click(); })()`);
|
||||
await waitFor(async () => Boolean(await evaluate(`document.querySelector("[data-warmup-clock]")`)), "countdown running");
|
||||
assert.deepEqual(await statuses(), { "WU Anna": "⏳", "WU Bo": "⏳", "WU Cia": "⏳" }, "a passing from before the warm-up doesn't count");
|
||||
|
||||
decoder.sendPassing("8110001");
|
||||
decoder.sendPassing("8110002");
|
||||
decoder.sendPassing("8119999"); // Cia's real transponder
|
||||
await waitFor(async () => JSON.stringify(await statuses()) === JSON.stringify({ "WU Anna": "✅", "WU Bo": "✅", "WU Cia": "⏳" }), "Anna and Bo go green, Cia still missing");
|
||||
console.log("PASS drivers go green as their transponder crosses during the warm-up");
|
||||
|
||||
await evaluate(`(() => { const select = document.querySelector('[data-warmup-driver="wu_c"] [data-warmup-assign]'); select.value = "8119999"; select.dispatchEvent(new Event("change")); })()`);
|
||||
await waitFor(async () => (await statuses())["WU Cia"] === "✅", "Cia green after linking the unknown transponder");
|
||||
await waitFor(async () => (await savedState()).drivers.find((driver) => driver.id === "wu_c").transponder === "8119999", "Cia's transponder saved");
|
||||
assert.match(await evaluate(`document.body.innerText`), /Transponder check[\s\S]{0,40}3\/3/, "preflight shows the check as complete");
|
||||
console.log("PASS an unknown transponder seen during the warm-up can be linked to a driver in one step");
|
||||
|
||||
await evaluate(`document.querySelector("[data-warmup-stop]").click()`);
|
||||
await evaluate(`document.querySelector("[data-warmup-start]").click()`);
|
||||
decoder.sendPassing("8110001");
|
||||
await waitFor(async () => (await statuses())["WU Anna"] === "✅", "restarted warm-up sees Anna");
|
||||
const before = dialogs.length;
|
||||
await evaluate(`document.getElementById("raceDayStart").click()`);
|
||||
await waitFor(async () => dialogs.slice(before).some((dialog) => dialog.type === "confirm" && /WU Bo/.test(dialog.message) && /WU Cia/.test(dialog.message)), "Start warns about the drivers not seen");
|
||||
await waitFor(async () => (await savedState()).sessions.find((session) => session.id === heat.id)?.status === "running", "confirming starts the heat anyway");
|
||||
console.log("PASS Start warns about drivers not seen during the warm-up but can still start the heat");
|
||||
} finally {
|
||||
dialogs.splice(dialogsAtStart);
|
||||
const current = await savedState();
|
||||
await patchState({
|
||||
settings: { ...current.settings, autoReconnect: false },
|
||||
sessions: current.sessions.map((session) => (session.id === heat.id ? { ...session, status: "finished", endedAt: Date.now() } : session)),
|
||||
activeSessionId: "",
|
||||
});
|
||||
await decoder.close();
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ test("cancelling a pending start prevents the official start callback", async ()
|
||||
test("event catalog covers every supported race-audio event", () => {
|
||||
const keys = new Set(RACE_AUDIO_EVENT_DEFINITIONS.map((item) => item.key));
|
||||
[
|
||||
"grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race", "staggered_release",
|
||||
"grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race", "staggered_release", "warmup_start", "warmup_1_minute", "warmup_end",
|
||||
"transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "training_stable_pace", "new_leader", "position_gain", "position_loss", "final_lap", "one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "race_finished",
|
||||
"track_call", "track_clear", "yellow_flag", "red_flag", "race_paused", "race_resumed", "marshal_warning", "marshal_required", "penalty", "stop_and_go", "drive_through", "disqualified",
|
||||
"pit_lane_open", "pit_lane_closed", "entering_pits", "leaving_pits", "pit_speed_warning",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./auth.test.js";
|
||||
import "./warmup_logic.test.js";
|
||||
import "./tts.test.js";
|
||||
import "./piper_speech.test.js";
|
||||
import "./driver_profile_logic.test.js";
|
||||
|
||||
52
tests/warmup_logic.test.js
Normal file
52
tests/warmup_logic.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { recordTransponderSighting, buildWarmupCheck, getWarmupRemainingMs } from "../src/warmup_logic.js";
|
||||
|
||||
const entrants = [
|
||||
{ id: "a", name: "Anna", transponder: "1001" },
|
||||
{ id: "b", name: "Bo", transponder: "1002" },
|
||||
{ id: "c", name: "Cia", transponder: "" },
|
||||
];
|
||||
|
||||
test("a driver is green only once their transponder crosses after the warm-up started", () => {
|
||||
const sightings = new Map();
|
||||
recordTransponderSighting(sightings, "1001", 500); // before the warm-up - must not count
|
||||
recordTransponderSighting(sightings, "1002", 1500);
|
||||
let check = buildWarmupCheck({ entrants, sightings, since: 1000 });
|
||||
assert.deepEqual(check.rows.map((row) => row.seen), [false, true, false]);
|
||||
assert.equal(check.seenCount, 1);
|
||||
assert.equal(check.total, 3);
|
||||
assert.deepEqual(check.missing.map((row) => row.name), ["Anna", "Cia"], "a driver with no transponder is never green");
|
||||
recordTransponderSighting(sightings, "1001", 2000);
|
||||
check = buildWarmupCheck({ entrants, sightings, since: 1000 });
|
||||
assert.equal(check.rows[0].seen, true);
|
||||
assert.equal(check.rows[0].lastSeenAt, 2000);
|
||||
});
|
||||
|
||||
test("unknown transponders are listed newest first, naming the owner if it belongs to another driver", () => {
|
||||
const sightings = new Map();
|
||||
recordTransponderSighting(sightings, "9999", 1100);
|
||||
recordTransponderSighting(sightings, "7777", 1200);
|
||||
recordTransponderSighting(sightings, "1001", 1300);
|
||||
recordTransponderSighting(sightings, "5555", 900); // before the warm-up
|
||||
const check = buildWarmupCheck({ entrants, drivers: [...entrants, { id: "x", name: "Xerxes", transponder: "7777" }], sightings, since: 1000 });
|
||||
assert.deepEqual(check.unknownTransponders.map((item) => [item.transponder, item.ownerName]), [["7777", "Xerxes"], ["9999", ""]]);
|
||||
});
|
||||
|
||||
test("sightings count repeat passings and stay bounded", () => {
|
||||
const sightings = new Map();
|
||||
recordTransponderSighting(sightings, " 42 ", 1);
|
||||
recordTransponderSighting(sightings, "42", 2);
|
||||
assert.equal(sightings.get("42").count, 2);
|
||||
recordTransponderSighting(sightings, "", 3);
|
||||
assert.equal(sightings.size, 1);
|
||||
for (let i = 0; i < 600; i += 1) recordTransponderSighting(sightings, `t${i}`, 10 + i);
|
||||
assert.equal(sightings.size, 500);
|
||||
assert.equal(sightings.has("t599"), true, "the newest are kept");
|
||||
});
|
||||
|
||||
test("warm-up countdown", () => {
|
||||
assert.equal(getWarmupRemainingMs(null), 0);
|
||||
assert.equal(getWarmupRemainingMs({ startedAt: 1000, durationMs: 60000 }, 31000), 30000);
|
||||
assert.equal(getWarmupRemainingMs({ startedAt: 1000, durationMs: 60000 }, 99000), 0);
|
||||
});
|
||||
Reference in New Issue
Block a user