200 lines
7.3 KiB
JavaScript
200 lines
7.3 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { connectDecoderHelper, processDecoderMessageHelper } from "../src/decoder_runtime.js";
|
|
import { ensureSessionResult } from "../src/timing_logic.js";
|
|
|
|
function createHarness(options = {}) {
|
|
const session = {
|
|
id: "session_1",
|
|
eventId: "event_1",
|
|
mode: "race",
|
|
type: options.sessionType || "team_race",
|
|
status: "running",
|
|
startMode: "mass",
|
|
startedAt: 1_000,
|
|
};
|
|
const state = {
|
|
events: [{
|
|
id: "event_1",
|
|
mode: "race",
|
|
teams: [{ id: "team_1", name: "Team One", driverIds: ["driver_1", "driver_2"], carIds: [] }],
|
|
}],
|
|
drivers: [
|
|
{ id: "driver_1", name: "Driver One", transponder: "123" },
|
|
{ id: "driver_2", name: "Driver Two", transponder: "456" },
|
|
],
|
|
cars: [],
|
|
resultsBySession: {},
|
|
};
|
|
let saveCount = 0;
|
|
const overlayEvents = [];
|
|
const audioEvents = [];
|
|
const deps = {
|
|
state,
|
|
t: (key) => key,
|
|
getActiveSession: () => session,
|
|
ensureSessionResult: (sessionId) => ensureSessionResult(sessionId, { state }),
|
|
normalizeStartMode: () => "mass",
|
|
getSessionLapWindow: () => ({
|
|
minLapMs: Number(options.minLapMs || 0),
|
|
maxLapMs: options.maxLapMs == null ? Number.POSITIVE_INFINITY : Number(options.maxLapMs),
|
|
}),
|
|
findEventTeamForPassing: (event, driverId) => event.teams.find((team) => team.driverIds.includes(driverId)) || null,
|
|
persistPassingToBackend: () => {},
|
|
pushOverlayEvent: (type, label) => overlayEvents.push({ type, label }),
|
|
buildLeaderboard: () => [],
|
|
formatLap: (ms) => `${ms}ms`,
|
|
announcePassing: () => {},
|
|
triggerRaceAudio: (key) => audioEvents.push(key),
|
|
saveState: () => { saveCount += 1; },
|
|
lastOverlayLeaderKeyBySession: {},
|
|
lastOverlayTop3BySession: {},
|
|
lastOverlayBestLapByKey: {},
|
|
};
|
|
const passing = (timestamp, role, transponder = "123") => processDecoderMessageHelper({
|
|
msg: "PASSING",
|
|
transponder,
|
|
rtc_time: new Date(timestamp).toISOString(),
|
|
}, deps, role);
|
|
return { state, session, passing, overlayEvents, audioEvents, getSaveCount: () => saveCount };
|
|
}
|
|
|
|
test("five controlled practice laps trigger one stable-pace voice cue", () => {
|
|
const harness = createHarness({ sessionType: "open_practice" });
|
|
for (let lap = 1; lap <= 5; lap += 1) harness.passing(1_000 + lap * 20_000, "main");
|
|
assert.equal(harness.audioEvents.filter((event) => event === "training_stable_pace").length, 1);
|
|
});
|
|
|
|
test("stable-pace training cue is never emitted during a race", () => {
|
|
const harness = createHarness({ sessionType: "team_race" });
|
|
for (let lap = 1; lap <= 5; lap += 1) harness.passing(1_000 + lap * 20_000, "main");
|
|
assert.equal(harness.audioEvents.includes("training_stable_pace"), false);
|
|
});
|
|
|
|
test("split decoder records intermediate time without changing main lap state", () => {
|
|
const harness = createHarness();
|
|
assert.equal(harness.passing(17_000, "main"), true);
|
|
const result = harness.state.resultsBySession.session_1;
|
|
const entry = result.competitors.team_team_1;
|
|
assert.equal(entry.laps, 1);
|
|
assert.equal(entry.lastTimestamp, 17_000);
|
|
|
|
harness.passing(25_000, "split");
|
|
assert.equal(entry.laps, 1);
|
|
assert.equal(entry.lastTimestamp, 17_000);
|
|
assert.equal(entry.lastSplitMs, 8_000);
|
|
assert.equal(entry.lastSplitLap, 2);
|
|
assert.equal(result.splits.length, 1);
|
|
assert.equal(result.passings.length, 1);
|
|
});
|
|
|
|
test("minimum lap time rejects a shortcut without adding a lap", () => {
|
|
const harness = createHarness({ minLapMs: 10_000, maxLapMs: 120_000 });
|
|
harness.passing(6_000, "main");
|
|
|
|
const result = harness.state.resultsBySession.session_1;
|
|
assert.equal(result.competitors.team_team_1.laps, 0);
|
|
assert.equal(result.passings[0].validLap, false);
|
|
assert.equal(result.passings[0].invalidReason, "below_min");
|
|
});
|
|
|
|
test("maximum lap time starts a statistics stint but still counts the race lap", () => {
|
|
const harness = createHarness({ minLapMs: 10_000, maxLapMs: 60_000 });
|
|
harness.passing(121_001, "main");
|
|
|
|
const result = harness.state.resultsBySession.session_1;
|
|
assert.equal(result.competitors.team_team_1.laps, 1);
|
|
assert.equal(result.passings[0].validLap, true);
|
|
assert.equal(result.passings[0].validationWarning, "above_max_stint");
|
|
assert.equal(result.passings[0].excludedFromPace, true);
|
|
assert.equal(result.passings[0].stintBoundary, true);
|
|
});
|
|
|
|
test("pit entry and exit create one timed stop without adding laps", () => {
|
|
const harness = createHarness();
|
|
harness.passing(17_000, "main");
|
|
harness.passing(30_000, "pit_entry");
|
|
harness.passing(31_000, "pit_entry");
|
|
harness.passing(42_500, "pit_exit", "456");
|
|
|
|
const result = harness.state.resultsBySession.session_1;
|
|
const entry = result.competitors.team_team_1;
|
|
assert.equal(entry.laps, 1);
|
|
assert.equal(entry.pitActive, false);
|
|
assert.equal(entry.pitStops, 1);
|
|
assert.equal(entry.lastPitStopMs, 12_500);
|
|
assert.equal(entry.totalPitMs, 12_500);
|
|
assert.equal(result.pitEvents.length, 2);
|
|
assert.equal(result.pitStops.length, 1);
|
|
assert.equal(result.pitStops[0].driverId, "driver_1");
|
|
assert.equal(result.pitStops[0].driverName, "Driver One");
|
|
assert.equal(result.pitStops[0].transponder, "123");
|
|
assert.equal(result.pitStops[0].exitTransponder, "456");
|
|
assert.equal(result.passings.length, 1);
|
|
assert.deepEqual(harness.audioEvents, ["entering_pits", "leaving_pits"]);
|
|
});
|
|
|
|
test("connect decoder opens one websocket per enabled role", () => {
|
|
const OriginalWebSocket = globalThis.WebSocket;
|
|
class FakeWebSocket {
|
|
constructor(url) { this.url = url; }
|
|
close() {}
|
|
}
|
|
globalThis.WebSocket = FakeWebSocket;
|
|
try {
|
|
const clients = {};
|
|
const timers = {};
|
|
let disconnected = 0;
|
|
let renderCount = 0;
|
|
const state = {
|
|
settings: {
|
|
wsUrl: "ws://server:9000",
|
|
splitDecoderEnabled: true,
|
|
splitWsUrl: "ws://server:9001",
|
|
pitDecoderEnabled: true,
|
|
pitEntryWsUrl: "ws://server:9002",
|
|
pitExitWsUrl: "ws://server:9003",
|
|
},
|
|
decoder: {},
|
|
};
|
|
connectDecoderHelper({
|
|
state,
|
|
t: (key) => key,
|
|
saveState: () => {},
|
|
getWsClient: (role) => clients[role] || null,
|
|
setWsClient: (role, client) => { clients[role] = client; },
|
|
getReconnectTimer: (role) => timers[role] || null,
|
|
setReconnectTimer: (role, timer) => { timers[role] = timer; },
|
|
getCurrentView: () => "settings",
|
|
renderView: () => { renderCount += 1; },
|
|
updateConnectionBadge: () => {},
|
|
processDecoderMessage: () => {},
|
|
disconnectDecoder: () => { disconnected += 1; },
|
|
});
|
|
assert.equal(disconnected, 1);
|
|
assert.deepEqual(Object.fromEntries(Object.entries(clients).map(([role, client]) => [role, client.url])), {
|
|
main: "ws://server:9000",
|
|
split: "ws://server:9001",
|
|
pit_entry: "ws://server:9002",
|
|
pit_exit: "ws://server:9003",
|
|
});
|
|
clients.main.onopen();
|
|
clients.main.onerror();
|
|
clients.main.onclose();
|
|
assert.equal(renderCount, 0);
|
|
} finally {
|
|
globalThis.WebSocket = OriginalWebSocket;
|
|
}
|
|
});
|
|
|
|
test("non-passing decoder messages do not persist race state", () => {
|
|
const harness = createHarness();
|
|
const changed = processDecoderMessageHelper({ msg: "STATUS", decoder: "online" }, {
|
|
state: harness.state,
|
|
getActiveSession: () => harness.session,
|
|
});
|
|
assert.equal(Boolean(changed), false);
|
|
assert.equal(harness.getSaveCount(), 0);
|
|
});
|