diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7ed2c..dd76d04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The application version is maintained in `package.json` and displayed in the adm ### Added +- Browser-local decoder/socket diagnostics in Settings: per-channel counters, bounded logs, pause/clear/copy and independent updates that preserve form drafts. + - A prioritized ROADMAP.md with completion checklists. - Server/save status, last load/save times, stale/offline warnings and manual retry of local changes. - Isolated browser regression checks for class/driver/car creation and editing, retained drafts, reload persistence and offline save recovery (`npm run test:browser`). diff --git a/README.md b/README.md index d539d1d..2658012 100644 --- a/README.md +++ b/README.md @@ -845,3 +845,11 @@ New browser profiles use the page origin (including its port) as the default bac 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. + +### Decoder Socket Diagnostics + +Open **Settings > Decoder / socket diagnostics** to inspect Main, Split, Pit entry and Pit exit independently. Each channel shows its saved WebSocket URL, the actual socket URL, connection/reception times and counters. A connected socket alone does not prove that the decoder is sending passings. + +Use the AMMC host IP in WebSocket URLs (for example `ws://192.168.1.75:9000`), not the physical decoder IP. Give each AMMC channel its own port; enable only installed channels. `127.0.0.1` means the computer running the browser. + +Frames include all received WebSocket messages; PASSING counts recognized passing messages, not accepted laps. Handled includes split/pit events and start references. Ignored, invalid laps and JSON errors are separate. Select a channel to inspect its latest raw frame and 50 diagnostic events. Pause freezes only this display; Clear resets only that channel's diagnostic counters/log, preserving connection timestamps. Copy provides a manual fallback on plain HTTP. Diagnostics are local to this browser, memory-only, capped at 512 characters per sample, and reset on page reload. They neither change timing nor expose a new public API. Logs may contain transponder numbers; review before sharing. diff --git a/README.sv.md b/README.sv.md index 123fa1c..732b73a 100644 --- a/README.sv.md +++ b/README.sv.md @@ -834,3 +834,11 @@ Nya webbläsarprofiler använder sidans adress inklusive port som standard för 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. + +### Decoder- och Socketdiagnostik + +Öppna **Inställningar > Decoder / socket-diagnostik** för Main, mellantid och depå in/ut. Varje kanal visar sparad WebSocket-url, faktisk socket-adress, anslutnings-/mottagningstid och räknare. Ansluten socket betyder inte att passeringar kommer in. + +Använd AMMC-datorns IP i WebSocket-url (exempel `ws://192.168.1.75:9000`), inte den fysiska decoderns IP. Varje AMMC-kanal behöver egen port; aktivera bara installerade kanaler. `127.0.0.1` avser datorn med webbläsaren. + +Frames räknar mottagna socketmeddelanden; PASSING är passeringsmeddelanden, inte godkända varv. Hanterade inkluderar mellantider, depåhändelser och startreferenser. Ignorerade, ogiltiga varv och JSON-fel visas separat. Välj kanal för senaste råmeddelandet och 50 händelser. Pausa stoppar bara visningen. Rensa nollställer kanalens diagnostikräknare/logg men behåller anslutningstider. Kopiera erbjuder manuell kopiering på HTTP. Diagnostiken gäller bara denna webbläsare, sparas endast i minnet och försvinner vid omladdning. Varje exempel begränsas till 512 tecken. Tidtagningen påverkas inte och inget nytt publikt API tillkommer. Loggen kan innehålla transpondernummer; granska innan delning. diff --git a/ROADMAP.md b/ROADMAP.md index 39ea33e..d35b4bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,17 +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 +### Implemented: 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. +- [x] Show Main, Split, Pit entry and Pit exit separately, with the configured WebSocket URL. +- [x] Show socket state, last connection, last received message and elapsed time since reception. +- [x] Count received frames, recognized passing messages (not accepted laps), ignored messages and parse errors per channel. +- [x] Add a bounded live message sample/log with pause, clear and copy controls. +- [x] Show why an incoming passing was ignored or rejected without changing lap validation. +- [x] Add simulated-socket tests for receiving, parsing, disconnecting and open status. +- [ ] Verify automatic reconnect against a real decoder/network interruption. +- [x] Document IP/port/channel setup and the difference between decoder connection and incoming data. ## 3. Clearer practice follow-up diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index a3777f6..9daefab 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -2663,3 +2663,15 @@ The application currently has no authentication or role model. Race-control soun - 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. + +## Decoder Diagnostics (Browser-Local) + +Dependency graph: `app.js -> decoder_runtime.js -> decoder_diagnostics.js`; `app.js -> settings.js -> decoder_diagnostics_view.js -> decoder_diagnostics.js`. + +`decoder_diagnostics.js` exports a shared in-memory collector and an injectable-clock factory for tests. Fixed channel keys are main/split/pit_entry/pit_exit. Socket callbacks record connecting/open/error/close, incoming frame count/time and JSON failures. Existing `processDecoderMessageHelper` branches report ignored reasons or handled/invalid outcomes without changing returns, lap validation, persistence or race state. PASSING is a message counter, not a lap counter. Handled includes non-lap pit/split/start-reference events. At most 50 events and 512 characters per sample are retained per channel; snapshots copy logs so consumers cannot mutate collection state. + +`decoder_diagnostics_view.js` mounts inside a details element outside settingsForm. A one-second timer patches only its own text nodes; it skips closed/paused panels, clears the old timer on remount, and self-clears after detachment. Pause affects display, not collection. Clearing diagnostics preserves socket state and timestamps. Copy uses Clipboard API with a selectable readonly textarea fallback for HTTP. Untrusted payloads are assigned through textContent/value, never interpolated into HTML. No SQLite/localStorage writes, backend endpoint, or public overlay exposure is added. Refresh resets diagnostics independently of saved settings. + +Reason codes: invalid_message (not an object), not_passing, no_running_session, missing_transponder, not_registered, already_in_pit, pit_exit_without_entry, split_without_reference, split_locktime, below_min (stored invalid lap). Handled events: pit_entry, pit_exit, split, start_reference, lap_counted or the existing validation warning. invalid_json means parsing failed before processing. The raw frame helps diagnose upstream protocol problems without changing parsing behavior. + +Tests: decoder_diagnostics.test.js covers bounds, isolation, snapshots and outcomes; decoder_runtime.test.js exercises socket reception/JSON errors/disconnection/open status without settings rerender. browser_smoke.js verifies live diagnostics update while an edited settings URL remains unchanged. Hardware/network reconnect behavior still requires a real-decoder smoke test. diff --git a/src/app.js b/src/app.js index 493e07d..72b699f 100644 --- a/src/app.js +++ b/src/app.js @@ -1704,7 +1704,7 @@ const TRANSLATIONS = { "guide.decoder_roles_3": "3. Depåtid kräver två loopar: Depå in öppnar stoppet och Depå ut stänger det. Exempel: `192.168.1.12` -> `9002`, samt `192.168.1.13` -> `9003`.", "guide.decoder_roles_4": "4. Decoder-IP är den fysiska decoderns adress. WebSocket-porten tillhör AMMC-processen på backend-hosten. Blanda inte ihop dessa två adresser.", "guide.decoder_roles_5": "5. Varje fysisk decoder ska ha unik IP och varje aktiverad AMMC-process ska ha unik port. Main/split/depå får aldrig dela samma WebSocket-port.", - "guide.decoder_roles_6": "6. I Inställningar aktiverar du endast de extrakanaler som finns fysiskt, sparar Managed AMMC och klickar Starta AMMC. Använd sedan serverns WS-url och Anslut decoder.", + "guide.decoder_roles_6": "6. I Inställningar aktiverar du endast de extrakanaler som finns fysiskt, sparar Managed AMMC och klickar Starta AMMC. Använd sedan serverns WS-url och Anslut decoder. Öppna Decoder / socket-diagnostik för mottagna meddelanden, fel och kanalens senaste 50 händelser. Pausa/rensa gäller endast diagnostiken, aldrig tidtagningen.", "guide.decoder_roles_7": "7. Öppna TCP 9000 och de valfria portarna 9001-9003 i backend-hostens brandvägg om webbläsaren kör på en annan maskin. Port 8081 används för webb/API.", "guide.decoder_roles_8": "8. Kontrollera i Tidtagning att varje aktiverad roll står som Ansluten. Om main är offline räknas inga varv; om en extrakanal är offline fortsätter vanlig varvräkning.", "guide.decoder_roles_9": "9. Tidtagning visar live-sammanställning och depåstoppslogg. Race Setup visar samma data per heat, och resultat-PDF inkluderar stoppantal, total depåtid samt varje depå in/ut.", @@ -3090,7 +3090,7 @@ const TRANSLATIONS = { "guide.decoder_roles_3": "3. Pit timing needs two loops: Pit entry opens the stop and Pit exit closes it. Example: `192.168.1.12` -> `9002`, and `192.168.1.13` -> `9003`.", "guide.decoder_roles_4": "4. Decoder IP is the physical decoder address. The WebSocket port belongs to the AMMC process on the backend host. Do not confuse these two addresses.", "guide.decoder_roles_5": "5. Every physical decoder needs a unique IP and every enabled AMMC process needs a unique port. Main/split/pit must never share a WebSocket port.", - "guide.decoder_roles_6": "6. In Settings, enable only physically installed extra channels, save Managed AMMC, and click Start AMMC. Then use the server WS URLs and Connect decoder.", + "guide.decoder_roles_6": "6. In Settings, enable only physically installed extra channels, save Managed AMMC, and click Start AMMC. Then use the server WS URLs and Connect decoder. Open Decoder / socket diagnostics for incoming messages, errors and the latest 50 channel events. Pause/clear affect diagnostics only, never timing.", "guide.decoder_roles_7": "7. Allow TCP 9000 and optional ports 9001-9003 through the backend host firewall when the browser runs on another machine. Port 8081 serves the web UI/API.", "guide.decoder_roles_8": "8. Verify in Timing that every enabled role is Connected. If main is offline no laps are counted; an offline optional channel does not stop normal lap counting.", "guide.decoder_roles_9": "9. Timing shows a live summary and pit-stop log. Race Setup shows the same data per heat, and result PDFs include stop count, total pit time, and every pit entry/exit.", diff --git a/src/decoder_diagnostics.js b/src/decoder_diagnostics.js new file mode 100644 index 0000000..e4ec10c --- /dev/null +++ b/src/decoder_diagnostics.js @@ -0,0 +1,50 @@ +export const DIAGNOSTIC_ROLES = ["main", "split", "pit_entry", "pit_exit"]; +export function createDecoderDiagnostics(now = Date.now) { + const channels = new Map(); + function channel(role) { + const key = DIAGNOSTIC_ROLES.includes(role) ? role : "main"; + if (!channels.has(key)) channels.set(key, { role: key, status: "disconnected", url: "", connectedAt: null, lastReceivedAt: null, frames: 0, messages: 0, passings: 0, handled: 0, ignored: 0, invalid: 0, parseErrors: 0, lastFrame: "", log: [] }); + return channels.get(key); + } + function log(entry, reason, sample = "") { + entry.log.push({ at: now(), reason, sample: String(sample).slice(0, 512) }); + if (entry.log.length > 50) entry.log.splice(0, entry.log.length - 50); + } + return { + socket(role, status, url = "") { + const entry = channel(role); + entry.status = status; + if (url) entry.url = String(url); + if (status === "connected") entry.connectedAt = now(); + log(entry, status); + }, + frame(role, data) { + const entry = channel(role); + entry.frames++; + entry.lastReceivedAt = now(); + entry.lastFrame = String(data).slice(0, 512); + }, + parseError(role) { + const entry = channel(role); + entry.parseErrors++; + log(entry, "invalid_json", entry.lastFrame); + }, + message(role, msg, reason, outcome = "ignored") { + const entry = channel(role); + entry.messages++; + if (String(msg?.msg || msg?.type || "").toUpperCase() === "PASSING") entry.passings++; + if (["handled", "ignored", "invalid"].includes(outcome)) entry[outcome]++; + let sample = ""; + try { sample = JSON.stringify({ type: msg?.msg || msg?.type, transponder: msg?.transponder ?? msg?.tran_code, rtc_time: msg?.rtc_time }); } catch {} + log(entry, reason, sample); + }, + snapshot() { return DIAGNOSTIC_ROLES.map(role => { const entry = channel(role); return { ...entry, log: entry.log.map(item => ({ ...item })) }; }); }, + clear(role) { + const entry = channel(role); + for (const key of ["frames", "messages", "passings", "handled", "ignored", "invalid", "parseErrors"]) entry[key] = 0; + entry.lastFrame = ""; + entry.log = []; + }, + }; +} +export const decoderDiagnostics = createDecoderDiagnostics(); diff --git a/src/decoder_diagnostics_view.js b/src/decoder_diagnostics_view.js new file mode 100644 index 0000000..ed08448 --- /dev/null +++ b/src/decoder_diagnostics_view.js @@ -0,0 +1,78 @@ +import { decoderDiagnostics, DIAGNOSTIC_ROLES } from "./decoder_diagnostics.js"; + +let stopPrevious = () => {}; +export function decoderDiagnosticsMarkup(language) { + return `
${language === "sv" ? "Decoder / socket-diagnostik" : "Decoder / socket diagnostics"}
`; +} + +export function mountDecoderDiagnostics(root, getSettings) { + stopPrevious(); + if (!root) return; + const sv = getSettings().language === "sv"; + const tr = (svText, enText) => sv ? svText : enText; + const body = root.querySelector("[data-diagnostics-body]"); + const note = document.createElement("p"); + note.textContent = tr("Endast denna webbläsare. Ansluten socket betyder inte att passeringar kommer in. Loggen sparas inte och nollställs vid omladdning.", "This browser only. A connected socket does not guarantee passings. Logs are not saved and reset on reload."); + body.append(note); + const controls = document.createElement("div"); + controls.className = "decoder-diagnostics-controls"; + const select = document.createElement("select"); + select.setAttribute("aria-label", tr("Decoderkanal", "Decoder channel")); + const names = ["Main", tr("Mellantid", "Split"), tr("Depå in", "Pit entry"), tr("Depå ut", "Pit exit")]; + DIAGNOSTIC_ROLES.forEach((role, i) => select.add(new Option(names[i], role))); + controls.append(select); + const button = (label, action) => { + const node = document.createElement("button"); + node.type = "button"; node.className = "btn"; node.textContent = label; + node.addEventListener("click", action); controls.append(node); return node; + }; + let paused = false; + const pause = button(tr("Pausa visning", "Pause display"), () => { + paused = !paused; + pause.textContent = paused ? tr("Fortsätt visning", "Resume display") : tr("Pausa visning", "Pause display"); + update(); + }); + button(tr("Rensa vald kanal", "Clear selected channel"), () => { decoderDiagnostics.clear(select.value); update(true); }); + const output = document.createElement("textarea"); + output.readOnly = true; output.hidden = true; output.rows = 6; + output.setAttribute("aria-label", tr("Logg att kopiera", "Log to copy")); + button(tr("Kopiera logg", "Copy log"), async () => { + const text = JSON.stringify(decoderDiagnostics.snapshot().find(entry => entry.role === select.value), null, 2); + try { + if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable"); + await navigator.clipboard.writeText(text); + copyStatus.textContent = tr("Kopierat", "Copied"); + } catch { + output.hidden = false; output.value = text; output.focus(); output.select(); + copyStatus.textContent = tr("Kopiera den markerade loggen manuellt.", "Copy the selected log manually."); + } + }); + const copyStatus = document.createElement("span"); + copyStatus.setAttribute("role", "status"); controls.append(copyStatus); + const cards = document.createElement("div"); cards.className = "decoder-diagnostics-cards"; + const nodes = DIAGNOSTIC_ROLES.map((role, i) => { + const card = document.createElement("pre"); card.setAttribute("data-channel", role); + cards.append(card); return card; + }); + const log = document.createElement("pre"); log.className = "decoder-diagnostics-log"; + body.append(controls, cards, log, output); + const urlKeys = ["wsUrl", "splitWsUrl", "pitEntryWsUrl", "pitExitWsUrl"]; + const date = value => value == null ? "-" : new Date(value).toLocaleTimeString(sv ? "sv-SE" : "en-GB"); + const statuses = sv ? {connected:"Ansluten", connecting:"Ansluter", disconnected:"Frånkopplad", error:"Anslutningsfel"} : {}; + function update(force = false) { + if (!force && (paused || !root.open)) return; + const settings = getSettings(); + const entries = decoderDiagnostics.snapshot(); + entries.forEach((entry, i) => { + const enabled = i === 0 || (i === 1 ? settings.splitDecoderEnabled : settings.pitDecoderEnabled); + nodes[i].textContent = `${names[i]}: ${enabled ? statuses[entry.status] || entry.status : tr("Avstängd", "Disabled")}\n${tr("Konfigurerad", "Configured")}: ${settings[urlKeys[i]] || "-"}\n${tr("Socket-adress", "Socket address")}: ${entry.url || "-"}\n${tr("Senast ansluten", "Last connected")}: ${date(entry.connectedAt)}\n${tr("Senast mottaget", "Last received")}: ${date(entry.lastReceivedAt)} (${entry.lastReceivedAt == null ? "-" : Math.max(0, Math.floor((Date.now() - entry.lastReceivedAt) / 1000))} s)\nFrames: ${entry.frames} | PASSING: ${entry.passings}\n${tr("Hanterade", "Handled")}: ${entry.handled} | ${tr("Ignorerade", "Ignored")}: ${entry.ignored}\n${tr("Ogiltiga varv", "Invalid laps")}: ${entry.invalid} | JSON ${tr("fel", "errors")}: ${entry.parseErrors}`; + }); + const entry = entries.find(item => item.role === select.value); + log.textContent = `${tr("Senaste råmeddelande (max 512 tecken)", "Latest raw frame (max 512 characters)")}:\n${entry.lastFrame || "-"}\n\n${tr("Senaste 50 händelser; orsakskoder", "Latest 50 events; reason codes")}:\n${entry.log.map(item => `${date(item.at)} ${item.reason} ${item.sample}`).join("\n")}`; + } + select.addEventListener("change", () => update(true)); + root.addEventListener("toggle", () => update()); + const timer = setInterval(() => { if (!root.isConnected) clearInterval(timer); else update(); }, 1000); + stopPrevious = () => clearInterval(timer); + update(); +} diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index 0fa1d48..12bfd39 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -1,3 +1,4 @@ +import { decoderDiagnostics } from "./decoder_diagnostics.js"; import { getControlledLapStreak } from "./training_logic.js"; const PRACTICE_SESSION_TYPES = new Set(["open_practice", "free_practice", "practice"]); @@ -164,10 +165,12 @@ function connectDecoderRole(role, deps) { if (!url) return; state.decoder[lastErrorKey] = ""; + decoderDiagnostics.socket(role, "connecting", url); saveState(); try { setWsClient(role, new WebSocket(url)); } catch (error) { + decoderDiagnostics.socket(role, "error", url); state.decoder[lastErrorKey] = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) }); saveState(); renderView(); @@ -176,6 +179,7 @@ function connectDecoderRole(role, deps) { const wsClient = getWsClient(role); wsClient.onopen = () => { + decoderDiagnostics.socket(role, "connected", url); state.decoder[connectedKey] = true; state.decoder[lastErrorKey] = ""; saveState(); @@ -187,11 +191,13 @@ function connectDecoderRole(role, deps) { wsClient.onmessage = (event) => { state.decoder[lastMessageKey] = Date.now(); + decoderDiagnostics.frame(role, event.data); let parsed; try { parsed = JSON.parse(String(event.data)); } catch { + decoderDiagnostics.parseError(role); return; } @@ -205,6 +211,7 @@ function connectDecoderRole(role, deps) { }; wsClient.onclose = () => { + decoderDiagnostics.socket(role, "disconnected", url); state.decoder[connectedKey] = false; saveState(); updateConnectionBadge(); @@ -227,6 +234,7 @@ function connectDecoderRole(role, deps) { }; wsClient.onerror = () => { + decoderDiagnostics.socket(role, "error", url); state.decoder[lastErrorKey] = t("error.decoder_connection"); saveState(); updateConnectionBadge(); @@ -239,6 +247,7 @@ function connectDecoderRole(role, deps) { export function disconnectDecoderHelper(options = {}, deps) { const { state, saveState, getWsClient, setWsClient, getReconnectTimer, updateConnectionBadge } = deps; Object.keys(DECODER_ROLES).forEach((role) => { + decoderDiagnostics.socket(role, "disconnected"); clearTimeout(getReconnectTimer(role)); const wsClient = getWsClient(role); if (wsClient) { @@ -268,29 +277,35 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { lastOverlayTop3BySession, lastOverlayBestLapByKey, triggerRaceAudio = () => {}, getTrackRecordMs = () => null, } = deps; + const report = (reason, outcome) => decoderDiagnostics.message(decoderRole, msg, reason, outcome); if (!msg || typeof msg !== "object") { + report("invalid_message"); return; } const type = String(msg.msg || msg.type || "").toUpperCase(); if (type !== "PASSING") { + report("not_passing"); return; } const session = getActiveSession(); if (!session || session.status !== "running") { + report("no_running_session"); return; } const timestamp = parseRtcTime(msg.rtc_time) || Date.now(); const transponder = String(msg.transponder ?? msg.tran_code ?? "").replace("ID:", "").trim(); if (!transponder) { + report("missing_transponder"); return; } const result = ensureSessionResult(session.id); const competitor = resolveCompetitor(session, transponder, { state, t, findEventTeamForPassing }); if (competitor.ignore) { + report("not_registered"); triggerRaceAudio("unknown_transponder", { variables: { transponder }, scope: transponder, logData: { raceId: session.eventId, sessionId: session.id } }); return; } @@ -331,7 +346,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { const startMode = normalizeStartMode(session.startMode); if (decoderRole === "pit_entry") { - if (entry.pitActive) return; + if (entry.pitActive) { report("already_in_pit"); return; } entry.pitEnteredAt = timestamp; entry.pitActive = true; entry.pitEntryDriverId = entry.driverId || null; @@ -344,12 +359,13 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT IN`); triggerRaceAudio("entering_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } }); saveState(); + report("pit_entry", "handled"); return true; } if (decoderRole === "pit_exit") { const enteredAt = Number(entry.pitEnteredAt || 0); - if (!enteredAt || timestamp <= enteredAt) return; + if (!enteredAt || timestamp <= enteredAt) { report("pit_exit_without_entry"); return; } const pitStopMs = timestamp - enteredAt; const pitDriverId = entry.pitEntryDriverId ?? entry.driverId ?? null; const pitDriverName = entry.pitEntryDriverName || entry.driverName || ""; @@ -374,13 +390,14 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT ${formatLap(pitStopMs)}`); triggerRaceAudio("leaving_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } }); saveState(); + report("pit_exit", "handled"); return true; } if (decoderRole === "split") { const baseTimestamp = Number(entry.lastTimestamp || (startMode === "mass" ? session.startedAt : 0) || 0); - if (!baseTimestamp || timestamp <= baseTimestamp) return; - if (entry.lastSplitTimestamp && timestamp - Number(entry.lastSplitTimestamp) < 1000) return; + if (!baseTimestamp || timestamp <= baseTimestamp) { report("split_without_reference"); return; } + if (entry.lastSplitTimestamp && timestamp - Number(entry.lastSplitTimestamp) < 1000) { report("split_locktime"); return; } const splitMs = timestamp - baseTimestamp; const previousSplitMs = Number(entry.lastSplitMs || 0) || 0; entry.previousSplitMs = previousSplitMs || null; @@ -406,6 +423,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { }); pushOverlayEvent("split", `${entry.displayName || entry.driverName} • ${formatLap(splitMs)}`); saveState(); + report("split", "handled"); return true; } @@ -414,6 +432,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { entry.lastTimestamp = timestamp; announcePassing(entry); saveState(); + report("start_reference", "handled"); return true; } @@ -463,6 +482,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { result.passings.push(passing); persistPassingToBackend(session.id, passing); saveState(); + report("below_min", "invalid"); return true; } @@ -529,5 +549,6 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { } announcePassing(entry); saveState(); + report(validationWarning || "lap_counted", "handled"); return true; } diff --git a/src/settings.js b/src/settings.js index 900f74f..0a6eeb9 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1,3 +1,5 @@ +import { decoderDiagnosticsMarkup, mountDecoderDiagnostics } from "./decoder_diagnostics_view.js"; + function formatBackupSize(bytes) { const value = Math.max(0, Number(bytes || 0)); if (value < 1024) return `${value} B`; @@ -195,6 +197,7 @@ export function renderSettings(deps) { + ${decoderDiagnosticsMarkup(state.settings.language)}

${t("audio.race_sounds")}

@@ -531,6 +534,7 @@ Erik Eriksson;654321;4WD;Associated;;
`; + mountDecoderDiagnostics(document.getElementById("decoderDiagnostics"), () => state.settings); const applyRaceAudioFormValues = (form) => { const events = {}; diff --git a/src/styles.css b/src/styles.css index a288772..3185a2e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4068,3 +4068,10 @@ select:focus { padding: 8px; } } + +/* Diagnostics update independently of editable settings. */ +.decoder-diagnostics-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.decoder-diagnostics-controls select { width: auto; } +.decoder-diagnostics-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 12px; } +.decoder-diagnostics-cards pre, .decoder-diagnostics-log { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.6; } +.decoder-diagnostics-log { max-height: 240px; overflow: auto; } diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 59ced70..68a650e 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -118,6 +118,20 @@ 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 navigate("settings", "settingsForm"); + await evaluate(`(async () => { + const panel = document.getElementById("decoderDiagnostics"); + if (!panel) throw new Error("Missing decoder diagnostics"); + panel.open = true; + const field = document.querySelector('#settingsForm [name="wsUrl"]'); + field.value = "ws://draft.example:9000"; + const { decoderDiagnostics } = await import("/src/decoder_diagnostics.js"); + decoderDiagnostics.frame("main", "diagnostic sample"); + await new Promise(resolve => setTimeout(resolve, 1200)); + if (!panel.textContent.includes("diagnostic sample")) throw new Error("Diagnostics did not update"); + if (field.value !== "ws://draft.example:9000") throw new Error("Diagnostics reset settings draft"); + })()`); + console.log("PASS decoder diagnostics update without resetting settings draft"); await runOverlaySmoke({ evaluate }); assert.deepEqual(errors, []); console.log("PASS offline save failure, local draft retention, retry and confirmed saved status"); diff --git a/tests/decoder_diagnostics.test.js b/tests/decoder_diagnostics.test.js new file mode 100644 index 0000000..51eaa29 --- /dev/null +++ b/tests/decoder_diagnostics.test.js @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createDecoderDiagnostics } from "../src/decoder_diagnostics.js"; + +test("diagnostics bounds logs, isolates channels and returns independent snapshots", () => { + const model = createDecoderDiagnostics(() => 1234); + model.socket("main", "connected", "ws://host:9000"); + for (let i = 0; i < 80; i++) { + model.frame("main", "x".repeat(2000)); + model.message("main", {msg:"PASSING", transponder:123}, "lap_counted", "handled"); + } + model.parseError("main"); + const entries = model.snapshot(); + assert.equal(entries[0].log.length, 50); + assert.equal(entries[0].lastFrame.length, 512); + assert.equal(entries[0].handled, 80); + assert.equal(entries[0].parseErrors, 1); + assert.equal(entries[1].frames, 0); + entries[0].log[0].reason = "mutated"; + assert.notEqual(model.snapshot()[0].log[0].reason, "mutated"); + model.clear("main"); + assert.equal(model.snapshot()[0].frames, 0); + assert.equal(model.snapshot()[0].status, "connected"); + assert.equal(model.snapshot()[0].connectedAt, 1234); +}); + +test("diagnostics separates ignored, invalid and handled messages", () => { + const model = createDecoderDiagnostics(); + model.message("split", {msg:"STATUS"}, "not_passing"); + model.message("split", {msg:"PASSING"}, "no_running_session"); + model.message("main", {msg:"PASSING"}, "below_min", "invalid"); + assert.equal(model.snapshot()[1].ignored, 2); + assert.equal(model.snapshot()[1].passings, 1); + assert.equal(model.snapshot()[0].invalid, 1); + assert.equal(model.snapshot()[0].handled, 0); +}); diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js index ab7a262..ce6e347 100644 --- a/tests/decoder_runtime.test.js +++ b/tests/decoder_runtime.test.js @@ -1,3 +1,4 @@ +import { decoderDiagnostics } from "../src/decoder_diagnostics.js"; import test from "node:test"; import assert from "node:assert/strict"; @@ -179,9 +180,17 @@ test("connect decoder opens one websocket per enabled role", () => { pit_entry: "ws://server:9002", pit_exit: "ws://server:9003", }); + decoderDiagnostics.clear("main"); clients.main.onopen(); + clients.main.onmessage({ data: "not json" }); + clients.main.onmessage({ data: JSON.stringify({ msg: "STATUS" }) }); + assert.equal(decoderDiagnostics.snapshot()[0].frames, 2); + assert.equal(decoderDiagnostics.snapshot()[0].parseErrors, 1); clients.main.onerror(); clients.main.onclose(); + assert.equal(decoderDiagnostics.snapshot()[0].status, "disconnected"); + clients.main.onopen(); + assert.equal(decoderDiagnostics.snapshot()[0].status, "connected"); assert.equal(renderCount, 0); } finally { globalThis.WebSocket = OriginalWebSocket; diff --git a/tests/run.js b/tests/run.js index a289baf..16a652a 100644 --- a/tests/run.js +++ b/tests/run.js @@ -17,3 +17,4 @@ import "./session_replay.test.js"; import "./public_training_refresh.test.js"; import "./list_sorting.test.js"; import "./sync_status.test.js"; +import "./decoder_diagnostics.test.js";