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:
@@ -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
|
||||
|
||||
11
ROADMAP.md
11
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.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
86
tests/browser_event_manager_tabs.js
Normal file
86
tests/browser_event_manager_tabs.js
Normal 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");
|
||||
}
|
||||
87
tests/browser_event_schedule.js
Normal file
87
tests/browser_event_schedule.js
Normal 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");
|
||||
}
|
||||
46
tests/browser_settings_tabs.js
Normal file
46
tests/browser_settings_tabs.js
Normal 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");
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user