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); }); 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); });