diff --git a/README.md b/README.md index 90b3803..04d15e8 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,12 @@ For internet publishing, proxy only these paths: Do not expose `/api/state` or the full admin application. Static serving is restricted to `/src` and `/docs`; `data/`, server scripts and bundled binaries are not public static files. +Training logic regression tests use Node's built-in test runner and require no extra dependency: + +```bash +npm test +``` + ## Create Race Wizard - `Race Setup` now starts with a four-step wizard for new races: diff --git a/README.sv.md b/README.sv.md index 28579ef..493eb3d 100644 --- a/README.sv.md +++ b/README.sv.md @@ -201,6 +201,12 @@ Vid extern publicering ska bara dessa paths proxas: Exponera inte `/api/state` eller hela admin-appen. Statisk servering är begränsad till `/src` och `/docs`; `data/`, serverscripts och bundlade binärer publiceras inte som statiska filer. +Regressionstesterna för träningslogiken använder Node:s inbyggda testverktyg och kräver ingen extra dependency: + +```bash +npm test +``` + ## Create Race Wizard - `Race Setup` startar nu med en fyrstegs wizard för nya race: diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index c134d20..90dbee2 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -2028,11 +2028,30 @@ Mitigation: - treat backend hydration and local dirty flags as runtime concerns - inspect `src/runtime_services.js` together with the affected save path -## 18. Recommended Future Additions +## 18. Automated Regression Tests + +Run: + +```bash +npm test +``` + +`tests/training_logic.test.js` covers: +- training-state normalization and duplicate rejection +- invalid, anonymous, and sub-500 ms lap exclusion +- replacement of current-session ledger rows after corrections +- retention of archived/deleted-session history +- per-driver isolation and day/session aggregation +- recommendation branch selection +- custom goal and automatic milestone progress + +The suite uses Node's built-in `node:test` runner and does not add a test-framework dependency. Windows PowerShell/CMD can run the same suite with `npm.cmd test`. + +## 19. Recommended Future Additions If the project grows further, the next useful additions to this document are: - exact `resultsBySession` competitor row schema - exact `settings` schema snapshot - SQLite table contract section - controller-to-view ID map for critical forms/buttons -- test checklist per module +- additional test checklists for race generation, timing, judging, and persistence modules diff --git a/package.json b/package.json index 18336af..52eba04 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "status": "node scripts/serverctl.js status", "restart": "node scripts/serverctl.js restart", "verify": "node scripts/verify-install.js", + "test": "node --experimental-default-type=module tests/training_logic.test.js", "setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1" }, "dependencies": { diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js new file mode 100644 index 0000000..5776418 --- /dev/null +++ b/tests/training_logic.test.js @@ -0,0 +1,134 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildDriverTrainingStats, + buildTrainingRecommendation, + getDriverTrainingLaps, + getNextTrainingMilestone, + getTrainingGoalProgress, + normalizeTrainingState, + syncTrainingLedger, +} from "../src/training_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 }, + { driverId: "driver_1", token: "b".repeat(24), enabled: true }, + { driverId: "driver_2", token: "short", enabled: true }, + ], + }, { 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)]); +}); + +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 }, + ], + }; + 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 }, + { 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(synced.goals.length, 1); + assert.equal(synced.shares.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); + assert.equal(stats.controlledLapStreak, 2); + assert.equal(stats.dailySummaries[0].laps, 1); + assert.equal(stats.dailySummaries[1].laps, 2); +}); + +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); +});