Add permanent browser tests for the Settings tabs, Event Manager tabs, and Schedule panel

These three UIs (all shipped earlier this session) had only been verified with throwaway
scratch scripts during development. Adds tests/browser_settings_tabs.js,
browser_event_manager_tabs.js and browser_event_schedule.js, wired into the existing
test:browser suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-11 16:45:24 +02:00
parent 2624ce8262
commit 05f0291ee9
8 changed files with 245 additions and 3 deletions

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
// Regression coverage for splitting Event Manager's race-mode page into tabs (Setup /
// Format / Live / Schedule). Two things previously would have silently broken here and
// need real DOM coverage rather than a unit test:
// 1. The pre-existing "quick jump" step cards and race-summary warning links point at
// element ids that now live inside a hidden tab panel - MANAGE_TAB_TARGETS has to
// switch tabs first, or scrollIntoView lands on something invisible.
// 2. Opening a session's grid editor (Open Grid button, in the always-visible session
// table above the tabs) jumps the user straight to the Live tab, since that's where
// the grid editor itself renders.
export async function runEventManagerTabsSmoke({ evaluate, navigate, savedState, waitFor, base }) {
const click = (id) => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
const patchState = async (patch) => {
const current = await savedState();
Object.assign(current, patch);
const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) });
assert.equal(response.ok, true);
};
const classId = "emt_class";
const driverA = { id: "emt_driver_a", name: "EMT Driver A", classId, transponder: "7820001" };
const driverB = { id: "emt_driver_b", name: "EMT Driver B", classId, transponder: "7820002" };
const raceEvent = {
id: "emt_event", name: "EMT Event", classId, mode: "race",
raceConfig: { participantsConfigured: true, driverIds: [driverA.id, driverB.id], teams: [] },
};
const qualifyingSession = { id: "emt_quali", eventId: raceEvent.id, name: "EMT Qualification", type: "qualification", startMode: "position", durationMin: 5, status: "ready" };
const seeded = await savedState();
await patchState({
drivers: [...seeded.drivers, driverA, driverB],
events: [...seeded.events, raceEvent],
sessions: [...seeded.sessions, qualifyingSession],
});
const activeTab = () => evaluate(`document.querySelector(".manage-tab.active")?.dataset.manageTab`);
const panelVisibility = () => evaluate(`
Object.fromEntries(Array.from(document.querySelectorAll("[data-manage-panel]"))
.map((panel) => [panel.dataset.managePanel, !panel.hidden]))
`);
await navigate("race_setup", "raceWizardStepForm");
await click("event-manage-" + raceEvent.id);
await waitFor(async () => await evaluate(`Boolean(document.querySelector(".manage-tabs"))`), "manage tabs rendered for a race-mode event");
assert.equal(await activeTab(), "setup", "Setup is the default manage tab");
let visible = await panelVisibility();
assert.equal(visible.setup, true);
assert.equal(visible.format, false);
assert.equal(visible.live, false);
assert.equal(visible.schedule, false);
console.log("PASS event manager defaults to the Setup tab with only that panel visible");
for (const tab of ["format", "live", "schedule", "setup"]) {
await evaluate(`document.querySelector('[data-manage-tab="${tab}"]').click()`);
assert.equal(await activeTab(), tab, `clicking the ${tab} manage tab activates it`);
visible = await panelVisibility();
for (const [panelId, isVisible] of Object.entries(visible)) {
assert.equal(isVisible, panelId === tab, `after switching to ${tab}, panel ${panelId} should be ${panelId === tab ? "visible" : "hidden"}`);
}
}
console.log("PASS clicking each manage tab shows only its own panel and hides the rest");
// Still on "setup" from the loop above. The step cards above the tabs are always visible
// and point at ids inside specific tab panels - clicking one must switch tabs first, not
// just scroll uselessly behind a still-hidden panel.
assert.equal(await activeTab(), "setup");
await evaluate(`document.querySelector('[data-target="manage-generation"]').click()`);
assert.equal(await activeTab(), "format", "the step card for session generation jumps into the Format tab, which is where manage-generation actually lives");
await evaluate(`document.querySelector('[data-manage-tab="setup"]').click()`);
await evaluate(`document.querySelector('[data-target="manage-live-results"]').click()`);
assert.equal(await activeTab(), "live", "the step card for live results jumps into the Live tab");
console.log("PASS quick-jump step cards switch to the tab their target lives in before scrolling");
// Open Grid (in the always-visible session table above the tabs) is a separate jump path
// with its own hard-coded activeManageTab = "live" assignment - cover it independently.
await evaluate(`document.querySelector('[data-manage-tab="schedule"]').click()`);
assert.equal(await activeTab(), "schedule");
await click("session-grid-" + qualifyingSession.id);
await waitFor(async () => (await activeTab()) === "live", "opening a session's grid switches to the Live tab");
visible = await panelVisibility();
assert.equal(visible.live, true);
assert.equal(visible.schedule, false);
console.log("PASS opening a session's grid editor switches to the Live tab");
}

View File

@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
// Regression coverage for the race day Schedule panel (Event Manager's "Schedule" tab).
// buildEventSchedule's cascading-time math and the getSessionSortWeight ordering bug it
// exposed are already covered at the unit level (race_timing_logic.test.js) - this file
// exercises the UI wiring on top of it: the form actually persists scheduleStartAt/
// scheduleGapMin onto event.raceConfig (previously silently dropped by normalizeEvent's
// field allowlist), the table renders sessions in running order (not seed order), a
// session that started late shows the Delayed badge, and Clear removes only the start
// time, not the configured gap.
export async function runEventScheduleSmoke({ evaluate, navigate, fill, savedState, waitFor, base }) {
const click = (id) => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
const submit = (id) => evaluate(`document.getElementById(${JSON.stringify(id)}).requestSubmit()`);
const findEvent = async (id) => (await savedState()).events.find((event) => event.id === id);
const patchState = async (patch) => {
const current = await savedState();
Object.assign(current, patch);
const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) });
assert.equal(response.ok, true);
};
const classId = "esc_class";
const driverA = { id: "esc_driver_a", name: "ESC Driver A", classId, transponder: "7830001" };
const raceEvent = {
id: "esc_event", name: "ESC Event", classId, mode: "race",
raceConfig: { participantsConfigured: true, driverIds: [driverA.id], teams: [] },
};
// practice (sort weight 2) runs before qualification (weight 3) - buildEventSchedule
// orders by getSessionSortWeight, not seed/array order, so the table proves that too.
const qualifyingSession = { id: "esc_quali", eventId: raceEvent.id, name: "ESC Qualification", type: "qualification", durationMin: 5, status: "ready" };
const practiceSession = { id: "esc_practice", eventId: raceEvent.id, name: "ESC Practice", type: "practice", durationMin: 5, status: "ready" };
const seeded = await savedState();
await patchState({
drivers: [...seeded.drivers, driverA],
events: [...seeded.events, raceEvent],
sessions: [...seeded.sessions, qualifyingSession, practiceSession],
});
const openSchedule = async () => {
await navigate("race_setup", "raceWizardStepForm");
await click("event-manage-" + raceEvent.id);
await waitFor(async () => await evaluate(`Boolean(document.querySelector(".manage-tabs"))`), "manage tabs rendered");
await evaluate(`document.querySelector('[data-manage-tab="schedule"]').click()`);
await waitFor(async () => await evaluate(`Boolean(document.getElementById("scheduleForm"))`), "schedule panel visible");
};
await openSchedule();
assert.equal(await evaluate(`document.querySelector('[data-manage-panel="schedule"]')?.hidden`), false, "schedule panel is visible after clicking its tab");
assert.equal(await evaluate(`Boolean(document.querySelector('[data-manage-panel="schedule"] table'))`), false, "with no start time set yet, the not-set hint is shown instead of a table");
await fill("scheduleForm", { scheduleStartAt: "2026-09-11T10:00", scheduleGapMin: "5" });
await submit("scheduleForm");
await waitFor(async () => Number.isFinite((await findEvent(raceEvent.id)).raceConfig.scheduleStartAt), "schedule form saves scheduleStartAt onto the event");
const savedEvent = await findEvent(raceEvent.id);
assert.equal(savedEvent.raceConfig.scheduleGapMin, 5, "schedule form saves scheduleGapMin onto the event");
const startAt = savedEvent.raceConfig.scheduleStartAt;
console.log("PASS saving the schedule form persists scheduleStartAt/scheduleGapMin onto event.raceConfig");
await openSchedule();
const rowNames = await evaluate(`Array.from(document.querySelectorAll('[data-manage-panel="schedule"] table tbody tr td:first-child')).map((td) => td.textContent)`);
assert.deepEqual(rowNames, [practiceSession.name, qualifyingSession.name], "the schedule table lists sessions in running order (practice before qualification), not seed order");
const delayedBadgesBefore = await evaluate(`document.querySelectorAll('[data-manage-panel="schedule"] .pill-warn').length`);
assert.equal(delayedBadgesBefore, 0, "no session has started yet, so nothing is flagged delayed");
console.log("PASS the schedule table cascades planned times in running order with no delayed sessions yet");
// Start practice 2.5 minutes after its own planned time - past buildEventSchedule's
// 1-minute delayed threshold - and leave it running (no endedAt) so the table has to
// show a real actualStart with no actualEnd.
await patchState({
sessions: (await savedState()).sessions.map((session) =>
session.id === practiceSession.id ? { ...session, status: "running", startedAt: startAt + 150000 } : session
),
});
await openSchedule();
const actualCells = await evaluate(`Array.from(document.querySelectorAll('[data-manage-panel="schedule"] table tbody tr td:nth-child(4)')).map((td) => td.textContent)`);
assert.notEqual(actualCells[0], "-", "practice's actual start is shown once the session has started");
const delayedBadgesAfter = await evaluate(`document.querySelectorAll('[data-manage-panel="schedule"] .pill-warn').length`);
assert.equal(delayedBadgesAfter, 1, "the late-started practice session is flagged delayed");
console.log("PASS a session that started more than a minute late is flagged with the Delayed badge");
await evaluate(`document.getElementById("scheduleClear").click()`);
await waitFor(async () => (await findEvent(raceEvent.id)).raceConfig.scheduleStartAt === null, "Clear removes scheduleStartAt");
assert.equal((await findEvent(raceEvent.id)).raceConfig.scheduleGapMin, 5, "Clear leaves the configured gap untouched");
await openSchedule();
assert.equal(await evaluate(`Boolean(document.querySelector('[data-manage-panel="schedule"] table'))`), false, "with no start time set, the schedule table is replaced by the not-set hint");
console.log("PASS clearing the schedule removes the start time but keeps the configured gap");
}

View File

@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
// Regression coverage for the Settings page's tab restructuring (previously one long
// scrolling page: Connection, Audio, Appearance & overlay, System & backup all at once).
// Tabs must show exactly one group of panels at a time, default to Connection on first
// load, and - the actual bug class this guards against - keep the selection when the page
// re-renders in place (e.g. after saving the form), not silently reset back to Connection.
// activeSettingsTab is a module-level variable specifically so it survives that re-render.
export async function runSettingsTabsSmoke({ evaluate, navigate }) {
await navigate("settings", "settingsForm");
const activeTab = () => evaluate(`document.querySelector(".settings-tab.active")?.dataset.settingsTab`);
const panelVisibility = () => evaluate(`
Object.fromEntries(Array.from(document.querySelectorAll("[data-settings-panel]"))
.map((panel) => [panel.dataset.settingsPanel, !panel.hidden]))
`);
assert.equal(await activeTab(), "connection", "Connection is the default tab on first load");
let visible = await panelVisibility();
assert.equal(visible.connection, true, "connection panel visible by default");
assert.equal(visible.audio, false, "audio panel hidden by default");
assert.equal(visible.appearance, false, "appearance panel hidden by default");
assert.equal(visible.system, false, "system panel hidden by default");
console.log("PASS settings defaults to the Connection tab with only that panel visible");
for (const tab of ["audio", "appearance", "system", "connection"]) {
await evaluate(`document.querySelector('[data-settings-tab="${tab}"]').click()`);
assert.equal(await activeTab(), tab, `clicking the ${tab} tab activates it`);
visible = await panelVisibility();
for (const [panelId, isVisible] of Object.entries(visible)) {
assert.equal(isVisible, panelId === tab, `after switching to ${tab}, panel ${panelId} should be ${panelId === tab ? "visible" : "hidden"}`);
}
}
console.log("PASS clicking each settings tab shows only its own panel and hides the rest");
await evaluate(`document.querySelector('[data-settings-tab="system"]').click()`);
assert.equal(await activeTab(), "system");
// Submitting the settings form re-renders the whole page in place (no navigation), which
// is exactly the path that used to drop the user back onto the Connection tab.
await evaluate(`document.getElementById("settingsForm").requestSubmit()`);
assert.equal(await activeTab(), "system", "the selected tab survives an in-place re-render after saving, instead of resetting to Connection");
visible = await panelVisibility();
assert.equal(visible.system, true);
assert.equal(visible.connection, false);
console.log("PASS the active settings tab is preserved across a save-triggered re-render");
}

View File

@@ -3,6 +3,9 @@ import { runRaceSetupSmoke } from "./browser_race_setup.js";
import { runRaceDaySmoke } from "./browser_race_day.js";
import { runRaceDayStartSequencesSmoke } from "./browser_race_day_start_sequences.js";
import { runTeamMembershipSmoke } from "./browser_team_membership.js";
import { runEventManagerTabsSmoke } from "./browser_event_manager_tabs.js";
import { runEventScheduleSmoke } from "./browser_event_schedule.js";
import { runSettingsTabsSmoke } from "./browser_settings_tabs.js";
import { runObsPopupSmoke } from "./browser_obs_popup.js";
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
import assert from "node:assert/strict";
@@ -136,6 +139,9 @@ try {
}
await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause });
await runTeamMembershipSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base });
await runEventManagerTabsSmoke({ evaluate, navigate, savedState, waitFor, base });
await runEventScheduleSmoke({ evaluate, navigate, fill, savedState, waitFor, base });
await runSettingsTabsSmoke({ evaluate, navigate });
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs });
await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs });
await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick });