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",