diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6b83c..4063ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,17 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. -## 1.20.2 - 2026-09-12 +## 1.21.0 - 2026-09-12 + +### Changed + +- The Guide page is now split into 5 tabs (Getting started, Create a race, Run a race, Training & results, Video guides) instead of ~20 panels stacked on one very long page. + +### Added + +- `tests/browser_guide_tabs.js`: permanent regression coverage for the new Guide tabs, wired into `npm run test:browser`. + +Investigated a reported "feels slow" concern about the Guide page while making this change: measured its actual render time in a real browser (median 0.6ms, comparable to or faster than Settings/Timing) - no rendering performance issue found. The perceived slowness was the page's length, which this change addresses directly. ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index a9b8ad9..65ee931 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -494,6 +494,18 @@ The user asked whether the videos were actually linked anywhere yet - they weren Verified 2026-09-11: confirmed all 7 files are served with `200` and the correct `video/x-matroska` content type via a real running (isolated) server instance. A full unit test run (205 tests) stayed green. A live headless-browser check of the Guide page in both languages confirmed the new panel renders with 7 correctly-labeled links to `/docs/videos/...` and no raw `guide.*` keys leaked - the first check of this appeared to show Swedish labels even with `settings.language: "en"` seeded, but that was the check script itself reading the page before its language-dependent re-render finished (a premature ready-check, not a real bug); waiting for the same hydration signal the rest of this session's browser tests already use confirmed English renders correctly. +## Guide page split into tabs + +Reported 2026-09-12 by the user: the Guide page itself needed to be "more överskådlig" (overviewable) - not just one long page. Fair complaint: by this point in the session the page had grown to ~20 stacked panels (Windows/Linux/SQLite setup, decoder roles, creating a sponsor event/regular race/race wizard, race format, the whole race-day operation topic list, free/open practice, team race, qualifying, dashboard, training, archive, audio, plus the new video guides panel from the entry above) - the same "one long scroll" problem already fixed twice this session for Settings and Event Manager. + +- [x] Split into 5 tabs mirroring that same pattern (`activeGuideTab`, module-scoped so it survives an in-place re-render, same as `activeSettingsTab`/`activeManageTab`): **Getting started** (Windows/Linux/SQLite install, decoder roles, Managed AMMC), **Create a race** (sponsor event, regular race, race wizard, race format, the time-of-day schedule), **Run a race** (Race Day, the 4-step manage flow, lap validation/corrections, dashboard schedule-drift, free/open practice, team race, qualifying, race audio), **Training & results** (training stats, results archive), **Video guides** (unchanged from the entry above, just now its own tab instead of the first panel on the long page). The always-visible overview cards + intro at the top stay exactly as they were - only the ~20 detail panels below them got grouped into tabs. +- [x] New `.guide-tabs`/`.guide-tab`/`[data-guide-panel][hidden]` CSS, copied from the `.manage-tabs` family rather than introducing a third slightly-different pattern. +- [x] `tests/browser_guide_tabs.js` (new): default tab, per-tab panel visibility, and - the actual regression class this guards against - the active tab surviving an in-place re-render. Reused the language-switch re-render (`languageSelect.onchange` calls `renderView()` without navigating away) as the trigger, since Guide has no form of its own to submit the way Settings does. + +Investigated alongside this, per the user's follow-up question about whether the page (or the app generally) had a real performance problem: checked how `tickClock`/`updateSyncStatus` (both on 1-second `setInterval`s) interact with an idle Guide page first - confirmed neither calls `renderView()` unless the current view is `timing`/`race_day`/`dashboard`/`overlay` (`tickClock`) or touches anything beyond a few specific status text nodes (`updateSyncStatus`), so an idle Guide page was never being silently re-rendered in the background. Then measured the Guide page's actual one-time render cost directly in a real headless browser (`performance.now()` around the nav click that triggers `renderGuideView()`, 20 repeated samples to rule out one-off JIT warmup noise): median 0.6ms, max 3.7ms, 433 DOM nodes, ~39KB of generated HTML - for comparison, Settings measured 5.7ms and Timing 2.5ms on the same run. No rendering performance issue exists or ever existed; the reported "slow" feeling was the page's length (now fixed above), not JavaScript performance. + +Verified 2026-09-12: `npm run test:browser` passes all 37 scenarios (3 new, 34 pre-existing) including the new Guide tabs coverage; `npm test` passes all 205 unit tests unchanged. Live headless-browser checks in both languages confirmed correct tab labels/translations, no raw `guide.*` keys, and that the "Run a race" tab's content (`guide.race_day_title`) renders correctly. Refreshed `docs/screenshots/guide.jpg` to show the new tab bar. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/docs/screenshots/guide.jpg b/docs/screenshots/guide.jpg index 9ff16e3..e161799 100644 Binary files a/docs/screenshots/guide.jpg and b/docs/screenshots/guide.jpg differ diff --git a/package-lock.json b/package-lock.json index d154e93..1dc592e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.20.2", + "version": "1.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.20.2", + "version": "1.21.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 9a100af..ff86821 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.20.2", + "version": "1.21.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index 99fce7b..1b446b1 100644 --- a/src/app.js +++ b/src/app.js @@ -1058,6 +1058,11 @@ const TRANSLATIONS = { "guide.video_settings_tabs": "Settings-flikarna", "guide.video_event_manager_tabs": "Event Manager-flikarna", "guide.video_event_schedule": "Race day-schemat", + "guide.tab_start": "Kom igång", + "guide.tab_create": "Skapa ett race", + "guide.tab_run": "Köra ett race", + "guide.tab_training": "Träning & resultat", + "guide.tab_videos": "Videoguider", "guide.schedule_1": "1. Öppna Hantera -> fliken Schema & utskrift för ett race-event. Sätt starttid och paus mellan sessioner (standard 3 minuter) och klicka Spara schema.", "guide.schedule_2": "2. Varje sessions planerade klockslag räknas ut i körordning (practice, sedan kval, sedan finaler) utifrån starttiden, sessionens längd/efterlöp och pausen mellan sessioner.", "guide.schedule_3": "3. Så fort en session faktiskt startar eller avslutas flyttas resten av schemat automatiskt fram till den verkliga tiden - en sen start eller en session som drar över (inklusive paus) skjuter resten av dagen framåt utan att du behöver räkna om manuellt. En session som startade mer än en minut sent märks \"Försenad\".", @@ -2611,6 +2616,11 @@ const TRANSLATIONS = { "guide.video_settings_tabs": "The Settings tabs", "guide.video_event_manager_tabs": "The Event Manager tabs", "guide.video_event_schedule": "The race day schedule", + "guide.tab_start": "Getting started", + "guide.tab_create": "Create a race", + "guide.tab_run": "Run a race", + "guide.tab_training": "Training & results", + "guide.tab_videos": "Video guides", "guide.schedule_1": "1. Open Manage -> the Schedule & print tab for a race event. Set a start time and the gap between sessions (default 3 minutes) and click Save schedule.", "guide.schedule_2": "2. Every session's planned clock time is worked out in running order (practice, then qualifying, then finals) from the start time, each session's own duration/follow-up, and the gap between sessions.", "guide.schedule_3": "3. As soon as a session actually starts or finishes, the rest of the schedule automatically shifts to match reality - a late start or a session that runs long (including any pause) pushes the rest of the day forward with no manual recalculation. A session that started more than a minute late is marked \"Delayed\".", diff --git a/src/misc_views.js b/src/misc_views.js index d162844..efa70e9 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -1,3 +1,6 @@ +let activeGuideTab = "start"; +const GUIDE_TAB_IDS = ["start", "create", "run", "training", "videos"]; + function renderGuidePanel(titleKey, itemKeys, extras = "", { t }) { return `
@@ -23,7 +26,7 @@ function renderGuideOverviewCard(titleKey, blurbKey, { t }) { export function renderGuideView({ dom, t }) { dom.view.innerHTML = ` -
+

${t("guide.title")}

${t("guide.intro")}

@@ -41,94 +44,121 @@ export function renderGuideView({ dom, t }) {
-
-
-

${t("guide.videos_title")}

+
+ + + + + +
+ +
+
+ ${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"], "", { t })} + ${renderGuidePanel("guide.linux_title", ["guide.linux_1", "guide.linux_2", "guide.linux_3"], "", { t })} +
+ +
+ ${renderGuidePanel("guide.decoder_roles_title", ["guide.decoder_roles_1", "guide.decoder_roles_2", "guide.decoder_roles_3", "guide.decoder_roles_4", "guide.decoder_roles_5", "guide.decoder_roles_6", "guide.decoder_roles_7", "guide.decoder_roles_8", "guide.decoder_roles_9", "guide.decoder_roles_10", "guide.decoder_roles_11", "guide.decoder_roles_12", "guide.decoder_roles_13", "guide.decoder_roles_14", "guide.decoder_roles_15"], "", { t })} + ${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })} +
+ +
+

${t("guide.sqlite_title")}

+

${t("guide.ammc_ref")}

-
- ${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6", "guide.race_day_7", "guide.race_day_8"], "", { t })} -
- -
- ${renderGuidePanel("guide.sponsor_title", ["guide.sponsor_1", "guide.sponsor_2", "guide.sponsor_3", "guide.sponsor_4", "guide.sponsor_5", "guide.sponsor_6"], "", { t })} - ${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6", "guide.race_wizard_7"], "", { t })} -
- -
- ${renderGuidePanel("guide.race_title", ["guide.race_1", "guide.race_2", "guide.race_3", "guide.race_4", "guide.race_4a", "guide.race_5", "guide.race_6", "guide.race_7", "guide.race_8", "guide.race_9", "guide.race_10"], "", { t })} - ${renderGuidePanel("guide.manage_steps_title", ["guide.manage_steps_1", "guide.manage_steps_2", "guide.manage_steps_3", "guide.manage_steps_4", "guide.manage_steps_5", "guide.manage_steps_6", "guide.manage_steps_7", "guide.manage_steps_8", "guide.manage_steps_9"], "", { t })} -
- -
- ${renderGuidePanel("guide.schedule_title", ["guide.schedule_1", "guide.schedule_2", "guide.schedule_3", "guide.schedule_4"], "", { t })} -
- -
- ${renderGuidePanel("guide.race_format_title", ["guide.race_format_0", "guide.race_format_1", "guide.race_format_2", "guide.race_format_3", "guide.race_format_4", "guide.race_format_5", "guide.race_format_6", "guide.race_format_7", "guide.race_format_8", "guide.race_format_9", "guide.race_format_10", "guide.race_format_11", "guide.race_format_11a", "guide.race_format_12", "guide.race_format_13", "guide.race_format_14"], "", { t })} - ${renderGuidePanel("guide.validation_title", ["guide.validation_1", "guide.validation_2", "guide.validation_3", "guide.validation_4", "guide.validation_5", "guide.validation_6", "guide.validation_7", "guide.validation_8"], "", { t })} -
- -
- ${renderGuidePanel("guide.free_practice_title", ["guide.free_practice_1", "guide.free_practice_2", "guide.free_practice_3"], "", { t })} - ${renderGuidePanel("guide.open_practice_title", ["guide.open_practice_1", "guide.open_practice_2", "guide.open_practice_3", "guide.open_practice_4", "guide.open_practice_5"], "", { t })} -
- -
- ${renderGuidePanel("guide.team_title", ["guide.team_1", "guide.team_2", "guide.team_3", "guide.team_4", "guide.team_5", "guide.team_6"], "", { t })} - ${renderGuidePanel("guide.qualifying_title", ["guide.qualifying_1", "guide.qualifying_2", "guide.qualifying_3", "guide.qualifying_4", "guide.qualifying_5", "guide.qualifying_6", "guide.qualifying_7"], "", { t })} -
- -
- ${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3", "guide.dashboard_4"], "", { t })} - ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9", "guide.training_10", "guide.training_11", "guide.training_12", "guide.training_13", "guide.training_14", "guide.training_15", "guide.training_16", "guide.training_17", "guide.training_18", "guide.training_19", "guide.training_20", "guide.training_21", "guide.training_22", "guide.training_23", "guide.training_24", "guide.training_25", "guide.training_26", "guide.training_27", "guide.training_28", "guide.training_29", "guide.training_30", "guide.training_31"], "", { t })} -
- -
- ${renderGuidePanel("guide.archive_title", ["guide.archive_1", "guide.archive_2", "guide.archive_3", "guide.archive_4", "guide.archive_5", "guide.archive_6"], "", { t })} -
- -
- ${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5", "guide.audio_6", "guide.audio_7", "guide.audio_8", "guide.audio_9"], "", { t })} -
- -
- ${renderGuidePanel("guide.decoder_roles_title", ["guide.decoder_roles_1", "guide.decoder_roles_2", "guide.decoder_roles_3", "guide.decoder_roles_4", "guide.decoder_roles_5", "guide.decoder_roles_6", "guide.decoder_roles_7", "guide.decoder_roles_8", "guide.decoder_roles_9", "guide.decoder_roles_10", "guide.decoder_roles_11", "guide.decoder_roles_12", "guide.decoder_roles_13", "guide.decoder_roles_14", "guide.decoder_roles_15"], "", { t })} - ${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })} -
- -
- ${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"], "", { t })} - ${renderGuidePanel("guide.linux_title", ["guide.linux_1", "guide.linux_2", "guide.linux_3"], "", { t })} -
- -
-

${t("guide.sqlite_title")}

-
-
    -
  • ${t("guide.sqlite_1")}
  • -
  • ${t("guide.sqlite_2")}
  • -
  • ${t("guide.sqlite_3")}
  • -
  • ${t("guide.sqlite_4")}
  • -
  • ${t("guide.sqlite_5")}
  • -
  • ${t("guide.sqlite_6")}
  • -
-

${t("guide.ammc_ref")}

+
+
+ ${renderGuidePanel("guide.sponsor_title", ["guide.sponsor_1", "guide.sponsor_2", "guide.sponsor_3", "guide.sponsor_4", "guide.sponsor_5", "guide.sponsor_6"], "", { t })} + ${renderGuidePanel("guide.race_title", ["guide.race_1", "guide.race_2", "guide.race_3", "guide.race_4", "guide.race_4a", "guide.race_5", "guide.race_6", "guide.race_7", "guide.race_8", "guide.race_9", "guide.race_10"], "", { t })}
-
+ +
+ ${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6", "guide.race_wizard_7"], "", { t })} + ${renderGuidePanel("guide.schedule_title", ["guide.schedule_1", "guide.schedule_2", "guide.schedule_3", "guide.schedule_4"], "", { t })} +
+ +
+ ${renderGuidePanel("guide.race_format_title", ["guide.race_format_0", "guide.race_format_1", "guide.race_format_2", "guide.race_format_3", "guide.race_format_4", "guide.race_format_5", "guide.race_format_6", "guide.race_format_7", "guide.race_format_8", "guide.race_format_9", "guide.race_format_10", "guide.race_format_11", "guide.race_format_11a", "guide.race_format_12", "guide.race_format_13", "guide.race_format_14"], "", { t })} +
+
+ +
+
+ ${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6", "guide.race_day_7", "guide.race_day_8"], "", { t })} + ${renderGuidePanel("guide.manage_steps_title", ["guide.manage_steps_1", "guide.manage_steps_2", "guide.manage_steps_3", "guide.manage_steps_4", "guide.manage_steps_5", "guide.manage_steps_6", "guide.manage_steps_7", "guide.manage_steps_8", "guide.manage_steps_9"], "", { t })} +
+ +
+ ${renderGuidePanel("guide.validation_title", ["guide.validation_1", "guide.validation_2", "guide.validation_3", "guide.validation_4", "guide.validation_5", "guide.validation_6", "guide.validation_7", "guide.validation_8"], "", { t })} + ${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3", "guide.dashboard_4"], "", { t })} +
+ +
+ ${renderGuidePanel("guide.free_practice_title", ["guide.free_practice_1", "guide.free_practice_2", "guide.free_practice_3"], "", { t })} + ${renderGuidePanel("guide.open_practice_title", ["guide.open_practice_1", "guide.open_practice_2", "guide.open_practice_3", "guide.open_practice_4", "guide.open_practice_5"], "", { t })} +
+ +
+ ${renderGuidePanel("guide.team_title", ["guide.team_1", "guide.team_2", "guide.team_3", "guide.team_4", "guide.team_5", "guide.team_6"], "", { t })} + ${renderGuidePanel("guide.qualifying_title", ["guide.qualifying_1", "guide.qualifying_2", "guide.qualifying_3", "guide.qualifying_4", "guide.qualifying_5", "guide.qualifying_6", "guide.qualifying_7"], "", { t })} +
+ +
+ ${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5", "guide.audio_6", "guide.audio_7", "guide.audio_8", "guide.audio_9"], "", { t })} +
+
+ +
+
+ ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9", "guide.training_10", "guide.training_11", "guide.training_12", "guide.training_13", "guide.training_14", "guide.training_15", "guide.training_16", "guide.training_17", "guide.training_18", "guide.training_19", "guide.training_20", "guide.training_21", "guide.training_22", "guide.training_23", "guide.training_24", "guide.training_25", "guide.training_26", "guide.training_27", "guide.training_28", "guide.training_29", "guide.training_30", "guide.training_31"], "", { t })} + ${renderGuidePanel("guide.archive_title", ["guide.archive_1", "guide.archive_2", "guide.archive_3", "guide.archive_4", "guide.archive_5", "guide.archive_6"], "", { t })} +
+
+ +
+ +
`; + + const guideTabButtons = Array.from(dom.view.querySelectorAll(".guide-tab")); + const guideTabPanels = Array.from(dom.view.querySelectorAll("[data-guide-panel]")); + const applyGuideTab = (tabId) => { + activeGuideTab = tabId; + guideTabButtons.forEach((button) => button.classList.toggle("active", button.dataset.guideTab === tabId)); + guideTabPanels.forEach((panel) => { panel.hidden = panel.dataset.guidePanel !== tabId; }); + }; + guideTabButtons.forEach((button) => { + button.addEventListener("click", () => applyGuideTab(button.dataset.guideTab)); + }); + applyGuideTab(GUIDE_TAB_IDS.includes(activeGuideTab) ? activeGuideTab : "start"); } export function renderOverlayPageView(deps) { diff --git a/src/styles.css b/src/styles.css index d8eba25..5bb7158 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1624,6 +1624,42 @@ body { display: none; } +.guide-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 14px 0; +} + +.guide-tab { + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + border-radius: 10px; + padding: 8px 14px; + cursor: pointer; + font-weight: 600; + font-size: 0.86rem; + transition: all 0.2s ease; +} + +.guide-tab:hover { + border-color: color-mix(in srgb, var(--accent-2) 42%, var(--line)); +} + +.guide-tab.active { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--accent) 30%, transparent), + color-mix(in srgb, var(--accent) 10%, transparent) + ); +} + +[data-guide-panel][hidden] { + display: none; +} + .decoder-settings-form { display: grid; gap: 16px; diff --git a/tests/browser_guide_tabs.js b/tests/browser_guide_tabs.js new file mode 100644 index 0000000..38dd1f6 --- /dev/null +++ b/tests/browser_guide_tabs.js @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; + +// Regression coverage for splitting the Guide page into tabs (previously ~20 stacked +// panels on one very long page - "Getting started", "Create a race", "Run a race", +// "Training & results", "Video guides"). Tabs must show exactly one group of panels at a +// time, default to "Getting started" on first load, and - the actual bug class this +// guards against - keep the selection when the page re-renders in place (a language +// switch calls renderView() without navigating away), not silently reset. activeGuideTab +// is a module-level variable specifically so it survives that re-render. +export async function runGuideTabsSmoke({ evaluate, navigate }) { + await navigate("guide", "guideOverview"); + + const activeTab = () => evaluate(`document.querySelector(".guide-tab.active")?.dataset.guideTab`); + const panelVisibility = () => evaluate(` + Object.fromEntries(Array.from(document.querySelectorAll("[data-guide-panel]")) + .map((panel) => [panel.dataset.guidePanel, !panel.hidden])) + `); + + assert.equal(await activeTab(), "start", "Getting started is the default tab on first load"); + let visible = await panelVisibility(); + assert.equal(visible.start, true, "start panel visible by default"); + assert.equal(visible.create, false, "create panel hidden by default"); + assert.equal(visible.run, false, "run panel hidden by default"); + assert.equal(visible.training, false, "training panel hidden by default"); + assert.equal(visible.videos, false, "videos panel hidden by default"); + console.log("PASS guide defaults to the Getting started tab with only that panel visible"); + + for (const tab of ["create", "run", "training", "videos", "start"]) { + await evaluate(`document.querySelector('[data-guide-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 guide tab shows only its own panel and hides the rest"); + + const videoLinkCount = await evaluate(`document.querySelectorAll('[data-guide-panel="videos"] a[href^="/docs/videos/"]').length`); + assert.equal(videoLinkCount, 7, "all 7 video guide links live under the Video guides tab"); + + await evaluate(`document.querySelector('[data-guide-tab="run"]').click()`); + assert.equal(await activeTab(), "run"); + // Switching language calls renderView() in place (no navigation) - exactly the path + // that would silently reset the tab if activeGuideTab weren't module-scoped state. + await evaluate(`(() => { const select = document.getElementById("languageSelect"); select.value = select.value === "en" ? "sv" : "en"; select.onchange(); })()`); + assert.equal(await activeTab(), "run", "the selected tab survives a language-switch re-render, instead of resetting to Getting started"); + visible = await panelVisibility(); + assert.equal(visible.run, true); + assert.equal(visible.start, false); + console.log("PASS the active guide tab is preserved across a language-switch re-render"); +} diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 92fae71..877319e 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -6,6 +6,7 @@ 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 { runGuideTabsSmoke } from "./browser_guide_tabs.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js"; import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js"; import assert from "node:assert/strict"; @@ -142,6 +143,7 @@ try { await runEventManagerTabsSmoke({ evaluate, navigate, savedState, waitFor, base }); await runEventScheduleSmoke({ evaluate, navigate, fill, savedState, waitFor, base }); await runSettingsTabsSmoke({ evaluate, navigate }); + await runGuideTabsSmoke({ 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 });