A thorough audit of practice/qualifying/heats/finals, bump-up,
judging and pause/timer code (requested by the user) found several
real, concrete bugs, fixed together:
- Qualifying reseed sorted heats as text ("H10" before "H2"),
scrambling grids at 10+ heats; now sorts numerically and reports a
clear warning instead of silently dropping/ghosting heats when the
participant count changes.
- A driver with zero laps in a finished heat/final (DNS) vanished
entirely - not just from that round, but silently from every future
reseeded round and the final grid. Now backfilled as a real,
last-place "DNS" row.
- Bump-up did nothing at all with "reserve bump slots" off (mains
were already seeded full, so the old capacity check always
blocked it), and a promoted driver stayed double-entered in their
old main's upcoming leg. An oversized bump count could also blow
the main-letter naming past "Z". All three fixed.
- Judging undo restored an absolute snapshot instead of reversing its
own delta - undoing two penalties out of order could leave a live
penalty behind two "undone" log entries. Now always nets correctly
regardless of undo order.
- A competitor's elapsed time didn't exclude paused time (only the
session clock did), making a paused heat's results incomparable to
an unpaused one in qualifying ranking. Finishing while paused also
dropped the final pause segment, so the displayed elapsed time kept
growing after finish. Both fixed.
Verified with new unit tests for every fix (including a new
session_control.test.js - that file had zero prior coverage) and a
real headless-browser run confirming the judging-undo and bump-up
fixes end to end through the actual UI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
708 lines
30 KiB
JavaScript
708 lines
30 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
applyBumpsForRace,
|
|
buildOpenPracticeBundle,
|
|
buildRaceFormatConfigFromForm,
|
|
buildRaceSessionsFromWizard,
|
|
generateFinalsForRace,
|
|
generateQualifyingForRace,
|
|
getTeamDriverPool,
|
|
normalizeEvent,
|
|
reassignTeamMembers,
|
|
reseedUpcomingQualifying,
|
|
} 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}`;
|
|
}
|
|
|
|
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("open practice bundle is untimed, unrestricted, and independent of race presets", () => {
|
|
const uid = createUid();
|
|
const bundle = buildOpenPracticeBundle(
|
|
{ name: "Friday Practice", date: "2026-08-24" },
|
|
{
|
|
uid,
|
|
normalizeEvent: (event) => ({ ...event, raceConfig: { presetId: "club_qualifying", driverIds: ["old"] } }),
|
|
buildRaceSession: (eventId, name, type, durationMin, overrides) => ({ eventId, name, type, durationMin, ...overrides }),
|
|
}
|
|
);
|
|
|
|
assert.equal(bundle.event.name, "Friday Practice");
|
|
assert.equal(bundle.event.classId, "");
|
|
assert.equal(bundle.event.mode, "race");
|
|
assert.equal(bundle.event.raceConfig.presetId, "custom");
|
|
assert.equal(bundle.event.raceConfig.practiceOnly, true);
|
|
assert.equal(bundle.event.raceConfig.participantsConfigured, false);
|
|
assert.deepEqual(bundle.event.raceConfig.driverIds, []);
|
|
assert.equal(bundle.session.eventId, bundle.event.id);
|
|
assert.equal(bundle.session.type, "open_practice");
|
|
assert.equal(bundle.session.startMode, "mass");
|
|
assert.equal(bundle.session.followUpSec, 0);
|
|
assert.deepEqual(bundle.session.driverIds, []);
|
|
assert.equal(isUntimedSession(bundle.session), true);
|
|
});
|
|
|
|
test("event normalization preserves the practice-only marker", () => {
|
|
const event = normalizeEvent(
|
|
{ id: "event_1", raceConfig: { practiceOnly: true } },
|
|
{
|
|
normalizeBrandingConfig: (branding) => branding || {},
|
|
normalizeStartMode,
|
|
normalizeRaceTeam: (team) => team,
|
|
}
|
|
);
|
|
|
|
assert.equal(event.raceConfig.practiceOnly, true);
|
|
assert.equal(event.raceConfig.presetId, "custom");
|
|
});
|
|
|
|
test("reassignTeamMembers strips a moved driver/car from every other team, leaving the excluded team untouched", () => {
|
|
const teams = [
|
|
{ id: "team_a", name: "A", driverIds: ["d1", "d2"], carIds: ["c1"] },
|
|
{ id: "team_b", name: "B", driverIds: ["d3"], carIds: [] },
|
|
];
|
|
const result = reassignTeamMembers(teams, { excludeTeamId: "team_b", driverIds: ["d1"], carIds: ["c1"] });
|
|
const teamA = result.find((team) => team.id === "team_a");
|
|
const teamB = result.find((team) => team.id === "team_b");
|
|
assert.deepEqual(teamA.driverIds, ["d2"], "d1 was moved out of team A");
|
|
assert.deepEqual(teamA.carIds, [], "c1 was moved out of team A too");
|
|
assert.deepEqual(teamB.driverIds, ["d3"], "the team being saved is left for the caller to update, not touched here");
|
|
});
|
|
|
|
test("reassignTeamMembers returns unchanged team objects as-is when nothing overlaps", () => {
|
|
const teams = [{ id: "team_a", name: "A", driverIds: ["d1"], carIds: [] }];
|
|
const result = reassignTeamMembers(teams, { excludeTeamId: "team_b", driverIds: ["d9"], carIds: [] });
|
|
assert.strictEqual(result[0], teams[0]);
|
|
});
|
|
|
|
test("getTeamDriverPool scopes to the event's class/participants, only falling back to every driver when that list is empty", () => {
|
|
const state = {
|
|
drivers: [
|
|
{ id: "d1", name: "One", classId: "2wd" },
|
|
{ id: "d2", name: "Two", classId: "4wd" },
|
|
],
|
|
};
|
|
const getEventDrivers = (event) => state.drivers.filter((driver) => !event?.classId || driver.classId === event.classId);
|
|
|
|
const scoped = getTeamDriverPool({ classId: "2wd", raceConfig: {} }, { getEventDrivers, state });
|
|
assert.deepEqual(scoped.drivers.map((driver) => driver.id), ["d1"]);
|
|
assert.equal(scoped.fallback, false);
|
|
|
|
const noMatches = getTeamDriverPool({ classId: "unknown_class", raceConfig: {} }, { getEventDrivers, state });
|
|
assert.deepEqual(noMatches.drivers.map((driver) => driver.id), ["d1", "d2"], "falls back to every driver rather than leaving zero checkboxes");
|
|
assert.equal(noMatches.fallback, true);
|
|
});
|
|
|
|
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, 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}` }));
|
|
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("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"] },
|
|
// 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" },
|
|
{ rank: "B1", driverId: "b1" },
|
|
{ rank: "B2", driverId: "b2" },
|
|
{ rank: "C1", driverId: "c1" },
|
|
];
|
|
const applied = applyBumpsForRace(
|
|
{ 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"], "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);
|
|
});
|
|
|
|
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", () => {
|
|
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("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: [
|
|
{ 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)");
|
|
});
|
|
|
|
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: {
|
|
team_1: { displayName: "Team One", driverName: "Driver One", carName: "Car One" },
|
|
},
|
|
splits: [
|
|
{ competitorKey: "team_1", splitMs: 8000 },
|
|
{ competitorKey: "team_1", splitMs: 7600 },
|
|
],
|
|
pitStops: [
|
|
{ competitorKey: "team_1", driverName: "Driver One", carName: "Car One", enteredAt: 100000, exitedAt: 112000, pitStopMs: 12000 },
|
|
{ competitorKey: "team_1", driverName: "Driver Two", carName: "Car Two", enteredAt: 200000, exitedAt: 209000, pitStopMs: 9000 },
|
|
],
|
|
};
|
|
const stats = buildSessionChannelStats({ id: "session_1" }, { ensureSessionResult: () => result });
|
|
assert.equal(stats.splitCount, 2);
|
|
assert.equal(stats.pitStopCount, 2);
|
|
assert.equal(stats.totalPitMs, 21000);
|
|
assert.equal(stats.competitors[0].averageSplitMs, 7800);
|
|
assert.equal(stats.competitors[0].bestPitStopMs, 9000);
|
|
assert.equal(stats.pitStops[1].driverName, "Driver Two");
|
|
});
|
|
|
|
test("paused session timing freezes and resumes without counting the pause", () => {
|
|
const getSessionTargetMs = () => 300_000;
|
|
const paused = getSessionTiming({ startedAt: 1_000, pausedAt: 61_000, totalPausedMs: 0, followUpSec: 0 }, 121_000, { getSessionTargetMs });
|
|
assert.equal(paused.elapsedMs, 60_000);
|
|
assert.equal(paused.remainingMs, 240_000);
|
|
const resumed = getSessionTiming({ startedAt: 1_000, pausedAt: null, totalPausedMs: 60_000, followUpSec: 0 }, 121_000, { getSessionTargetMs });
|
|
assert.equal(resumed.elapsedMs, 60_000);
|
|
assert.equal(resumed.remainingMs, 240_000);
|
|
});
|
|
|
|
test("ensureSessionResult backfills a missing passings array on an already-existing result", () => {
|
|
// A result can already exist (loaded from an older export, or a hand-edited/imported race
|
|
// package) without ever going through the brand-new-session initialization branch, the same
|
|
// way it can be missing adjustments/splits/pitEvents/pitStops - passings needs the same guard.
|
|
const state = { resultsBySession: { session_1: { competitors: {} } } };
|
|
const result = ensureSessionResult("session_1", { state });
|
|
assert.deepEqual(result.passings, []);
|
|
assert.deepEqual(result.adjustments, []);
|
|
assert.deepEqual(result.splits, []);
|
|
assert.deepEqual(result.pitEvents, []);
|
|
assert.deepEqual(result.pitStops, []);
|
|
|
|
const brandNew = ensureSessionResult("session_2", { state });
|
|
assert.deepEqual(brandNew, { passings: [], splits: [], pitEvents: [], pitStops: [], competitors: {}, adjustments: [] });
|
|
});
|