Files
Live_RC/tests/runtime_persistence.test.js
2026-08-28 21:12:43 +02:00

314 lines
11 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",
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.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);
});