diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 087ed1a..403d6f5 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -2073,6 +2073,23 @@ npm test - personal timer aggregate and frozen-window calculations - clock-skew-safe query normalization and bounds +`tests/race_timing_logic.test.js` covers: +- race-format parsing, defaults, lap limits, participants, and teams +- wizard generation of practice, qualifying, and team-race sessions +- qualifying heat generation and manual-grid-safe reseeding +- final generation, reserved bump slots, and bump-up promotion +- timed, follow-up, and untimed session clocks +- race lap windows and best/consecutive seed metrics +- qualifying points, tie comparisons, and leaderboard gaps +- multi-leg final standings and counted-leg scoring + +`tests/judging_logic.test.js` covers: +- cumulative lap/time corrections, reset, and correction-state undo +- manual lap invalidation and competitor-result recalculation +- restoration and undo of passing validity +- safe handling when no counted lap exists +- competitor and judging-log filters + 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 @@ -2082,4 +2099,4 @@ If the project grows further, the next useful additions to this document are: - exact `settings` schema snapshot - SQLite table contract section - controller-to-view ID map for critical forms/buttons -- additional test checklists for race generation, timing, judging, and persistence modules +- additional test checklists for persistence modules diff --git a/tests/judging_logic.test.js b/tests/judging_logic.test.js new file mode 100644 index 0000000..269e387 --- /dev/null +++ b/tests/judging_logic.test.js @@ -0,0 +1,163 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + applyCompetitorCorrection, + findPassingByUndoMarker, + getJudgeFilteredLog, + getJudgeFilteredRows, + getManualCorrectionSummary, + invalidateCompetitorLastLap, + recalculateCompetitorFromPassings, + restoreCompetitorLastInvalidLap, + undoJudgingAdjustment, +} from "../src/judging_logic.js"; + +function createHarness() { + const session = { id: "session_1", startedAt: 1000 }; + const result = { + passings: [ + { competitorKey: "driver_1", timestamp: 17000, lapMs: 16000, validLap: true }, + { competitorKey: "driver_1", timestamp: 34000, lapMs: 17000, validLap: true }, + { competitorKey: "driver_1", timestamp: 50500, lapMs: 16500, validLap: true }, + ], + competitors: { + driver_1: { + key: "driver_1", + driverName: "Driver One", + startTimestamp: 1000, + laps: 3, + lastLapMs: 16500, + lastTimestamp: 50500, + bestLapMs: 16000, + manualLapAdjustment: 0, + manualTimeAdjustmentMs: 0, + }, + }, + adjustments: [], + }; + let id = 0; + let saveCount = 0; + const ensureSessionResult = () => result; + const isCountedPassing = (passing) => passing.validLap !== false; + const getCompetitorPassings = (_session, row, options = {}) => result.passings + .filter((passing) => passing.competitorKey === row.key) + .filter((passing) => options.includeInvalid || isCountedPassing(passing)) + .sort((left, right) => left.timestamp - right.timestamp); + const recalculate = (targetSession, rowKey) => recalculateCompetitorFromPassings(targetSession, rowKey, { + ensureSessionResult, + getCompetitorPassings, + isCountedPassing, + }); + const findPassing = (targetSession, rowKey, timestamp) => findPassingByUndoMarker(targetSession, rowKey, timestamp, { + ensureSessionResult, + }); + const deps = { + ensureSessionResult, + getCompetitorPassings, + isCountedPassing, + recalculateCompetitorFromPassings: recalculate, + findPassingByUndoMarker: findPassing, + uid: (prefix) => `${prefix}_${++id}`, + t: (key) => key, + formatLap: (lapMs) => `${lapMs}ms`, + saveState: () => { saveCount += 1; }, + }; + return { + session, + result, + row: result.competitors.driver_1, + deps, + getSaveCount: () => saveCount, + }; +} + +test("manual corrections accumulate, reset, and undo to the prior state", () => { + const harness = createHarness(); + + applyCompetitorCorrection( + harness.session, + harness.row, + { lapDelta: -1, timeMsDelta: 5000 }, + harness.deps + ); + assert.equal(harness.row.manualLapAdjustment, -1); + assert.equal(harness.row.manualTimeAdjustmentMs, 5000); + assert.equal(getManualCorrectionSummary(harness.row, harness.deps), "-1L • +5000ms"); + assert.equal(harness.result.adjustments[0].detail, "-1L • +5000ms"); + + applyCompetitorCorrection(harness.session, harness.row, { reset: true }, harness.deps); + assert.equal(harness.row.manualLapAdjustment, 0); + assert.equal(harness.row.manualTimeAdjustmentMs, 0); + + const resetAdjustment = harness.result.adjustments[1]; + assert.equal(undoJudgingAdjustment(harness.session, resetAdjustment.id, harness.deps), true); + assert.equal(harness.row.manualLapAdjustment, -1); + assert.equal(harness.row.manualTimeAdjustmentMs, 5000); + assert.equal(resetAdjustment.undoneAt > 0, true); + assert.equal(harness.result.adjustments.at(-1).category, "undo"); + assert.equal(harness.getSaveCount(), 3); +}); + +test("invalidating and restoring the latest lap recalculates the competitor", () => { + const harness = createHarness(); + + assert.equal(invalidateCompetitorLastLap(harness.session, harness.row, harness.deps), true); + assert.equal(harness.result.passings[2].validLap, false); + assert.equal(harness.result.passings[2].invalidReason, "manual_invalid"); + assert.equal(harness.row.laps, 2); + assert.equal(harness.row.lastLapMs, 17000); + assert.equal(harness.row.lastTimestamp, 34000); + assert.equal(harness.row.bestLapMs, 16000); + + assert.equal(restoreCompetitorLastInvalidLap(harness.session, harness.row, harness.deps), true); + assert.equal(harness.result.passings[2].validLap, true); + assert.equal(harness.row.laps, 3); + assert.equal(harness.row.lastLapMs, 16500); + assert.equal(harness.row.lastTimestamp, 50500); + assert.equal(harness.getSaveCount(), 2); +}); + +test("undo restores passing validity and cannot be applied twice", () => { + const harness = createHarness(); + invalidateCompetitorLastLap(harness.session, harness.row, harness.deps); + const invalidation = harness.result.adjustments[0]; + + assert.equal(undoJudgingAdjustment(harness.session, invalidation.id, harness.deps), true); + assert.equal(harness.result.passings[2].validLap, true); + assert.equal(harness.result.passings[2].invalidReason, ""); + assert.equal(harness.row.laps, 3); + assert.equal(undoJudgingAdjustment(harness.session, invalidation.id, harness.deps), false); +}); + +test("lap invalidation safely rejects competitors without counted laps", () => { + const harness = createHarness(); + harness.result.passings.forEach((passing) => { + passing.validLap = false; + }); + assert.equal(invalidateCompetitorLastLap(harness.session, harness.row, harness.deps), false); + assert.equal(harness.result.adjustments.length, 0); + assert.equal(harness.getSaveCount(), 0); +}); + +test("judging row and action-log filters select only matching entries", () => { + const rows = [ + { key: "invalid", invalidPending: true }, + { key: "corrected", manualLapAdjustment: -1 }, + { key: "team", teamId: "team_1" }, + { key: "plain" }, + ]; + const log = [ + { id: "correction", category: "correction" }, + { id: "invalid", category: "invalid" }, + { id: "undo", category: "undo" }, + ]; + + assert.deepEqual(getJudgeFilteredRows(rows, "invalid").map((row) => row.key), ["invalid"]); + assert.deepEqual(getJudgeFilteredRows(rows, "corrected").map((row) => row.key), ["corrected"]); + assert.deepEqual(getJudgeFilteredRows(rows, "team").map((row) => row.key), ["team"]); + assert.equal(getJudgeFilteredRows(rows, "all").length, 4); + assert.deepEqual(getJudgeFilteredLog(log, "corrections").map((entry) => entry.id), ["correction"]); + assert.deepEqual(getJudgeFilteredLog(log, "invalid").map((entry) => entry.id), ["invalid"]); + assert.deepEqual(getJudgeFilteredLog(log, "undo").map((entry) => entry.id), ["undo"]); +}); diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js new file mode 100644 index 0000000..b0d4480 --- /dev/null +++ b/tests/race_timing_logic.test.js @@ -0,0 +1,383 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + applyBumpsForRace, + buildRaceFormatConfigFromForm, + buildRaceSessionsFromWizard, + generateFinalsForRace, + generateQualifyingForRace, + reseedUpcomingQualifying, +} from "../src/event_race_logic.js"; +import { + buildFinalStandings, + compareNumberSet, + formatLeaderboardGap, + getCompetitorSeedMetric, + getQualifyingPointsValue, + getSessionLapWindow, + getSessionTargetMs, + getSessionTiming, + isHighPointsTable, + isUntimedSession, +} from "../src/timing_logic.js"; + +function createUid() { + let index = 0; + return (prefix) => `${prefix}_${++index}`; +} + +function createForm(values) { + const fields = new Map(Object.entries(values)); + return { get: (name) => fields.get(name) ?? null }; +} + +const normalizeStartMode = (mode) => ["mass", "staggered", "position"].includes(mode) ? mode : "mass"; +const chunkArray = (items, size) => { + const chunks = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +}; + +test("race format form normalizes limits and preserves participants and teams", () => { + const event = { + raceConfig: { + driverIds: ["driver_1", "driver_2"], + participantsConfigured: true, + teams: [{ id: "team_1", name: "Red" }], + }, + }; + const config = buildRaceFormatConfigFromForm( + createForm({ + presetId: "club_qualifying", + qualifyingScoring: "best", + qualifyingRounds: "0", + carsPerHeat: "1", + qualDurationMin: "0", + qualStartMode: "position", + qualSeedLapCount: "3", + qualSeedMethod: "invalid", + countedQualRounds: "2", + qualifyingPointsTable: "ifmar", + qualifyingTieBreak: "best_lap", + carsPerFinal: "1", + finalLegs: "3", + countedFinalLegs: "2", + finalDurationMin: "5", + finalStartMode: "staggered", + followUpSec: "15", + minLapSec: "11.25", + maxLapSec: "60.5", + bumpCount: "1", + reserveBumpSlots: "on", + finalsSource: "practice", + }), + event, + { normalizeStartMode, getEventTeams: (item) => item.raceConfig.teams } + ); + + assert.equal(config.qualifyingRounds, 3); + assert.equal(config.carsPerHeat, 2); + assert.equal(config.qualDurationMin, 5); + assert.equal(config.qualSeedMethod, "best_sum"); + assert.equal(config.carsPerFinal, 2); + assert.equal(config.minLapMs, 11250); + assert.equal(config.maxLapMs, 60500); + assert.equal(config.reserveBumpSlots, true); + assert.equal(config.finalsSource, "practice"); + assert.deepEqual(config.driverIds, ["driver_1", "driver_2"]); + assert.deepEqual(config.teams, [{ id: "team_1", name: "Red" }]); +}); + +test("race wizard creates configured practice, qualifying, and endurance sessions", () => { + const event = { + id: "event_1", + raceConfig: { + driverIds: ["driver_1", "driver_2"], + qualDurationMin: 6, + qualStartMode: "staggered", + qualSeedLapCount: 3, + qualSeedMethod: "consecutive", + finalDurationMin: 240, + followUpSec: 20, + }, + }; + let sequence = 0; + const buildRaceSession = (eventId, name, type, durationMin, overrides) => ({ + id: `session_${++sequence}`, + eventId, + name, + type, + durationMin, + ...overrides, + }); + const sessions = buildRaceSessionsFromWizard( + event, + { + createPractice: true, + practiceSessions: 2, + createQualifying: true, + qualifyingRounds: 3, + createTeamRace: true, + teamRaceDurationMin: 180, + }, + { t: (key) => key, buildRaceSession } + ); + + assert.equal(sessions.length, 6); + assert.deepEqual(sessions.map((session) => session.type), [ + "practice", + "practice", + "qualification", + "qualification", + "qualification", + "team_race", + ]); + assert.equal(sessions[2].durationMin, 6); + assert.equal(sessions[2].seedMethod, "consecutive"); + assert.equal(sessions[2].followUpSec, 20); + assert.equal(sessions[5].durationMin, 180); + assert.deepEqual(sessions[5].driverIds, ["driver_1", "driver_2"]); +}); + +test("qualifying generation splits seeded drivers into every configured round", () => { + const state = { sessions: [] }; + const event = { + id: "event_1", + raceConfig: { + qualifyingRounds: 2, + carsPerHeat: 3, + qualDurationMin: 7, + qualStartMode: "staggered", + qualSeedLapCount: 2, + qualSeedMethod: "average", + followUpSec: 12, + }, + }; + const standings = Array.from({ length: 7 }, (_, index) => ({ + driverId: `driver_${index + 1}`, + rank: index + 1, + })); + let cleared = false; + const created = generateQualifyingForRace(event, { + buildPracticeStandings: () => standings, + getEventDrivers: () => [], + state, + normalizeStartMode, + normalizeSession: (session) => session, + uid: createUid(), + chunkArray, + clearGeneratedQualifying: () => { cleared = true; }, + }); + + assert.equal(cleared, true); + assert.equal(created, 6); + assert.deepEqual(state.sessions.map((session) => session.name), ["Q1 H1", "Q2 H1", "Q1 H2", "Q2 H2", "Q1 H3", "Q2 H3"]); + assert.deepEqual(state.sessions[0].driverIds, ["driver_1", "driver_2", "driver_3"]); + assert.deepEqual(state.sessions[4].driverIds, ["driver_7"]); + assert.equal(state.sessions[0].durationMin, 7); + assert.equal(state.sessions[0].seedMethod, "average"); + assert.equal(state.sessions[0].followUpSec, 12); +}); + +test("qualifying reseed updates ready auto grids but leaves manual grids locked", () => { + const sessions = [ + { name: "Q2 H1", type: "qualification", generated: true, status: "ready", driverIds: ["old_1"], manualGridIds: ["old_1"], gridCustomized: false }, + { name: "Q2 H2", type: "qualification", generated: true, status: "ready", driverIds: ["locked"], manualGridIds: ["locked"], gridCustomized: true }, + { name: "Q1 H1", type: "qualification", generated: true, status: "finished", driverIds: ["finished"] }, + ]; + const standings = ["driver_3", "driver_1", "driver_2"].map((driverId, index) => ({ driverId, rank: index + 1 })); + const result = reseedUpcomingQualifying( + { id: "event_1", raceConfig: { carsPerHeat: 2 } }, + { + buildQualifyingStandings: () => standings, + buildPracticeStandings: () => [], + getEventDrivers: () => [], + getSessionsForEvent: () => sessions, + chunkArray, + } + ); + + assert.deepEqual(result, { updated: 1, locked: 1 }); + assert.deepEqual(sessions[0].driverIds, ["driver_3", "driver_1"]); + assert.deepEqual(sessions[1].driverIds, ["locked"]); + assert.deepEqual(sessions[2].driverIds, ["finished"]); +}); + +test("final generation reserves bump slots and keeps seeded grid order", () => { + const state = { sessions: [] }; + const sourceRows = Array.from({ length: 7 }, (_, index) => ({ driverId: `driver_${index + 1}` })); + const event = { + id: "event_1", + raceConfig: { + finalsSource: "qualifying", + finalLegs: 2, + carsPerFinal: 4, + finalDurationMin: 8, + finalStartMode: "position", + followUpSec: 15, + bumpCount: 1, + reserveBumpSlots: true, + }, + }; + const created = generateFinalsForRace(event, { + buildPracticeStandings: () => [], + buildQualifyingStandings: () => sourceRows, + clearGeneratedFinals: () => {}, + state, + normalizeStartMode, + normalizeSession: (session) => session, + uid: createUid(), + chunkArray, + t: (key) => key, + }); + + assert.equal(created, 6); + assert.deepEqual(state.sessions.map((session) => session.name), [ + "A session.final 1", + "A session.final 2", + "B session.final 1", + "B session.final 2", + "C session.final 1", + "C session.final 2", + ]); + assert.deepEqual(state.sessions[0].driverIds, ["driver_1", "driver_2", "driver_3"]); + assert.equal(state.sessions[0].reservedBumpSlots, 1); + assert.equal(state.sessions[4].reservedBumpSlots, 0); + assert.deepEqual(state.sessions[0].manualGridIds, state.sessions[0].driverIds); +}); + +test("bump-up promotes lower-main winners into reserved upper-main slots", () => { + const sessions = [ + { name: "A final 1", type: "final", status: "ready", maxCars: 4, reservedBumpSlots: 1, driverIds: ["a1", "a2", "a3"], manualGridIds: ["a1", "a2", "a3"] }, + { name: "B final 1", type: "final", status: "ready", maxCars: 4, reservedBumpSlots: 1, driverIds: ["b1", "b2", "b3"], manualGridIds: ["b1", "b2", "b3"] }, + { name: "C final 1", type: "final", status: "ready", maxCars: 4, reservedBumpSlots: 0, driverIds: ["c1"] }, + ]; + const standings = [ + { rank: "A1", driverId: "a1" }, + { rank: "B1", driverId: "b1" }, + { rank: "B2", driverId: "b2" }, + { rank: "C1", driverId: "c1" }, + ]; + const applied = applyBumpsForRace( + { id: "event_1", raceConfig: { bumpCount: 1, carsPerFinal: 4 } }, + { buildFinalStandings: () => standings, getSessionsForEvent: () => sessions } + ); + + assert.equal(applied, 2); + assert.deepEqual(sessions[0].driverIds, ["a1", "a2", "a3", "b1"]); + assert.deepEqual(sessions[1].driverIds, ["b1", "b2", "b3", "c1"]); + assert.equal(sessions[0].reservedBumpSlots, 0); + assert.equal(sessions[1].reservedBumpSlots, 0); +}); + +test("session timing handles untimed practice, countdown, and follow-up", () => { + const getTarget = (session) => getSessionTargetMs(session, { isUntimedSession }); + + assert.equal(getTarget({ type: "open_practice", durationMin: 5 }), null); + assert.equal(getTarget({ type: "final", durationMin: 5 }), 300000); + + const timing = getSessionTiming( + { type: "final", durationMin: 5, startedAt: 1000, followUpSec: 15, followUpStartedAt: 301000 }, + 306000, + { getSessionTargetMs: getTarget } + ); + assert.equal(timing.elapsedMs, 305000); + assert.equal(timing.remainingMs, 0); + assert.equal(timing.followUpActive, true); + assert.equal(timing.followUpRemainingMs, 10000); + + const untimed = getSessionTiming({ type: "open_practice", startedAt: 1000 }, 61000, { getSessionTargetMs: getTarget }); + assert.equal(untimed.untimed, true); + assert.equal(untimed.remainingMs, null); + assert.equal(untimed.elapsedMs, 60000); +}); + +test("race lap window and seed metrics enforce timing rules", () => { + const state = { + events: [ + { id: "race", mode: "race", raceConfig: { minLapMs: 11000, maxLapMs: 60000 } }, + { id: "track", mode: "track" }, + { id: "invalid", mode: "race", raceConfig: { minLapMs: 20000, maxLapMs: 15000 } }, + ], + }; + assert.deepEqual(getSessionLapWindow({ eventId: "race" }, { state }), { minLapMs: 11000, maxLapMs: 60000 }); + assert.deepEqual(getSessionLapWindow({ eventId: "invalid" }, { state }), { minLapMs: 20000, maxLapMs: 20000 }); + assert.equal(getSessionLapWindow({ eventId: "track" }, { state }).maxLapMs, Number.POSITIVE_INFINITY); + + const passings = [16000, 21000, 16500, 17000].map((lapMs) => ({ lapMs })); + const consecutive = getCompetitorSeedMetric( + { seedBestLapCount: 2, seedMethod: "consecutive" }, + {}, + { getCompetitorPassings: () => passings } + ); + const best = getCompetitorSeedMetric( + { seedBestLapCount: 2, seedMethod: "best_sum" }, + {}, + { getCompetitorPassings: () => passings } + ); + assert.deepEqual(consecutive.laps, [16500, 17000]); + assert.equal(consecutive.totalMs, 33500); + assert.deepEqual(best.laps, [16000, 16500]); + assert.equal(best.totalMs, 32500); +}); + +test("points tables, tie comparison, and leaderboard gaps remain deterministic", () => { + assert.equal(getQualifyingPointsValue(2, 8, "rank_low"), 2); + assert.equal(getQualifyingPointsValue(2, 8, "field_desc"), 7); + assert.equal(getQualifyingPointsValue(2, 8, "ifmar"), 9); + assert.equal(isHighPointsTable("rank_low"), false); + assert.equal(isHighPointsTable("ifmar"), true); + assert.equal(compareNumberSet([1, 3], [1, 4]), -1); + assert.equal(compareNumberSet([10, 8], [10, 7], true), -1); + + const t = (key) => key; + const leader = { key: "leader", laps: 17, comparisonMs: 16000, totalElapsedMs: 301000, distanceToTargetMs: 1000 }; + assert.equal(formatLeaderboardGap(leader, leader, { isFreePractice: true }, { t }), "status.free_practice"); + assert.equal(formatLeaderboardGap({ key: "second", laps: 17, comparisonMs: 16550 }, leader, { isFreePractice: true }, { t }), "+0.550s"); + assert.equal(formatLeaderboardGap({ key: "second", laps: 16, totalElapsedMs: 290000 }, leader, {}, { t }), "+1L"); + assert.equal( + formatLeaderboardGap( + { key: "second", laps: 17, totalElapsedMs: 306000, distanceToTargetMs: 6000 }, + leader, + { useTargetTieBreak: true }, + { t } + ), + "+5.000s" + ); +}); + +test("final standings count the configured best legs per main", () => { + const sessions = [ + { id: "a1", type: "final", name: "A final 1" }, + { id: "a2", type: "final", name: "A final 2" }, + { id: "a3", type: "final", name: "A final 3" }, + { id: "b1", type: "final", name: "B final 1" }, + ]; + const rowsBySession = { + a1: [ + { driverId: "driver_1", driverName: "One", totalElapsedMs: 301000 }, + { driverId: "driver_2", driverName: "Two", totalElapsedMs: 302000 }, + ], + a2: [ + { driverId: "driver_2", driverName: "Two", totalElapsedMs: 300000 }, + { driverId: "driver_1", driverName: "One", totalElapsedMs: 303000 }, + ], + a3: [ + { driverId: "driver_1", driverName: "One", totalElapsedMs: 299000 }, + { driverId: "driver_2", driverName: "Two", totalElapsedMs: 304000 }, + ], + b1: [{ driverId: "driver_3", driverName: "Three", totalElapsedMs: 305000 }], + }; + const standings = buildFinalStandings( + { id: "event_1", raceConfig: { countedFinalLegs: 2 } }, + { getSessionsForEvent: () => sessions, buildLeaderboard: (session) => rowsBySession[session.id] } + ); + + assert.deepEqual(standings.map((row) => row.rank), ["A1", "A2", "B1"]); + assert.equal(standings[0].driverId, "driver_1"); + assert.equal(standings[0].score, "2 (1+1+2)"); + assert.equal(standings[1].score, "3 (1+2+2)"); +}); diff --git a/tests/run.js b/tests/run.js index f2eefac..fd9204b 100644 --- a/tests/run.js +++ b/tests/run.js @@ -1,2 +1,4 @@ import "./training_logic.test.js"; import "./public_training_profile.test.js"; +import "./race_timing_logic.test.js"; +import "./judging_logic.test.js";