From 84a99a3636ab8b9f872474d5fee3171059ada5de Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 4 Aug 2026 21:31:30 +0200 Subject: [PATCH] [fix](trx-frontend-http): load the decoders that own the digital modes panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIS, VDES and both APRS decoders were listed under the map plugin group alone, so opening Digital modes and clicking AIS or APRS gave an empty panel reading "Connected, listening for packets" while the decodes piled up unprocessed in the plugin runtime. They appeared only if something had opened the Map tab first, which flushed the queue. There is also a map-data group naming exactly those four that nothing loads: the loader is called with tab names and no tab is called map-data. They load with the tab whose panels they fill now. map-core stays lazy, since their calls into it are optional and the Map tab can go on paying for Leaflet by itself. tests/decode-flow.mjs follows a decode from the wire to the map: an AIS vessel and an APRS beacon arrive on /decode, and it asserts both panels fill with the map module confirmed absent, the mini view names the vessel and offers a pin, following that pin lands on /map centred on the vessel, and both decoders leave a marker. Nothing exercised any of this before — the fixture served an empty decode stream, which is how the map links came to be broken for every decoder at once. The fixture stamps decodes as it sends them, since the client prunes anything outside the retention window, and repeats them, since the views collapse by vessel and need more than one frame to behave. Signed-off-by: Stan Grams --- .../assets/web/generated/app.js | 19 +++- .../trx-frontend-http/frontend/package.json | 2 +- .../frontend/src/plugin-loader.ts | 9 +- .../frontend/tests/decode-flow.mjs | 99 +++++++++++++++++++ .../frontend/tests/web-fixture.mjs | 30 +++++- 5 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index c3be2c22..784163bc 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -1689,7 +1689,24 @@ function estimateNoiseFloorDb(bins) { // src/plugin-loader.ts var pluginGroups = { - "digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"], + // AIS, VDES and the two APRS decoders have panels on this tab, so they load + // with it. They used to come only with the map group, which left their + // sub-tabs empty — decodes queueing in the runtime — until something opened + // the Map tab. Their map calls are optional, so map-core stays lazy. + "digital-modes": [ + "/ft8.js", + "/ft4.js", + "/ft2.js", + "/wspr.js", + "/cw.js", + "/background-decode.js", + "/sat.js", + "/wefax.js", + "/ais.js", + "/vdes.js", + "/aprs.js", + "/hf-aprs.js" + ], "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], statistics: ["/map-core.js"], diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json index d69fc920..ec41ae2f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "test": "node --test tests/*.test.mjs", - "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs", + "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs", "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" }, "devDependencies": { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts index 78894eb6..94406db5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts @@ -5,7 +5,14 @@ type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings"; const pluginGroups: Readonly> = { - "digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"], + // AIS, VDES and the two APRS decoders have panels on this tab, so they load + // with it. They used to come only with the map group, which left their + // sub-tabs empty — decodes queueing in the runtime — until something opened + // the Map tab. Their map calls are optional, so map-core stays lazy. + "digital-modes": [ + "/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", + "/sat.js", "/wefax.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", + ], "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], statistics: ["/map-core.js"], diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs new file mode 100644 index 00000000..ae47e94d --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +// What happens to a decode after it arrives: the panel on its tab, the mini +// view over the waterfall, the marker on the map, and the link between them. +// Nothing exercised this before — the fixture served an empty decode stream — +// which is how the map links came to be broken for every decoder at once. + +import assert from "node:assert/strict"; +import { chromium } from "playwright-core"; +import { startBrowser, startWebFixture } from "./web-fixture.mjs"; + +/* global document, getComputedStyle, window, location */ + +const VESSEL = { + type: "ais", mmsi: 244660000, lat: 52.37, lon: 4.89, vessel_name: "NEDERLAND", + callsign: "PBTX", sog_knots: 8.2, cog_deg: 91, channel: "A", message_type: 1, rig_id: "rig-a", +}; +const BEACON = { + type: "aprs", src_call: "SP2SJG-9", dest_call: "APRS", path: "WIDE1-1", info: "Test beacon", + packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65, + symbol_table: "/", symbol_code: ">", rig_id: "rig-a", +}; + +// AIS is what the mini view for vessels is gated on; the rig has to be on it. +const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" }); +const { browser, page, runtimeErrors } = await startBrowser(chromium); + +try { + await page.setViewportSize({ width: 1500, height: 950 }); + await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" }); + await page.locator("#tab-digital-modes").waitFor({ state: "visible" }); + await page.waitForTimeout(2000); + + // The decoders with panels on this tab have to load with it. They used to + // come only with the map group, so these panels stayed empty — decodes + // queued in the plugin runtime — until something opened the Map tab. + const panels = await page.evaluate(() => ({ + ais: document.getElementById("ais-messages")?.children.length ?? 0, + aprs: document.getElementById("aprs-packets")?.children.length ?? 0, + aisStatus: document.getElementById("ais-status")?.textContent ?? "", + aprsStatus: document.getElementById("aprs-status")?.textContent ?? "", + mapLoaded: !!window.trx.modules.map, + })); + assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing"); + assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`); + assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`); + + // The mini view rides over the waterfall on the radio page. + await page.locator('.tab[data-tab="main"]').click(); + await page.waitForTimeout(1500); + const miniView = await page.evaluate(() => { + const bar = document.getElementById("ais-bar-overlay"); + return { + shown: getComputedStyle(bar).display !== "none", + pins: bar.querySelectorAll(".aprs-bar-pin").length, + names: bar.textContent.includes("NEDERLAND"), + }; + }); + assert.equal(miniView.shown, true, "the AIS mini view did not appear"); + assert.ok(miniView.pins > 0, "the mini view has no pin to follow"); + assert.equal(miniView.names, true, "the mini view does not name the vessel"); + + // Following the pin: the map opens, on the vessel. This is the path that was + // broken for every decoder — the module that owned the navigation had not + // been loaded, so the pin did nothing at all. + await page.locator("#ais-bar-overlay .aprs-bar-pin").first().click(); + await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" }); + await page.waitForTimeout(800); + const followed = await page.evaluate(() => { + const centre = window.trx.modules.map?.aprsMap?.getCenter?.(); + return { + path: location.pathname, + lat: centre ? Number(centre.lat.toFixed(2)) : null, + lon: centre ? Number(centre.lng.toFixed(2)) : null, + }; + }); + assert.equal(followed.path, "/map", `the pin left the page on ${followed.path}`); + assert.equal(followed.lat, VESSEL.lat, `the map centred on ${followed.lat}, not the vessel`); + assert.equal(followed.lon, VESSEL.lon, `the map centred on ${followed.lon}, not the vessel`); + + // Both decoders put their own marker on it. + await page.waitForTimeout(1200); + const markers = await page.evaluate(() => { + const map = window.trx.modules.map; + const size = (collection) => (collection instanceof Map + ? collection.size + : Object.keys(collection ?? {}).length); + return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) }; + }); + assert.ok(markers.ais > 0, "the vessel never reached the map"); + assert.ok(markers.stations > 0, "the APRS station never reached the map"); + + assert.deepEqual(runtimeErrors, []); +} finally { + await browser.close(); + await fixture.close(); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs index 5568d0f8..6fbbea91 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs @@ -65,6 +65,8 @@ export async function startWebFixture({ spectrum = false, tx = false, meterDb = -70, + decodes = [], + mode = "FM", bookmarks = [], bandplan = {}, bandplanEnabled = false, @@ -112,7 +114,7 @@ export async function startWebFixture({ signal_meter: spectrum, }, }, - status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null }, + status: { freq: { hz: 100_000_000 }, mode, tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null }, // Reported only by SDR backends, and what makes the client show the // squelch control at all. filter: spectrum @@ -161,6 +163,7 @@ export async function startWebFixture({ ["/status", status], ["/bookmarks", bookmarks], ["/bandplan.json", bandplan], + ["/decode/history", {}], ["/api/recorder/status", []], ["/api/recorder/files", []], ]); @@ -228,6 +231,31 @@ export async function startWebFixture({ request.on("close", () => clearInterval(timer)); return; } + // Decodes arrive on this stream in the server's own shape: a routing + // "type" naming the decoder, snake_case fields inside. The mini views, the + // map markers and the history all hang off it, and serving nothing left + // every one of them untested. + if (url.pathname === "/decode") { + response.writeHead(200, { + "cache-control": "no-cache", + connection: "keep-alive", + "content-type": "text/event-stream", + }); + response.write(": decode stream\n\n"); + // Repeats: a live decoder keeps producing, and the views that collapse + // by station or vessel need more than one frame to behave like they do + // in front of a radio. + let sent = 0; + const timer = setInterval(() => { + if (!decodes.length) return; + const decode = decodes[sent++ % decodes.length]; + // Stamped as they leave: the client prunes anything older than the + // retention window, so a fixed epoch would be dropped on arrival. + response.write(`data: ${JSON.stringify({ ts_ms: Date.now(), ...decode })}\n\n`); + }, 400); + request.on("close", () => clearInterval(timer)); + return; + } if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) { response.writeHead(200, { "cache-control": "no-cache",