From 627179b95a7a3fe241cbd5b66a6dcb17cc337140 Mon Sep 17 00:00:00 2001 From: larssand Date: Sun, 27 Sep 2026 21:24:44 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 12 +++++ ROADMAP.md | 17 +++++++ docs/javascript-architecture.md | 17 +++++-- package-lock.json | 4 +- package.json | 2 +- server.js | 2 +- server/public_training_profile.js | 26 ++++++++++- src/app.js | 58 +++++++++++++++++------ src/core_views.js | 7 ++- src/public_training.css | 1 + src/public_training.js | 34 ++++++++++++++ src/race_day_view.js | 6 ++- src/styles.css | 9 ++++ src/track_layout_bar.js | 35 ++++++++++++++ src/track_layouts_logic.js | 23 +++++++++ src/training_logic.js | 33 +++++++++---- src/training_views.js | 53 ++++++++++++++++++--- tests/browser_smoke.js | 2 + tests/browser_track_layouts.js | 67 +++++++++++++++++++++++++++ tests/public_training_profile.test.js | 26 +++++++++++ tests/track_layouts_logic.test.js | 17 ++++++- tests/training_logic.test.js | 47 +++++++++++++++++++ 22 files changed, 454 insertions(+), 44 deletions(-) create mode 100644 src/track_layout_bar.js create mode 100644 tests/browser_track_layouts.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 629a3be..6878e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.25.0 - 2026-09-27 + +### Added + +- "New layout" button on Training stats, Race Day and the Dashboard - no need to dig through Settings. Only offered around practice, and refused while a race session is running. +- "Stats for" selector on Training stats and in the driver's mobile profile: current layout, any older layout, laps from before the first layout, or All layouts. Lifetime totals always count every lap. + +### Changed + +- Practice laps now count toward the layout active when each lap was driven, so one Open Practice can run all season and the layout can be switched mid-session. +- A track layout that has practice laps on it can no longer be deleted. + ## 1.24.0 - 2026-09-27 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 07a8adf..71b9ab4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,6 +219,23 @@ Extended 2026-09-10, per the user pointing out this matters most for Open Practi Verified 2026-09-10: new unit tests in `tests/training_logic.test.js` and `tests/public_training_profile.test.js` cover a driver with laps on two different layouts, confirming lifetime totals count every lap while personal-best/consistency numbers only reflect the current layout, and that `buildTrainingStintAnalysis`'s "best stint" pick never surfaces a faster stint from an old, incomparable layout. A full headless-browser run seeded a driver with a fast old-layout lap and a slower current-layout lap, then confirmed **both** the admin Training page and the real public mobile profile page show the slower current-layout lap as "Best lap" (never the faster but incomparable old one) while still showing the correct full lifetime lap count in both places. +### Follow-up 2026-09-27: switching layout mid-season, and stats per layout + +Reported by the user: they remembered being able to create Layout 1, 2, 3, 4 for Open Practice to get stats per layout - but couldn't find where to switch to a new one, and wanted both overall training stats and a per-layout view. Three real gaps, found by checking the code rather than assuming: + +1. **Hard to find:** layouts were only managed under Settings -> Appearance & overlay, where nobody looks for track layouts. +2. **No per-layout view:** pace/best/consistency were hard-wired to the active layout; lifetime totals to all history. No way to look at an older layout or at everything together, even though every practice lap was already stored with its layout. +3. **Didn't fit how the user actually runs practice** (their follow-up: "the same Open Practice all season, just switching layout"): a session's layout was frozen when it started, and every training lap inherited it - so in one season-long Open Practice every lap would have stayed on the first layout forever. + +Also clarified by the user mid-work: layouts belong to practice, never to a race format. + +- [x] **Per-lap layout tagging** (`syncTrainingLedger`, `src/training_logic.js`): each training lap gets the layout active when it was driven, not when its session started. Switching layout in a running Open Practice splits its laps correctly, no restart needed; the running practice session itself also moves onto the new layout. The race track-record cue (`getTrackRecordMs`) is unchanged - it compares whole other sessions and never a session's own laps. +- [x] **Practice-only rules** (`src/track_layouts_logic.js`): `findRaceSessionBlockingLayoutChange` refuses a new layout while a race-format session (anything but open/free practice - including a structured event's "practice" slot) is running or paused, with a message naming it; applies everywhere, Settings included. `isTrackLayoutInUse` also counts practice laps, so a layout that only received laps through a mid-session switch can't be deleted and silently shift those laps to the previous layout. +- [x] **"New layout" where you drive** (`src/track_layout_bar.js`, new): a "Track layout: Layout 3 - since 12 Sep [New layout]" strip on Training stats (always), Race Day and the Dashboard (button only when the session in focus is Open/Free Practice or there is none). One prompt for the name - blank gives the next "Layout N" - active from now. +- [x] **Stats for: current layout / an older layout / no layout (before the first) / All layouts** on Training stats (`training-layout`) and in the driver's mobile profile (`profileLayout`, `?layout=` on `GET /public-training/api/:shareToken`, which now also returns `trackLayouts: { selected, currentId, options[{id,label}], hasUntaggedLaps }`). The choice drives best lap, pace, consistency, stints and the driver comparison; lifetime totals always cover every lap. `buildDriverTrainingStats`/`buildTrainingStintAnalysis` take `{ layout }`, and the server copy in `server/public_training_profile.js` mirrors it. Found on the way: the `buildDriverTrainingStats` wrapper in `app.js` passed on only three arguments, which would have silently dropped the layout choice. + +Verified 2026-09-27: unit tests cover an Open Practice spanning a layout switch (laps split 2+2 despite the session being stamped with the first layout), each layout choice's best lap and lap selection, lifetime totals unaffected, `"none"`, the practice-only blocking rules, the in-use rule counting practice laps, and the public profile's layout choice/fallback/option list. `tests/browser_track_layouts.js` drives the real UI: New layout on Training stats creates "Layout 1" and the selector appears, Race Day hides the button for a qualifying session and shows it for Open Practice, and a running qualifying session blocks a new layout with a message naming it. A real browser run with a driver's laps on two layouts showed the right best lap for every choice in both admin and the mobile profile: current (Layout 2) 0:16.100, Layout 1 0:12.000, All layouts 0:12.000. + ## PDF export character encoding Fixed 2026-09-09, reported while exporting an old race from the new Results archive - correctly suspected as a general PDF export bug, not specific to that one feature. `buildSimplePdf` in `server.js` (the shared renderer behind every PDF export: race results, start lists, heat sheets, team reports, and the archive) declared its font with no `/Encoding` at all, so viewers fell back to the standard font's built-in encoding, which has no å/ä/ö glyphs - and separately, the content stream was written as UTF-8, so even a correctly-encoded font would have received the wrong bytes (a multi-byte UTF-8 sequence where a single WinAnsi byte was expected), the classic "åäö" mojibake pattern. Fixed by declaring `/Encoding /WinAnsiEncoding` and writing the content stream through a proper WinAnsi (~CP1252) byte mapper instead of UTF-8 - Swedish å/ä/ö/Å/Ä/Ö map directly to their Latin-1 byte values, with the handful of CP1252 "smart punctuation" code points (curly quotes, en/em dash, €, etc.) mapped explicitly; anything else falls back to `?` rather than corrupting the byte stream. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index e966010..c469354 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -569,6 +569,13 @@ Owns: - `piper_speech.js`: `speakWithPiper` fetches the server's WAV and plays it; on any error or after 2.5 s it calls the browser-voice fallback instead. `speakTextHelper` (`runtime_services.js`) routes through it when `settings.ttsEngine === "piper"`, so legacy speaker cues and the race-audio engine both use it - `tts_settings_view.js`: the "Voice engine" panel in Settings -> Audio (engine choice, sv/en install status, install / pre-generate / test buttons) +### `src/track_layout_bar.js` +Type: +- shared UI strip + +Owns: +- "Track layout: + ${trainingLayoutSelectMarkup(state, t, escapeHtml)} + ${renderTrackLayoutBar({ state, t, escapeHtml })}
${profileShare ? ` @@ -863,6 +900,7 @@ export function renderTrainingView(deps) { setSelectedClassId(""); renderView(); }); + bindTrainingLayoutControls({ state, t, addTrackLayout, renderView }); document.getElementById("training-class")?.addEventListener("change", (event) => { setSelectedClassId(event.currentTarget.value); renderView(); @@ -1022,7 +1060,7 @@ export function buildTrainingOverviewRows(state, profiles, buildDriverTrainingSt driver: profile.representative, classId, className: state.classes.find((item) => String(item.id || "") === String(classId || ""))?.name || classId || "-", - stats: buildDriverTrainingStats(state, profile.id, classId), + stats: buildDriverTrainingStats(state, profile.id, classId, { layout: selectedTrainingLayout }), })); }).sort((left, right) => ( right.stats.lastLapAt - left.stats.lastLapAt @@ -1046,7 +1084,7 @@ export function buildTrainingProgressFeed(rows, limit = 6) { }); } -function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, saveState, profiles, buildDriverTrainingStats, getNextTrainingMilestone, getClubTrainingGoalProgress, countExerciseSessions, setSelectedDriverId, setSelectedClassId, renderView }) { +function renderTrainingOverview({ addTrackLayout, state, dom, t, escapeHtml, formatLap, uid, saveState, profiles, buildDriverTrainingStats, getNextTrainingMilestone, getClubTrainingGoalProgress, countExerciseSessions, setSelectedDriverId, setSelectedClassId, renderView }) { const rows = buildTrainingOverviewRows(state, profiles, buildDriverTrainingStats); const progressFeed = buildTrainingProgressFeed(rows); const ledger = Array.isArray(state.training?.ledger) ? state.training.ledger : []; @@ -1130,6 +1168,8 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, sav ${driverOptions} + ${trainingLayoutSelectMarkup(state, t, escapeHtml)} + ${renderTrackLayoutBar({ state, t, escapeHtml })}
@@ -1185,6 +1225,7 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, sav `; + bindTrainingLayoutControls({ state, t, addTrackLayout, renderView }); document.getElementById("training-overview-driver")?.addEventListener("change", (event) => { if (!event.currentTarget.value) return; setSelectedDriverId(event.currentTarget.value); diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 9fed12c..f21c3b5 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -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 }); diff --git a/tests/browser_track_layouts.js b/tests/browser_track_layouts.js new file mode 100644 index 0000000..8bf1068 --- /dev/null +++ b/tests/browser_track_layouts.js @@ -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)), + }); + } +} diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index aeb8f33..e0e6dec 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -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); +}); diff --git a/tests/track_layouts_logic.test.js b/tests/track_layouts_logic.test.js index a062dbd..3f3a789 100644 --- a/tests/track_layouts_logic.test.js +++ b/tests/track_layouts_logic.test.js @@ -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"); +}); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index 1b55e85..039852f 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -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); +});