feat(layouts): switch track layout mid-practice and view stats per layout
A club can run one Open Practice all season and re-lay the track now and then. Previously a session's layout was frozen at start and every training lap inherited it, so a season-long practice stayed on its first layout forever, and changing layout was hidden under Settings -> Appearance. - Each practice lap now counts toward the layout active when it was driven; a running practice session moves onto a new layout when one is added - "New layout" strip on Training stats, Race Day and the Dashboard, offered only around practice; a running race session blocks layout changes everywhere - "Stats for" selector (current / older layout / before layouts / all layouts) on Training stats and in the driver's mobile profile (?layout= on the public API) - A layout that has practice laps on it can no longer be deleted - Fix: the buildDriverTrainingStats wrapper in app.js dropped extra arguments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { runSettingsTabsSmoke } from "./browser_settings_tabs.js";
|
||||
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 { runObsPopupSmoke } from "./browser_obs_popup.js";
|
||||
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -148,6 +149,7 @@ try {
|
||||
await runGuideTabsSmoke({ evaluate, navigate });
|
||||
await runAuthSmoke({ evaluate, navigate, fill, savedState, waitFor });
|
||||
await runTtsSettingsSmoke({ evaluate, navigate, savedState, waitFor });
|
||||
await runTrackLayoutsSmoke({ 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 });
|
||||
|
||||
67
tests/browser_track_layouts.js
Normal file
67
tests/browser_track_layouts.js
Normal file
@@ -0,0 +1,67 @@
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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, 1, "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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -464,3 +464,29 @@ test("a goal targeting completed sessions computes progress from real exerciseSe
|
||||
assert.ok(!serialized.includes("driver_1"), "exercise session history must never expose driverId");
|
||||
assert.ok(!serialized.includes("class_1"), "exercise session history must never expose classId");
|
||||
});
|
||||
|
||||
test("public profile lets the driver pick a layout, an older one, laps from before layouts, or all of them", () => {
|
||||
const now = Date.now();
|
||||
const oldLayoutDay = now - 60 * 24 * 60 * 60 * 1000;
|
||||
const state = buildState(now);
|
||||
state.trackLayouts = [
|
||||
{ id: "layout_2", label: "Layout 2", startedAt: now - 24 * 60 * 60 * 1000 },
|
||||
{ id: "layout_1", label: "Layout 1", startedAt: oldLayoutDay - 1000 },
|
||||
];
|
||||
state.training.ledger.push(
|
||||
{ driverId: "driver_1", sessionId: "old_track", timestamp: oldLayoutDay, lapMs: 9000, trackLayoutId: "layout_1" },
|
||||
{ driverId: "driver_1", sessionId: "old_track", timestamp: oldLayoutDay + 10000, lapMs: 9200, trackLayoutId: "layout_1" },
|
||||
);
|
||||
const best = (layout) => buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", 0, 0, 0, 0, layout).classProfiles.find((item) => item.classId === "class_1").stats.bestLapMs;
|
||||
assert.equal(best("layout_1"), 9000, "an older layout");
|
||||
assert.equal(best("all"), 9000, "all layouts = overall best");
|
||||
assert.ok(best("none") > 9000, "laps from before any layout existed, without the layout_1 laps");
|
||||
assert.equal(best("current"), 0, "nothing driven on the current layout yet");
|
||||
assert.equal(best("layout_that_does_not_exist"), 0, "an unknown choice falls back to the current layout");
|
||||
|
||||
const layouts = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", 0, 0, 0, 0, "all").trackLayouts;
|
||||
assert.equal(layouts.selected, "all");
|
||||
assert.equal(layouts.currentId, "layout_2");
|
||||
assert.deepEqual(layouts.options, [{ id: "layout_1", label: "Layout 1" }, { id: "layout_2", label: "Layout 2" }], "oldest first, only id and label are exposed");
|
||||
assert.equal(layouts.hasUntaggedLaps, true);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } from "../src/track_layouts_logic.js";
|
||||
import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel, isPracticeSessionType, findRaceSessionBlockingLayoutChange, isTrackLayoutInUse } from "../src/track_layouts_logic.js";
|
||||
import { getTrackRecordMs } from "../src/timing_logic.js";
|
||||
|
||||
test("normalizeTrackLayout fills in an id via uid() when missing and coerces startedAt", () => {
|
||||
@@ -98,3 +98,18 @@ test("getTrackRecordMs does not conflate a layout-tagged session with an untagge
|
||||
});
|
||||
assert.equal(getTrackRecordMs(state.sessions[0], { state }), null, "the legacy session (no trackLayoutId) is a different bucket from layout_1");
|
||||
});
|
||||
|
||||
test("layouts change only around practice: a running or paused race session blocks it", () => {
|
||||
assert.equal(isPracticeSessionType("open_practice"), true);
|
||||
assert.equal(isPracticeSessionType("free_practice"), true);
|
||||
assert.equal(isPracticeSessionType("qualification"), false);
|
||||
assert.equal(findRaceSessionBlockingLayoutChange([{ type: "open_practice", status: "running" }, { type: "final", status: "ready" }]), null, "running practice and a not-yet-started final don't block");
|
||||
assert.equal(findRaceSessionBlockingLayoutChange([{ name: "Q1", type: "qualification", status: "paused" }])?.name, "Q1");
|
||||
assert.equal(findRaceSessionBlockingLayoutChange([{ name: "Heat", type: "heat", status: "running" }])?.name, "Heat");
|
||||
});
|
||||
|
||||
test("a layout is in use when a session started on it or a practice lap was driven on it", () => {
|
||||
assert.equal(isTrackLayoutInUse("layout_2", { sessions: [{ trackLayoutId: "layout_1" }], ledger: [] }), false);
|
||||
assert.equal(isTrackLayoutInUse("layout_2", { sessions: [{ trackLayoutId: "layout_2" }] }), true);
|
||||
assert.equal(isTrackLayoutInUse("layout_2", { sessions: [{ trackLayoutId: "layout_1" }], ledger: [{ trackLayoutId: "layout_2" }] }), true, "a mid-session switch leaves laps on layout_2 even though no session started on it");
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getBestTrainingDurationResult,
|
||||
getDriverTrainingLaps,
|
||||
getDriverTrainingLapsForCurrentLayout,
|
||||
getDriverTrainingLapsForLayout,
|
||||
getNextTrainingMilestone,
|
||||
getClubTrainingGoalProgress,
|
||||
getTrainingGoalProgress,
|
||||
@@ -908,3 +909,49 @@ test("stint analysis hides race pace 10 for short stints and flags slow laps", (
|
||||
assert.equal(shortStint.consistencyPct, null);
|
||||
assert.equal(shortStint.insufficient, true);
|
||||
});
|
||||
|
||||
test("one Open Practice run across a layout switch: each lap lands on the layout active when it was driven", () => {
|
||||
const switchAt = 10_000;
|
||||
const state = {
|
||||
trackLayouts: [{ id: "layout_1", label: "Layout 1", startedAt: 1 }, { id: "layout_2", label: "Layout 2", startedAt: switchAt }],
|
||||
// The session itself was stamped with layout_1 when it started - that must not decide the laps.
|
||||
sessions: [{ id: "season", name: "Open Practice", eventId: "event_1", type: "open_practice", trackLayoutId: "layout_1" }],
|
||||
events: [{ id: "event_1", name: "Season practice" }],
|
||||
resultsBySession: {
|
||||
season: {
|
||||
passings: [
|
||||
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 12_000, timestamp: 5_000, validLap: true },
|
||||
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 11_800, timestamp: 8_000, validLap: true },
|
||||
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 15_000, timestamp: 20_000, validLap: true },
|
||||
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 14_600, timestamp: 35_000, validLap: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const training = syncTrainingLedger({}, state, { uid: createUid() });
|
||||
assert.deepEqual(training.ledger.map((lap) => lap.trackLayoutId), ["layout_1", "layout_1", "layout_2", "layout_2"]);
|
||||
|
||||
const withLedger = { ...state, training };
|
||||
assert.equal(buildDriverTrainingStats(withLedger, "driver_1").bestLapMs, 14_600, "default = current layout (layout_2)");
|
||||
assert.equal(buildDriverTrainingStats(withLedger, "driver_1", "", { layout: "layout_1" }).bestLapMs, 11_800, "an older layout can be picked");
|
||||
const overall = buildDriverTrainingStats(withLedger, "driver_1", "", { layout: "all" });
|
||||
assert.equal(overall.bestLapMs, 11_800, "all layouts = overall best");
|
||||
assert.equal(overall.totalLaps, 4);
|
||||
assert.equal(buildDriverTrainingStats(withLedger, "driver_1", "", { layout: "layout_1" }).totalLaps, 4, "lifetime totals never depend on the layout choice");
|
||||
assert.equal(buildTrainingStintAnalysis(withLedger, "driver_1", "", { layout: "layout_2" }).rows.reduce((sum, row) => sum + row.lapCount, 0), 2, "stint analysis follows the layout choice");
|
||||
});
|
||||
|
||||
test("layout choice \"none\" picks the laps driven before any layout existed", () => {
|
||||
const state = {
|
||||
trackLayouts: [{ id: "layout_1", label: "Layout 1", startedAt: 50_000 }],
|
||||
training: {
|
||||
ledger: [
|
||||
{ driverId: "driver_1", sessionId: "s0", timestamp: 1000, lapMs: 13_000, trackLayoutId: "" },
|
||||
{ driverId: "driver_1", sessionId: "s1", timestamp: 60_000, lapMs: 16_000, trackLayoutId: "layout_1" },
|
||||
],
|
||||
},
|
||||
};
|
||||
assert.deepEqual(getDriverTrainingLapsForLayout(state, "driver_1", "", "none").map((lap) => lap.lapMs), [13_000]);
|
||||
assert.deepEqual(getDriverTrainingLapsForLayout(state, "driver_1", "", "current").map((lap) => lap.lapMs), [16_000]);
|
||||
assert.equal(getDriverTrainingLapsForLayout(state, "driver_1", "", "all").length, 2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user