Andra guidade träningsövningen i Open Practice: konfigurera en stegserie (antal varv + lugnt/kontrollerat/snabbt per steg), förvalt till klassiska pyramiden. Training-sidan visar aktuellt steg, kvarvarande varv och en progress-pill per steg. När sekvensen är klar jämförs de två kontrollerade stegens snitt neutralt (t.ex. "+0.90s"), utan att kalla en försämring för trötthet. syncTrainingLedger sprider nu resten av det normaliserade training-objektet istället för att lista fält manuellt, så samma klass av bugg som drabbade Jämna varv inte kan upprepas för framtida fält. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
752 lines
33 KiB
JavaScript
752 lines
33 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,
|
|
getBestConsecutiveTrainingLaps,
|
|
getBestTrainingDurationResult,
|
|
getDriverTrainingLaps,
|
|
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 },
|
|
],
|
|
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, 1);
|
|
assert.equal(normalized.goals[0].title, "Bronze");
|
|
assert.equal(normalized.goals[0].reward, "Badge");
|
|
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("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("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("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("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);
|
|
});
|