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>
This commit is contained in:
larssand
2026-10-07 20:33:22 +02:00
parent 8601954ea2
commit 9d79bcb4d3
13 changed files with 174 additions and 15 deletions

View File

@@ -1,7 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel, isPracticeSessionType, findRaceSessionBlockingLayoutChange, isTrackLayoutInUse } from "../src/track_layouts_logic.js";
import { getTrackRecordMs } from "../src/timing_logic.js";
import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel, isPracticeSessionType, findRaceSessionBlockingLayoutChange, isTrackLayoutInUse, getLayoutLapWindowOverride } from "../src/track_layouts_logic.js";
import { getTrackRecordMs, getSessionLapWindow } from "../src/timing_logic.js";
import { buildRaceDayPreflight } from "../src/race_day_logic.js";
test("normalizeTrackLayout fills in an id via uid() when missing and coerces startedAt", () => {
const withoutId = normalizeTrackLayout({ label: "Layout 1", startedAt: 1000 }, { uid: (prefix) => `${prefix}_generated` });
@@ -128,3 +129,33 @@ test("a layout is in use when a session started on it or a practice lap was driv
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");
});
test("practice uses its layout's min/max lap time where set; races always keep the event's", () => {
const state = {
events: [{ id: "event_1", mode: "race", raceConfig: { minLapMs: 12000, maxLapMs: 60000 } }],
trackLayouts: [
{ id: "layout_1", label: "Layout 1", startedAt: 1000 },
{ id: "layout_2", label: "Layout 2 (short)", startedAt: 2000, minLapMs: 8000, maxLapMs: 40000 },
{ id: "layout_3", label: "Layout 3 (min only)", startedAt: 3000, minLapMs: 15000 },
],
};
const practice = (trackLayoutId) => ({ eventId: "event_1", type: "open_practice", trackLayoutId });
assert.deepEqual(getSessionLapWindow(practice("layout_1"), { state }), { minLapMs: 12000, maxLapMs: 60000 }, "no layout values -> the event's");
assert.deepEqual(getSessionLapWindow(practice("layout_2"), { state }), { minLapMs: 8000, maxLapMs: 40000 });
assert.deepEqual(getSessionLapWindow(practice("layout_3"), { state }), { minLapMs: 15000, maxLapMs: 60000 }, "only min set -> the event's max stays");
assert.deepEqual(getSessionLapWindow({ eventId: "event_1", type: "heat", trackLayoutId: "layout_2" }, { state }), { minLapMs: 12000, maxLapMs: 60000 }, "a heat ignores the layout");
assert.deepEqual(getLayoutLapWindowOverride(state.trackLayouts, { type: "free_practice", trackLayoutId: "layout_2" }), { minLapMs: 8000, maxLapMs: 40000 });
assert.deepEqual(normalizeTrackLayout({ id: "x", minLapMs: -5, maxLapMs: "40000" }), { ...normalizeTrackLayout({ id: "x" }), minLapMs: 0, maxLapMs: 40000, startedAt: normalizeTrackLayout({ id: "x", minLapMs: -5, maxLapMs: "40000" }).startedAt });
});
test("Race Day preflight shows the layout's lap window for practice", () => {
const state = {
events: [{ id: "event_1", mode: "race", raceConfig: { minLapMs: 12000, maxLapMs: 60000, practiceOnly: true } }],
trackLayouts: [{ id: "layout_2", startedAt: 2000, minLapMs: 8000, maxLapMs: 40000 }],
sessions: [], drivers: [], classes: [], resultsBySession: {}, settings: {}, decoder: {},
};
const session = { id: "op", eventId: "event_1", type: "open_practice", trackLayoutId: "layout_2", status: "ready", mode: "race" };
const lapWindow = buildRaceDayPreflight(state, session, { backendAvailable: true }).checks.find((check) => check.id === "lap_window");
assert.equal(lapWindow.minLapMs, 8000);
assert.equal(lapWindow.maxLapMs, 40000);
});