339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
applyPersistedStateHelper,
|
|
hydrateFromBackendHelper,
|
|
syncStateToBackendHelper,
|
|
tickClockHelper,
|
|
} from "../src/runtime_services.js";
|
|
|
|
function withMockFetch(mock, callback) {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mock;
|
|
return Promise.resolve()
|
|
.then(callback)
|
|
.finally(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
}
|
|
|
|
function createState() {
|
|
return {
|
|
classes: [],
|
|
drivers: [],
|
|
cars: [],
|
|
events: [],
|
|
sessions: [],
|
|
resultsBySession: {},
|
|
training: {},
|
|
activeSessionId: null,
|
|
settings: {
|
|
wsUrl: "ws://old:9000",
|
|
backendUrl: "http://old:8081",
|
|
language: "sv",
|
|
theme: "dark",
|
|
passingSoundMode: "beep",
|
|
clubName: "Old club",
|
|
clubTagline: "Old tagline",
|
|
pdfFooter: "Old footer",
|
|
pdfTheme: "classic",
|
|
logoDataUrl: "",
|
|
racePresets: [],
|
|
obsOverlay: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
test("backend hydration normalizes persisted entities and settings", () => {
|
|
const state = createState();
|
|
let themeApplied = 0;
|
|
const normalizeNamed = (value) => ({ ...value, name: String(value?.name || "").trim() });
|
|
|
|
applyPersistedStateHelper(
|
|
{
|
|
classes: [{ id: "class_1", name: "Modified" }],
|
|
drivers: [{ id: "driver_1", name: " Driver One " }, { id: "driver_empty", name: " " }],
|
|
cars: [{ id: "car_1", name: " Car One " }],
|
|
events: [{ id: "event_1", name: "Race" }],
|
|
sessions: [{ id: "session_1", name: "Final" }],
|
|
resultsBySession: { session_1: { passings: [] } },
|
|
audioEventLog: [{ id: "audio_1", event: "race_start" }],
|
|
training: { goals: [{ id: "goal_1" }] },
|
|
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",
|
|
publicBaseUrl: "https://training.example.test",
|
|
language: "en",
|
|
theme: "light",
|
|
autoReconnect: false,
|
|
audioEnabled: false,
|
|
clubName: "JMK RB RaceController",
|
|
clubTagline: "RC Timing System",
|
|
pdfFooter: "Generated by JMK RB RaceController",
|
|
speakerPassingCueEnabled: true,
|
|
racePresets: [{ id: "preset_1", name: " Club " }, { id: "preset_empty", name: " " }],
|
|
obsOverlay: { rows: 8 },
|
|
},
|
|
},
|
|
{
|
|
state,
|
|
getDefaultBackendUrl: () => "http://default:8081",
|
|
DEFAULT_LANGUAGE: "sv",
|
|
DEFAULT_THEME: "dark",
|
|
AVAILABLE_THEMES: ["dark", "nord", "light"],
|
|
normalizeDriver: normalizeNamed,
|
|
normalizeCar: normalizeNamed,
|
|
normalizeEvent: (event) => ({ ...event, normalized: true }),
|
|
normalizeSession: (session) => ({ ...session, normalized: true }),
|
|
normalizeStoredRacePreset: normalizeNamed,
|
|
normalizeObsOverlaySettings: (settings) => ({ rows: Number(settings?.rows || 10) }),
|
|
normalizeTrainingState: (training) => ({ ...training, normalized: true }),
|
|
applyTheme: () => { themeApplied += 1; },
|
|
}
|
|
);
|
|
|
|
assert.deepEqual(state.classes, [{ id: "class_1", name: "Modified" }]);
|
|
assert.deepEqual(state.drivers.map((driver) => driver.name), ["Driver One"]);
|
|
assert.deepEqual(state.cars.map((car) => car.name), ["Car One"]);
|
|
assert.equal(state.events[0].normalized, true);
|
|
assert.equal(state.sessions[0].normalized, true);
|
|
assert.equal(state.training.normalized, true);
|
|
assert.equal(state.activeSessionId, "session_1");
|
|
assert.deepEqual(state.audioEventLog, [{ id: "audio_1", event: "race_start" }]);
|
|
assert.equal(state.settings.theme, "light");
|
|
assert.equal(state.settings.publicBaseUrl, "https://training.example.test");
|
|
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.clubName, "JMK RB");
|
|
assert.equal(state.settings.clubTagline, "Live Timing & Race Control");
|
|
assert.equal(state.settings.pdfFooter, "Generated by RC RaceCore");
|
|
assert.equal(state.settings.speakerPassingCueEnabled, true);
|
|
assert.deepEqual(state.settings.racePresets.map((preset) => preset.name), ["Club"]);
|
|
assert.deepEqual(state.settings.obsOverlay, { rows: 8 });
|
|
assert.equal(themeApplied, 1);
|
|
});
|
|
|
|
test("invalid persisted theme falls back without discarding local setting defaults", () => {
|
|
const state = createState();
|
|
state.settings.theme = "nord";
|
|
state.settings.clubName = "Local club";
|
|
|
|
applyPersistedStateHelper(
|
|
{ settings: { theme: "unknown" } },
|
|
{
|
|
state,
|
|
getDefaultBackendUrl: () => "http://default:8081",
|
|
DEFAULT_LANGUAGE: "sv",
|
|
DEFAULT_THEME: "dark",
|
|
AVAILABLE_THEMES: ["dark", "nord", "light"],
|
|
normalizeDriver: (value) => value,
|
|
normalizeCar: (value) => value,
|
|
normalizeEvent: (value) => value,
|
|
normalizeSession: (value) => value,
|
|
normalizeStoredRacePreset: (value) => value,
|
|
normalizeObsOverlaySettings: (value) => value || {},
|
|
normalizeTrainingState: (value) => value || {},
|
|
applyTheme: () => {},
|
|
}
|
|
);
|
|
|
|
assert.equal(state.settings.theme, "dark");
|
|
assert.equal(state.settings.clubName, "Local club");
|
|
assert.equal(state.settings.language, "sv");
|
|
});
|
|
|
|
test("successful backend hydration applies state without scheduling a write-back", async () => {
|
|
const backend = { available: false, lastError: "old", lastSyncAt: null };
|
|
const applied = [];
|
|
const saves = [];
|
|
|
|
await withMockFetch(
|
|
async (url, options) => {
|
|
assert.equal(url, "http://race:8081/api/state");
|
|
assert.equal(options, undefined);
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ state: { activeSessionId: "session_1" }, updatedAt: "2026-08-24T10:00:00.000Z" }),
|
|
};
|
|
},
|
|
() => hydrateFromBackendHelper({
|
|
backend,
|
|
t: (key, vars) => `${key}:${vars.msg}`,
|
|
getBackendUrl: () => "http://race:8081",
|
|
applyPersistedState: (state) => applied.push(state),
|
|
saveState: (options) => saves.push(options),
|
|
})
|
|
);
|
|
|
|
assert.deepEqual(applied, [{ activeSessionId: "session_1" }]);
|
|
assert.deepEqual(saves, [{ skipBackend: true }]);
|
|
assert.equal(backend.available, true);
|
|
assert.equal(backend.lastError, "");
|
|
assert.equal(backend.lastSyncAt, "2026-08-24T10:00:00.000Z");
|
|
});
|
|
|
|
test("failed hydration preserves local state and reports backend offline", async () => {
|
|
const backend = { available: true, lastError: "" };
|
|
let applied = false;
|
|
let saved = false;
|
|
|
|
await withMockFetch(
|
|
async () => ({ ok: false, status: 503, json: async () => ({}) }),
|
|
() => hydrateFromBackendHelper({
|
|
backend,
|
|
t: (key, vars) => `${key}:${vars.msg}`,
|
|
getBackendUrl: () => "http://race:8081",
|
|
applyPersistedState: () => { applied = true; },
|
|
saveState: () => { saved = true; },
|
|
})
|
|
);
|
|
|
|
assert.equal(applied, false);
|
|
assert.equal(saved, false);
|
|
assert.equal(backend.available, false);
|
|
assert.equal(backend.lastError, "error.backend_offline:HTTP 503");
|
|
});
|
|
|
|
test("state sync posts one snapshot and clears dirty only when no newer edit exists", async () => {
|
|
const backend = { available: false, lastError: "" };
|
|
const payload = { events: [{ id: "event_1" }] };
|
|
let syncedVersion = 2;
|
|
let dirty = true;
|
|
let posted = null;
|
|
|
|
await withMockFetch(
|
|
async (url, options) => {
|
|
posted = { url, options };
|
|
return { ok: true };
|
|
},
|
|
() => syncStateToBackendHelper({
|
|
backend,
|
|
t: (key, vars) => `${key}:${vars.msg}`,
|
|
getBackendUrl: () => "http://race:8081",
|
|
buildPersistableState: () => payload,
|
|
getLocalStateVersion: () => 4,
|
|
getSyncedStateVersion: () => syncedVersion,
|
|
setSyncedStateVersion: (value) => { syncedVersion = value; },
|
|
setLocalStateDirty: (value) => { dirty = value; },
|
|
})
|
|
);
|
|
|
|
assert.equal(posted.url, "http://race:8081/api/state");
|
|
assert.equal(posted.options.method, "POST");
|
|
assert.deepEqual(JSON.parse(posted.options.body), payload);
|
|
assert.equal(syncedVersion, 4);
|
|
assert.equal(dirty, false);
|
|
assert.equal(backend.available, true);
|
|
assert.equal(backend.lastError, "");
|
|
});
|
|
|
|
test("completed state sync keeps dirty when an edit occurs during the request", async () => {
|
|
const backend = { available: false, lastError: "" };
|
|
let versionReadCount = 0;
|
|
let syncedVersion = 0;
|
|
let dirtyWrites = 0;
|
|
|
|
await withMockFetch(
|
|
async () => ({ ok: true }),
|
|
() => syncStateToBackendHelper({
|
|
backend,
|
|
t: (key, vars) => `${key}:${vars.msg}`,
|
|
getBackendUrl: () => "http://race:8081",
|
|
buildPersistableState: () => ({ revision: 4 }),
|
|
getLocalStateVersion: () => (++versionReadCount === 1 ? 4 : 5),
|
|
getSyncedStateVersion: () => syncedVersion,
|
|
setSyncedStateVersion: (value) => { syncedVersion = value; },
|
|
setLocalStateDirty: () => { dirtyWrites += 1; },
|
|
})
|
|
);
|
|
|
|
assert.equal(syncedVersion, 4);
|
|
assert.equal(dirtyWrites, 0);
|
|
assert.equal(backend.available, true);
|
|
});
|
|
|
|
test("failed state sync leaves version and dirty state unchanged", async () => {
|
|
const backend = { available: true, lastError: "" };
|
|
let syncedVersion = 3;
|
|
let dirtyWrites = 0;
|
|
|
|
await withMockFetch(
|
|
async () => { throw new Error("connection refused"); },
|
|
() => syncStateToBackendHelper({
|
|
backend,
|
|
t: (key, vars) => `${key}:${vars.msg}`,
|
|
getBackendUrl: () => "http://race:8081",
|
|
buildPersistableState: () => ({ revision: 4 }),
|
|
getLocalStateVersion: () => 4,
|
|
getSyncedStateVersion: () => syncedVersion,
|
|
setSyncedStateVersion: (value) => { syncedVersion = value; },
|
|
setLocalStateDirty: () => { dirtyWrites += 1; },
|
|
})
|
|
);
|
|
|
|
assert.equal(syncedVersion, 3);
|
|
assert.equal(dirtyWrites, 0);
|
|
assert.equal(backend.available, false);
|
|
assert.equal(backend.lastError, "error.sync_failed:connection refused");
|
|
});
|
|
|
|
test("running timing clock updates without rebuilding the leaderboard details modal", () => {
|
|
let renders = 0;
|
|
let dynamicRefreshes = 0;
|
|
let headerRefreshes = 0;
|
|
const base = {
|
|
dom: { clock: { textContent: "" } },
|
|
currentLanguage: () => "sv",
|
|
getActiveSession: () => ({ status: "running" }),
|
|
getCurrentView: () => "timing",
|
|
getOverlayMode: () => false,
|
|
renderView: () => { renders += 1; },
|
|
refreshTimingDynamics: () => { dynamicRefreshes += 1; },
|
|
updateHeaderState: () => { headerRefreshes += 1; },
|
|
};
|
|
|
|
tickClockHelper({ ...base, handleSessionTimerTick: () => ({ changed: false }) });
|
|
assert.equal(dynamicRefreshes, 1);
|
|
assert.equal(renders, 0);
|
|
assert.equal(headerRefreshes, 0);
|
|
|
|
tickClockHelper({ ...base, handleSessionTimerTick: () => ({ changed: true }) });
|
|
assert.equal(dynamicRefreshes, 1);
|
|
assert.equal(renders, 1);
|
|
assert.equal(headerRefreshes, 1);
|
|
});
|
|
|
|
|
|
test("Race Day clock refreshes in place while running or paused", () => {
|
|
let renders = 0;
|
|
let dynamicRefreshes = 0;
|
|
const base = {
|
|
dom: { clock: { textContent: "" } },
|
|
currentLanguage: () => "en",
|
|
getActiveSession: () => ({ status: "running" }),
|
|
getCurrentView: () => "race_day",
|
|
getOverlayMode: () => false,
|
|
handleSessionTimerTick: () => ({ changed: false }),
|
|
renderView: () => { renders += 1; },
|
|
refreshTimingDynamics: () => { dynamicRefreshes += 1; },
|
|
updateHeaderState: () => {},
|
|
};
|
|
|
|
tickClockHelper(base);
|
|
tickClockHelper({ ...base, getActiveSession: () => ({ status: "paused" }) });
|
|
|
|
assert.equal(dynamicRefreshes, 2);
|
|
assert.equal(renders, 0);
|
|
});
|