@@ -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";