The user pointed out that track layout changes matter most for Open Practice/training, not races - so the admin Training page and the driver's own mobile profile (server/public_training_profile.js, the actual thing most viewed after practice) needed the same layout scoping already applied to the live "track record" audio cue. Every stat now splits into two groups: lifetime/commitment numbers (total laps, sessions, training days, milestone badges) still read a driver's whole history, since a track rebuild doesn't erase past practice. Pace/record/consistency numbers (personal best, recent-pace trend, consistency spread, the "stable pace" control zone, stint achievements, "best stint") now read only laps tagged with whichever layout is currently active. Each ledger entry is tagged with trackLayoutId at the moment it's synced from a session, so it survives the source session later being deleted. Verified with new unit tests covering both implementations and a real headless-browser run confirming both the admin page and the actual public mobile profile show the current-layout best lap (never a faster but incomparable old-layout one) while keeping the correct full lifetime lap count in both places. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
911 lines
43 KiB
JavaScript
911 lines
43 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
buildDriverActivePracticeStats,
|
|
buildEvenLapsProgress,
|
|
buildStepTrainingProgress,
|
|
buildDriverTrainingStats,
|
|
buildDriverTimingChannelStats,
|
|
buildTrainingAchievements,
|
|
buildTrainingHighlights,
|
|
buildTrainingMarkerComparisons,
|
|
buildTrainingMissions,
|
|
buildTrainingPaceBuckets,
|
|
buildTrainingRecommendation,
|
|
buildTrainingStintAnalysis,
|
|
buildTrainingWeekdayStats,
|
|
countExerciseSessions,
|
|
countDriverExerciseSessions,
|
|
getBestConsecutiveTrainingLaps,
|
|
getBestTrainingDurationResult,
|
|
getDriverTrainingLaps,
|
|
getDriverTrainingLapsForCurrentLayout,
|
|
getNextTrainingMilestone,
|
|
getClubTrainingGoalProgress,
|
|
getTrainingGoalProgress,
|
|
normalizeTrainingState,
|
|
splitTrainingStints,
|
|
syncTrainingLedger,
|
|
} from "../src/training_logic.js";
|
|
import { getDriverProfileGroups } from "../src/driver_profile_logic.js";
|
|
|
|
function createUid() {
|
|
let index = 0;
|
|
return (prefix) => `${prefix}_${++index}`;
|
|
}
|
|
|
|
test("normalization removes invalid and duplicate training records", () => {
|
|
const normalized = normalizeTrainingState({
|
|
goals: [
|
|
{ id: "goal_1", driverId: "driver_1", targetLaps: 500, title: " Bronze ", reward: " Badge " },
|
|
{ id: "goal_invalid", driverId: "", targetLaps: 100 },
|
|
{ id: "goal_sessions", driverId: "driver_1", classId: "class_1", metric: "sessions", exerciseKind: "stepTraining", targetSessions: 10, title: " Coffee mug " },
|
|
{ id: "goal_sessions_no_kind", driverId: "driver_1", metric: "sessions", targetSessions: 10 },
|
|
],
|
|
exerciseSessions: [
|
|
{ id: "ex_1", driverId: "driver_1", classId: "class_1", kind: "stepTraining", startedAt: 1000, completedAt: "2026-09-09T10:00:00.000Z", summary: { stepsCompleted: 5 } },
|
|
{ id: "ex_duplicate", driverId: "driver_1", classId: "class_1", kind: "stepTraining", startedAt: 1000, completedAt: "2026-09-09T10:05:00.000Z", summary: {} },
|
|
{ id: "ex_invalid_kind", driverId: "driver_1", classId: "class_1", kind: "unknown", startedAt: 2000 },
|
|
{ id: "ex_no_driver", driverId: "", classId: "class_1", kind: "evenLaps", startedAt: 3000 },
|
|
],
|
|
ledger: [
|
|
{ id: "lap_1", sourceKey: "source_1", driverId: "driver_1", lapMs: 16000, timestamp: 1000 },
|
|
{ id: "lap_duplicate", sourceKey: "source_1", driverId: "driver_1", lapMs: 17000, timestamp: 2000 },
|
|
{ id: "lap_short", sourceKey: "source_2", driverId: "driver_1", lapMs: 500, timestamp: 3000 },
|
|
{ id: "lap_anonymous", sourceKey: "source_3", driverId: "", lapMs: 18000, timestamp: 4000 },
|
|
],
|
|
shares: [
|
|
{ driverId: "driver_1", token: "a".repeat(24), enabled: true, visibility: { history: false } },
|
|
{ driverId: "driver_1", token: "b".repeat(24), enabled: true },
|
|
{ driverId: "driver_2", token: "short", enabled: true },
|
|
],
|
|
markers: [
|
|
{ id: "marker_1", driverId: "driver_1", classId: "class_1", timestamp: 5000, type: "tires", note: " New tires " },
|
|
{ id: "marker_invalid", driverId: "", classId: "class_1", timestamp: 5000 },
|
|
],
|
|
clubGoal: {
|
|
id: "club_goal_1",
|
|
title: " Club to 10000 ",
|
|
reward: " Pizza ",
|
|
targetLaps: 10000,
|
|
baselineLaps: 120,
|
|
},
|
|
}, { uid: createUid() });
|
|
|
|
assert.equal(normalized.goals.length, 2, "the sessions goal without an exerciseKind is dropped, same as a laps goal with no target");
|
|
assert.equal(normalized.goals[0].title, "Bronze");
|
|
assert.equal(normalized.goals[0].reward, "Badge");
|
|
assert.equal(normalized.goals[0].metric, "laps");
|
|
const sessionsGoal = normalized.goals.find((goal) => goal.id === "goal_sessions");
|
|
assert.equal(sessionsGoal.metric, "sessions");
|
|
assert.equal(sessionsGoal.exerciseKind, "stepTraining");
|
|
assert.equal(sessionsGoal.targetSessions, 10);
|
|
assert.equal(normalized.exerciseSessions.length, 1, "the duplicate same driver/class/kind/startedAt session, the invalid kind, and the driver-less entry are all dropped");
|
|
assert.equal(normalized.exerciseSessions[0].id, "ex_1");
|
|
assert.deepEqual(normalized.exerciseSessions[0].summary, { stepsCompleted: 5 });
|
|
assert.deepEqual(normalized.ledger.map((lap) => lap.id), ["lap_1"]);
|
|
assert.deepEqual(normalized.shares.map((share) => share.token), ["a".repeat(24)]);
|
|
assert.deepEqual(normalized.shares[0].visibility, { progress: true, achievements: true, performance: true, goals: true, history: false });
|
|
assert.equal(normalized.markers.length, 1);
|
|
assert.equal(normalized.markers[0].note, "New tires");
|
|
assert.equal(normalized.clubGoal.title, "Club to 10000");
|
|
assert.equal(normalized.clubGoal.reward, "Pizza");
|
|
assert.equal(normalized.clubGoal.baselineLaps, 120);
|
|
});
|
|
|
|
test("a club goal can also target completed sessions of one exercise kind, club-wide", () => {
|
|
const withSessions = normalizeTrainingState({
|
|
clubGoal: { title: "Mug run", reward: "Mug", metric: "sessions", exerciseKind: "evenLaps", targetSessions: 50, baselineSessions: 5 },
|
|
}, { uid: createUid() });
|
|
assert.equal(withSessions.clubGoal.metric, "sessions");
|
|
assert.equal(withSessions.clubGoal.exerciseKind, "evenLaps");
|
|
assert.equal(withSessions.clubGoal.targetSessions, 50);
|
|
assert.equal(withSessions.clubGoal.baselineSessions, 5);
|
|
|
|
const missingKind = normalizeTrainingState({
|
|
clubGoal: { title: "Invalid", metric: "sessions", targetSessions: 50 },
|
|
}, { uid: createUid() });
|
|
assert.equal(missingKind.clubGoal, null, "a sessions club goal without an exerciseKind is dropped, same as a laps goal with no target");
|
|
});
|
|
|
|
test("ledger sync retains archived laps and rebuilds current session laps", () => {
|
|
const training = {
|
|
goals: [{ id: "goal_1", driverId: "driver_1", targetLaps: 100 }],
|
|
shares: [{ driverId: "driver_1", token: "s".repeat(24), enabled: true }],
|
|
ledger: [
|
|
{ id: "archived", sourceKey: "old:1", sessionId: "old", driverId: "driver_1", lapMs: 19000, timestamp: 1000 },
|
|
{ id: "stale", sourceKey: "live:old", sessionId: "live", driverId: "driver_1", lapMs: 30000, timestamp: 2000 },
|
|
],
|
|
markers: [{ id: "marker_1", driverId: "driver_1", classId: "class_1", timestamp: 2500, type: "setup" }],
|
|
};
|
|
const state = {
|
|
sessions: [{ id: "live", name: "Free Practice", eventId: "event_1" }],
|
|
events: [{ id: "event_1", name: "Club practice" }],
|
|
resultsBySession: {
|
|
live: {
|
|
passings: [
|
|
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 16500, timestamp: 3000, validLap: true, validationWarning: "above_max_stint", excludedFromPace: true, stintBoundary: true },
|
|
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 9000, timestamp: 4000, validLap: false },
|
|
{ driverId: "", competitorKey: "anonymous", lapMs: 17000, timestamp: 5000, validLap: true },
|
|
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 500, timestamp: 6000, validLap: true },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const synced = syncTrainingLedger(training, state, { uid: createUid() });
|
|
|
|
assert.equal(synced.ledger.length, 2);
|
|
assert.equal(synced.ledger.some((lap) => lap.id === "archived"), true);
|
|
assert.equal(synced.ledger.some((lap) => lap.id === "stale"), false);
|
|
const liveLap = synced.ledger.find((lap) => lap.sessionId === "live");
|
|
assert.equal(liveLap.lapMs, 16500);
|
|
assert.equal(liveLap.sessionName, "Free Practice");
|
|
assert.equal(liveLap.eventName, "Club practice");
|
|
assert.equal(liveLap.excludedFromPace, true);
|
|
assert.equal(liveLap.stintBoundary, true);
|
|
assert.equal(synced.goals.length, 1);
|
|
assert.equal(synced.shares.length, 1);
|
|
assert.equal(synced.markers.length, 1);
|
|
});
|
|
|
|
test("driver statistics remain isolated and aggregate days and sessions", () => {
|
|
const firstDay = new Date(2026, 0, 10, 12, 0, 0).getTime();
|
|
const secondDay = new Date(2026, 0, 11, 12, 0, 0).getTime();
|
|
const state = {
|
|
training: {
|
|
ledger: [
|
|
{ driverId: "driver_1", sessionId: "practice_1", timestamp: firstDay, lapMs: 18000 },
|
|
{ driverId: "driver_1", sessionId: "practice_1", timestamp: firstDay + 20000, lapMs: 16500 },
|
|
{ driverId: "driver_1", sessionId: "practice_2", timestamp: secondDay, lapMs: 17000 },
|
|
{ driverId: "driver_2", sessionId: "practice_2", timestamp: secondDay + 20000, lapMs: 12000 },
|
|
],
|
|
},
|
|
};
|
|
|
|
assert.equal(getDriverTrainingLaps(state, "driver_1").length, 3);
|
|
const stats = buildDriverTrainingStats(state, "driver_1");
|
|
assert.equal(stats.totalLaps, 3);
|
|
assert.equal(stats.totalSessions, 2);
|
|
assert.equal(stats.trainingDays, 2);
|
|
assert.equal(stats.bestLapMs, 16500);
|
|
assert.equal(stats.averageLapMs, 17167);
|
|
// A flow streak never carries over into another session or stint.
|
|
assert.equal(stats.controlledLapStreak, 1);
|
|
assert.equal(stats.dailySummaries[0].laps, 1);
|
|
assert.equal(stats.dailySummaries[1].laps, 2);
|
|
});
|
|
|
|
test("driver pace/PB/consistency stats only compare laps from the current track layout, but lifetime totals still count every lap", () => {
|
|
const oldLayoutDay = new Date(2026, 0, 1, 12, 0, 0).getTime();
|
|
const newLayoutDay = new Date(2026, 2, 1, 12, 0, 0).getTime();
|
|
const state = {
|
|
trackLayouts: [{ id: "layout_1", label: "Layout 1", startedAt: new Date(2026, 0, 1).getTime() }, { id: "layout_2", label: "Layout 2", startedAt: new Date(2026, 1, 1).getTime() }],
|
|
training: {
|
|
ledger: [
|
|
// Old layout: much faster (e.g. a shorter loop) - must not count as today's record.
|
|
{ driverId: "driver_1", sessionId: "old_session", timestamp: oldLayoutDay, lapMs: 10_000, trackLayoutId: "layout_1" },
|
|
{ driverId: "driver_1", sessionId: "old_session", timestamp: oldLayoutDay + 11_000, lapMs: 10_500, trackLayoutId: "layout_1" },
|
|
// Current layout: slower, genuinely the best time achievable on today's track.
|
|
{ driverId: "driver_1", sessionId: "new_session", timestamp: newLayoutDay, lapMs: 18_000, trackLayoutId: "layout_2" },
|
|
{ driverId: "driver_1", sessionId: "new_session", timestamp: newLayoutDay + 19_000, lapMs: 17_500, trackLayoutId: "layout_2" },
|
|
],
|
|
},
|
|
};
|
|
|
|
const layoutLaps = getDriverTrainingLapsForCurrentLayout(state, "driver_1");
|
|
assert.equal(layoutLaps.length, 2, "only the current layout's laps");
|
|
assert.ok(layoutLaps.every((lap) => lap.trackLayoutId === "layout_2"));
|
|
|
|
const stats = buildDriverTrainingStats(state, "driver_1");
|
|
assert.equal(stats.totalLaps, 4, "lifetime total still counts every lap ever driven");
|
|
assert.equal(stats.totalSessions, 2, "lifetime session count is unaffected by layout scoping");
|
|
assert.equal(stats.trainingDays, 2, "training-day history is unaffected by layout scoping");
|
|
assert.equal(stats.bestLapMs, 17_500, "best lap only compares laps from today's layout, not the faster old-layout lap");
|
|
});
|
|
|
|
test("with no track layouts configured, layout scoping is a no-op (every lap has trackLayoutId \"\")", () => {
|
|
const state = {
|
|
training: {
|
|
ledger: [
|
|
{ driverId: "driver_1", sessionId: "s1", timestamp: 1000, lapMs: 15_000 },
|
|
{ driverId: "driver_1", sessionId: "s1", timestamp: 16_000, lapMs: 14_000 },
|
|
],
|
|
},
|
|
};
|
|
assert.deepEqual(getDriverTrainingLapsForCurrentLayout(state, "driver_1"), getDriverTrainingLaps(state, "driver_1"));
|
|
assert.equal(buildDriverTrainingStats(state, "driver_1").bestLapMs, 14_000);
|
|
});
|
|
|
|
test("buildTrainingStintAnalysis's best-stint pick is scoped to the current track layout", () => {
|
|
const state = {
|
|
trackLayouts: [{ id: "layout_1", label: "Layout 1", startedAt: 0 }, { id: "layout_2", label: "Layout 2", startedAt: 50_000 }],
|
|
training: {
|
|
ledger: [
|
|
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "fast_old", timestamp: 10_000, lapMs: 10_000, trackLayoutId: "layout_1" },
|
|
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "fast_old", timestamp: 20_000, lapMs: 10_000, trackLayoutId: "layout_1" },
|
|
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "fast_old", timestamp: 30_000, lapMs: 10_000, trackLayoutId: "layout_1" },
|
|
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "slower_new", timestamp: 100_000, lapMs: 16_000, trackLayoutId: "layout_2" },
|
|
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "slower_new", timestamp: 116_000, lapMs: 16_000, trackLayoutId: "layout_2" },
|
|
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "slower_new", timestamp: 132_000, lapMs: 16_000, trackLayoutId: "layout_2" },
|
|
],
|
|
},
|
|
};
|
|
const { rows, best } = buildTrainingStintAnalysis(state, "driver_1");
|
|
assert.equal(rows.length, 1, "the old-layout stint is excluded entirely");
|
|
assert.equal(rows[0].sessionId, "slower_new");
|
|
assert.equal(best.sessionId, "slower_new", "the faster old-layout stint is never picked as \"best\"");
|
|
});
|
|
|
|
test("counted stint-boundary laps do not distort driver pace averages", () => {
|
|
const state = {
|
|
training: {
|
|
ledger: [
|
|
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "practice", timestamp: 37_000, lapMs: 37_000 },
|
|
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "practice", timestamp: 74_000, lapMs: 37_000 },
|
|
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "practice", timestamp: 164_000, lapMs: 90_000, excludedFromPace: true, stintBoundary: true },
|
|
],
|
|
},
|
|
};
|
|
|
|
const stats = buildDriverTrainingStats(state, "driver_1");
|
|
assert.equal(stats.totalLaps, 3);
|
|
assert.equal(stats.bestLapMs, 37_000);
|
|
assert.equal(stats.averageLapMs, 37_000);
|
|
assert.equal(stats.stintCount, 2);
|
|
});
|
|
|
|
test("split and pit statistics aggregate by personal profile and selected class", () => {
|
|
const state = {
|
|
drivers: [
|
|
{ id: "driver_2wd_a", name: "Driver", profileId: "profile_1", classId: "class_2wd" },
|
|
{ id: "driver_2wd_b", name: "Driver", profileId: "profile_1", classId: "class_2wd" },
|
|
{ id: "driver_4wd", name: "Driver", profileId: "profile_1", classId: "class_4wd" },
|
|
],
|
|
sessions: [
|
|
{ id: "session_1", name: "Practice", eventId: "event_1" },
|
|
{ id: "session_2", name: "Endurance", eventId: "event_1" },
|
|
],
|
|
events: [{ id: "event_1", name: "Club day" }],
|
|
resultsBySession: {
|
|
session_1: {
|
|
splits: [
|
|
{ driverId: "driver_2wd_a", splitMs: 8100, timestamp: 1000 },
|
|
{ driverId: "driver_2wd_b", splitMs: 7900, timestamp: 2000 },
|
|
{ driverId: "driver_4wd", splitMs: 6500, timestamp: 3000 },
|
|
],
|
|
pitStops: [],
|
|
},
|
|
session_2: {
|
|
splits: [],
|
|
pitStops: [
|
|
{ driverId: "driver_2wd_a", pitStopMs: 12500, enteredAt: 4000 },
|
|
{ driverId: "driver_2wd_b", pitStopMs: 9500, enteredAt: 5000 },
|
|
{ driverId: "driver_4wd", pitStopMs: 7000, enteredAt: 6000 },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const stats = buildDriverTimingChannelStats(state, "profile_1", "class_2wd");
|
|
assert.equal(stats.splitCount, 2);
|
|
assert.equal(stats.bestSplitMs, 7900);
|
|
assert.equal(stats.averageSplitMs, 8000);
|
|
assert.equal(stats.latestSplitGainMs, 200);
|
|
assert.equal(stats.pitStopCount, 2);
|
|
assert.equal(stats.totalPitMs, 22000);
|
|
assert.equal(stats.bestPitStopMs, 9500);
|
|
assert.equal(stats.recentPitStops[0].sessionName, "Endurance");
|
|
});
|
|
|
|
test("one driver profile combines transponders within a class but keeps 2WD and 4WD separate", () => {
|
|
const state = {
|
|
drivers: [
|
|
{ id: "driver_2wd_a", name: "Same Driver", profileId: "profile_same", classId: "class_2wd" },
|
|
{ id: "driver_2wd_b", name: "Same Driver", profileId: "profile_same", classId: "class_2wd" },
|
|
{ id: "driver_4wd", name: "Same Driver", profileId: "profile_same", classId: "class_4wd" },
|
|
],
|
|
training: {
|
|
ledger: [
|
|
{ driverId: "driver_2wd_a", sessionId: "practice", timestamp: 10_000, lapMs: 18_000 },
|
|
{ driverId: "driver_2wd_b", sessionId: "practice", timestamp: 30_000, lapMs: 17_500 },
|
|
{ driverId: "driver_4wd", sessionId: "practice", timestamp: 50_000, lapMs: 15_000 },
|
|
],
|
|
},
|
|
};
|
|
|
|
const twoWheelDrive = buildDriverTrainingStats(state, "profile_same", "class_2wd");
|
|
const fourWheelDrive = buildDriverTrainingStats(state, "profile_same", "class_4wd");
|
|
|
|
assert.equal(twoWheelDrive.totalLaps, 2);
|
|
assert.equal(twoWheelDrive.bestLapMs, 17_500);
|
|
assert.equal(fourWheelDrive.totalLaps, 1);
|
|
assert.equal(fourWheelDrive.bestLapMs, 15_000);
|
|
});
|
|
|
|
test("legacy same-name entries share a profile while an explicit profile can remain separate", () => {
|
|
const groups = getDriverProfileGroups({
|
|
drivers: [
|
|
{ id: "legacy_2wd", name: "Test Driver", classId: "2wd", club: "JMK RB", country: "Sverige" },
|
|
{ id: "legacy_4wd", name: " test driver ", classId: "4wd" },
|
|
{ id: "separate", name: "Test Driver", profileId: "profile_other", classId: "2wd" },
|
|
],
|
|
});
|
|
|
|
assert.equal(groups.length, 2);
|
|
assert.equal(groups.find((group) => group.id.startsWith("legacy:"))?.drivers.length, 2);
|
|
assert.equal(groups.find((group) => group.id.startsWith("legacy:"))?.club, "JMK RB");
|
|
assert.equal(groups.find((group) => group.id.startsWith("legacy:"))?.country, "Sverige");
|
|
assert.equal(groups.find((group) => group.id === "profile_other")?.drivers.length, 1);
|
|
});
|
|
|
|
test("active practice profile statistics include only linked entries in the selected class", () => {
|
|
const state = {
|
|
activeSessionId: "practice",
|
|
drivers: [
|
|
{ id: "two_a", name: "Driver", profileId: "profile", classId: "2wd" },
|
|
{ id: "two_b", name: "Driver", profileId: "profile", classId: "2wd" },
|
|
{ id: "four", name: "Driver", profileId: "profile", classId: "4wd" },
|
|
],
|
|
sessions: [{ id: "practice", name: "Open", type: "open_practice", status: "running", startedAt: 1_000 }],
|
|
resultsBySession: {
|
|
practice: {
|
|
passings: [
|
|
{ driverId: "two_a", timestamp: 20_000, lapMs: 18_000, validLap: true },
|
|
{ driverId: "two_b", timestamp: 39_000, lapMs: 17_500, validLap: true },
|
|
{ driverId: "four", timestamp: 55_000, lapMs: 15_000, validLap: true },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const stats = buildDriverActivePracticeStats(state, "profile", 60_000, "2wd");
|
|
assert.equal(stats.laps, 2);
|
|
assert.equal(stats.bestLapMs, 17_500);
|
|
});
|
|
|
|
test("active practice stats include only the selected driver's current valid laps", () => {
|
|
const startedAt = 10_000;
|
|
const state = {
|
|
activeSessionId: "practice_1",
|
|
sessions: [{ id: "practice_1", name: "Wednesday Open Practice", type: "open_practice", status: "running", startedAt }],
|
|
resultsBySession: {
|
|
practice_1: {
|
|
passings: [
|
|
{ driverId: "driver_1", timestamp: 9_000, lapMs: 15_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 20_000, lapMs: 16_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 37_000, lapMs: 17_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 45_000, lapMs: 8_000, validLap: false },
|
|
{ driverId: "driver_2", timestamp: 54_000, lapMs: 16_500, validLap: true },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
assert.deepEqual(buildDriverActivePracticeStats(state, "driver_1", 70_000), {
|
|
sessionId: "practice_1",
|
|
sessionName: "Wednesday Open Practice",
|
|
startedAt,
|
|
elapsedMs: 60_000,
|
|
laps: 2,
|
|
bestLapMs: 16_000,
|
|
averageLapMs: 16_500,
|
|
lastLapMs: 17_000,
|
|
controlZoneMinMs: 16_000,
|
|
controlZoneMaxMs: 17_280,
|
|
controlledLapStreak: 2,
|
|
});
|
|
});
|
|
|
|
test("active practice stats ignore races and stopped practice sessions", () => {
|
|
const base = { activeSessionId: "session_1", resultsBySession: {} };
|
|
assert.equal(buildDriverActivePracticeStats({ ...base, sessions: [{ id: "session_1", type: "final", status: "running" }] }, "driver_1"), null);
|
|
assert.equal(buildDriverActivePracticeStats({ ...base, sessions: [{ id: "session_1", type: "free_practice", status: "finished" }] }, "driver_1"), null);
|
|
});
|
|
|
|
test("even laps progress tracks a live streak within tolerance and keeps today's best after a miss", () => {
|
|
const startedAt = 10_000;
|
|
const state = {
|
|
activeSessionId: "practice_1",
|
|
sessions: [{ id: "practice_1", name: "Evening Practice", type: "open_practice", status: "running", startedAt }],
|
|
resultsBySession: {
|
|
practice_1: {
|
|
passings: [
|
|
{ driverId: "driver_1", timestamp: 11_000, lapMs: 20_000, validLap: true }, // within target 20000 +/-500
|
|
{ driverId: "driver_1", timestamp: 32_000, lapMs: 20_300, validLap: true }, // within
|
|
{ driverId: "driver_1", timestamp: 53_000, lapMs: 21_500, validLap: true }, // outside -> breaks streak
|
|
{ driverId: "driver_1", timestamp: 75_000, lapMs: 19_800, validLap: true }, // within -> new streak
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const config = { targetMs: 20_000, toleranceMs: 500, lapCount: 5 };
|
|
const progress = buildEvenLapsProgress(state, "driver_1", "", config, 90_000);
|
|
assert.equal(progress.active, true);
|
|
assert.equal(progress.currentStreak, 1, "the streak reset after the 21.5s lap and rebuilt to one lap");
|
|
assert.equal(progress.bestStreakToday, 2, "the earlier two-lap streak remains the best even after the reset");
|
|
assert.equal(progress.lastLapMs, 19_800);
|
|
assert.equal(progress.lastLapWithinTolerance, true);
|
|
});
|
|
|
|
test("even laps progress breaks the streak on a lap excluded from pace", () => {
|
|
const startedAt = 10_000;
|
|
const state = {
|
|
activeSessionId: "practice_1",
|
|
sessions: [{ id: "practice_1", type: "open_practice", status: "running", startedAt }],
|
|
resultsBySession: {
|
|
practice_1: {
|
|
passings: [
|
|
{ driverId: "driver_1", timestamp: 11_000, lapMs: 20_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 32_000, lapMs: 20_000, validationWarning: "above_max_stint", validLap: true },
|
|
{ driverId: "driver_1", timestamp: 53_000, lapMs: 20_100, validLap: true },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const config = { targetMs: 20_000, toleranceMs: 500, lapCount: 5 };
|
|
const progress = buildEvenLapsProgress(state, "driver_1", "", config, 90_000);
|
|
assert.equal(progress.currentStreak, 1, "an excluded lap in the middle still resets the streak even though its own time is within tolerance");
|
|
});
|
|
|
|
test("even laps progress without a config or an active session", () => {
|
|
assert.equal(buildEvenLapsProgress({}, "driver_1", "", null), null);
|
|
assert.equal(buildEvenLapsProgress({}, "driver_1", "", { targetMs: 0, toleranceMs: 500 }), null);
|
|
const progress = buildEvenLapsProgress({ activeSessionId: "x", sessions: [], resultsBySession: {}, training: { ledger: [] } }, "driver_1", "", { targetMs: 20_000, toleranceMs: 500, lapCount: 5 });
|
|
assert.equal(progress.active, false);
|
|
assert.equal(progress.currentStreak, 0);
|
|
assert.equal(progress.bestStreakToday, 0);
|
|
assert.equal(progress.lastLapMs, null);
|
|
});
|
|
|
|
test("step training progress advances through steps and has nothing to compare until two controlled steps have laps", () => {
|
|
const startedAt = 10_000;
|
|
const state = {
|
|
activeSessionId: "practice_1",
|
|
sessions: [{ id: "practice_1", type: "open_practice", status: "running", startedAt }],
|
|
resultsBySession: {
|
|
practice_1: {
|
|
passings: [
|
|
{ driverId: "driver_1", timestamp: 11_000, lapMs: 25_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 37_000, lapMs: 25_500, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 59_000, lapMs: 21_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 81_200, lapMs: 21_200, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 99_200, lapMs: 18_000, validLap: true },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const config = { steps: [
|
|
{ effort: "calm", lapCount: 2 },
|
|
{ effort: "controlled", lapCount: 2 },
|
|
{ effort: "fast", lapCount: 2 },
|
|
{ effort: "controlled", lapCount: 2 },
|
|
] };
|
|
const progress = buildStepTrainingProgress(state, "driver_1", "", config, 120_000);
|
|
assert.equal(progress.active, true);
|
|
assert.equal(progress.totalSteps, 4);
|
|
assert.equal(progress.totalLaps, 8);
|
|
assert.equal(progress.completedLaps, 5);
|
|
assert.equal(progress.complete, false);
|
|
assert.equal(progress.currentStepIndex, 2, "the third step (fast) is current after two calm and two controlled laps");
|
|
assert.deepEqual(progress.currentStep, { index: 2, effort: "fast", lapCount: 2, lapsCompleted: 1, lapsRemaining: 1 });
|
|
assert.equal(progress.steps[1].averageLapMs, 21_100, "the first controlled step's pace is averaged");
|
|
assert.equal(progress.controlledComparison, null, "only one controlled step has laps so far, nothing to compare yet");
|
|
});
|
|
|
|
test("step training progress compares the first and last controlled steps as a neutral pace difference", () => {
|
|
const startedAt = 10_000;
|
|
const state = {
|
|
activeSessionId: "practice_1",
|
|
sessions: [{ id: "practice_1", type: "open_practice", status: "running", startedAt }],
|
|
resultsBySession: {
|
|
practice_1: {
|
|
passings: [
|
|
{ driverId: "driver_1", timestamp: 11_000, lapMs: 25_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 37_000, lapMs: 25_500, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 59_000, lapMs: 21_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 81_200, lapMs: 21_200, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 99_200, lapMs: 18_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 117_000, lapMs: 17_800, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 138_800, lapMs: 21_800, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 161_000, lapMs: 22_200, validLap: true },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const config = { steps: [
|
|
{ effort: "calm", lapCount: 2 },
|
|
{ effort: "controlled", lapCount: 2 },
|
|
{ effort: "fast", lapCount: 2 },
|
|
{ effort: "controlled", lapCount: 2 },
|
|
] };
|
|
const progress = buildStepTrainingProgress(state, "driver_1", "", config, 200_000);
|
|
assert.equal(progress.complete, true);
|
|
assert.equal(progress.currentStepIndex, 3);
|
|
assert.deepEqual(progress.controlledComparison, { firstAvgMs: 21_100, lastAvgMs: 22_000, diffMs: 900 });
|
|
});
|
|
|
|
test("step training progress counts an excluded lap toward the step but not its pace average", () => {
|
|
const startedAt = 10_000;
|
|
const state = {
|
|
activeSessionId: "practice_1",
|
|
sessions: [{ id: "practice_1", type: "open_practice", status: "running", startedAt }],
|
|
resultsBySession: {
|
|
practice_1: {
|
|
passings: [
|
|
{ driverId: "driver_1", timestamp: 11_000, lapMs: 21_000, validLap: true },
|
|
{ driverId: "driver_1", timestamp: 200_000, lapMs: 180_000, validLap: true, validationWarning: "above_max_stint" },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const config = { steps: [{ effort: "controlled", lapCount: 2 }] };
|
|
const progress = buildStepTrainingProgress(state, "driver_1", "", config, 400_000);
|
|
assert.equal(progress.steps[0].lapsCompleted, 2, "both laps count toward the step's lap count");
|
|
assert.equal(progress.steps[0].averageLapMs, 21_000, "the excluded lap is left out of the pace average");
|
|
assert.equal(progress.complete, true);
|
|
});
|
|
|
|
test("step training progress without a config or an active session", () => {
|
|
assert.equal(buildStepTrainingProgress({}, "driver_1", "", null), null);
|
|
assert.equal(buildStepTrainingProgress({}, "driver_1", "", { steps: [] }), null);
|
|
const progress = buildStepTrainingProgress({ activeSessionId: "x", sessions: [], resultsBySession: {} }, "driver_1", "", { steps: [{ effort: "calm", lapCount: 3 }] });
|
|
assert.equal(progress.active, false);
|
|
assert.equal(progress.completedLaps, 0);
|
|
assert.equal(progress.complete, false);
|
|
});
|
|
|
|
test("training stints split after a break over two minutes or a session change", () => {
|
|
const laps = [
|
|
{ sessionId: "practice_1", timestamp: 18_000, lapMs: 18_000 },
|
|
{ sessionId: "practice_1", timestamp: 36_000, lapMs: 18_000 },
|
|
{ sessionId: "practice_1", timestamp: 200_001, lapMs: 164_001 },
|
|
{ sessionId: "practice_2", timestamp: 218_001, lapMs: 18_000 },
|
|
];
|
|
const stints = splitTrainingStints(laps);
|
|
|
|
assert.equal(stints.length, 3);
|
|
assert.equal(stints[0].laps.length, 2);
|
|
assert.equal(stints[1].laps.length, 1);
|
|
assert.equal(stints[1].laps[0].excludedFromPace, true);
|
|
assert.equal(stints[1].durationMs, 0);
|
|
assert.equal(stints[1].startedAt, 200_001);
|
|
assert.equal(stints[2].sessionId, "practice_2");
|
|
});
|
|
|
|
test("configured maximum lap time creates a new pace stint below the fallback break", () => {
|
|
const stints = splitTrainingStints([
|
|
{ sessionId: "practice_1", sourceId: "tp_1", timestamp: 37_000, lapMs: 37_000 },
|
|
{ sessionId: "practice_1", sourceId: "tp_1", timestamp: 74_000, lapMs: 37_000 },
|
|
{ sessionId: "practice_1", sourceId: "tp_1", timestamp: 164_000, lapMs: 90_000, excludedFromPace: true, stintBoundary: true },
|
|
{ sessionId: "practice_1", sourceId: "tp_1", timestamp: 201_000, lapMs: 37_000 },
|
|
]);
|
|
|
|
assert.equal(stints.length, 2);
|
|
assert.equal(stints[1].startedAt, 164_000);
|
|
assert.equal(stints[1].durationMs, 37_000);
|
|
assert.equal(stints[1].laps.filter((lap) => !lap.excludedFromPace).length, 1);
|
|
});
|
|
|
|
test("best consecutive laps never cross a stint boundary", () => {
|
|
const stints = splitTrainingStints([
|
|
{ sessionId: "practice_1", timestamp: 18_000, lapMs: 18_000 },
|
|
{ sessionId: "practice_1", timestamp: 35_000, lapMs: 17_000 },
|
|
{ sessionId: "practice_1", timestamp: 51_000, lapMs: 16_000 },
|
|
{ sessionId: "practice_1", timestamp: 66_000, lapMs: 15_000 },
|
|
{ sessionId: "practice_1", timestamp: 201_001, lapMs: 15_000 },
|
|
]);
|
|
const best = getBestConsecutiveTrainingLaps(stints, 3);
|
|
|
|
assert.equal(best.totalMs, 48_000);
|
|
assert.equal(best.averageLapMs, 16_000);
|
|
assert.equal(best.startedAt, 18_000);
|
|
});
|
|
|
|
test("best five-minute result counts a final lap started before the cutoff and reports its pace", () => {
|
|
const laps = Array.from({ length: 16 }, (_, index) => ({
|
|
sessionId: "practice_1",
|
|
timestamp: (index + 1) * 18_000,
|
|
lapMs: 18_000,
|
|
}));
|
|
laps.push({ sessionId: "practice_1", timestamp: 314_123, lapMs: 26_123 });
|
|
const result = getBestTrainingDurationResult(splitTrainingStints(laps), 300_000);
|
|
|
|
assert.equal(result.lapCount, 17);
|
|
assert.equal(result.totalMs, 314_123);
|
|
assert.equal(result.averageLapMs, 18_478);
|
|
assert.equal(result.startedAt, 0);
|
|
assert.equal(result.endedAt, 314_123);
|
|
});
|
|
|
|
test("best five-minute result never combines simultaneous transponder sources", () => {
|
|
const laps = ["transponder_a", "transponder_b"].flatMap((sourceId) => (
|
|
Array.from({ length: 9 }, (_, index) => ({
|
|
driverId: sourceId,
|
|
sourceId,
|
|
sessionId: "practice_1",
|
|
timestamp: (index + 1) * 37_000,
|
|
lapMs: 37_000,
|
|
}))
|
|
));
|
|
const stints = splitTrainingStints(laps);
|
|
const result = getBestTrainingDurationResult(stints, 300_000);
|
|
|
|
assert.equal(stints.length, 2);
|
|
assert.equal(result.lapCount, 9);
|
|
assert.equal(result.totalMs, 333_000);
|
|
assert.ok(result.totalMs <= result.targetMs + 37_000);
|
|
});
|
|
|
|
test("a stint shorter than the target duration has no five-minute result", () => {
|
|
const stints = splitTrainingStints([
|
|
{ sessionId: "practice_1", timestamp: 18_000, lapMs: 18_000 },
|
|
{ sessionId: "practice_1", timestamp: 36_000, lapMs: 18_000 },
|
|
]);
|
|
assert.equal(getBestTrainingDurationResult(stints, 300_000), null);
|
|
});
|
|
|
|
test("training progress recognizes a clean five-minute stint and strong finish", () => {
|
|
const ledger = Array.from({ length: 16 }, (_, index) => ({
|
|
driverId: "driver_1",
|
|
sourceId: "transponder_1",
|
|
sessionId: "practice_1",
|
|
timestamp: (index + 1) * 20_000,
|
|
lapMs: 20_000,
|
|
}));
|
|
const state = { training: { ledger } };
|
|
const stats = buildDriverTrainingStats(state, "driver_1");
|
|
const analysis = buildTrainingStintAnalysis(state, "driver_1");
|
|
|
|
assert.equal(stats.controlZoneMinMs, 20_000);
|
|
assert.equal(stats.controlZoneMaxMs, 21_600);
|
|
assert.equal(stats.controlledLapStreak, 16);
|
|
assert.equal(stats.outlierLaps, 0);
|
|
assert.equal(stats.strongFinishStints, 1);
|
|
assert.equal(stats.cleanFiveMinuteStints, 1);
|
|
assert.equal(stats.achievements.find((item) => item.id === "strong_finish").unlocked, true);
|
|
assert.equal(stats.achievements.find((item) => item.id === "clean_five_minute").unlocked, true);
|
|
assert.equal(analysis.rows[0].strongFinish, true);
|
|
assert.equal(analysis.rows[0].fiveMinuteResult.lapCount, 15);
|
|
assert.equal(analysis.rows[0].fiveMinuteResult.totalMs, 300_000);
|
|
});
|
|
|
|
test("recommendations select baseline, consistency, and progression", () => {
|
|
assert.equal(buildTrainingRecommendation({ totalLaps: 9 }).level, "baseline");
|
|
assert.deepEqual(
|
|
buildTrainingRecommendation({ totalLaps: 30, recentLapCount: 30, consistencySpreadPct: 9, bestLapMs: 16000 }),
|
|
{
|
|
level: "consistency",
|
|
titleKey: "training.plan_consistency_title",
|
|
bodyKey: "training.plan_consistency_body",
|
|
targetLapMs: 17280,
|
|
}
|
|
);
|
|
assert.equal(buildTrainingRecommendation({ totalLaps: 30, recentLapCount: 30, consistencySpreadPct: 4, bestLapMs: 16000 }).level, "progression");
|
|
});
|
|
|
|
test("goal and automatic milestone progress are bounded", () => {
|
|
assert.deepEqual(getTrainingGoalProgress({ targetLaps: 500 }, { totalLaps: 125 }), {
|
|
currentLaps: 125,
|
|
targetLaps: 500,
|
|
remainingLaps: 375,
|
|
percent: 25,
|
|
reached: false,
|
|
});
|
|
assert.equal(getTrainingGoalProgress({ targetLaps: 100 }, { totalLaps: 150 }).percent, 100);
|
|
assert.equal(getTrainingGoalProgress({ targetLaps: 100 }, { totalLaps: 150 }).reached, true);
|
|
assert.equal(getNextTrainingMilestone({ totalLaps: 250 }).targetLaps, 500);
|
|
assert.equal(getNextTrainingMilestone({ totalLaps: 5200 }).targetLaps, 6000);
|
|
});
|
|
|
|
test("a goal can target completed guided-exercise sessions instead of laps", () => {
|
|
const goal = { metric: "sessions", exerciseKind: "stepTraining", targetSessions: 10 };
|
|
assert.deepEqual(getTrainingGoalProgress(goal, { totalLaps: 999999 }, 4), {
|
|
currentLaps: 4,
|
|
targetLaps: 10,
|
|
remainingLaps: 6,
|
|
percent: 40,
|
|
reached: false,
|
|
});
|
|
assert.equal(getTrainingGoalProgress(goal, {}, 10).reached, true);
|
|
assert.equal(getTrainingGoalProgress(goal, {}, 15).percent, 100, "percent is bounded at 100 past the target");
|
|
});
|
|
|
|
test("countExerciseSessions scopes by driver, class and kind independently", () => {
|
|
const sessions = [
|
|
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
|
|
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
|
|
{ driverId: "driver_1", classId: "class_1", kind: "evenLaps" },
|
|
{ driverId: "driver_1", classId: "class_2", kind: "stepTraining" },
|
|
{ driverId: "driver_2", classId: "class_1", kind: "stepTraining" },
|
|
];
|
|
assert.equal(countExerciseSessions(sessions, { driverId: "driver_1", classId: "class_1", kind: "stepTraining" }), 2);
|
|
assert.equal(countExerciseSessions(sessions, { driverId: "driver_1", classId: "class_1", kind: "evenLaps" }), 1);
|
|
assert.equal(countExerciseSessions(sessions, { driverId: "driver_1", classId: "class_2", kind: "stepTraining" }), 1);
|
|
assert.equal(countExerciseSessions(sessions, { kind: "stepTraining" }), 4, "an unscoped driver/class counts club-wide");
|
|
assert.equal(countExerciseSessions(undefined, { kind: "stepTraining" }), 0);
|
|
});
|
|
|
|
test("countDriverExerciseSessions bridges a profile id (how admin goals store driverId) to the specific driver row id (how exerciseSessions store it)", () => {
|
|
const state = {
|
|
drivers: [{ id: "driver_1", profileId: "profile_1", name: "Driver One", classId: "class_1" }],
|
|
training: {
|
|
exerciseSessions: [
|
|
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
|
|
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
|
|
{ driverId: "driver_1", classId: "class_1", kind: "evenLaps" },
|
|
{ driverId: "driver_2", classId: "class_1", kind: "stepTraining" },
|
|
],
|
|
},
|
|
};
|
|
assert.equal(countDriverExerciseSessions(state, "profile_1", "class_1", "stepTraining"), 2, "resolves through the profile id, the same id an admin-created goal stores");
|
|
assert.equal(countDriverExerciseSessions(state, "profile_1", "class_1", "evenLaps"), 1);
|
|
assert.equal(countDriverExerciseSessions(state, "profile_1", "class_2", "stepTraining"), 0, "wrong class");
|
|
assert.equal(countDriverExerciseSessions(state, "driver_2", "class_1", "stepTraining"), 1, "a driver with no profile entry falls back to matching its own id directly");
|
|
});
|
|
|
|
test("development trend compares the latest 30 laps with the preceding 30", () => {
|
|
const start = new Date(2026, 0, 10, 12, 0, 0).getTime();
|
|
const ledger = Array.from({ length: 60 }, (_, index) => ({
|
|
driverId: "driver_1",
|
|
sessionId: "practice_1",
|
|
timestamp: start + ((index + 1) * 18_000),
|
|
lapMs: index < 30 ? 18_000 : 17_000,
|
|
}));
|
|
const stats = buildDriverTrainingStats({ training: { ledger } }, "driver_1");
|
|
|
|
assert.equal(stats.trendSampleReady, true);
|
|
assert.equal(stats.previousRecentAverageMs, 18_000);
|
|
assert.equal(stats.recentAverageMs, 17_000);
|
|
assert.equal(stats.averageTrendMs, 1_000);
|
|
assert.equal(stats.consistencyTrendPct, 0);
|
|
});
|
|
|
|
test("automatic achievements expose unlocked and upcoming personal goals", () => {
|
|
const achievements = buildTrainingAchievements({
|
|
totalSessions: 2,
|
|
totalLaps: 520,
|
|
trainingDays: 4,
|
|
controlledLapStreak: 7,
|
|
bestFiveMinuteResult: { lapCount: 17 },
|
|
});
|
|
|
|
assert.equal(achievements.find((item) => item.id === "first_session").unlocked, true);
|
|
assert.equal(achievements.find((item) => item.id === "laps_500").unlocked, true);
|
|
assert.equal(achievements.find((item) => item.id === "laps_1000").percent, 52);
|
|
assert.equal(achievements.find((item) => item.id === "days_10").unlocked, false);
|
|
assert.equal(achievements.find((item) => item.id === "five_minute").unlocked, true);
|
|
assert.equal(achievements.find((item) => item.id === "laps_250").unlocked, true);
|
|
assert.equal(achievements.find((item) => item.id === "controlled_10").unlocked, false);
|
|
});
|
|
|
|
test("daily missions recognize volume, control, and completed five-minute stints", () => {
|
|
const missions = buildTrainingMissions({
|
|
todayLaps: 12,
|
|
todayControlledLapStreak: 5,
|
|
todayCompletedFiveMinuteStints: 1,
|
|
});
|
|
|
|
assert.equal(missions.find((item) => item.id === "laps_today").percent, 60);
|
|
assert.equal(missions.find((item) => item.id === "controlled_today").completed, true);
|
|
assert.equal(missions.find((item) => item.id === "five_minute_today").completed, true);
|
|
});
|
|
|
|
test("training highlights prioritize current personal progress", () => {
|
|
const highlights = buildTrainingHighlights({
|
|
totalLaps: 600,
|
|
todayLaps: 24,
|
|
personalBestGainMs: 320,
|
|
trendSampleReady: true,
|
|
averageTrendMs: 180,
|
|
consistencyTrendPct: 1.4,
|
|
todayControlledLapStreak: 6,
|
|
});
|
|
|
|
assert.deepEqual(highlights.map((item) => item.id), ["today_laps", "personal_best", "pace_gain"]);
|
|
});
|
|
|
|
test("club goal progress starts at its saved baseline and is bounded", () => {
|
|
const progress = getClubTrainingGoalProgress({
|
|
ledger: Array.from({ length: 175 }, (_, index) => ({ id: `lap_${index}` })),
|
|
clubGoal: { targetLaps: 100, baselineLaps: 125 },
|
|
});
|
|
|
|
assert.equal(progress.currentLaps, 50);
|
|
assert.equal(progress.remainingLaps, 50);
|
|
assert.equal(progress.percent, 50);
|
|
assert.equal(progress.reached, false);
|
|
assert.equal(getClubTrainingGoalProgress({ ledger: [] }), null);
|
|
});
|
|
|
|
test("club goal progress can also target completed guided-exercise sessions, club-wide", () => {
|
|
const exerciseSessions = [
|
|
{ driverId: "driver_1", classId: "class_1", kind: "evenLaps" },
|
|
{ driverId: "driver_2", classId: "class_2", kind: "evenLaps" },
|
|
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
|
|
];
|
|
const progress = getClubTrainingGoalProgress({
|
|
exerciseSessions,
|
|
clubGoal: { metric: "sessions", exerciseKind: "evenLaps", targetSessions: 5, baselineSessions: 1 },
|
|
});
|
|
assert.equal(progress.currentLaps, 1, "2 evenLaps sessions club-wide minus baseline 1");
|
|
assert.equal(progress.targetLaps, 5);
|
|
assert.equal(progress.reached, false);
|
|
|
|
assert.equal(getClubTrainingGoalProgress({ exerciseSessions: [], clubGoal: { metric: "sessions", exerciseKind: "evenLaps", targetSessions: 5 } }).currentLaps, 0, "no matching sessions yet still computes normally, just at zero");
|
|
assert.equal(getClubTrainingGoalProgress({ exerciseSessions, clubGoal: { metric: "sessions", targetSessions: 5 } }), null, "a sessions goal without an exerciseKind is invalid");
|
|
});
|
|
|
|
test("pace buckets use a trimmed average and keep classes isolated", () => {
|
|
const start = new Date(2026, 7, 23, 12, 0, 0).getTime();
|
|
const twoWheelLaps = [10_000, ...Array.from({ length: 10 }, () => 20_000), 60_000];
|
|
const state = {
|
|
drivers: [
|
|
{ id: "driver_2wd", profileId: "profile", classId: "2wd" },
|
|
{ id: "driver_4wd", profileId: "profile", classId: "4wd" },
|
|
],
|
|
training: {
|
|
ledger: [
|
|
...twoWheelLaps.map((lapMs, index) => ({ driverId: "driver_2wd", sessionId: "practice", timestamp: start + index * 60_000, lapMs })),
|
|
{ driverId: "driver_4wd", sessionId: "practice", timestamp: start + 60_000, lapMs: 14_000 },
|
|
],
|
|
markers: [],
|
|
},
|
|
};
|
|
const buckets = buildTrainingPaceBuckets(state, "profile", "2wd", 30);
|
|
const weekdays = buildTrainingWeekdayStats(state, "profile", "2wd");
|
|
|
|
assert.equal(buckets.length, 1);
|
|
assert.equal(buckets[0].lapCount, 12);
|
|
assert.equal(buckets[0].paceMs, 20_000);
|
|
assert.equal(weekdays.find((day) => day.weekday === 0).lapCount, 12);
|
|
});
|
|
|
|
test("setup markers compare only the selected class before and after the change", () => {
|
|
const markerAt = new Date(2026, 7, 23, 13, 0, 0).getTime();
|
|
const state = {
|
|
drivers: [
|
|
{ id: "driver_2wd", profileId: "profile", classId: "2wd" },
|
|
{ id: "driver_4wd", profileId: "profile", classId: "4wd" },
|
|
],
|
|
training: {
|
|
ledger: [
|
|
...[25, 20, 15].map((minutes, index) => ({ driverId: "driver_2wd", timestamp: markerAt - minutes * 60_000, lapMs: 20_000 + index * 100 })),
|
|
...[5, 10, 15].map((minutes, index) => ({ driverId: "driver_2wd", timestamp: markerAt + minutes * 60_000, lapMs: 18_000 + index * 100 })),
|
|
{ driverId: "driver_4wd", timestamp: markerAt + 60_000, lapMs: 12_000 },
|
|
],
|
|
markers: [{ id: "marker", driverId: "driver_2wd", classId: "2wd", timestamp: markerAt, type: "tires" }],
|
|
},
|
|
};
|
|
const [comparison] = buildTrainingMarkerComparisons(state, "profile", "2wd", 30);
|
|
|
|
assert.equal(comparison.beforeLapCount, 3);
|
|
assert.equal(comparison.afterLapCount, 3);
|
|
assert.equal(comparison.beforePaceMs, 20_100);
|
|
assert.equal(comparison.afterPaceMs, 18_100);
|
|
assert.equal(comparison.improvementMs, 2_000);
|
|
});
|
|
|
|
test("stint analysis hides race pace 10 for short stints and flags slow laps", () => {
|
|
const firstStart = new Date(2026, 7, 23, 12, 0, 0).getTime();
|
|
const firstLaps = [...Array.from({ length: 10 }, () => 18_000), 30_000];
|
|
let timestamp = firstStart;
|
|
const ledger = firstLaps.map((lapMs) => {
|
|
timestamp += lapMs;
|
|
return { driverId: "driver", sessionId: "practice", timestamp, lapMs };
|
|
});
|
|
ledger.push({ driverId: "driver", sessionId: "practice", timestamp: timestamp + 180_001, lapMs: 18_000 });
|
|
ledger.push({ driverId: "driver", sessionId: "practice", timestamp: timestamp + 198_001, lapMs: 18_000 });
|
|
const analysis = buildTrainingStintAnalysis({ training: { ledger } }, "driver");
|
|
|
|
assert.equal(analysis.rows.length, 2);
|
|
const longStint = analysis.rows.find((row) => row.lapCount === 11);
|
|
const shortStint = analysis.rows.find((row) => row.lapCount === 2);
|
|
assert.equal(longStint.racePace10Ms, 18_000);
|
|
assert.equal(longStint.mistakes, 1);
|
|
assert.equal(shortStint.racePace10Ms, 0);
|
|
assert.equal(shortStint.consistencyPct, null);
|
|
assert.equal(shortStint.insufficient, true);
|
|
});
|