race day tester + fixa open practice start och decoder-omritning

Nytt browser-test för start/paus/fortsätt/finish och byte till
nästa heat i kön, mot en simulerad decoder-socket (ingen riktig
AMMC-hårdvara). Hittade och fixade två buggar under vägen:

- Open Practice på sponsor/track-event kunde inte startas trots
  grönt ljus i förkontrollen (separat, striktare kontroll saknade
  samma undantag)
- Race Day-vyn ritades aldrig om när decodern anslöt/kopplades ner

Testramverket hanterar nu även native alert()/confirm()-dialoger
automatiskt, annars fryser en headless körning helt.
This commit is contained in:
larssand
2026-09-09 06:59:54 +02:00
parent b3bec531de
commit acab99ed4d
6 changed files with 188 additions and 5 deletions

150
tests/browser_race_day.js Normal file
View File

@@ -0,0 +1,150 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import { createServer as createNetServer } from "node:net";
async function freePort() {
const server = createNetServer();
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
const port = server.address().port;
await new Promise(resolve => server.close(resolve));
return port;
}
// 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: this scenario only needs an established socket, not lap data.
async function startFakeDecoder() {
const port = await freePort();
const server = createServer();
// The upgraded sockets are intentionally left open (no decoder frames needed for this
// scenario), so server.close() alone would wait forever for them to end; track and
// destroy them explicitly instead. closeAllConnections() does not reach upgraded sockets.
const sockets = new Set();
server.on("upgrade", (req, socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
const key = req.headers["sec-websocket-key"];
if (!key) { socket.destroy(); return; }
const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
socket.write(["HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${accept}`, "", ""].join("\r\n"));
});
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", resolve); });
return {
port,
close: () => new Promise(resolve => {
for (const socket of sockets) socket.destroy();
server.close(() => resolve());
}),
};
}
export async function runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs = [] }) {
const click = id => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
const submit = id => evaluate(`document.getElementById(${JSON.stringify(id)}).requestSubmit()`);
const findEvent = async name => (await savedState()).events.find(event => event.name === name);
const findSession = async name => (await savedState()).sessions.find(session => session.name === name);
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 buttonDisabled = async id => await evaluate(`document.getElementById(${JSON.stringify(id)})?.disabled`);
const decoder = await startFakeDecoder();
try {
// Reliability tests must not depend on real AMMC hardware; a socket that only completes
// the WebSocket handshake is enough to satisfy Race Day's decoder-connected preflight check.
const seeded = await savedState();
await patchState({ settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}` } });
await navigate("events", "eventForm");
// Decoder connection state is deliberately excluded from buildPersistableState() in
// src/app.js (it's per-tab, not shared/persisted), so check the live connection badge
// in the page instead of polling backend state.
await waitFor(async () => await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "fake decoder connected");
await fill("eventForm", { name: "Race Day Event", date: "2026-09-08", classId: "test_class" });
await submit("eventForm");
await waitFor(async () => Boolean(await findEvent("Race Day Event")), "create race day event");
const event = await findEvent("Race Day Event");
await click("event-manage-" + event.id);
await fill("sessionForm", { name: "Race Day Heat 1", type: "open_practice", durationMin: "5" });
await submit("sessionForm");
await waitFor(async () => Boolean(await findSession("Race Day Heat 1")), "create heat 1");
await fill("sessionForm", { name: "Race Day Heat 2", type: "open_practice", durationMin: "5" });
await submit("sessionForm");
await waitFor(async () => Boolean(await findSession("Race Day Heat 2")), "create heat 2");
const heat1 = await findSession("Race Day Heat 1");
const heat2 = await findSession("Race Day Heat 2");
// Pin the active session explicitly: other scenarios may have left earlier "ready" sessions
// that would otherwise win the Race Day queue's global fallback lookup.
await patchState({ activeSessionId: heat1.id });
await navigate("race_day", "raceDayStart");
await waitFor(async () => (await buttonDisabled("raceDayStart")) === false, "start enabled once preflight is ready");
assert.equal(await buttonDisabled("raceDayPause"), true);
assert.equal(await buttonDisabled("raceDayFinish"), true);
await click("raceDayStart");
await waitFor(async () => (await findSession("Race Day Heat 1")).status === "running", "heat 1 running");
const started = await findSession("Race Day Heat 1");
assert.ok(started.startedAt, "startedAt recorded");
// normalizeSession() in src/app.js stores "not paused" as 0, not null (Number(null) === 0
// passes its Number.isFinite check) - every real usage treats pausedAt as falsy, so match that.
assert.ok(!started.pausedAt, "not paused after start");
await waitFor(async () => (await buttonDisabled("raceDayPause")) === false, "pause enabled after start");
assert.equal(await buttonDisabled("raceDayFinish"), false);
assert.equal(await buttonDisabled("raceDayResume"), true);
await click("raceDayPause");
await waitFor(async () => (await findSession("Race Day Heat 1")).status === "paused", "heat 1 paused");
assert.ok((await findSession("Race Day Heat 1")).pausedAt, "pausedAt recorded");
await waitFor(async () => (await buttonDisabled("raceDayResume")) === false, "resume enabled after pause");
assert.equal(await buttonDisabled("raceDayPause"), true);
await pause(1200);
await click("raceDayResume");
await waitFor(async () => (await findSession("Race Day Heat 1")).status === "running", "heat 1 resumed");
const resumed = await findSession("Race Day Heat 1");
assert.ok(!resumed.pausedAt, "not paused after resume");
assert.equal(resumed.startedAt, started.startedAt, "resume does not reset the original start time");
assert.ok(resumed.totalPausedMs >= 1000, "paused duration accumulated in totalPausedMs");
await waitFor(async () => (await buttonDisabled("raceDayPause")) === false, "pause enabled after resume");
await click("raceDayFinish");
await waitFor(async () => (await findSession("Race Day Heat 1")).status === "finished", "heat 1 finished");
const finished = await findSession("Race Day Heat 1");
assert.ok(finished.endedAt, "endedAt recorded");
assert.ok(!finished.pausedAt, "not paused after finish");
console.log("PASS race day heat 1 start/pause/resume/finish preserves session timing fields");
await waitFor(async () => await evaluate(`Boolean(document.querySelector('[data-race-day-select="${heat2.id}"]'))`), "heat 2 listed as next in queue");
await evaluate(`document.querySelector('[data-race-day-select="${heat2.id}"]').click()`);
await waitFor(async () => (await savedState()).activeSessionId === heat2.id, "heat 2 selected as active session");
await waitFor(async () => (await evaluate('document.querySelector(".race-day-hero h2")?.textContent')) === "Race Day Heat 2", "heat 2 shown as current");
assert.equal(await buttonDisabled("raceDayStart"), false, "next heat is startable");
await click("raceDayStart");
await waitFor(async () => (await findSession("Race Day Heat 2")).status === "running", "heat 2 running");
await click("raceDayFinish");
await waitFor(async () => (await findSession("Race Day Heat 2")).status === "finished", "heat 2 finished");
const heat1After = await findSession("Race Day Heat 1");
assert.equal(heat1After.status, "finished");
assert.equal(heat1After.startedAt, started.startedAt, "heat 1 timing untouched by heat 2's lifecycle");
assert.ok(heat1After.totalPausedMs >= 1000);
console.log("PASS switching to next heat keeps the previous heat's recorded timing intact");
// A native alert()/confirm() anywhere in this flow (e.g. a start validation surprise)
// would otherwise pass silently: it freezes the page rather than failing an assertion.
assert.deepEqual(dialogs, [], "no unexpected native dialog during the race day flow");
} finally {
// Leave autoReconnect off again so later scenarios in the shared harness don't retry
// against a decoder socket that no longer exists.
const cleanup = await savedState();
await patchState({ settings: { ...cleanup.settings, autoReconnect: false } });
await decoder.close();
}
}