diff --git a/CHANGELOG.md b/CHANGELOG.md index 1547e12..592d73b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.10.3 - 2026-09-10 + +### Fixed + +- Qualifying reseed could scramble the grid in rounds with 10+ heats (heats were sorted as text, so "H10" came before "H2"), and silently dropped drivers or left empty "ghost" heats when the participant count changed - it now reseeds numerically and warns clearly instead of failing silently. +- A driver with zero laps in a qualifying heat or final (DNS) used to vanish entirely rather than showing as a real last-place result - including silently from every future round and the final grid. They now show up correctly as "Ej start (DNS)". +- Bump-up did nothing at all when "reserve bump slots" was turned off, and a promoted driver could stay gridded in their old main's upcoming leg at the same time as their new one. Both fixed; an oversized bump count can also no longer blow past 26 mains. +- Undoing a judging correction (e.g. a time penalty) out of order could leave a driver with a live penalty even though every log entry showed "undone" - undo now always nets to the correct result regardless of order. +- A paused heat's results weren't comparable to an unpaused heat's in qualifying ranking, and a session finished while paused kept showing a growing elapsed time afterward. Both now correctly exclude paused time. + ## 1.10.2 - 2026-09-10 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index dee4df1..20bcad3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -221,6 +221,34 @@ Verified 2026-09-10 (sidebar link): a full headless-browser run against a throwa Verified 2026-09-09: a new integration test (`tests/server_persistence.integration.test.js`) builds a throwaway local bare git repo plus two clones (standing in for "this installation" and "somewhere a newer commit was pushed"), confirms `available: false` initially, pushes a version bump from the second clone, confirms `available: true` with the correct `remoteVersion` read from its `package.json`, and confirms the 10-minute cache is respected until `force=1`. A full headless-browser run went further than any test this session before it: cloned the real project into a throwaway git working copy (own bare "remote", own temporary SQLite data dir - the real installation and its git history were never touched), started the real server from that clone, pushed a version bump to the throwaway remote from a second clone, then in a real browser opened Settings, saw "Version 9.9.9 finns tillgänglig", clicked "Uppdatera nu", accepted the confirmation dialog, and confirmed end-to-end: the server actually stopped, `git pull --ff-only` fast-forwarded, `npm install` ran, the server came back up on the same port serving the new code, and the open browser tab auto-reloaded on its own and displayed the new version - with zero uncaught JS exceptions throughout. +## Race flow correctness: qualifying, finals, bump-up, judging, pause timing + +Reported 2026-09-10 by the user, asking specifically about practice/qualifying/heats/finals, bump-up, judging, pause/start, timers and audio. A thorough read-only audit of the actual code (not assumption) found several real, concrete bugs across this area - fixed together, at the user's request ("don't want those bugs"). + +**Qualifying reseed** (`reseedUpcomingQualifying` in `src/event_race_logic.js`): +- [x] Heats were sorted by comparing the full session name as text ("H10" sorts before "H2" lexicographically), scrambling the grid for any round with 10+ heats. Now sorts by the numeric heat number the naming regex already captured but was discarding. +- [x] If the participant count changed since a round was generated (drivers added or removed), the extra drivers were silently dropped, or an existing heat was left with an empty "ghost" grid - with no indication anything was wrong. Reseed now still updates whichever heats line up, but reports a clear warning naming how many rounds no longer match, telling the admin to regenerate instead of trusting a partial reseed. +- [x] The function's own return shape was inconsistent (a bare `0` on one early-return path, `{updated, locked}` on the normal path) - the caller silently read `undefined` in that case. Now always returns the same shape. + +**A driver with zero laps in a heat or final (DNS)** used to vanish entirely rather than showing as a real, last-place result - not just from that round's standings, but silently from every future round they'd otherwise be reseeded into, and from the final grid entirely, because `buildQualifyingStandings`/`buildFinalStandings` (`src/timing_logic.js`) only ever saw drivers who had at least one recorded passing. Both now backfill a driver assigned to a *finished* heat/final who never appears in its results as an explicit "Ej start (DNS)" row, ranked last - a still-running or not-yet-started session's assigned drivers are correctly left alone, since they simply haven't raced yet. + +**Bump-up** (`generateFinalsForRace`/`applyBumpsForRace` in `src/event_race_logic.js`): +- [x] With "reserve bump slots" turned off, bump-up did *nothing at all*, silently. Mains are seeded to full capacity when slots aren't reserved, and the old code only ever let a bumped driver in if the main had room below its own capacity - which it never did. Bump-up now reads the event's actual reserve-slots setting to decide whether to cap the main at its nominal size (reserved) or simply grow it by the bumped driver(s) (not reserved), instead of conflating "never reserved any room" with "already used up the room that was reserved." +- [x] A promoted driver stayed gridded in the lower main's still-upcoming leg(s) at the same time as their new, higher main - the same kind of double-entry the team-roster bug had, for multi-leg finals. Bump-up now also removes a promoted driver from the lower main's remaining (not-yet-run) legs; the leg that already produced the promotion decision keeps its real historical result untouched. +- [x] An oversized bump count (relative to the final's size) could seed as few as one driver per main, and with a large enough field that ran the A/B/C... main-letter naming past "Z" into unrelated punctuation characters - which then all silently collapsed onto main "A" when standings were read back. `bumpCount` is now clamped below the final size, and main width is additionally widened as needed so a generation run never produces more than 26 mains. + +**Judging** (`applyCompetitorCorrection`/`undoJudgingAdjustment` in `src/judging_logic.js`): undoing a manual correction restored an *absolute* snapshot of the value from just before that specific correction was first applied, rather than reversing only that correction's own contribution. Two +5s penalties undone out of order (the first one undone first) wiped out both penalties on the first undo, then silently reinstated one of them on the second undo - leaving the driver with a live penalty behind two log entries both marked "undone", no warning anywhere. Undo now reverses each correction's own recorded delta from whatever the current cumulative value is, which is correct regardless of undo order. + +**Pause timing**: +- [x] A competitor's own elapsed/comparison time (`buildLeaderboard`, `getCompetitorElapsedMs` in `src/timing_logic.js`) didn't exclude paused wall-clock time, even though the session's own countdown already did (`getSessionTiming`). A heat that had a track call wasn't comparable to one that didn't for qualifying ranking purposes, even though nobody actually lost race time to the stoppage. Fixed by subtracting the session's accumulated pause time from a competitor's elapsed time too - correct as long as passings aren't recorded while paused, which they aren't (the decoder already ignores them: `session.status !== "running"` is checked before any passing is processed). +- [x] Finishing a session while it was paused (`finishSession` in `src/session_control.js`) never folded that final, still-open pause segment into `totalPausedMs` the way resuming does - so a finished session's displayed elapsed time kept growing after finish, using how long ago it finished rather than how long it actually ran. `getSessionTiming` also now freezes a finished session's clock at `endedAt` rather than the live clock, so simply reopening or leaving a finished session's timing view open no longer shows a number that keeps climbing. + +Also checked and confirmed already correct, no changes needed: the countdown/light start sequence's cancellation (no stuck "ready" state), the `one_minute_remaining`/`thirty_seconds_remaining`/`ten_seconds_remaining`/`final_lap` audio cues (each fires exactly once per start regardless of pauses, since they're gated on `elapsedMs`, which already excluded pause time correctly), and multi-cycle pause/resume accumulation and pause-during-follow-up math. + +Not built in this pass, out of scope for a bug-fixing sweep: there is no disqualification/stop-and-go/drive-through judging action at all today (only lap/time corrections and invalidation) - the corresponding audio cues exist in `RACE_AUDIO_EVENT_DEFINITIONS` but nothing in the app ever triggers them. That's a missing feature, not a bug, and would need its own design pass. + +Verified 2026-09-10: new unit tests cover every fix above - `tests/race_timing_logic.test.js` (numeric heat sort, field-size mismatch reporting, DNS backfill in both qualifying and final standings without ever inventing a fake 0ms "fastest lap", the reserve/non-reserve bump-up paths, the 26-main cap, pause exclusion in `buildLeaderboard`/`getCompetitorElapsedMs`, the frozen finished-session clock), a new `tests/session_control.test.js` (previously zero coverage for this file - pause/resume accumulation, follow-up pause, and the finish-while-paused fold-in), and `tests/judging_logic.test.js` (undoing two penalties in both orders, confirming the net always reaches zero). A full headless-browser run against a real, isolated server confirmed both the judging undo fix (two +5s penalties applied via the real Judging UI, undone out of order through the real undo buttons, correctly netting to +5000ms after the first undo and exactly 0 after both) and the bump-up fix (a real Event Manager "Apply bumps" click, with `reserveBumpSlots` off, actually grew the upper final's grid instead of silently doing nothing) end to end. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index f1cb6a7..5fab864 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.10.2", + "version": "1.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.10.2", + "version": "1.10.3", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 02128ae..ec5a0cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.10.2", + "version": "1.10.3", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index 9a41056..bbb4508 100644 --- a/src/app.js +++ b/src/app.js @@ -112,7 +112,7 @@ const buildTeamStintLog = (session, row) => buildTeamStintLogLogic(session, row, const getSessionGridEntries = (session) => getSessionGridEntriesLogic(session, { state, t, getEventTeams, getDriverDisplayById, getSessionGridOrder }); const getSessionGridOrder = (session) => getSessionGridOrderLogic(session); const ensureSessionDriverOrder = (session) => ensureSessionDriverOrderLogic(session, { getSessionEntrants }); -const buildFinalStandings = (event) => buildFinalStandingsLogic(event, { getSessionsForEvent, buildLeaderboard }); +const buildFinalStandings = (event) => buildFinalStandingsLogic(event, { getSessionsForEvent, buildLeaderboard, getSessionEntrants, t }); const getManualCorrectionSummary = (row) => getManualCorrectionSummaryLogic(row, { formatLap }); const renderTeamStintLog = (session, rows) => renderTeamStintLogHelper(session, rows, { t, escapeHtml, buildTeamStintLog, formatTeamActiveMemberLabel, renderTable, formatRaceClock }); @@ -923,6 +923,8 @@ const TRANSLATIONS = { "events.reseed_done": "Kommande kval heat reseedade från aktuell ranking.", "events.no_reseed_done": "Inga kommande kval kunde reseedas.", "events.reseed_locked": "{count} heat hoppades över eftersom manuell grid är låst.", + "events.reseed_mismatched": "Antal deltagare matchar inte längre antal genererade heat i {count} omgång(ar) - generera om kvalet istället för att lita på en delvis reseed.", + "events.dns": "Ej start (DNS)", "events.reserve_bump_slots": "Reservera bump-platser i finaler", "events.bump_reserved_note": "Om bump används kan finalgeneratorn reservera platser i högre finaler redan från start.", "events.actions": "Åtgärder", @@ -2429,6 +2431,8 @@ const TRANSLATIONS = { "events.reseed_done": "Upcoming qualifying heats reseeded from current standings.", "events.no_reseed_done": "No upcoming qualifying heats could be reseeded.", "events.reseed_locked": "{count} heats were skipped because manual grid is locked.", + "events.reseed_mismatched": "The number of participants no longer matches the generated heats in {count} round(s) - regenerate qualifying instead of relying on a partial reseed.", + "events.dns": "Did not start (DNS)", "events.reserve_bump_slots": "Reserve bump slots in finals", "events.bump_reserved_note": "If bump-up is used, finals can reserve slots in upper mains from the start.", "events.actions": "Actions", diff --git a/src/event_manager_controller.js b/src/event_manager_controller.js index 7570768..23da0c1 100644 --- a/src/event_manager_controller.js +++ b/src/event_manager_controller.js @@ -801,6 +801,9 @@ export function renderEventManagerView(context) { if (result.locked > 0) { messages.push(t("events.reseed_locked", { count: result.locked })); } + if (result.mismatched > 0) { + messages.push(t("events.reseed_mismatched", { count: result.mismatched })); + } alert(messages.join("\n")); }); diff --git a/src/event_race_logic.js b/src/event_race_logic.js index 1d522ff..ecfe8b3 100644 --- a/src/event_race_logic.js +++ b/src/event_race_logic.js @@ -686,7 +686,7 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil })); if (!sourceRows.length) { - return 0; + return { updated: 0, locked: 0, mismatched: 0 }; } const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8); @@ -697,17 +697,30 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil .forEach((session) => { const match = String(session.name || "").match(/^Q(\d+)\s+H(\d+)/i); const round = match ? Number(match[1]) : 0; + // The regex above already captures the heat number - sorting the full session name + // as a string instead ("H10" < "H2" lexicographically) used to scramble the grid + // for any round with 10+ heats. + const heatNumber = match ? Number(match[2]) : 0; if (!sessionsByRound.has(round)) { sessionsByRound.set(round, []); } - sessionsByRound.get(round).push(session); + sessionsByRound.get(round).push({ session, heatNumber }); }); let updated = 0; let locked = 0; - [...sessionsByRound.entries()].forEach(([, roundSessions]) => { - roundSessions.sort((a, b) => String(a.name).localeCompare(String(b.name))); - roundSessions.forEach((session, heatIndex) => { + let mismatched = 0; + [...sessionsByRound.entries()].forEach(([, roundEntries]) => { + roundEntries.sort((a, b) => a.heatNumber - b.heatNumber); + // The field size changed since these heats were generated (drivers added or removed) + // - the number of ready heats no longer matches how many the current participant list + // needs. Heats that do line up still get reseeded below (better than leaving a stale + // grid), but this is surfaced to the caller rather than silently dropping the extra + // drivers or leaving empty "ghost" heats with no explanation. + if (roundEntries.length !== heats.length) { + mismatched += 1; + } + roundEntries.forEach(({ session }, heatIndex) => { if (session.gridCustomized) { locked += 1; return; @@ -719,7 +732,7 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil }); }); - return { updated, locked }; + return { updated, locked, mismatched }; } @@ -741,9 +754,17 @@ export function generateFinalsForRace(event, { buildPracticeStandings, buildQual const finalDurationMin = Math.max(1, Number(event.raceConfig?.finalDurationMin || 5) || 5); const finalStartMode = normalizeStartMode(event.raceConfig?.finalStartMode || "position"); const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0); - const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0); + // Clamped below carsPerFinal so a session never claims to reserve more bump slots than + // its own final can hold. + const bumpCount = Math.min(Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0), Math.max(0, carsPerFinal - 1)); const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0); - const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal; + // A large bumpCount relative to carsPerFinal (even after the clamp above) still seeds + // very few drivers per main - with a big enough field, that used to produce dozens of + // one-car mains, running the A/B/C... letter naming past "Z" into unrelated punctuation + // characters (and every main past Z then collapsed onto "A" in buildFinalStandings, + // silently merging their results). Mains are capped at 26 by widening each main instead. + const minSlotsForMainCap = Math.max(1, Math.ceil(sourceRows.length / 26)); + const seededSlotsPerMain = Math.max(minSlotsForMainCap, reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal); const mains = chunkArray(sourceRows, seededSlotsPerMain); let created = 0; @@ -807,6 +828,13 @@ export function applyBumpsForRace(event, { buildFinalStandings, getSessionsForEv }); const mainKeys = [...grouped.keys()].sort(); + // Read from the event's current setting rather than each session's own + // reservedBumpSlots value - that value reaches 0 both when reservation was never turned + // on (mains were seeded to full capacity, and bumping should still grow the field) and + // when a reserved main's slots have all been used already (which should keep the main + // capped). Those need different behavior below, and reservedBumpSlots alone can't tell + // them apart. + const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots); let applied = 0; for (let index = 1; index < mainKeys.length; index += 1) { @@ -836,7 +864,10 @@ export function applyBumpsForRace(event, { buildFinalStandings, getSessionsForEv session.manualGridIds = Array.isArray(session.manualGridIds) ? session.manualGridIds : [...session.driverIds]; const reservedSlots = Math.max(0, Number(session.reservedBumpSlots || 0) || 0); const capacity = Math.max(0, Number(session.maxCars || event.raceConfig?.carsPerFinal || 0) || 0); - const allowedSize = reservedSlots > 0 ? Math.min(capacity, session.driverIds.length + reservedSlots) : capacity || Infinity; + // Reserved mode: capped at capacity, gated by whatever reserved room is left (0 once + // exhausted, correctly blocking further growth). Not reserved: no cap at all - the + // main was already seeded to full capacity, so bumping has to grow it to do anything. + const allowedSize = reserveBumpSlots ? Math.min(capacity, session.driverIds.length + reservedSlots) : Infinity; let addedHere = 0; bumpDriverIds.forEach((driverId) => { if (!session.driverIds.includes(driverId) && session.driverIds.length < allowedSize) { @@ -850,6 +881,22 @@ export function applyBumpsForRace(event, { buildFinalStandings, getSessionsForEv }); session.reservedBumpSlots = Math.max(0, reservedSlots - addedHere); }); + + // A bumped driver only leaves the lower main's leg(s) that haven't run yet - the leg(s) + // already completed keep their real, historical result. Without this they'd stay + // gridded in both the upper main (their new final) and the lower main's remaining + // legs at once, the same kind of double-entry the team-roster bug had. + const remainingLowerSessions = getSessionsForEvent(event.id).filter((session) => + session.type === "final" && String(session.name || "").toUpperCase().startsWith(lowerMainKey) && session.status === "ready" + ); + remainingLowerSessions.forEach((session) => { + if (Array.isArray(session.driverIds)) { + session.driverIds = session.driverIds.filter((id) => !bumpDriverIds.includes(id)); + } + if (Array.isArray(session.manualGridIds)) { + session.manualGridIds = session.manualGridIds.filter((id) => !bumpDriverIds.includes(id)); + } + }); } return applied; diff --git a/src/judging_logic.js b/src/judging_logic.js index 2e96117..7d12409 100644 --- a/src/judging_logic.js +++ b/src/judging_logic.js @@ -30,17 +30,19 @@ export function applyCompetitorCorrection(session, row, options = {}, { ensureSe action: t("timing.penalty_reset"), detail: "-", category: "correction", + // Undo reverses this action's own delta (-previous, since it zeroed out whatever was + // there) from whatever the CURRENT value is at undo time, rather than overwriting + // with an absolute snapshot - see the note on undoJudgingAdjustment below for why + // that matters whenever more than one correction is undone out of order. undo: { type: "restore_correction_state", - previousLapAdjustment, - previousTimeAdjustmentMs, + lapDelta: -previousLapAdjustment, + timeMsDelta: -previousTimeAdjustmentMs, }, }); } else { const lapDelta = Number(options.lapDelta || 0) || 0; const timeMsDelta = Number(options.timeMsDelta || 0) || 0; - const previousLapAdjustment = Number(entry.manualLapAdjustment || 0) || 0; - const previousTimeAdjustmentMs = Number(entry.manualTimeAdjustmentMs || 0) || 0; entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) + lapDelta; entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) + timeMsDelta; const detailBits = []; @@ -60,8 +62,8 @@ export function applyCompetitorCorrection(session, row, options = {}, { ensureSe category: "correction", undo: { type: "restore_correction_state", - previousLapAdjustment, - previousTimeAdjustmentMs, + lapDelta, + timeMsDelta, }, }); } @@ -167,8 +169,12 @@ export function undoJudgingAdjustment(session, adjustmentId, { ensureSessionResu } if (adjustment.undo.type === "restore_correction_state") { - entry.manualLapAdjustment = Number(adjustment.undo.previousLapAdjustment || 0) || 0; - entry.manualTimeAdjustmentMs = Number(adjustment.undo.previousTimeAdjustmentMs || 0) || 0; + // Reverses this adjustment's own delta from the current cumulative value, not an + // absolute snapshot from when it was first applied - two +5s penalties undone in + // either order must each remove exactly their own +5s, not the older one wiping out + // both and the newer one silently re-adding one back. See applyCompetitorCorrection. + entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) - (Number(adjustment.undo.lapDelta || 0) || 0); + entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) - (Number(adjustment.undo.timeMsDelta || 0) || 0); } else if (adjustment.undo.type === "set_passing_validity") { const passing = findPassingByUndoMarker(session, adjustment.competitorKey, adjustment.undo.passingTimestamp); if (!passing) { diff --git a/src/session_control.js b/src/session_control.js index 6d4966d..0b9d6e7 100644 --- a/src/session_control.js +++ b/src/session_control.js @@ -12,6 +12,14 @@ export function beginSession(session, nowTs = Date.now()) { } export function finishSession(session, nowTs = Date.now()) { + // Finishing while paused must fold the still-open pause segment into totalPausedMs the + // same way resumeSession does - otherwise that final segment's duration is silently + // dropped, and the session's own displayed elapsed time (getSessionTiming) keeps + // growing after finish, since it would still be missing from totalPausedMs. + if (session?.status === "paused" && session.pausedAt) { + const pausedDuration = Math.max(0, nowTs - Number(session.pausedAt || nowTs)); + session.totalPausedMs = Math.max(0, Number(session.totalPausedMs || 0)) + pausedDuration; + } session.status = "finished"; session.endedAt = nowTs; session.finishedByTimer = false; diff --git a/src/timing_logic.js b/src/timing_logic.js index d6a804b..d864b38 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -82,7 +82,12 @@ export function getSessionTiming(session, nowTs = Date.now(), { getSessionTarget const targetMs = getSessionTargetMs(session); const startedAt = Number(session?.startedAt || 0); const pausedAt = Number(session?.pausedAt || 0) || 0; - const clockNow = pausedAt || nowTs; + const endedAt = Number(session?.endedAt || 0) || 0; + // A finished session's elapsed time is a fixed historical fact - without this, reopening + // or simply leaving a finished session's timing view open would show its "elapsed" time + // still counting up against the real clock, using how long ago it finished rather than + // how long the session itself actually ran. + const clockNow = session?.status === "finished" && endedAt ? endedAt : (pausedAt || nowTs); const totalPausedMs = Math.max(0, Number(session?.totalPausedMs || 0) || 0); const elapsedMs = startedAt ? Math.max(0, clockNow - startedAt - totalPausedMs) : 0; const followUpMs = Math.max(0, Number(session?.followUpSec || 0) || 0) * 1000; @@ -172,8 +177,15 @@ export function buildLeaderboard(session, { ensureSessionResult, getSessionTarge const latestAnyPassing = allPassings.length ? allPassings[allPassings.length - 1] : null; const latestPassing = passings.length ? passings[passings.length - 1] : null; const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0; + // Excludes paused wall-clock time, the same way the session's own countdown already + // does (getSessionTiming) - otherwise a heat that had a track call isn't comparable to + // one that didn't, even though nobody actually lost race time to the stoppage. Correct + // as long as passings aren't recorded while paused (they aren't - the decoder ignores + // them), so a driver's last pass is always from before or after the full pause, never + // mid-pause. + const totalPausedMs = Math.max(0, Number(session?.totalPausedMs || 0) || 0); const rawElapsedMs = lastPassingTs - ? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs)) + ? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs) - totalPausedMs) : getCompetitorElapsedMs(session, row); const manualLapAdjustment = Number(row.manualLapAdjustment || 0) || 0; const manualTimeAdjustmentMs = Number(row.manualTimeAdjustmentMs || 0) || 0; @@ -350,7 +362,8 @@ export function getCompetitorElapsedMs(session, row) { if (!startTs || !row?.lastTimestamp) { return 0; } - return Math.max(0, row.lastTimestamp - startTs); + const totalPausedMs = Math.max(0, Number(session?.totalPausedMs || 0) || 0); + return Math.max(0, row.lastTimestamp - startTs - totalPausedMs); } @@ -537,8 +550,26 @@ export function buildQualifyingStandings(event, { getSessionsForEvent, buildLead sessions.forEach((session) => { const rows = buildLeaderboard(session); - const entrantCount = Math.max(rows.length, getSessionEntrants(session).length || 0); - rows.forEach((row, index) => { + const entrants = getSessionEntrants(session); + const entrantCount = Math.max(rows.length, entrants.length || 0); + // A driver assigned to this heat who never recorded a single valid passing (dead + // battery, transponder issue, no-show) has no entry in buildLeaderboard's rows at all - + // without this they'd silently vanish not just from this round's standings, but from + // every future round they'd otherwise be reseeded into, and from the final grid + // entirely. Only backfilled once the heat has actually finished - a still-running or + // not-yet-started heat's assigned drivers simply haven't raced yet, which is different. + const rowsWithDns = session.status === "finished" + ? [ + ...rows, + ...entrants + .filter((driver) => !rows.some((row) => (row.driverId || row.key) === driver.id)) + // bestLapMs is deliberately omitted (not 0) - Number.isFinite(0) is true, and a + // "0ms" lap would otherwise count as the fastest single lap of the round for + // the best_lap tie-break, ahead of every driver who actually raced. + .map((driver) => ({ driverId: driver.id, driverName: driver.name, comparisonMs: Number.MAX_SAFE_INTEGER, resultDisplay: t("events.dns") })), + ] + : rows; + rowsWithDns.forEach((row, index) => { const key = row.driverId || row.key; if (!competitorMap.has(key)) { competitorMap.set(key, { @@ -867,20 +898,30 @@ export function ensureSessionDriverOrder(session, { getSessionEntrants }) { -export function buildFinalStandings(event, { getSessionsForEvent, buildLeaderboard }) { +export function buildFinalStandings(event, { getSessionsForEvent, buildLeaderboard, getSessionEntrants, t }) { const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "final"); const groupedByMain = new Map(); const countedFinalLegs = Math.max(1, Number(event.raceConfig?.countedFinalLegs || 1) || 1); sessions.forEach((session) => { const rows = buildLeaderboard(session); + // See the identical note in buildQualifyingStandings above: a driver assigned to this + // final who never recorded a valid passing must not vanish from the results entirely. + const rowsWithDns = session.status === "finished" + ? [ + ...rows, + ...getSessionEntrants(session) + .filter((driver) => !rows.some((row) => (row.driverId || row.key) === driver.id)) + .map((driver) => ({ driverId: driver.id, driverName: driver.name, totalElapsedMs: Number.MAX_SAFE_INTEGER, resultDisplay: t("events.dns") })), + ] + : rows; const mainMatch = String(session.name || "").match(/^([A-Z])/i); const mainKey = mainMatch ? mainMatch[1].toUpperCase() : "A"; if (!groupedByMain.has(mainKey)) { groupedByMain.set(mainKey, new Map()); } const competitorMap = groupedByMain.get(mainKey); - rows.forEach((row, index) => { + rowsWithDns.forEach((row, index) => { const key = row.driverId || row.key; if (!competitorMap.has(key)) { competitorMap.set(key, { diff --git a/tests/judging_logic.test.js b/tests/judging_logic.test.js index 269e387..4719bfd 100644 --- a/tests/judging_logic.test.js +++ b/tests/judging_logic.test.js @@ -99,6 +99,33 @@ test("manual corrections accumulate, reset, and undo to the prior state", () => assert.equal(harness.getSaveCount(), 3); }); +test("undoing two time penalties in either order always removes exactly their own contribution", () => { + const forward = createHarness(); + applyCompetitorCorrection(forward.session, forward.row, { timeMsDelta: 5000 }, forward.deps); + applyCompetitorCorrection(forward.session, forward.row, { timeMsDelta: 5000 }, forward.deps); + assert.equal(forward.row.manualTimeAdjustmentMs, 10000, "both +5s penalties applied"); + const [first, second] = forward.result.adjustments; + + // Undo the FIRST one first. + assert.equal(undoJudgingAdjustment(forward.session, first.id, forward.deps), true); + assert.equal(forward.row.manualTimeAdjustmentMs, 5000, "only the first penalty's +5s is removed, the second's stays"); + assert.equal(undoJudgingAdjustment(forward.session, second.id, forward.deps), true); + assert.equal(forward.row.manualTimeAdjustmentMs, 0, "both undone, in first-applied order, nets to zero"); + + const reverse = createHarness(); + applyCompetitorCorrection(reverse.session, reverse.row, { timeMsDelta: 5000 }, reverse.deps); + applyCompetitorCorrection(reverse.session, reverse.row, { timeMsDelta: 5000 }, reverse.deps); + const [firstR, secondR] = reverse.result.adjustments; + + // Undo the SECOND one first (out of order) - this is exactly the sequence the user + // reported: undoing an earlier penalty used to wipe out both, then undoing the other + // silently re-added one back, leaving a live +5s penalty behind two "undone" entries. + assert.equal(undoJudgingAdjustment(reverse.session, secondR.id, reverse.deps), true); + assert.equal(reverse.row.manualTimeAdjustmentMs, 5000, "only the second penalty's +5s is removed, the first's stays"); + assert.equal(undoJudgingAdjustment(reverse.session, firstR.id, reverse.deps), true); + assert.equal(reverse.row.manualTimeAdjustmentMs, 0, "both undone, in reverse order, still nets to zero - order must not matter"); +}); + test("invalidating and restoring the latest lap recalculates the competitor", () => { const harness = createHarness(); diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js index 9047a95..f55a198 100644 --- a/tests/race_timing_logic.test.js +++ b/tests/race_timing_logic.test.js @@ -15,19 +15,28 @@ import { } from "../src/event_race_logic.js"; import { buildFinalStandings, + buildLeaderboard, + buildQualifyingStandings, + buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, buildSessionChannelStats, compareNumberSet, formatLeaderboardGap, + getCompetitorElapsedMs, getCompetitorSeedMetric, getQualifyingPointsValue, getSessionLapWindow, getSessionTargetMs, getSessionTiming, + hasQualifyingPrimaryTie, isHighPointsTable, isUntimedSession, ensureSessionResult, } from "../src/timing_logic.js"; +const testFormatLap = (ms) => (Number.isFinite(ms) ? `${ms}ms` : "-"); +const testFormatRaceClock = (ms) => `${ms}ms`; +const testTranslate = (key, vars) => (vars ? `${key}:${JSON.stringify(vars)}` : key); + function createUid() { let index = 0; return (prefix) => `${prefix}_${++index}`; @@ -283,12 +292,44 @@ test("qualifying reseed updates ready auto grids but leaves manual grids locked" } ); - assert.deepEqual(result, { updated: 1, locked: 1 }); + assert.deepEqual(result, { updated: 1, locked: 1, mismatched: 0 }); assert.deepEqual(sessions[0].driverIds, ["driver_3", "driver_1"]); assert.deepEqual(sessions[1].driverIds, ["locked"]); assert.deepEqual(sessions[2].driverIds, ["finished"]); }); +test("qualifying reseed sorts heats numerically, not as text (H10 must not sort before H2)", () => { + const sessions = ["H1", "H2", "H3", "H4", "H5", "H6", "H7", "H8", "H9", "H10", "H11"].map((heat) => ({ + name: `Q1 ${heat}`, type: "qualification", generated: true, status: "ready", driverIds: [], manualGridIds: [], gridCustomized: false, + })); + const standings = Array.from({ length: 22 }, (_, index) => ({ driverId: `driver_${index + 1}`, rank: index + 1 })); + const result = reseedUpcomingQualifying( + { id: "event_1", raceConfig: { carsPerHeat: 2 } }, + { buildQualifyingStandings: () => standings, buildPracticeStandings: () => [], getEventDrivers: () => [], getSessionsForEvent: () => sessions, chunkArray } + ); + + assert.deepEqual(result, { updated: 11, locked: 0, mismatched: 0 }); + const heat2 = sessions.find((session) => session.name === "Q1 H2"); + const heat10 = sessions.find((session) => session.name === "Q1 H10"); + assert.deepEqual(heat2.driverIds, ["driver_3", "driver_4"], "the 2nd-fastest pair lands in heat 2, not wherever text-sort would put it"); + assert.deepEqual(heat10.driverIds, ["driver_19", "driver_20"], "heat 10 gets the pair its numeric position actually calls for"); +}); + +test("qualifying reseed reports a field-size mismatch instead of silently dropping or ghosting heats", () => { + const sessions = [ + { name: "Q1 H1", type: "qualification", generated: true, status: "ready", driverIds: [], manualGridIds: [], gridCustomized: false }, + { name: "Q1 H2", type: "qualification", generated: true, status: "ready", driverIds: [], manualGridIds: [], gridCustomized: false }, + ]; + // 4 heats' worth of drivers now exist, but only 2 ready heat sessions do. + const standings = Array.from({ length: 8 }, (_, index) => ({ driverId: `driver_${index + 1}`, rank: index + 1 })); + const result = reseedUpcomingQualifying( + { id: "event_1", raceConfig: { carsPerHeat: 2 } }, + { buildQualifyingStandings: () => standings, buildPracticeStandings: () => [], getEventDrivers: () => [], getSessionsForEvent: () => sessions, chunkArray } + ); + + assert.equal(result.mismatched, 1, "the round's ready-heat count no longer matches the participant list"); +}); + 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}` })); @@ -332,11 +373,34 @@ test("final generation reserves bump slots and keeps seeded grid order", () => { assert.deepEqual(state.sessions[0].manualGridIds, state.sessions[0].driverIds); }); -test("bump-up promotes lower-main winners into reserved upper-main slots", () => { +test("final generation never produces more than 26 mains, even with a large field and an aggressive bump count", () => { + const state = { sessions: [] }; + const sourceRows = Array.from({ length: 60 }, (_, index) => ({ driverId: `driver_${index + 1}` })); + const event = { + id: "event_1", + raceConfig: { + finalsSource: "qualifying", finalLegs: 1, carsPerFinal: 4, finalDurationMin: 5, + finalStartMode: "position", followUpSec: 0, bumpCount: 50, reserveBumpSlots: true, + }, + }; + generateFinalsForRace(event, { + buildPracticeStandings: () => [], buildQualifyingStandings: () => sourceRows, clearGeneratedFinals: () => {}, + state, normalizeStartMode, normalizeSession: (session) => session, uid: createUid(), chunkArray, t: (key) => key, + }); + + assert.ok(state.sessions.length <= 26, `expected at most 26 mains, got ${state.sessions.length}`); + assert.ok(state.sessions.every((session) => /^[A-Z] /.test(session.name)), "every main name is a single real letter, never running past Z"); +}); + +test("bump-up promotes lower-main winners into reserved upper-main slots and removes them from the lower main's remaining legs", () => { 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"] }, + // B final 1 already ran (produced the standings below) and keeps its real result. B + // final 2 is the next leg still to come - that's the one that should lose the + // promoted driver, not the one that already happened. + { name: "B final 1", type: "final", status: "finished", maxCars: 4, driverIds: ["b1", "b2", "b3"] }, + { name: "B final 2", type: "final", status: "ready", maxCars: 4, reservedBumpSlots: 1, driverIds: ["b1", "b2", "b3"], manualGridIds: ["b1", "b2", "b3"] }, + { name: "C final 1", type: "final", status: "finished", maxCars: 4, driverIds: ["c1"] }, ]; const standings = [ { rank: "A1", driverId: "a1" }, @@ -345,15 +409,33 @@ test("bump-up promotes lower-main winners into reserved upper-main slots", () => { rank: "C1", driverId: "c1" }, ]; const applied = applyBumpsForRace( - { id: "event_1", raceConfig: { bumpCount: 1, carsPerFinal: 4 } }, + { id: "event_1", raceConfig: { bumpCount: 1, carsPerFinal: 4, reserveBumpSlots: true } }, { 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.deepEqual(sessions[0].driverIds, ["a1", "a2", "a3", "b1"], "b1 promoted into A"); + assert.deepEqual(sessions[1].driverIds, ["b1", "b2", "b3"], "B's already-finished leg keeps its real historical result"); + assert.deepEqual(sessions[2].driverIds, ["b2", "b3", "c1"], "b1 removed from B's upcoming leg (promoted out), c1 added (promoted in) - no more double-entry"); assert.equal(sessions[0].reservedBumpSlots, 0); - assert.equal(sessions[1].reservedBumpSlots, 0); +}); + +test("bump-up still grows a main when reserveBumpSlots is off, instead of silently doing nothing because the main looks full", () => { + const sessions = [ + { name: "A final 1", type: "final", status: "ready", maxCars: 4, reservedBumpSlots: 0, driverIds: ["a1", "a2", "a3", "a4"], manualGridIds: ["a1", "a2", "a3", "a4"] }, + { name: "B final 1", type: "final", status: "finished", maxCars: 4, driverIds: ["b1", "b2", "b3", "b4"] }, + ]; + const standings = [ + { rank: "A1", driverId: "a1" }, + { rank: "B1", driverId: "b1" }, + ]; + const applied = applyBumpsForRace( + { id: "event_1", raceConfig: { bumpCount: 1, carsPerFinal: 4, reserveBumpSlots: false } }, + { buildFinalStandings: () => standings, getSessionsForEvent: () => sessions } + ); + + assert.equal(applied, 1, "b1 is actually added, not blocked because A already has 4/4 seeded drivers"); + assert.deepEqual(sessions[0].driverIds, ["a1", "a2", "a3", "a4", "b1"]); }); test("session timing handles untimed practice, countdown, and follow-up", () => { @@ -378,6 +460,58 @@ test("session timing handles untimed practice, countdown, and follow-up", () => assert.equal(untimed.elapsedMs, 60000); }); +test("a finished session's elapsed time is frozen at endedAt, not still counting up against the real clock", () => { + const getTarget = (session) => getSessionTargetMs(session, { isUntimedSession }); + const finished = { type: "final", durationMin: 5, status: "finished", startedAt: 1000, endedAt: 301000 }; + + const rightAfter = getSessionTiming(finished, 301000, { getSessionTargetMs: getTarget }); + const muchLater = getSessionTiming(finished, 301000 + 3600000, { getSessionTargetMs: getTarget }); + assert.equal(rightAfter.elapsedMs, 300000); + assert.equal(muchLater.elapsedMs, 300000, "checking an hour later must report the exact same, fixed elapsed time"); +}); + +test("getCompetitorElapsedMs excludes the session's accumulated paused time", () => { + const session = { startedAt: 1000, totalPausedMs: 5000 }; + assert.equal(getCompetitorElapsedMs(session, { lastTimestamp: 21000 }), 15000); + assert.equal(getCompetitorElapsedMs({ startedAt: 1000, totalPausedMs: 0 }, { lastTimestamp: 21000 }), 20000, "no pause, no change"); + assert.equal(getCompetitorElapsedMs(session, { lastTimestamp: 0 }), 0, "no passing recorded yet"); +}); + +test("buildLeaderboard excludes paused wall-clock time from a competitor's elapsed/comparison time, so a paused heat stays comparable to an unpaused one", () => { + const result = { + competitors: { + driver_1: { key: "driver_1", driverName: "One", startTimestamp: 1000, laps: 1, lastLapMs: 20000, lastTimestamp: 21000, bestLapMs: 20000, manualLapAdjustment: 0, manualTimeAdjustmentMs: 0 }, + }, + }; + const passings = [{ competitorKey: "driver_1", timestamp: 21000, lapMs: 20000, validLap: true }]; + // A 5000ms track call happened mid-heat - nobody actually raced during it, so it must + // not count against this driver's comparison time. + const session = { id: "s1", type: "final", startedAt: 1000, totalPausedMs: 5000, durationMin: 5 }; + + const rows = buildLeaderboard(session, { + ensureSessionResult: () => result, + getSessionTargetMs: (s) => getSessionTargetMs(s, { isUntimedSession }), + getCompetitorPassings: (_s, row, options = {}) => passings + .filter((passing) => passing.competitorKey === row.key) + .filter((passing) => options.includeInvalid || passing.validLap !== false), + isCountedPassing: (passing) => passing.validLap !== false, + getCompetitorElapsedMs, + getCompetitorSeedMetric: () => null, + getSessionLapWindow: () => ({ minLapMs: 0, maxLapMs: Infinity }), + getPassingValidationLabel: () => "", + formatSeedMetric: () => "", + formatRaceClock: (ms) => `${ms}`, + formatLeaderboardGap: () => "", + formatLapDelta: () => "", + formatLap: (ms) => `${ms}`, + t: (key) => key, + }); + + assert.equal(rows.length, 1); + assert.equal(rows[0].totalElapsedMs, 15000, "the 5000ms pause is excluded from the driver's own elapsed/comparison time"); + assert.equal(rows[0].comparisonMs, 15000); +}); + test("race lap window and seed metrics enforce timing rules", () => { const state = { events: [ @@ -465,6 +599,64 @@ test("final standings count the configured best legs per main", () => { assert.equal(standings[1].score, "3 (1+2+2)"); }); +test("final standings keep a driver who never recorded a passing (DNS) instead of dropping them, ranked last in their main", () => { + const sessions = [{ id: "a1", type: "final", name: "A final 1", status: "finished" }]; + const rowsBySession = { + a1: [{ driverId: "driver_1", driverName: "One", totalElapsedMs: 301000, resultDisplay: "12/5:01.000" }], + }; + const entrants = [{ id: "driver_1", name: "One" }, { id: "driver_2", name: "Two" }]; + const standings = buildFinalStandings( + { id: "event_1", raceConfig: {} }, + { + getSessionsForEvent: () => sessions, + buildLeaderboard: (session) => rowsBySession[session.id], + getSessionEntrants: () => entrants, + t: testTranslate, + } + ); + + assert.deepEqual(standings.map((row) => row.driverId), ["driver_1", "driver_2"]); + assert.equal(standings[1].driverId, "driver_2"); + assert.equal(standings[1].rank, "A2", "the DNS driver still gets a real, last-place rank in their main"); + + // A not-yet-run final must not invent DNS rows for drivers who simply haven't raced yet. + const notYetRun = buildFinalStandings( + { id: "event_1", raceConfig: {} }, + { + getSessionsForEvent: () => [{ id: "a1", type: "final", name: "A final 1", status: "ready" }], + buildLeaderboard: () => rowsBySession.a1, + getSessionEntrants: () => entrants, + t: testTranslate, + } + ); + assert.deepEqual(notYetRun.map((row) => row.driverId), ["driver_1"], "driver_2 hasn't raced yet, not DNS - correctly absent"); +}); + +test("qualifying standings keep a driver who never recorded a passing (DNS) so they aren't silently dropped from future rounds or finals", () => { + const sessions = [{ id: "q1h1", type: "qualification", name: "Q1 H1", status: "finished" }]; + const rows = [{ driverId: "driver_1", driverName: "One", comparisonMs: 16000, bestLapMs: 16000, resultDisplay: "16.000" }]; + const entrants = [{ id: "driver_1", name: "One" }, { id: "driver_2", name: "Two" }]; + const standings = buildQualifyingStandings( + { id: "event_1", raceConfig: {} }, + { + getSessionsForEvent: () => sessions, + buildLeaderboard: () => rows, + getSessionEntrants: () => entrants, + isHighPointsTable, + getQualifyingPointsValue, + compareNumberSet, + buildQualifyingTieBreakNote: (row, tieBreak) => buildQualifyingTieBreakNoteLogic(row, tieBreak, { t: testTranslate, formatLap: testFormatLap }), + formatLap: testFormatLap, + formatRaceClock: testFormatRaceClock, + t: testTranslate, + hasQualifyingPrimaryTie, + } + ); + + assert.deepEqual(standings.map((row) => row.driverId), ["driver_1", "driver_2"], "driver_2 (DNS) still appears, ranked last"); + assert.equal(standings[1].bestSingleLapMs, Number.MAX_SAFE_INTEGER, "a DNS driver's missing lap is never confused for a 0ms fastest lap"); +}); + test("session channel statistics summarize splits and pit stops per competitor", () => { const result = { competitors: { diff --git a/tests/run.js b/tests/run.js index 1b9f018..4c4829d 100644 --- a/tests/run.js +++ b/tests/run.js @@ -5,6 +5,7 @@ import "./track_layouts_logic.test.js"; import "./race_day_logic.test.js"; import "./backup_service.test.js"; import "./judging_logic.test.js"; +import "./session_control.test.js"; import "./runtime_persistence.test.js"; import "./audio_runtime.test.js"; import "./race_audio.test.js"; diff --git a/tests/session_control.test.js b/tests/session_control.test.js new file mode 100644 index 0000000..65c0a34 --- /dev/null +++ b/tests/session_control.test.js @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { beginSession, finishSession, pauseSession, resumeSession } from "../src/session_control.js"; + +test("beginSession resets pause/timing bookkeeping for a fresh start", () => { + const session = { status: "ready", pausedAt: 999, totalPausedMs: 5000, followUpPausedMs: 2000 }; + beginSession(session, 1000); + assert.equal(session.status, "running"); + assert.equal(session.startedAt, 1000); + assert.equal(session.pausedAt, null); + assert.equal(session.totalPausedMs, 0); + assert.equal(session.followUpPausedMs, 0); +}); + +test("pauseSession only takes effect on a running session", () => { + const session = { status: "running" }; + assert.equal(pauseSession(session, 1000), true); + assert.equal(session.status, "paused"); + assert.equal(session.pausedAt, 1000); + + assert.equal(pauseSession({ status: "paused" }, 1000), false, "already paused"); + assert.equal(pauseSession({ status: "ready" }, 1000), false, "not started yet"); +}); + +test("resumeSession accumulates paused duration across multiple pause/resume cycles", () => { + const session = { status: "running", startedAt: 0, totalPausedMs: 0 }; + pauseSession(session, 1000); + resumeSession(session, 3000); // paused 2000ms + assert.equal(session.totalPausedMs, 2000); + assert.equal(session.pausedAt, null); + assert.equal(session.status, "running"); + + pauseSession(session, 5000); + resumeSession(session, 5750); // paused another 750ms + assert.equal(session.totalPausedMs, 2750, "second pause segment adds to the running total, not replaces it"); +}); + +test("resumeSession also folds the pause into an active follow-up period", () => { + const session = { status: "running", followUpStartedAt: 4000, followUpPausedMs: 0, totalPausedMs: 0 }; + pauseSession(session, 4500); + resumeSession(session, 5200); // paused 700ms during follow-up + assert.equal(session.totalPausedMs, 700); + assert.equal(session.followUpPausedMs, 700); +}); + +test("finishSession while paused folds the still-open pause segment into totalPausedMs instead of dropping it", () => { + const session = { status: "paused", startedAt: 0, pausedAt: 10000, totalPausedMs: 2000 }; + finishSession(session, 13000); // was paused for 3000ms when finished + assert.equal(session.status, "finished"); + assert.equal(session.endedAt, 13000); + assert.equal(session.pausedAt, null); + assert.equal(session.totalPausedMs, 5000, "the 3000ms final pause segment is added to the 2000ms already accumulated"); +}); + +test("finishSession on a running (not paused) session leaves totalPausedMs untouched", () => { + const session = { status: "running", startedAt: 0, totalPausedMs: 1500 }; + finishSession(session, 20000); + assert.equal(session.totalPausedMs, 1500); + assert.equal(session.endedAt, 20000); +});