Ny automatisk testsvit för träningslogiken.
This commit is contained in:
@@ -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.
|
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
|
## Create Race Wizard
|
||||||
- `Race Setup` now starts with a four-step wizard for new races:
|
- `Race Setup` now starts with a four-step wizard for new races:
|
||||||
|
|||||||
@@ -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.
|
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
|
## Create Race Wizard
|
||||||
- `Race Setup` startar nu med en fyrstegs wizard för nya race:
|
- `Race Setup` startar nu med en fyrstegs wizard för nya race:
|
||||||
|
|||||||
@@ -2028,11 +2028,30 @@ Mitigation:
|
|||||||
- treat backend hydration and local dirty flags as runtime concerns
|
- treat backend hydration and local dirty flags as runtime concerns
|
||||||
- inspect `src/runtime_services.js` together with the affected save path
|
- 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:
|
If the project grows further, the next useful additions to this document are:
|
||||||
- exact `resultsBySession` competitor row schema
|
- exact `resultsBySession` competitor row schema
|
||||||
- exact `settings` schema snapshot
|
- exact `settings` schema snapshot
|
||||||
- SQLite table contract section
|
- SQLite table contract section
|
||||||
- controller-to-view ID map for critical forms/buttons
|
- controller-to-view ID map for critical forms/buttons
|
||||||
- test checklist per module
|
- additional test checklists for race generation, timing, judging, and persistence modules
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"status": "node scripts/serverctl.js status",
|
"status": "node scripts/serverctl.js status",
|
||||||
"restart": "node scripts/serverctl.js restart",
|
"restart": "node scripts/serverctl.js restart",
|
||||||
"verify": "node scripts/verify-install.js",
|
"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"
|
"setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
134
tests/training_logic.test.js
Normal file
134
tests/training_logic.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user