new raceday and backup
This commit is contained in:
108
tests/backup_service.test.js
Normal file
108
tests/backup_service.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3");
|
||||
const { createBackupService, safeSegment } = require("../server/backup_service.js");
|
||||
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
function createTestDb(dataDir) {
|
||||
const db = new Database(join(dataDir, "rc_timing.sqlite"));
|
||||
db.exec(`
|
||||
CREATE TABLE app_state (id INTEGER PRIMARY KEY, state_json TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE passings (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL);
|
||||
`);
|
||||
db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?)").run(JSON.stringify({ events: [{ id: "event_1" }] }), "2026-08-31T10:00:00.000Z");
|
||||
db.prepare("INSERT INTO passings (session_id) VALUES (?)").run("session_1");
|
||||
return db;
|
||||
}
|
||||
|
||||
test("backup service creates consistent SQLite snapshots and metadata", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "racecore-backup-"));
|
||||
const db = createTestDb(dataDir);
|
||||
try {
|
||||
const service = createBackupService({ db, dataDir, maxBackups: 3, now: () => new Date("2026-08-31T10:15:30.123Z") });
|
||||
const backup = await service.createBackup({ reason: "pre_start", sessionId: "session_1", eventId: "event_1" });
|
||||
assert.match(backup.fileName, /pre_start__session_1\.sqlite$/);
|
||||
assert.equal(backup.reason, "pre_start");
|
||||
assert.ok(backup.sizeBytes > 0);
|
||||
|
||||
db.prepare("UPDATE app_state SET state_json = ? WHERE id = 1").run(JSON.stringify({ changed: true }));
|
||||
const snapshot = new Database(join(dataDir, "backups", backup.fileName), { readonly: true });
|
||||
try {
|
||||
assert.deepEqual(JSON.parse(snapshot.prepare("SELECT state_json FROM app_state WHERE id = 1").get().state_json), { events: [{ id: "event_1" }] });
|
||||
assert.equal(snapshot.prepare("SELECT COUNT(*) AS count FROM passings").get().count, 1);
|
||||
} finally {
|
||||
snapshot.close();
|
||||
}
|
||||
|
||||
const listed = service.listBackups();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].sessionId, "session_1");
|
||||
const metadata = JSON.parse(readFileSync(join(dataDir, "backups", backup.fileName.replace(/\.sqlite$/, ".json")), "utf8"));
|
||||
assert.equal(metadata.eventId, "event_1");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("backup service keeps only the configured number of snapshots", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "racecore-backup-retention-"));
|
||||
const db = createTestDb(dataDir);
|
||||
let index = 0;
|
||||
try {
|
||||
const service = createBackupService({ db, dataDir, maxBackups: 2, now: () => new Date(1_800_000_000_000 + index++ * 1000) });
|
||||
await service.createBackup({ reason: "manual", sessionId: "one" });
|
||||
await service.createBackup({ reason: "manual", sessionId: "two" });
|
||||
await service.createBackup({ reason: "manual", sessionId: "three" });
|
||||
const listed = service.listBackups();
|
||||
assert.equal(listed.length, 2);
|
||||
assert.ok(listed.some((item) => item.sessionId === "three"));
|
||||
assert.ok(!listed.some((item) => item.sessionId === "one"));
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("local restore validates and replaces a stopped test database with a safety backup", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "racecore-restore-"));
|
||||
let db = createTestDb(dataDir);
|
||||
try {
|
||||
const service = createBackupService({ db, dataDir, maxBackups: 5 });
|
||||
const backup = await service.createBackup({ reason: "manual", sessionId: "session_1" });
|
||||
db.prepare("UPDATE app_state SET state_json = ? WHERE id = 1").run(JSON.stringify({ changed: true }));
|
||||
db.close();
|
||||
db = null;
|
||||
|
||||
const result = spawnSync(process.execPath, [join(projectRoot, "scripts", "restore-backup.js"), backup.fileName, "--confirm"], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env, RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: join(dataDir, "missing.pid") },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const restored = new Database(join(dataDir, "rc_timing.sqlite"), { readonly: true });
|
||||
try {
|
||||
assert.deepEqual(JSON.parse(restored.prepare("SELECT state_json FROM app_state WHERE id = 1").get().state_json), { events: [{ id: "event_1" }] });
|
||||
} finally {
|
||||
restored.close();
|
||||
}
|
||||
const safetyFiles = service.listBackups().filter((item) => item.reason === "pre_restore");
|
||||
assert.equal(safetyFiles.length, 1);
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("backup file segments are bounded and filesystem safe", () => {
|
||||
assert.equal(safeSegment("A final / ../ unsafe"), "a-final-unsafe");
|
||||
assert.equal(safeSegment(""), "backup");
|
||||
});
|
||||
68
tests/race_day_logic.test.js
Normal file
68
tests/race_day_logic.test.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildRaceDayPreflight, buildRaceDayQueue } from "../src/race_day_logic.js";
|
||||
|
||||
function createState() {
|
||||
return {
|
||||
activeSessionId: "session_1",
|
||||
decoder: { connected: true, splitConnected: true, pitEntryConnected: true, pitExitConnected: true },
|
||||
settings: { splitDecoderEnabled: false, pitDecoderEnabled: false, raceAudio: { enabled: true } },
|
||||
classes: [{ id: "class_1", name: "2WD" }],
|
||||
drivers: [
|
||||
{ id: "driver_1", classId: "class_1", name: "One", transponder: "111" },
|
||||
{ id: "driver_2", classId: "class_1", name: "Two", transponder: "222" },
|
||||
],
|
||||
cars: [],
|
||||
events: [{
|
||||
id: "event_1",
|
||||
mode: "race",
|
||||
classId: "class_1",
|
||||
raceConfig: { participantsConfigured: true, driverIds: ["driver_1", "driver_2"], minLapMs: 10000, maxLapMs: 60000 },
|
||||
}],
|
||||
sessions: [
|
||||
{ id: "session_1", eventId: "event_1", name: "Q1", mode: "race", type: "qualification", status: "ready", startMode: "mass", driverIds: ["driver_1", "driver_2"] },
|
||||
{ id: "session_2", eventId: "event_1", name: "Q2", mode: "race", type: "qualification", status: "ready", startMode: "mass", driverIds: ["driver_1", "driver_2"] },
|
||||
{ id: "session_3", eventId: "event_1", name: "A final", mode: "race", type: "final", status: "ready", startMode: "position", driverIds: ["driver_1", "driver_2"], manualGridIds: ["driver_1", "driver_2"] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("Race Day queue follows the selected event session order", () => {
|
||||
const queue = buildRaceDayQueue(createState());
|
||||
assert.equal(queue.current.id, "session_1");
|
||||
assert.equal(queue.next.id, "session_2");
|
||||
assert.equal(queue.afterNext.id, "session_3");
|
||||
});
|
||||
|
||||
test("Race Day preflight accepts a complete race session", () => {
|
||||
const state = createState();
|
||||
const result = buildRaceDayPreflight(state, state.sessions[0], { backendAvailable: true });
|
||||
assert.equal(result.ready, true);
|
||||
assert.ok(result.checks.every((item) => item.level === "ok"));
|
||||
});
|
||||
|
||||
test("Race Day preflight blocks decoder, transponder, duplicate and grid errors", () => {
|
||||
const state = createState();
|
||||
state.decoder.connected = false;
|
||||
state.drivers[1].transponder = "111";
|
||||
state.sessions[2].manualGridIds = ["driver_1"];
|
||||
const result = buildRaceDayPreflight(state, state.sessions[2], { backendAvailable: true });
|
||||
assert.equal(result.ready, false);
|
||||
const levels = new Map(result.checks.map((item) => [item.id, item.level]));
|
||||
assert.equal(levels.get("decoder"), "error");
|
||||
assert.equal(levels.get("duplicate_transponders"), "error");
|
||||
assert.equal(levels.get("grid"), "error");
|
||||
});
|
||||
|
||||
test("open practice allows an unset lap window but still requires the main decoder", () => {
|
||||
const state = createState();
|
||||
state.events[0].raceConfig.minLapMs = 0;
|
||||
state.events[0].raceConfig.maxLapMs = 0;
|
||||
const practice = { ...state.sessions[0], type: "open_practice", driverIds: [] };
|
||||
let result = buildRaceDayPreflight(state, practice, { backendAvailable: true });
|
||||
assert.equal(result.ready, true);
|
||||
assert.equal(result.checks.find((item) => item.id === "lap_window").level, "warning");
|
||||
state.decoder.connected = false;
|
||||
result = buildRaceDayPreflight(state, practice, { backendAvailable: true });
|
||||
assert.equal(result.ready, false);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import "./training_logic.test.js";
|
||||
import "./public_training_profile.test.js";
|
||||
import "./race_timing_logic.test.js";
|
||||
import "./race_day_logic.test.js";
|
||||
import "./backup_service.test.js";
|
||||
import "./judging_logic.test.js";
|
||||
import "./runtime_persistence.test.js";
|
||||
import "./audio_runtime.test.js";
|
||||
|
||||
@@ -311,3 +311,26 @@ test("running timing clock updates without rebuilding the leaderboard details mo
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -160,6 +160,24 @@ test("SQLite API persists state, passings, and overlay session results", { timeo
|
||||
assert.equal(savePayload.ok, true);
|
||||
assert.equal(typeof savePayload.updatedAt, "string");
|
||||
|
||||
const backupResponse = await fetch(`${baseUrl}/api/backups`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: "pre_start", sessionId: "session_1", eventId: "event_1" }),
|
||||
});
|
||||
const backupPayload = await backupResponse.json();
|
||||
assert.equal(backupResponse.status, 201);
|
||||
assert.equal(backupPayload.ok, true);
|
||||
assert.equal(backupPayload.backup.reason, "pre_start");
|
||||
assert.equal(backupPayload.backup.sessionId, "session_1");
|
||||
assert.ok(backupPayload.backup.sizeBytes > 0);
|
||||
|
||||
const backupsResponse = await fetch(`${baseUrl}/api/backups`);
|
||||
const backupsPayload = await backupsResponse.json();
|
||||
assert.equal(backupsResponse.status, 200);
|
||||
assert.equal(backupsPayload.backups.length, 1);
|
||||
assert.equal(backupsPayload.backups[0].fileName, backupPayload.backup.fileName);
|
||||
|
||||
const storedResponse = await fetch(`${baseUrl}/api/state`);
|
||||
const storedPayload = await storedResponse.json();
|
||||
assert.deepEqual(storedPayload.state, initialState);
|
||||
|
||||
Reference in New Issue
Block a user