Race Day Start now runs a count-down (Go, 30 s - 5 min, default per event) with calls every minute, 30 s and 10 s, grid call and 9..3 count for grid starts, hold/resume/+1 min/to 30 s/to 10 s/Go now, and starts the heat by itself when it runs out. The transponder check runs during the count-down, replacing the separate warm-up. Adds a flying start mode (horn + clock is running, own clock per car) as the qualifying default, and the latest personal clock start rules from EFRA 9.3 / IFMAR 4.2.3 for flying and staggered starts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
6.4 KiB
JavaScript
122 lines
6.4 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
recordTransponderSighting, buildWarmupCheck, createPrologue, getPrologueRemainingMs, holdPrologue, resumePrologue,
|
|
setPrologueRemaining, takeDuePrologueCall, getPrologueHandoverMs, normalizePrologueSec, formatPrologueTime,
|
|
PROLOGUE_OPTIONS_SEC, getLatestClockStartTs, usesPersonalClock,
|
|
} from "../src/prologue_logic.js";
|
|
|
|
const entrants = [
|
|
{ id: "a", name: "Anna", transponder: "1001" },
|
|
{ id: "b", name: "Bo", transponder: "1002" },
|
|
{ id: "c", name: "Cia", transponder: "" },
|
|
];
|
|
|
|
test("a driver is green only once their transponder crosses after the warm-up started", () => {
|
|
const sightings = new Map();
|
|
recordTransponderSighting(sightings, "1001", 500); // before the warm-up - must not count
|
|
recordTransponderSighting(sightings, "1002", 1500);
|
|
let check = buildWarmupCheck({ entrants, sightings, since: 1000 });
|
|
assert.deepEqual(check.rows.map((row) => row.seen), [false, true, false]);
|
|
assert.equal(check.seenCount, 1);
|
|
assert.equal(check.total, 3);
|
|
assert.deepEqual(check.missing.map((row) => row.name), ["Anna", "Cia"], "a driver with no transponder is never green");
|
|
recordTransponderSighting(sightings, "1001", 2000);
|
|
check = buildWarmupCheck({ entrants, sightings, since: 1000 });
|
|
assert.equal(check.rows[0].seen, true);
|
|
assert.equal(check.rows[0].lastSeenAt, 2000);
|
|
});
|
|
|
|
test("unknown transponders are listed newest first, naming the owner if it belongs to another driver", () => {
|
|
const sightings = new Map();
|
|
recordTransponderSighting(sightings, "9999", 1100);
|
|
recordTransponderSighting(sightings, "7777", 1200);
|
|
recordTransponderSighting(sightings, "1001", 1300);
|
|
recordTransponderSighting(sightings, "5555", 900); // before the warm-up
|
|
const check = buildWarmupCheck({ entrants, drivers: [...entrants, { id: "x", name: "Xerxes", transponder: "7777" }], sightings, since: 1000 });
|
|
assert.deepEqual(check.unknownTransponders.map((item) => [item.transponder, item.ownerName]), [["7777", "Xerxes"], ["9999", ""]]);
|
|
});
|
|
|
|
test("sightings count repeat passings and stay bounded", () => {
|
|
const sightings = new Map();
|
|
recordTransponderSighting(sightings, " 42 ", 1);
|
|
recordTransponderSighting(sightings, "42", 2);
|
|
assert.equal(sightings.get("42").count, 2);
|
|
recordTransponderSighting(sightings, "", 3);
|
|
assert.equal(sightings.size, 1);
|
|
for (let i = 0; i < 600; i += 1) recordTransponderSighting(sightings, `t${i}`, 10 + i);
|
|
assert.equal(sightings.size, 500);
|
|
assert.equal(sightings.has("t599"), true, "the newest are kept");
|
|
});
|
|
|
|
test("prologue counts down, holds and resumes without losing time", () => {
|
|
let prologue = createPrologue("s1", 120000, 0);
|
|
assert.equal(getPrologueRemainingMs(prologue, 30000), 90000);
|
|
prologue = holdPrologue(prologue, 30000);
|
|
assert.equal(getPrologueRemainingMs(prologue, 500000), 90000, "a held count-down doesn't move");
|
|
prologue = resumePrologue(prologue, 500000);
|
|
assert.equal(getPrologueRemainingMs(prologue, 510000), 80000);
|
|
assert.equal(getPrologueRemainingMs(prologue, 999999), 0);
|
|
assert.equal(getPrologueRemainingMs(null), 0);
|
|
});
|
|
|
|
// Runs a prologue second by second and collects what the announcer says.
|
|
function collectCalls(totalMs, startMode) {
|
|
let prologue = createPrologue("s1", totalMs, 0);
|
|
const calls = [];
|
|
for (let now = 0; now <= totalMs; now += 250) {
|
|
const next = takeDuePrologueCall(prologue, startMode, now);
|
|
prologue = next.prologue;
|
|
if (next.call) calls.push(next.call.variables?.seconds ?? next.call.variables?.minutes ?? next.call.event);
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
test("flying start calls the EFRA 9.2b marks: 1 min, 30 s, 10 s (2 min is the opening call)", () => {
|
|
assert.deepEqual(collectCalls(120000, "flying"), ["race_1_minute", "race_30_seconds", "race_10_seconds"]);
|
|
assert.deepEqual(collectCalls(300000, "flying"), [4, 3, 2, "race_1_minute", "race_30_seconds", "race_10_seconds"]);
|
|
assert.deepEqual(collectCalls(30000, "flying"), ["race_10_seconds"]);
|
|
});
|
|
|
|
test("grid start calls cars to the grid at 30 s and counts 10..3 second by second (IFMAR 4.2.5-4.2.6)", () => {
|
|
assert.deepEqual(collectCalls(90000, "position"), ["race_1_minute", "prologue_grid", "race_10_seconds", 9, 8, 7, 6, 5, 4, 3]);
|
|
assert.equal(getPrologueHandoverMs("position"), 3000, "the start signal follows 0-5 s after the 3 s mark");
|
|
assert.equal(getPrologueHandoverMs("flying"), 0);
|
|
});
|
|
|
|
test("jumping to 10 s marks the skipped calls done and says only the latest", () => {
|
|
let prologue = createPrologue("s1", 120000, 0);
|
|
prologue = setPrologueRemaining(prologue, 10000, 1000);
|
|
const { prologue: after, call } = takeDuePrologueCall(prologue, "flying", 1000);
|
|
assert.equal(call.event, "race_10_seconds");
|
|
assert.deepEqual(after.announced, ["m1", "s30", "s10"]);
|
|
// +1 min from there brings the 1 min / 30 s calls back.
|
|
const back = setPrologueRemaining(after, 70000, 1000);
|
|
assert.deepEqual(back.announced, []);
|
|
assert.equal(getPrologueRemainingMs(back, 1000), 70000);
|
|
});
|
|
|
|
test("prologue lengths snap to the offered choices", () => {
|
|
assert.deepEqual(PROLOGUE_OPTIONS_SEC, [0, 30, 60, 90, 120, 180, 300]);
|
|
assert.equal(normalizePrologueSec(90), 90);
|
|
assert.equal(normalizePrologueSec("0"), 0);
|
|
assert.equal(normalizePrologueSec(45), 120);
|
|
assert.equal(normalizePrologueSec(undefined), 120);
|
|
assert.equal(formatPrologueTime(90000, "sv"), "1 minut och 30 sekunder");
|
|
assert.equal(formatPrologueTime(120000, "en"), "2 minutes");
|
|
assert.equal(formatPrologueTime(30000, "sv"), "30 sekunder");
|
|
});
|
|
|
|
test("latest personal clock start follows EFRA 9.3 / IFMAR 4.2.3", () => {
|
|
const passings = [{ timestamp: 30000, lapMs: 20000, validLap: true }, { timestamp: 25000, lapMs: 3000, validLap: false }];
|
|
// Flying: 150 % of the first completed lap after the horn.
|
|
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "flying" }, { passings }), 10000 + 30000);
|
|
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "flying" }, { passings: [] }), null, "unknown until someone completes a lap");
|
|
// Staggered: when the first car completes a lap, or 1 minute after the start.
|
|
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "staggered" }, { passings }), 30000);
|
|
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "staggered" }, { passings: [] }), 70000);
|
|
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "mass" }, { passings }), null);
|
|
assert.equal(usesPersonalClock("flying"), true);
|
|
assert.equal(usesPersonalClock("position"), false);
|
|
});
|