diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d3731..fb7ed2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The application version is maintained in `package.json` and displayed in the adm ## Unreleased +### Reliability checks + +- Browser coverage for sponsor-event edits, endurance race creation, team membership changes/deletion, session-type conversion and lap-limit persistence. +- Overlay sorting headings share the same column tracks as driver rows. Narrow displays scroll the headings and values together. + ### Added - A prioritized ROADMAP.md with completion checklists. diff --git a/README.md b/README.md index fa1a288..d539d1d 100644 --- a/README.md +++ b/README.md @@ -843,3 +843,5 @@ The menu shows server connection, pending changes, saving, confirmed saves and s New browser profiles use the page origin (including its port) as the default backend URL. An explicitly configured backend URL takes precedence. Run `npm test` for logic and API regression tests. Run `npm run test:browser` (Windows PowerShell: `npm.cmd run test:browser`) for real create/edit/reload checks of classes, drivers and cars, draft retention during background polling, and offline-save/retry behavior. Chrome, Chromium or Edge must be installed; set `CHROME_PATH` if its executable is not found automatically. The runner creates a temporary server database and browser profile and removes them on exit. It does not use your normal race data or start a decoder. See ROADMAP.md for workflows not yet covered. + +Browser coverage also includes sponsor-event edits, the endurance race wizard, team creation/membership edits/deletion, Open Practice to team-race conversion, and lap-limit persistence. Overlay sort headings align with their columns; narrow displays scroll the headings and rows together. diff --git a/README.sv.md b/README.sv.md index 159de78..123fa1c 100644 --- a/README.sv.md +++ b/README.sv.md @@ -832,3 +832,5 @@ Menyn visar serveranslutning, väntande ändringar, pågående sparning, bekräf Nya webbläsarprofiler använder sidans adress inklusive port som standard för backend. En uttryckligen konfigurerad backend-adress går före standardvärdet. Kör `npm test` för logik- och API-tester. Kör `npm run test:browser` (Windows PowerShell: `npm.cmd run test:browser`) för riktiga skapa/redigera/ladda-om-tester av klasser, förare och bilar, bibehållna formulärutkast under bakgrundsuppdatering samt offline-sparning och nytt sparförsök. Chrome, Chromium eller Edge måste finnas installerat; sätt `CHROME_PATH` om programmet inte hittas automatiskt. Testet skapar en tillfällig serverdatabas och webbläsarprofil och tar bort dem efteråt. Dina ordinarie racedata används inte och ingen decoder startas. ROADMAP.md visar vilka flöden som ännu saknar täckning. + +Webbläsartesterna täcker även sponsor-event, endurance-raceguiden, skapa/redigera/radera lag och ändra medlemmar, byte från Open Practice till lagrace samt sparade varvgränser. Overlayns sorteringsrubriker ligger ovanför sina kolumner; på smala skärmar skrollas rubriker och förarrader tillsammans i sidled. diff --git a/ROADMAP.md b/ROADMAP.md index 829a094..39ea33e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,10 +20,13 @@ Acceptance: real create/edit/save flows persist after reload; background updates - [x] Add a repeatable browser smoke-test command with temporary database/profile. - [x] Cover create/edit/save for classes, drivers and cars, including reload verification. - [x] Verify register form drafts remain intact during background version polling. -- [ ] Cover sponsor events and race setup edits. -- [ ] Cover team creation, membership changes, editing and deletion. -- [ ] Cover session types, lap limits and start/finish controls. -- [ ] Cover browser sorting, popup overlays, and compact OBS layout automatically. +- [x] Cover sponsor-event creation/editing and endurance race wizard/name edits with reload verification. +- [x] Cover team creation, membership changes, editing and deletion. +- [x] Cover Open Practice to team-race conversion, duration/follow-up edits and persisted lap limits. +- [ ] Cover other session-type transitions and start/finish controls. +- [x] Verify leaderboard/TV/team sorting heading alignment at 390/900/1600 px and preserved race positions. +- [x] Verify OBS views have no interactive sorting controls. +- [ ] Cover actual popup launching and compact OBS layout automatically. - [ ] Add Windows and Linux CI runs for supported runtimes. ## 2. Connection and save status @@ -38,6 +41,18 @@ Acceptance: users can distinguish server contact, pending edits, an active save - [ ] Extend stale-data feedback to the personal mobile profile. - [ ] Review concurrent writes, reconnect recovery and multiple controllers editing the same race. +### Next: decoder/socket diagnostics + +Acceptance: each configured channel distinguishes an open socket from actual received data and successfully parsed passing messages. Inspecting diagnostics must not interrupt timing or rerender an active settings form. + +- [ ] Show Main, Split, Pit entry and Pit exit separately, with the configured WebSocket URL. +- [ ] Show socket state, last connection, last received message and elapsed time since reception. +- [ ] Count received frames, valid passing messages, ignored messages and parse errors per channel. +- [ ] Add a bounded live message sample/log with pause, clear and copy controls. +- [ ] Show why an incoming passing was ignored or rejected without changing lap validation. +- [ ] Add simulated-socket tests for receiving, parsing, disconnecting and reconnecting. +- [ ] Document IP/port/channel setup and the difference between decoder connection and incoming data. + ## 3. Clearer practice follow-up Acceptance: a driver receives a short, encouraging summary based on their own class-specific data, with one useful next goal. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index c4b7579..a3777f6 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -2661,3 +2661,5 @@ The application currently has no authentication or role model. Race-control soun - `index.html` owns `syncStatus`, `syncStatusLabel`, `syncStatusDetail`, `syncStatusRetry`, and `syncWarning`. Retry uses the existing full-state POST when local changes or write errors exist. Successful full-state synchronization at the current local version clears the passing error too. Keep the page open when unsaved changes exist; this is not a new durable offline queue or a multi-controller conflict-resolution mechanism. - `tests/sync_status.test.js` verifies status precedence and state-write failure/retry. `tests/browser_smoke.js` drives Chrome/Chromium/Edge over its local debugging protocol with Node's WebSocket API. It launches an isolated server using `RC_TIMING_DATA_DIR` and a temporary browser profile. `CHROME_PATH` overrides discovery. `RC_BROWSER_NO_SANDBOX=1` is an explicit opt-in for restricted test environments; normal runs keep the browser sandbox enabled. - The browser suite covers register CRUD creation/editing (not deletion), drafts through two version polls, reload persistence, failed offline saves and retry. Session/team flows and CI platform coverage remain tracked in ROADMAP.md. + +Browser scenarios are split into `tests/browser_race_setup.js` (sponsor-event/race/team/session persistence) and `tests/browser_overlays.js` (shared overlay column alignment and sorting isolation). Both are invoked by `tests/browser_smoke.js` using its isolated browser/server harness. Overlay headings and rows use the same `--overlay-columns` grid tracks in `src/styles.css`; narrow interactive lists scroll horizontally as one unit. OBS grid rows retain their compact-screen wrapping because they have no sorting toolbar. diff --git a/src/styles.css b/src/styles.css index 3951e2d..a288772 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4057,3 +4057,14 @@ select:focus { font-size: 0.64rem; overflow-wrap: anywhere; } +@media print { + .overlay-race-list > .list-sort-toolbar { display: none; } +} +@media (max-width: 620px) { + .overlay-shell-obs .overlay-race-row { + min-width: 0; + grid-template-columns: 42px minmax(0, 1fr); + gap: 6px; + padding: 8px; + } +} diff --git a/tests/browser_overlays.js b/tests/browser_overlays.js new file mode 100644 index 0000000..051e308 --- /dev/null +++ b/tests/browser_overlays.js @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; + +export async function runOverlaySmoke({ evaluate }) { + const results = await evaluate(`(async () => { + const { renderOverlayLeaderboard, renderTeamOverlay, renderObsOverlay } = await import('/src/overlays.js'); + const { escapeHtml } = await import('/src/html_escape.js'); + const { installListSorting } = await import('/src/list_sorting.js'); + const frame = document.createElement('iframe'); + frame.style.cssText = 'position:fixed;left:0;top:0;height:900px;border:0;z-index:99999'; + document.body.append(frame); + const doc = frame.contentDocument; + const style = doc.createElement('link'); style.rel='stylesheet'; style.href='/src/styles.css'; + const loaded = new Promise((resolve,reject) => {style.onload=resolve;style.onerror=reject;}); + doc.head.append(style); await loaded; + const labels = {'table.pos':'Pos','table.driver':'Förare','table.laps':'Varv','table.result':'Resultat','table.gap':'Gap','table.ahead_gap':'Gap fram','table.own_delta':'Eget delta','table.best_lap':'Bästa varv'}; + let layout='leaderboard'; + const deps={t:key=>labels[key]||key, escapeHtml, formatLap:()=> '0:36.619', formatTeamActiveMemberLabel:()=> 'Test Driver', formatPredictedLapDelta:()=> '-', getVisiblePassings:()=>[], getEventName:()=> 'Club Practice', getSessionTypeLabel:()=> 'Practice', getStartModeLabel:()=> 'Mass start', getStatusLabel:()=> 'Running', getObsOverlayConfig:()=>({layout,rows:10,showClock:true,showLaps:true,showResult:true,showBest:true,showGap:true}), normalizeStartMode:v=>v, renderPositionGrid:()=>'', getSessionGridEntries:()=>[]}; + const rows=[{key:'a',displayName:'Karl Bodell',laps:27,resultDisplay:'0:36.619',bestLapMs:36619},{key:'b',displayName:'Anders',laps:19,resultDisplay:'0:37.854',bestLapMs:37854}]; + const results=[]; + const tick=()=>new Promise(resolve=>setTimeout(resolve,0)); + try { + for (const mode of ['leaderboard','tv','team']) { + for (const width of [390,900,1600]) { + frame.style.width=width+'px'; + doc.body.innerHTML='
'; + const root=doc.querySelector('.overlay-board'); + root.innerHTML=mode==='team' ? renderTeamOverlay(rows,{}, {},deps) : renderOverlayLeaderboard(rows,deps); + const stop=installListSorting(root); await tick(); + const list=root.querySelector('.overlay-race-list'); + const header=list.querySelector('.list-sort-toolbar'); + const row=list.querySelector('.overlay-race-row'); + const deltas=[...header.children].map((button,index)=>Math.abs(button.getBoundingClientRect().left-row.children[index].getBoundingClientRect().left)); + for(let attempt=0;attempt<3 && !header.children[1].textContent.includes("▲");attempt++) header.children[1].click(); + await tick(); + const firstName=list.querySelector('.overlay-race-driver strong').textContent; + const firstPosition=list.querySelector('.pos-pill').textContent; + results.push({mode,width,aligned:deltas.every(delta=>delta<1),firstName,firstPosition,columns:header.children.length,tableScroll:list.scrollWidth>list.clientWidth}); + stop(); + } + } + for (layout of ['leaderboard','grid']) { + for (const width of [390,1600]) { + frame.style.width=width+'px'; + doc.body.innerHTML='
'; + const root=doc.body.firstElementChild; + root.innerHTML=renderObsOverlay({name:'Practice',eventId:'e',status:'running'},rows,{}, {remainingMs:300000},{},deps); + const stop=installListSorting(root);await tick(); + results.push({mode:'obs-'+layout,width,controls:root.querySelectorAll('.list-sort-toolbar').length}); + stop(); + } + } + } finally {frame.remove();} + return results; + })()`); + for (const result of results) { + if (result.mode.startsWith("obs")) assert.equal(result.controls, 0, JSON.stringify(result)); + else { + assert.equal(result.aligned, true, JSON.stringify(result)); + assert.equal(result.columns, 8, JSON.stringify(result)); + assert.equal(result.firstName, "Anders", JSON.stringify(result)); + assert.equal(result.firstPosition, "2", JSON.stringify(result)); + } + } + console.log("PASS overlay heading alignment at 390/900/1600px, sorting preserves race position, OBS has no sort controls"); +} diff --git a/tests/browser_race_setup.js b/tests/browser_race_setup.js new file mode 100644 index 0000000..5fceef1 --- /dev/null +++ b/tests/browser_race_setup.js @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; + +export async function runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause }) { + const click = id => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`); + const submit = id => evaluate(`document.getElementById(${JSON.stringify(id)}).requestSubmit()`); + const findEvent = async name => (await savedState()).events.find(event => event.name === name); + async function editEvent(event, view, readyForm) { + await click("event-edit-" + event.id); + await fill("eventEditForm", { name: event.name + " Updated" }); + await submit("eventEditForm"); + await waitFor(async () => Boolean(await findEvent(event.name + " Updated")), "event name saved"); + await navigate(view, readyForm); + await click("event-edit-" + event.id); + assert.equal(await evaluate('document.getElementById("eventEditForm").elements.namedItem("name").value'), event.name + " Updated"); + await click("eventEditCancel"); + await click("event-manage-" + event.id); + } + + await navigate("events", "eventForm"); + await fill("eventForm", { name: "Browser Sponsor", date: "2026-09-08", classId: "test_class" }); + await submit("eventForm"); + await waitFor(async () => Boolean(await findEvent("Browser Sponsor")), "create sponsor event"); + const sponsor = await findEvent("Browser Sponsor"); + await editEvent(sponsor, "events", "eventForm"); + await fill("sessionForm", { name: "Sponsor Heat", type: "heat", durationMin: "5", startMode: "mass" }); + await submit("sessionForm"); + await waitFor(async () => (await savedState()).sessions.some(session => session.name === "Sponsor Heat" && session.eventId === sponsor.id), "sponsor session"); + console.log("PASS sponsor-event create/edit/reload and session creation"); + + await navigate("race_setup", "raceWizardStepForm"); + await fill("raceWizardStepForm", { name: "Browser Endurance", date: "2026-09-08", classId: "test_class", presetId: "endurance" }); + await click("raceWizardNext"); + await click("wizardSelectAllParticipants"); + await click("raceWizardNext"); + await evaluate('document.querySelector("#raceWizardPlanForm [name=createTeamRace]").checked = true'); + await fill("raceWizardPlanForm", { teamRaceDurationMin: "45" }); + await click("raceWizardNext"); + await click("raceWizardCreate"); + await waitFor(async () => Boolean(await findEvent("Browser Endurance")), "wizard race creation"); + const race = await findEvent("Browser Endurance"); + await navigate("race_setup", "raceWizardStepForm"); + await editEvent(race, "race_setup", "raceWizardStepForm"); + const current = await savedState(); + const driver = current.drivers.find(item => item.name === "Browser Driver Updated"); + const car = current.cars.find(item => item.name === "Browser Car Updated"); + const raceState = () => findEvent("Browser Endurance Updated"); + await fill("teamForm", { teamName: "Browser Team" }); + await evaluate(`document.querySelector('[form="teamForm"][name="teamDriverIds"][value="${driver.id}"]').checked=true`); + await submit("teamForm"); + await waitFor(async () => (await raceState()).raceConfig.teams.some(team => team.name === "Browser Team"), "team create"); + const team = (await raceState()).raceConfig.teams.find(item => item.name === "Browser Team"); + assert.deepEqual(team.driverIds, [driver.id]); + await click("team-edit-" + team.id); + await fill("teamEditForm", { teamName: "Browser Team Updated" }); + await evaluate(`document.querySelector('#teamEditForm [name="teamCarIds"][value="${car.id}"]').checked=true`); + await pause(5500); + assert.equal(await evaluate('document.getElementById("teamEditForm").elements.namedItem("teamName").value'), "Browser Team Updated"); + await click("teamEditSave"); + await waitFor(async () => (await raceState()).raceConfig.teams.some(item => item.name === "Browser Team Updated" && item.carIds.includes(car.id)), "team membership update"); + await navigate("race_setup", "raceWizardStepForm"); + await click("event-manage-" + race.id); + await click("team-edit-" + team.id); + assert.equal(await evaluate('document.getElementById("teamEditForm").elements.namedItem("teamName").value'), "Browser Team Updated"); + assert.ok(await evaluate(`document.querySelector('#teamEditForm [name="teamCarIds"][value="${car.id}"]').checked`)); + await click("teamEditCancel"); + + await fill("sessionForm", { name: "Editable Practice", type: "open_practice", durationMin: "5" }); + await submit("sessionForm"); + await waitFor(async () => (await savedState()).sessions.some(session => session.name === "Editable Practice"), "practice create"); + const session = (await savedState()).sessions.find(item => item.name === "Editable Practice"); + await click("session-edit-" + session.id); + await fill("sessionEditForm", { name: "Edited Team Session", type: "team_race", durationMin: "45", followUpSec: "30" }); + await submit("sessionEditForm"); + await waitFor(async () => (await savedState()).sessions.some(item => item.id === session.id && item.type === "team_race" && item.durationMin === 45 && item.followUpSec === 30), "session edit type and duration"); + await fill("raceFormatForm", { minLapSec: "30", maxLapSec: "120" }); + await submit("raceFormatForm"); + await waitFor(async () => (await raceState()).raceConfig.minLapMs === 30000 && (await raceState()).raceConfig.maxLapMs === 120000, "lap limits saved"); + await navigate("race_setup", "raceWizardStepForm"); + await click("event-manage-" + race.id); + await click("session-edit-" + session.id); + assert.equal(await evaluate('document.getElementById("sessionEditForm").elements.namedItem("type").value'), "team_race"); + await click("sessionEditCancel"); + assert.equal(await evaluate('document.getElementById("raceFormatForm").elements.namedItem("minLapSec").value'), "30.0"); + await click("team-delete-" + team.id); + await waitFor(async () => !(await raceState()).raceConfig.teams.some(item => item.id === team.id), "team deletion"); + console.log("PASS race wizard/edit/reload, team create/member-edit/delete, session type/duration and lap limits"); +} diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 13b109f..59ced70 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -1,3 +1,5 @@ +import { runOverlaySmoke } from "./browser_overlays.js"; +import { runRaceSetupSmoke } from "./browser_race_setup.js"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; @@ -105,6 +107,7 @@ try { assert.ok(await evaluate(`document.getElementById("view").textContent.includes(${JSON.stringify(fields.name + " Updated")})`)); console.log("PASS create/edit/reload and draft retention: " + collection); } + await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause }); await navigate("classes", "classForm"); await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); await fill("classForm", { name: "Offline Draft" }); @@ -115,6 +118,7 @@ try { await evaluate('document.getElementById("syncStatusRetry").click()'); await waitFor(async () => (await savedState()).classes.some(item => item.name === "Offline Draft"), "retry persisted draft"); await waitFor(async () => (await status()) === "saved", "confirmed saved status"); + await runOverlaySmoke({ evaluate }); assert.deepEqual(errors, []); console.log("PASS offline save failure, local draft retention, retry and confirmed saved status"); } finally {