feat(training): base training stats on practice, keep race laps separate
Training stats count Open/Free practice by default; laps from qualifying, heats and finals are kept and shown under Based on: Races or All, marked with race, session and series. Races are listed per race session and never split into stints. Same picker in the mobile profile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { runSuspectLapsSmoke } from "./browser_suspect_laps.js";
|
||||
import { runCorrectionsScopeSmoke } from "./browser_corrections_scope.js";
|
||||
import { runDriversPageSmoke } from "./browser_drivers_page.js";
|
||||
import { runSeriesSmoke } from "./browser_series.js";
|
||||
import { runTrainingSourceSmoke } from "./browser_training_source.js";
|
||||
import { runObsPopupSmoke } from "./browser_obs_popup.js";
|
||||
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -162,6 +163,7 @@ try {
|
||||
await runCorrectionsScopeSmoke({ evaluate, navigate, savedState, waitFor, base });
|
||||
await runDriversPageSmoke({ evaluate, navigate, savedState, waitFor, base });
|
||||
await runSeriesSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs });
|
||||
await runTrainingSourceSmoke({ evaluate, navigate, savedState, waitFor, base });
|
||||
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 });
|
||||
|
||||
60
tests/browser_training_source.js
Normal file
60
tests/browser_training_source.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Training stats "Based on": practice by default; "Races" replaces the stint table with one
|
||||
// row per race session, marked 🏁 with race, session and series - race laps never form stints.
|
||||
export async function runTrainingSourceSmoke({ evaluate, navigate, savedState, waitFor, base }) {
|
||||
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 seeded = await savedState();
|
||||
const t0 = Date.now() - 3600000;
|
||||
const lap = (sessionId, offset, lapMs) => ({ driverId: "ts_driver", competitorKey: "driver_ts_driver", sessionId, lapMs, timestamp: t0 + offset, validLap: true });
|
||||
await patchState({
|
||||
classes: [...seeded.classes, { id: "ts_class", name: "TS 2WD" }],
|
||||
drivers: [...seeded.drivers, { id: "ts_driver", profileId: "ts_profile", name: "TS Driver", classId: "ts_class", transponder: "8770001" }],
|
||||
events: [...seeded.events, { id: "ts_practice_event", name: "TS Practice", classId: "ts_class", mode: "race", raceConfig: { practiceOnly: true } }, { id: "ts_race_event", name: "TS Winter #3 TS 2WD", classId: "ts_class", mode: "race", date: "2026-11-29" }],
|
||||
sessions: [...seeded.sessions, { id: "ts_op", eventId: "ts_practice_event", name: "TS Open Practice", type: "open_practice", status: "finished" }, { id: "ts_q2", eventId: "ts_race_event", name: "Kval 2", type: "qualification", status: "finished" }],
|
||||
series: [...(seeded.series || []), { id: "ts_series", name: "TS Winter Cup", eventIds: ["ts_race_event"], classIds: ["ts_class"] }],
|
||||
resultsBySession: {
|
||||
...seeded.resultsBySession,
|
||||
ts_op: { passings: [lap("ts_op", 20000, 20000), lap("ts_op", 40000, 20000), lap("ts_op", 59500, 19500), lap("ts_op", 79000, 19500)], competitors: {} },
|
||||
ts_q2: { passings: [lap("ts_q2", 900000, 18000), lap("ts_q2", 918000, 18200), lap("ts_q2", 936400, 18400)], competitors: {} },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await navigate("training", "view");
|
||||
// The overview has its own driver picker; the driver page has another one.
|
||||
await waitFor(async () => Boolean(await evaluate(`[...document.querySelectorAll("#training-overview-driver option, #training-driver option")].some((option) => option.value === "ts_profile")`)), "driver listed");
|
||||
await evaluate(`(() => { const select = document.getElementById("training-overview-driver") || document.getElementById("training-driver"); select.value = "ts_profile"; select.dispatchEvent(new Event("change")); })()`);
|
||||
await waitFor(async () => (await evaluate(`document.getElementById("training-driver")?.value`)) === "ts_profile" && Boolean(await evaluate(`document.getElementById("training-source")`)), "training stats for the driver");
|
||||
assert.equal(await evaluate(`document.getElementById("training-source").value`), "practice", "practice is the default");
|
||||
assert.equal(await evaluate(`Boolean(document.querySelector('[data-sort-id="training-stints-featured"]'))`), true, "stints for practice");
|
||||
assert.equal(await evaluate(`document.querySelectorAll(".training-race-origin").length`), 0, "no race laps mixed into practice");
|
||||
|
||||
await evaluate(`(() => { const select = document.getElementById("training-source"); select.value = "race"; select.dispatchEvent(new Event("change")); })()`);
|
||||
await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-sort-id="training-race-sessions"]')`)), "race sessions table");
|
||||
assert.equal(await evaluate(`Boolean(document.querySelector('[data-sort-id="training-stints-featured"]'))`), false, "no stints for races");
|
||||
const row = await evaluate(`[...document.querySelectorAll('[data-sort-id="training-race-sessions"] tbody tr')].map((tr) => [...tr.children].map((td) => td.textContent.trim()))[0]`);
|
||||
assert.equal(row[1], "🏁 TS Winter #3 TS 2WD · Kval 2 · TS Winter Cup", "race, session and series");
|
||||
assert.equal(row[2], "3");
|
||||
console.log("PASS training stats: practice by default; races shown per race session with 🏁 race · session · series, never as stints");
|
||||
} finally {
|
||||
const current = await savedState();
|
||||
const results = { ...current.resultsBySession };
|
||||
delete results.ts_op;
|
||||
delete results.ts_q2;
|
||||
await patchState({
|
||||
resultsBySession: results,
|
||||
sessions: current.sessions.filter((session) => !session.id.startsWith("ts_")),
|
||||
events: current.events.filter((event) => !event.id.startsWith("ts_")),
|
||||
series: (current.series || []).filter((series) => series.id !== "ts_series"),
|
||||
drivers: current.drivers.filter((driver) => driver.id !== "ts_driver"),
|
||||
classes: current.classes.filter((item) => item.id !== "ts_class"),
|
||||
training: { ...current.training, ledger: (current.training?.ledger || []).filter((entry) => !String(entry.sessionId).startsWith("ts_")) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -490,3 +490,20 @@ test("public profile lets the driver pick a layout, an older one, laps from befo
|
||||
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);
|
||||
});
|
||||
|
||||
test("public profile counts practice by default; race laps only with source race or all", () => {
|
||||
const now = Date.now();
|
||||
const state = buildState(now);
|
||||
state.sessions = [{ id: "free", type: "free_practice" }, { id: "heat_1", type: "heat" }];
|
||||
state.training.ledger.push(
|
||||
{ driverId: "driver_1", sessionId: "heat_1", sessionType: "heat", timestamp: now - 9000, lapMs: 15000 },
|
||||
{ driverId: "driver_1", sessionId: "heat_1", timestamp: now - 7000, lapMs: 15500 }, // type comes from the session
|
||||
);
|
||||
const stats = (source) => buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", 0, 0, 0, 0, "current", source).classProfiles.find((item) => item.classId === "class_1").stats;
|
||||
assert.equal(stats(undefined).totalLaps, 4, "practice only by default");
|
||||
assert.equal(stats("practice").bestLapMs, 16000);
|
||||
assert.equal(stats("race").totalLaps, 2);
|
||||
assert.equal(stats("race").bestLapMs, 15000);
|
||||
assert.equal(stats("all").totalLaps, 6);
|
||||
assert.equal(buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", 0, 0, 0, 0, "current", "bogus").lapSource, "practice");
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildTrainingPaceBuckets,
|
||||
buildTrainingRecommendation,
|
||||
buildTrainingStintAnalysis,
|
||||
buildTrainingRaceSessions,
|
||||
buildTrainingWeekdayStats,
|
||||
countExerciseSessions,
|
||||
countDriverExerciseSessions,
|
||||
@@ -983,3 +984,47 @@ test("layout choice \"none\" picks the laps driven before any layout existed", (
|
||||
assert.deepEqual(getDriverTrainingLapsForLayout(state, "driver_1", "", "current").map((lap) => lap.lapMs), [16_000]);
|
||||
assert.equal(getDriverTrainingLapsForLayout(state, "driver_1", "", "all").length, 2);
|
||||
});
|
||||
|
||||
test("training stats count practice by default; race laps are kept, marked and shown per race session, never as stints", () => {
|
||||
let n = 0;
|
||||
const uid = (prefix) => `${prefix}_${++n}`;
|
||||
const lap = (sessionId, timestamp, lapMs) => ({ driverId: "d1", competitorKey: "d1", sessionId, lapMs, timestamp, validLap: true });
|
||||
const state = {
|
||||
drivers: [{ id: "d1", name: "D1", classId: "c1" }],
|
||||
classes: [{ id: "c1", name: "2WD" }],
|
||||
events: [{ id: "e_practice", name: "Club practice" }, { id: "e_race", name: "Winter #3 2WD" }],
|
||||
sessions: [
|
||||
{ id: "op", name: "Open Practice", type: "open_practice", eventId: "e_practice" },
|
||||
{ id: "q2", name: "Kval 2", type: "qualification", eventId: "e_race" },
|
||||
],
|
||||
series: [{ id: "s", name: "Winter Cup", eventIds: ["e_race"] }],
|
||||
resultsBySession: {
|
||||
op: { passings: [lap("op", 1000, 20000), lap("op", 21000, 20000), lap("op", 41000, 19500), lap("op", 60500, 19500)] },
|
||||
q2: { passings: [lap("q2", 500000, 18000), lap("q2", 518000, 18200), lap("q2", 536200, 18400)] },
|
||||
},
|
||||
};
|
||||
state.training = syncTrainingLedger({}, state, { uid });
|
||||
assert.deepEqual(state.training.ledger.map((entry) => entry.sessionType), ["open_practice", "open_practice", "open_practice", "open_practice", "qualification", "qualification", "qualification"]);
|
||||
|
||||
assert.equal(getDriverTrainingLaps(state, "d1").length, 4, "practice by default");
|
||||
assert.equal(getDriverTrainingLaps(state, "d1", "", { source: "race" }).length, 3);
|
||||
assert.equal(getDriverTrainingLaps(state, "d1", "", { source: "all" }).length, 7);
|
||||
assert.equal(buildDriverTrainingStats(state, "d1").bestLapMs, 19500, "a race lap doesn't become the training best");
|
||||
assert.equal(buildDriverTrainingStats(state, "d1", "", { source: "all" }).bestLapMs, 18000);
|
||||
|
||||
// Stints come from practice laps only, even with "all"; with "race" there are none.
|
||||
const allStints = buildTrainingStintAnalysis(state, "d1", "", { source: "all" });
|
||||
assert.equal(allStints.rows.reduce((sum, row) => sum + row.lapCount, 0), 4);
|
||||
assert.equal(buildTrainingStintAnalysis(state, "d1", "", { source: "race" }).rows.length, 0);
|
||||
|
||||
const races = buildTrainingRaceSessions(state, "d1");
|
||||
assert.equal(races.length, 1);
|
||||
assert.deepEqual([races[0].sessionName, races[0].eventName, races[0].eventId, races[0].lapCount, races[0].bestLapMs, races[0].averageLapMs], ["Kval 2", "Winter #3 2WD", "e_race", 3, 18000, 18200]);
|
||||
|
||||
// A lap saved before session types were recorded gets it from its (still existing) session.
|
||||
const old = { ...state, training: { ...state.training, ledger: state.training.ledger.map(({ sessionType, ...entry }) => entry) } };
|
||||
assert.equal(getDriverTrainingLaps(old, "d1", "", { source: "race" }).length, 3);
|
||||
delete old.resultsBySession;
|
||||
const resynced = syncTrainingLedger(old.training, old, { uid });
|
||||
assert.equal(resynced.ledger.filter((entry) => entry.sessionType === "qualification").length, 3, "back-filled on the next sync");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user