Clubs that reconfigure their track (e.g. running indoors in the same building) can now add named track layouts under Settings, each with just a start date/time - layouts are contiguous, so a layout's end is implicitly the next one's start, with no separate field to keep in sync. Every session freezes which layout was active when it started, so the "track record" audio cue and the Results Archive (which now shows each session's layout) only compare laps run on the same physical track. With no layouts configured, everything behaves exactly as before - the feature is fully inert until a club uses it. Design settled through conversation with the user: global scope (not per class), start-date-only (no redundant stop date), and no changes needed to driver/transponder mapping, since that's already fully independent of track geometry in this app. Deliberately out of scope for this pass: the mobile/driver training profile's own cross-session personal-best trend stats still compare across a driver's whole lap ledger regardless of layout - logged in ROADMAP.md as a real remaining gap for a later pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
101 lines
4.5 KiB
JavaScript
101 lines
4.5 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } 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", () => {
|
|
const withoutId = normalizeTrackLayout({ label: "Layout 1", startedAt: 1000 }, { uid: (prefix) => `${prefix}_generated` });
|
|
assert.equal(withoutId.id, "layout_generated");
|
|
assert.equal(withoutId.startedAt, 1000);
|
|
|
|
const malformed = normalizeTrackLayout({ id: "x", label: " Layout 2 ", startedAt: "not-a-number" });
|
|
assert.equal(malformed.label, "Layout 2");
|
|
assert.ok(Number.isFinite(malformed.startedAt), "falls back to Date.now() rather than NaN");
|
|
});
|
|
|
|
test("normalizeTrackLayouts sorts chronologically and drops entries with no id", () => {
|
|
const layouts = normalizeTrackLayouts([
|
|
{ id: "b", label: "Later", startedAt: 2000 },
|
|
{ id: "a", label: "Earlier", startedAt: 1000 },
|
|
{ label: "" },
|
|
], { uid: () => "" });
|
|
assert.deepEqual(layouts.map((layout) => layout.id), ["a", "b"]);
|
|
});
|
|
|
|
test("getTrackLayoutIdAt returns the latest layout that has already started, treating layouts as contiguous", () => {
|
|
const layouts = [
|
|
{ id: "layout_1", label: "Layout 1", startedAt: 1000 },
|
|
{ id: "layout_2", label: "Layout 2", startedAt: 2000 },
|
|
{ id: "layout_3", label: "Layout 3", startedAt: 3000 },
|
|
];
|
|
assert.equal(getTrackLayoutIdAt(layouts, 500), "", "before any layout existed");
|
|
assert.equal(getTrackLayoutIdAt(layouts, 1000), "layout_1", "exactly at a layout's start");
|
|
assert.equal(getTrackLayoutIdAt(layouts, 2500), "layout_2", "between two layouts belongs to the earlier one");
|
|
assert.equal(getTrackLayoutIdAt(layouts, 9999), "layout_3", "after the last change, still the latest layout");
|
|
});
|
|
|
|
test("getTrackLayoutIdAt and getTrackLayoutLabel are inert with no layouts configured", () => {
|
|
assert.equal(getTrackLayoutIdAt([], Date.now()), "");
|
|
assert.equal(getTrackLayoutLabel([], ""), "");
|
|
assert.equal(getTrackLayoutLabel([{ id: "layout_1", label: "Layout 1" }], ""), "", "an empty id never matches a real layout");
|
|
});
|
|
|
|
test("getTrackLayoutLabel resolves a known id and nextTrackLayoutLabel numbers sequentially", () => {
|
|
const layouts = [{ id: "layout_1", label: "Layout 1", startedAt: 1000 }];
|
|
assert.equal(getTrackLayoutLabel(layouts, "layout_1"), "Layout 1");
|
|
assert.equal(getTrackLayoutLabel(layouts, "missing"), "");
|
|
assert.equal(nextTrackLayoutLabel([]), "Layout 1");
|
|
assert.equal(nextTrackLayoutLabel(layouts), "Layout 2");
|
|
});
|
|
|
|
function createTrackRecordState({ sessions, results }) {
|
|
return {
|
|
events: [{ id: "event_1", classId: "class_1" }],
|
|
sessions,
|
|
resultsBySession: results,
|
|
};
|
|
}
|
|
|
|
test("getTrackRecordMs finds the fastest lap among other sessions in the same class when no layout is set", () => {
|
|
const state = createTrackRecordState({
|
|
sessions: [
|
|
{ id: "session_1", eventId: "event_1" },
|
|
{ id: "session_2", eventId: "event_1" },
|
|
],
|
|
results: {
|
|
session_2: { competitors: { a: { bestLapMs: 12345 } } },
|
|
},
|
|
});
|
|
assert.equal(getTrackRecordMs(state.sessions[0], { state }), 12345);
|
|
});
|
|
|
|
test("getTrackRecordMs only compares sessions run on the same track layout once layouts are in use", () => {
|
|
const state = createTrackRecordState({
|
|
sessions: [
|
|
{ id: "current", eventId: "event_1", trackLayoutId: "layout_2" },
|
|
{ id: "same_layout", eventId: "event_1", trackLayoutId: "layout_2" },
|
|
{ id: "old_layout", eventId: "event_1", trackLayoutId: "layout_1" },
|
|
],
|
|
results: {
|
|
same_layout: { competitors: { a: { bestLapMs: 15000 } } },
|
|
old_layout: { competitors: { a: { bestLapMs: 9000 } } },
|
|
},
|
|
});
|
|
// The 9000ms lap belongs to a different, earlier layout - it must not count as the record
|
|
// for a lap set on the current layout, even though it is numerically faster.
|
|
assert.equal(getTrackRecordMs(state.sessions[0], { state }), 15000);
|
|
});
|
|
|
|
test("getTrackRecordMs does not conflate a layout-tagged session with an untagged legacy one", () => {
|
|
const state = createTrackRecordState({
|
|
sessions: [
|
|
{ id: "current", eventId: "event_1", trackLayoutId: "layout_1" },
|
|
{ id: "legacy", eventId: "event_1" },
|
|
],
|
|
results: {
|
|
legacy: { competitors: { a: { bestLapMs: 5000 } } },
|
|
},
|
|
});
|
|
assert.equal(getTrackRecordMs(state.sessions[0], { state }), null, "the legacy session (no trackLayoutId) is a different bucket from layout_1");
|
|
});
|