diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c4502..6cef6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.17.2 - 2026-09-11 + +### Changed + +- Added permanent browser regression tests for the three UIs restructured this session: Settings tabs (default tab, panel switching, selection surviving an in-place save/re-render), Event Manager tabs (default tab, panel switching, the quick-jump step cards and Open Grid button correctly switching tabs before scrolling), and the race day Schedule panel (saving/clearing the schedule, running-order table, and the Delayed badge for a session that started late). All added to the `npm run test:browser` suite. + ## 1.17.1 - 2026-09-11 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 9f501b5..2760237 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -422,6 +422,17 @@ Requested 2026-09-11 by the user as the first of two "make it easier for a new, Verified 2026-09-11 with a live headless-browser check in both languages: confirmed the new Schedule section and overview card render with the correct title/content, confirmed no raw untranslated `guide.*` keys leaked onto the page in either language, and spot-checked the new staggered-done-flag, Settings-tabs, and Event-Manager-tabs bullets are all present and correctly translated. +## Permanent regression tests for the three UI restructurings above + +Requested 2026-09-11 by the user in response to "what's left in the tests, and what can be done to improve them" - the Settings tabs, Event Manager tabs and Schedule panel entries above had only been verified with throwaway scratch scripts during development, never with permanent coverage in `npm run test:browser`. + +- [x] `tests/browser_settings_tabs.js` (new): Connection is the default tab with only its panel(s) visible; clicking each of the four tabs shows exactly its own panel and hides the rest; the selection survives an in-place re-render triggered by submitting `settingsForm` (`requestSubmit()`) instead of silently resetting to Connection - the actual failure mode `activeSettingsTab` being module-scoped state exists to prevent. +- [x] `tests/browser_event_manager_tabs.js` (new): Setup is the default manage tab with only its panel visible; clicking each of the four tabs shows exactly its own panel; the pre-existing quick-jump step cards and warning links (`MANAGE_TAB_TARGETS`) switch to the correct tab before their `scrollIntoView`, covering both a Format-tab target (`manage-generation`) and a Live-tab target (`manage-live-results`); and opening a session's grid editor (Open Grid button, in the always-visible session table above the tabs) switches to the Live tab via its own separate `activeManageTab = "live"` assignment. +- [x] `tests/browser_event_schedule.js` (new): the schedule form actually persists `scheduleStartAt`/`scheduleGapMin` onto `event.raceConfig` (the exact field this session's own investigation found `normalizeEvent`'s allowlist was silently dropping before the fix); the table lists sessions in running order (practice before qualification) rather than seed order; a session started more than a minute after its planned time shows a real actual-start time and the "Delayed" badge; and the Clear button removes only the start time, leaving the configured gap untouched. +- [x] All three wired into `tests/browser_smoke.js` alongside the existing suite, sharing the one isolated server/browser instance the rest of the suite already spins up. + +Verified 2026-09-11: `npm run test:browser` passes all 34 scenarios (11 new across the 3 new files, plus the pre-existing 23) against a real, isolated server and headless browser; `npm test` passes all 205 unit tests unchanged. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 413f0c6..45ae681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.17.1", + "version": "1.17.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.17.1", + "version": "1.17.2", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index f6ddf28..4914505 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.17.1", + "version": "1.17.2", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/tests/browser_event_manager_tabs.js b/tests/browser_event_manager_tabs.js new file mode 100644 index 0000000..6ee9e2b --- /dev/null +++ b/tests/browser_event_manager_tabs.js @@ -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"); +} diff --git a/tests/browser_event_schedule.js b/tests/browser_event_schedule.js new file mode 100644 index 0000000..0edcccd --- /dev/null +++ b/tests/browser_event_schedule.js @@ -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"); +} diff --git a/tests/browser_settings_tabs.js b/tests/browser_settings_tabs.js new file mode 100644 index 0000000..aecf3e8 --- /dev/null +++ b/tests/browser_settings_tabs.js @@ -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"); +} diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 5af3137..92fae71 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -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 });