Add multi-decoder split and pit timing support
This commit is contained in:
137
tests/decoder_runtime.test.js
Normal file
137
tests/decoder_runtime.test.js
Normal file
@@ -0,0 +1,137 @@
|
||||
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() {
|
||||
const session = {
|
||||
id: "session_1",
|
||||
eventId: "event_1",
|
||||
mode: "race",
|
||||
type: "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"], carIds: [] }],
|
||||
}],
|
||||
drivers: [{ id: "driver_1", name: "Driver One", transponder: "123" }],
|
||||
cars: [],
|
||||
resultsBySession: {},
|
||||
};
|
||||
let saveCount = 0;
|
||||
const overlayEvents = [];
|
||||
const deps = {
|
||||
state,
|
||||
t: (key) => key,
|
||||
getActiveSession: () => session,
|
||||
ensureSessionResult: (sessionId) => ensureSessionResult(sessionId, { state }),
|
||||
normalizeStartMode: () => "mass",
|
||||
getSessionLapWindow: () => ({ minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY }),
|
||||
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: () => {},
|
||||
saveState: () => { saveCount += 1; },
|
||||
lastOverlayLeaderKeyBySession: {},
|
||||
lastOverlayTop3BySession: {},
|
||||
lastOverlayBestLapByKey: {},
|
||||
};
|
||||
const passing = (timestamp, role) => processDecoderMessageHelper({
|
||||
msg: "PASSING",
|
||||
transponder: "123",
|
||||
rtc_time: new Date(timestamp).toISOString(),
|
||||
}, deps, role);
|
||||
return { state, session, passing, overlayEvents, getSaveCount: () => saveCount };
|
||||
}
|
||||
|
||||
test("split decoder records intermediate time without changing main lap state", () => {
|
||||
const harness = createHarness();
|
||||
harness.passing(17_000, "main");
|
||||
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("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");
|
||||
|
||||
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.passings.length, 1);
|
||||
});
|
||||
|
||||
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;
|
||||
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: () => "dashboard",
|
||||
renderView: () => {},
|
||||
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",
|
||||
});
|
||||
} finally {
|
||||
globalThis.WebSocket = OriginalWebSocket;
|
||||
}
|
||||
});
|
||||
@@ -4,4 +4,5 @@ import "./race_timing_logic.test.js";
|
||||
import "./judging_logic.test.js";
|
||||
import "./runtime_persistence.test.js";
|
||||
import "./audio_runtime.test.js";
|
||||
import "./decoder_runtime.test.js";
|
||||
import "./server_persistence.integration.test.js";
|
||||
|
||||
@@ -61,6 +61,11 @@ test("backend hydration normalizes persisted entities and settings", () => {
|
||||
activeSessionId: "session_1",
|
||||
settings: {
|
||||
wsUrl: "ws://decoder:9000",
|
||||
splitDecoderEnabled: true,
|
||||
splitWsUrl: "ws://decoder:9001",
|
||||
pitDecoderEnabled: true,
|
||||
pitEntryWsUrl: "ws://decoder:9002",
|
||||
pitExitWsUrl: "ws://decoder:9003",
|
||||
backendUrl: "http://race:8081",
|
||||
language: "en",
|
||||
theme: "light",
|
||||
@@ -96,6 +101,10 @@ test("backend hydration normalizes persisted entities and settings", () => {
|
||||
assert.equal(state.training.normalized, true);
|
||||
assert.equal(state.activeSessionId, "session_1");
|
||||
assert.equal(state.settings.theme, "light");
|
||||
assert.equal(state.settings.splitDecoderEnabled, true);
|
||||
assert.equal(state.settings.splitWsUrl, "ws://decoder:9001");
|
||||
assert.equal(state.settings.pitDecoderEnabled, true);
|
||||
assert.equal(state.settings.pitExitWsUrl, "ws://decoder:9003");
|
||||
assert.equal(state.settings.autoReconnect, false);
|
||||
assert.equal(state.settings.audioEnabled, false);
|
||||
assert.equal(state.settings.speakerPassingCueEnabled, true);
|
||||
|
||||
@@ -87,6 +87,40 @@ test("SQLite API persists state, passings, and overlay session results", { timeo
|
||||
assert.equal(health.ok, true);
|
||||
assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite"));
|
||||
|
||||
const ammcConfigResponse = await fetch(`${baseUrl}/api/ammc/config`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
managedEnabled: true,
|
||||
decoderHost: "192.168.1.10",
|
||||
wsPort: 9000,
|
||||
splitEnabled: true,
|
||||
splitDecoderHost: "192.168.1.11",
|
||||
splitWsPort: 9001,
|
||||
pitEnabled: true,
|
||||
pitEntryDecoderHost: "192.168.1.12",
|
||||
pitEntryWsPort: 9002,
|
||||
pitExitDecoderHost: "192.168.1.13",
|
||||
pitExitWsPort: 9003,
|
||||
}),
|
||||
});
|
||||
const ammcConfigPayload = await ammcConfigResponse.json();
|
||||
assert.equal(ammcConfigResponse.status, 200);
|
||||
assert.equal(ammcConfigPayload.config.splitWsPort, 9001);
|
||||
assert.equal(ammcConfigPayload.config.pitExitDecoderHost, "192.168.1.13");
|
||||
assert.equal(ammcConfigPayload.status.role, "main");
|
||||
assert.equal(ammcConfigPayload.status.split.role, "split");
|
||||
assert.equal(ammcConfigPayload.status.pitEntry.wsPort, 9002);
|
||||
|
||||
const duplicatePortResponse = await fetch(`${baseUrl}/api/ammc/config`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ splitEnabled: true, splitWsPort: 9000 }),
|
||||
});
|
||||
const duplicatePortPayload = await duplicatePortResponse.json();
|
||||
assert.equal(duplicatePortResponse.status, 400);
|
||||
assert.match(duplicatePortPayload.error, /unique WebSocket port/);
|
||||
|
||||
const emptyResponse = await fetch(`${baseUrl}/api/state`);
|
||||
assert.deepEqual(await emptyResponse.json(), { state: null, updatedAt: null });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user