Add staggered-start car call-outs and a per-driver done indicator
Staggered-start qualifying heats now announce each car's transponder number in grid order (~3s apart) right after the official start, matching what a track announcer does when releasing cars one at a time; mass/position starts are unaffected. The Timing leaderboard and every overlay variant now also show a green "Done" flag next to a driver's name once their own personal timing window (which already started at their own first crossing) has elapsed, even while the session is still running for other drivers. Also confirmed two things already worked correctly: finals already always start everyone together with the shared countdown/lights/tone sequence, and practice's time-up announcement already says "finish the lap you are on" - only a "2 minutes remaining" cue is genuinely missing from the existing 1min/30s/10s ladder, left as an open question for the user. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,43 @@ test("start lights, random delay and tone precede the official start callback",
|
||||
assert.deepEqual(calls.slice(-3), [["wait", 2500], ["effect", "/race-sounds/sounds/effects/start_beep.wav"], ["timing-start"]]);
|
||||
});
|
||||
|
||||
test("staggered start mode calls out each car in grid order, 3s apart, after the official start", async () => {
|
||||
const calls = [];
|
||||
const engine = createRaceAudioEngine({
|
||||
fetchImpl: async () => ({ ok: true, json: async () => ({ ...manifest, voice_events: { sv: { ...manifest.voice_events.sv, staggered_release: "Bil {tp}." } } }) }),
|
||||
audioFactory: (src) => ({ play: () => { calls.push(["effect", src]); return Promise.resolve(); }, volume: 1 }),
|
||||
speak: async (text) => { calls.push(["voice", text]); return true; },
|
||||
wait: async (ms) => calls.push(["wait", ms]),
|
||||
getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, startLights: false, minStartDelay: 0, maxStartDelay: 0 }),
|
||||
});
|
||||
await engine.runStartSequence(
|
||||
{ id: "q1h1", type: "qualification", startMode: "staggered" },
|
||||
{ onTone: () => calls.push(["timing-start"]), staggerOrder: ["111", "222", "333"] }
|
||||
);
|
||||
const afterStart = calls.slice(calls.findIndex((call) => call[0] === "timing-start") + 1);
|
||||
assert.deepEqual(afterStart, [
|
||||
["voice", "Bil 111."], ["wait", 3000],
|
||||
["voice", "Bil 222."], ["wait", 3000],
|
||||
["voice", "Bil 333."],
|
||||
]);
|
||||
});
|
||||
|
||||
test("a mass-start session never calls out individual cars, even if a grid order is passed", async () => {
|
||||
const calls = [];
|
||||
const engine = createRaceAudioEngine({
|
||||
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
|
||||
audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
|
||||
speak: async (text) => { calls.push(text); return true; },
|
||||
wait: async () => {},
|
||||
getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, startLights: false, minStartDelay: 0, maxStartDelay: 0 }),
|
||||
});
|
||||
await engine.runStartSequence(
|
||||
{ id: "final", type: "final", startMode: "mass" },
|
||||
{ staggerOrder: ["111", "222"] }
|
||||
);
|
||||
assert.ok(!calls.some((text) => text.startsWith("Bil") || text.startsWith("Car")), "no per-car call-out for a mass start: " + JSON.stringify(calls));
|
||||
});
|
||||
|
||||
test("cancelling a pending start prevents the official start callback", async () => {
|
||||
let releaseDelay;
|
||||
let started = false;
|
||||
@@ -87,7 +124,7 @@ test("cancelling a pending start prevents the official start callback", async ()
|
||||
test("event catalog covers every supported race-audio event", () => {
|
||||
const keys = new Set(RACE_AUDIO_EVENT_DEFINITIONS.map((item) => item.key));
|
||||
[
|
||||
"grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race",
|
||||
"grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race", "staggered_release",
|
||||
"transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "training_stable_pace", "new_leader", "position_gain", "position_loss", "final_lap", "one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "race_finished",
|
||||
"track_call", "track_clear", "yellow_flag", "red_flag", "race_paused", "race_resumed", "marshal_warning", "marshal_required", "penalty", "stop_and_go", "drive_through", "disqualified",
|
||||
"pit_lane_open", "pit_lane_closed", "entering_pits", "leaving_pits", "pit_speed_warning",
|
||||
|
||||
@@ -546,6 +546,70 @@ test("buildLeaderboard excludes paused wall-clock time from a competitor's elaps
|
||||
assert.equal(rows[0].comparisonMs, 15000);
|
||||
});
|
||||
|
||||
// Regression coverage for the user's report: in a staggered-start qualifying heat, each
|
||||
// driver's own personal 5-minute window runs from their own first crossing, independent of
|
||||
// the shared session clock - so one driver can be "done" while the session is still
|
||||
// running for others. Nothing surfaced that distinction anywhere in the UI before this.
|
||||
test("buildLeaderboard flags a staggered-mode competitor whose own personal window has elapsed, and only in staggered mode", () => {
|
||||
const buildRows = (session, competitors) => buildLeaderboard(session, {
|
||||
ensureSessionResult: () => ({ competitors }),
|
||||
getSessionTargetMs: (s) => getSessionTargetMs(s, { isUntimedSession }),
|
||||
getCompetitorPassings: () => [],
|
||||
isCountedPassing: () => true,
|
||||
getCompetitorElapsedMs,
|
||||
getCompetitorSeedMetric: () => null,
|
||||
getSessionLapWindow: () => ({ minLapMs: 0, maxLapMs: Infinity }),
|
||||
getPassingValidationLabel: () => "",
|
||||
formatSeedMetric: () => "",
|
||||
formatRaceClock: (ms) => `${ms}`,
|
||||
formatLeaderboardGap: () => "",
|
||||
formatLapDelta: () => "",
|
||||
formatLap: (ms) => `${ms}`,
|
||||
t: (key) => key,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const competitors = {
|
||||
driver_done: { key: "driver_done", driverName: "Done", startTimestamp: now - 6 * 60 * 1000, laps: 10, lastLapMs: 17000, lastTimestamp: now - 60000, bestLapMs: 16500, manualLapAdjustment: 0, manualTimeAdjustmentMs: 0 },
|
||||
driver_still_going: { key: "driver_still_going", driverName: "Still going", startTimestamp: now - 2 * 60 * 1000, laps: 4, lastLapMs: 17000, lastTimestamp: now - 5000, bestLapMs: 16800, manualLapAdjustment: 0, manualTimeAdjustmentMs: 0 },
|
||||
};
|
||||
|
||||
const staggered = buildRows({ id: "q1h1", type: "qualification", startMode: "staggered", durationMin: 5, totalPausedMs: 0 }, competitors);
|
||||
assert.equal(staggered.find((row) => row.key === "driver_done").personalTimeUp, true, "6 minutes into their own 5-minute window - done");
|
||||
assert.equal(staggered.find((row) => row.key === "driver_still_going").personalTimeUp, false, "only 2 minutes into their own window - still going");
|
||||
|
||||
const mass = buildRows({ id: "final", type: "final", startMode: "mass", durationMin: 5, totalPausedMs: 0 }, competitors);
|
||||
assert.ok(mass.every((row) => row.personalTimeUp === false), "mass/position starts share one clock already shown at the session level - never flagged per-row");
|
||||
});
|
||||
|
||||
test("buildLeaderboard's personalTimeUp excludes paused time from a staggered competitor's own window too", () => {
|
||||
const now = Date.now();
|
||||
const competitors = {
|
||||
driver_1: { key: "driver_1", driverName: "One", startTimestamp: now - 5 * 60 * 1000 - 30000, laps: 10, lastLapMs: 17000, lastTimestamp: now - 60000, bestLapMs: 16500, manualLapAdjustment: 0, manualTimeAdjustmentMs: 0 },
|
||||
};
|
||||
const deps = {
|
||||
ensureSessionResult: () => ({ competitors }),
|
||||
getSessionTargetMs: (s) => getSessionTargetMs(s, { isUntimedSession }),
|
||||
getCompetitorPassings: () => [],
|
||||
isCountedPassing: () => true,
|
||||
getCompetitorElapsedMs,
|
||||
getCompetitorSeedMetric: () => null,
|
||||
getSessionLapWindow: () => ({ minLapMs: 0, maxLapMs: Infinity }),
|
||||
getPassingValidationLabel: () => "",
|
||||
formatSeedMetric: () => "",
|
||||
formatRaceClock: (ms) => `${ms}`,
|
||||
formatLeaderboardGap: () => "",
|
||||
formatLapDelta: () => "",
|
||||
formatLap: (ms) => `${ms}`,
|
||||
t: (key) => key,
|
||||
};
|
||||
// 5:30 has passed since this driver's own first crossing, but a 1-minute track call is
|
||||
// excluded from their personal window the same way it already is from the shared clock -
|
||||
// net real driving time is only 4:30 of their 5:00, so they are not done yet.
|
||||
const paused = buildLeaderboard({ id: "q1h1", type: "qualification", startMode: "staggered", durationMin: 5, totalPausedMs: 60000 }, deps);
|
||||
assert.equal(paused[0].personalTimeUp, false);
|
||||
});
|
||||
|
||||
test("race lap window and seed metrics enforce timing rules", () => {
|
||||
const state = {
|
||||
events: [
|
||||
|
||||
Reference in New Issue
Block a user