Files
Live_RC/tests/browser_track_layouts.js
larssand 9d79bcb4d3 feat(layouts): optional min/max lap time per track layout for practice
Each layout can carry its own lap window (set on creation, editable in
Settings, saved from Race Day during practice). Open/Free practice on
that layout uses it; races always keep the event's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:33:22 +02:00

100 lines
7.6 KiB
JavaScript

import assert from "node:assert/strict";
// Track layouts from the operator's side: create one from Training stats with the
// "New layout" button, see the per-layout stats selector appear, and confirm layout
// changes stay a practice-only action - hidden on Race Day while a race session is in
// focus, and refused outright while a race session is running.
export async function runTrackLayoutsSmoke({ 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 layoutButtonVisible = () => evaluate(`Boolean(document.querySelector("[data-track-layout-new]"))`);
// This scenario triggers prompts/alerts on purpose; drop them afterwards so the shared
// "no unexpected native dialog" checks in later scenarios stay meaningful.
const dialogsAtStart = dialogs.length;
const seeded = await savedState();
const practiceEvent = { id: "tl_practice_event", name: "TL Practice", classId: "test_class", mode: "race", raceConfig: { practiceOnly: true } };
const raceEvent = { id: "tl_race_event", name: "TL Race", classId: "test_class", mode: "race", raceConfig: { minLapMs: 3000, maxLapMs: 60000 } };
const practice = { id: "tl_practice", eventId: practiceEvent.id, name: "TL Open Practice", type: "open_practice", durationMin: 60, status: "ready" };
const quali = { id: "tl_quali", eventId: raceEvent.id, name: "TL Q1", type: "qualification", durationMin: 5, status: "ready" };
// Earlier scenarios in the shared suite can leave a race-format session running, which
// would (correctly) block every layout change - finish those first.
const settled = seeded.sessions.map((session) => (["running", "paused"].includes(session.status) ? { ...session, status: "finished", endedAt: Date.now() } : session));
await patchState({ trackLayouts: [], events: [...seeded.events, practiceEvent, raceEvent], sessions: [...settled, practice, quali] });
try {
await navigate("training", "view");
await waitFor(async () => layoutButtonVisible(), "New layout button on Training stats");
assert.equal(await evaluate(`Boolean(document.getElementById("training-layout"))`), false, "no per-layout selector before any layout exists");
await evaluate(`document.querySelector("[data-track-layout-new]").click()`);
await waitFor(async () => (await savedState()).trackLayouts?.length === 1, "layout created from Training stats");
assert.equal((await savedState()).trackLayouts[0].label, "Layout 1", "an empty prompt answer gets the next default name");
await waitFor(async () => Boolean(await evaluate(`document.getElementById("training-layout")`)), "per-layout selector appears");
const options = await evaluate(`[...document.querySelectorAll("#training-layout option")].map((option) => option.value)`);
assert.deepEqual(options.filter((value) => ["current", "all"].includes(value)), ["current", "all"]);
assert.ok(options.includes((await savedState()).trackLayouts[0].id));
console.log("PASS a track layout can be created from Training stats and the per-layout stats selector appears");
// Settings: the layout gets its own min/max lap time; max <= min is refused.
await navigate("settings", "settingsForm");
const layoutId = (await savedState()).trackLayouts[0].id;
const submitLapWindow = (min, max) => evaluate(`(() => { const form = document.querySelector('[data-layout-lap-window="${layoutId}"]'); form.elements.minLapSec.value = "${min}"; form.elements.maxLapSec.value = "${max}"; form.requestSubmit(); })()`);
await submitLapWindow("8.5", "40");
await waitFor(async () => (await savedState()).trackLayouts[0].minLapMs === 8500, "layout min lap time saved");
assert.equal((await savedState()).trackLayouts[0].maxLapMs, 40000);
const alertsBefore = dialogs.length;
await submitLapWindow("30", "20");
await waitFor(async () => dialogs.slice(alertsBefore).some((dialog) => dialog.type === "alert"), "an impossible lap window is refused");
assert.equal((await savedState()).trackLayouts[0].minLapMs, 8500, "unchanged");
console.log("PASS each track layout can have its own min/max lap time");
await patchState({ activeSessionId: quali.id });
await navigate("race_day", "raceDayStart");
assert.equal(await layoutButtonVisible(), false, "no New layout button while a race session is in focus");
await patchState({ activeSessionId: practice.id });
await navigate("race_day", "raceDayStart");
assert.equal(await layoutButtonVisible(), true, "New layout is offered when Open Practice is in focus");
console.log("PASS Race Day only offers a new layout when a practice session is in focus");
// A running open practice moves onto the new layout and its best laps start over, so the
// old layout's times can't block or fake a personal best on the rebuilt track.
await patchState({
sessions: (await savedState()).sessions.map((session) => (session.id === practice.id ? { ...session, status: "running", startedAt: Date.now() - 60000 } : session)),
resultsBySession: { ...(await savedState()).resultsBySession, [practice.id]: { passings: [], competitors: { tl_driver: { key: "tl_driver", driverName: "TL Driver", laps: 3, bestLapMs: 18000, bestLapLayoutId: (await savedState()).trackLayouts[0].id } } } },
});
await navigate("training", "view");
await evaluate(`document.querySelector("[data-track-layout-new]").click()`);
await waitFor(async () => (await savedState()).trackLayouts?.length === 2, "second layout created");
const afterSwitch = await savedState();
const newLayoutId = afterSwitch.trackLayouts[1].id;
assert.equal(afterSwitch.sessions.find((session) => session.id === practice.id).trackLayoutId, newLayoutId, "the running practice moved onto the new layout");
const competitor = afterSwitch.resultsBySession[practice.id].competitors.tl_driver;
assert.equal(competitor.bestLapMs, null, "the old layout's best lap no longer counts");
assert.equal(competitor.bestLapLayoutId, newLayoutId);
assert.equal(competitor.laps, 3, "laps already driven are kept");
await patchState({ sessions: (await savedState()).sessions.map((session) => (session.id === practice.id ? { ...session, status: "finished", endedAt: Date.now() } : session)) });
console.log("PASS a new layout during open practice restarts every driver's best lap");
await patchState({ sessions: (await savedState()).sessions.map((session) => (session.id === quali.id ? { ...session, status: "running", startedAt: Date.now() } : session)) });
await navigate("training", "view");
const dialogCount = dialogs.length;
await evaluate(`document.querySelector("[data-track-layout-new]").click()`);
await waitFor(async () => dialogs.slice(dialogCount).some((dialog) => dialog.type === "alert" && dialog.message.includes("TL Q1")), "blocked with a message naming the running race");
assert.equal((await savedState()).trackLayouts.length, 2, "no layout was added while the race ran");
console.log("PASS a new track layout is refused while a race session is running");
} finally {
dialogs.splice(dialogsAtStart);
await patchState({
trackLayouts: [],
activeSessionId: "",
sessions: (await savedState()).sessions.filter((session) => ![practice.id, quali.id].includes(session.id)),
events: (await savedState()).events.filter((event) => ![practiceEvent.id, raceEvent.id].includes(event.id)),
});
}
}