// 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, requestAnimationFrame, MutationObserver */ 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", }; // A second rig listening in the background, on its own band. Both pages // describe the rig on screen, so its traffic belongs in neither the panel nor // the mini view — only on the map, which shows the whole station. const OTHER_RIG_VESSEL = { ...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY", lat: 51.92, lon: 4.48, rig_id: "rig-b", }; // 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, OTHER_RIG_VESSEL], 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 ?? "", otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false, aisRows: document.getElementById("ais-messages")?.children.length ?? 0, 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})`); assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel"); // 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"), otherRig: bar.textContent.includes("ELDERBERRY"), }; }); 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"); assert.equal(miniView.otherRig, false, "the mini view shows a background rig's 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.stations > 0, "the APRS station never reached the map"); // The map is the whole station's view — the one place a background rig's // traffic belongs — so both vessels are on it even though the panel and the // mini view show only the selected rig's. assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`); assert.deepEqual(runtimeErrors, []); } finally { await browser.close(); await fixture.close(); } // Stored history, which is what is on screen a second after a page load. The // endpoint answers in CBOR, so the fixture speaks CBOR: serving anything else // left the client on its retry path and the history path untested. const HISTORY_AIS = 900; const HISTORY_APRS = 300; const HISTORY_FT8 = 12; const HISTORY_WSPR = 8; const historyFixture = await startWebFixture({ spectrum: true, mode: "AIS", history: { ais: Array.from({ length: HISTORY_AIS }, (_, index) => ({ mmsi: 244660000 + index, lat: 52.3 + index * 0.001, lon: 4.8, vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, })), ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({ message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2, freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, })), wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({ message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5, freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, })), aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({ src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1", info: `history ${index}`, packet_type: "position", crc_ok: true, lat: 54.3 + (index % 15) * 0.01, lon: 18.6, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, })), }, }); const replay = await startBrowser(chromium); // Installed before the page's own scripts, so nothing can be missed: every // time the progress element becomes visible, its geometry is recorded. await replay.page.addInitScript(() => { window.__historyProgressSamples = []; const watch = () => { const element = document.getElementById("decode-history-overlay"); if (!element) { requestAnimationFrame(watch); return; } const sample = () => { if (element.classList.contains("is-hidden")) return; const rect = element.getBoundingClientRect(); window.__historyProgressSamples.push({ width: Math.round(rect.width), coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay", }); }; new MutationObserver(sample).observe(element, { attributes: true, attributeFilter: ["class"] }); sample(); }; watch(); }); try { await replay.page.setViewportSize({ width: 1400, height: 900 }); await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" }); await replay.page.locator("#tab-digital-modes").waitFor({ state: "visible" }); // While it loads, the operator can still see the radio. This used to be a // full-screen scrim over everything for as long as the replay ran. // // Watched from inside the page rather than polled from here: a fast replay // can start and finish between two polls, and then the test reports that no // progress was ever shown when what happened is that it blinked. const shown = await replay.page.evaluate(() => window.__historyProgressSamples ?? []); assert.ok(shown.length > 0, "no progress was shown while the history loaded"); for (const sample of shown) { assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`); assert.equal(sample.coversCentre, false, "the progress sits over the page"); } // And all of it arrives, on the first load. await replay.page.waitForTimeout(2000); const restored = await replay.page.evaluate(() => ({ ais: document.getElementById("ais-messages")?.children.length ?? 0, aprs: document.getElementById("aprs-packets")?.children.length ?? 0, progressHidden: document.getElementById("decode-history-overlay").classList.contains("is-hidden"), })); assert.equal(restored.ais, HISTORY_AIS, `restored ${restored.ais} of ${HISTORY_AIS} AIS records`); assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`); assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished"); // Opening the map for the first time has to show the stored history too. // The map module is lazy, so at the moment the history was restored its // aprsMapAddStation/aisMapAddVessel hooks did not exist yet and every // position was dropped. Nothing replayed them when the module finally // arrived, so the map came up empty and only filled in from decodes heard // afterwards -- a station heard once was never plotted at all, and it took a // second reload (module cached, so loaded early enough to beat the history // fetch) before the map showed anything. // // This fixture serves no live decode stream on purpose: with one, fresh // frames arriving after the module loads would paper over the whole thing. const mapLoadedDuringReplay = await replay.page.evaluate(() => !!window.trx.modules.map); assert.equal(mapLoadedDuringReplay, false, "the map was already loaded, so this proves nothing"); await replay.page.locator('.tab[data-tab="map"]').click(); await replay.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" }); await replay.page.waitForTimeout(1500); const plotted = await replay.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.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`); assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`); // The Statistics page counts the same history. Its decode log lives in the // map module, which is lazy, so every decode that arrived before the module // did used to be recorded into nothing at all — the page opened empty and // only filled in from decodes heard afterwards, and it took a reload landing // on the tab (module loaded at startup, before the history) to show the lot. await replay.page.evaluate(() => window.navigateToTab("statistics")); await replay.page.waitForTimeout(1500); const counted = await replay.page.evaluate(() => ({ decodes: Number(document.getElementById("stats-total-decodes")?.textContent ?? "0"), grids: Number(document.getElementById("stats-unique-grids")?.textContent ?? "0"), })); assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR, `the statistics counted ${counted.decodes} decodes`); // Grid squares come from the FT8 and WSPR spots, which had no map replay of // their own: the locators of everything heard before the map loaded were lost. assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR, `the statistics counted ${counted.grids} grid squares`); assert.deepEqual(replay.runtimeErrors, []); } finally { await replay.browser.close(); await historyFixture.close(); } // The APRS list: one line per frame, with the information field read for the // operator rather than shown as it arrives on the air. const APRS_FRAMES = [ { packet_type: "weather", info: "_10090556c220s004g005t077r000p000P000h50b09900" }, { packet_type: "message", info: ":SP2SJG-9 :Hello from the field{01" }, { packet_type: "telemetry", info: "T#005,199,000,255,073,123,01101001" }, { packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 }, ].map((frame, index) => ({ src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true, // Only position reports carry a symbol here, which is the case the columns // have to survive: a frame without one used to close the gap and shift // everything after it left. ...(frame.packet_type === "position" ? { symbol_table: "/", symbol_code: ">" } : {}), rig_id: "rig-a", ts_ms: Date.now() - index * 1000, ...frame, })); // The AIS list is the same shape: a line per message, saying where the vessel // is and what it is doing, with the identifiers behind it. const AIS_MESSAGES = [ { message_type: 1, mmsi: 244660001, vessel_name: "NEDERLAND", channel: "A", lat: 54.35, lon: 18.65, sog_knots: 8.2, cog_deg: 91.4 }, { message_type: 5, mmsi: 244660002, vessel_name: "STENA SPIRIT", channel: "B", callsign: "PBTX", destination: "GDANSK" }, ].map((message, index) => ({ rig_id: "rig-a", ts_ms: Date.now() - index * 1000, ...message })); const aprsFixture = await startWebFixture({ spectrum: true, mode: "AIS", history: { aprs: APRS_FRAMES, ais: AIS_MESSAGES }, }); const aprs = await startBrowser(chromium); try { await aprs.page.setViewportSize({ width: 1400, height: 900 }); await aprs.page.goto(`${aprsFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" }); await aprs.page.locator("#tab-digital-modes").waitFor({ state: "visible" }); await aprs.page.waitForTimeout(2000); await aprs.page.locator('.sub-tab[data-subtab="aprs"]').click(); await aprs.page.waitForTimeout(400); const rows = await aprs.page.evaluate(() => [...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({ tag: row.tagName, height: Math.round(row.getBoundingClientRect().height), type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "", summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "", }))); // Newest first, so find each by the type it carries rather than by position. const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? ""; assert.equal(rows.length, APRS_FRAMES.length, `rendered ${rows.length} frames`); for (const row of rows) { assert.equal(row.tag, "DETAILS", "a frame is not expandable in place"); assert.ok(row.height < 44, `a frame is ${row.height}px tall; it used to be a card of about 140`); } // Each payload read rather than echoed: 25 °C from t077, the addressee from // a message, the sequence from telemetry, the fix from a position report. assert.match(summaryOf("Weather"), /25 °C/, `weather summary: ${summaryOf("Weather")}`); assert.match(summaryOf("Weather"), /990\.0 hPa/, `weather summary: ${summaryOf("Weather")}`); assert.match(summaryOf("Message"), /→ SP2SJG-9: Hello from the field/, `message summary: ${summaryOf("Message")}`); assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`); assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`); // Frames with and without a symbol line up: the slot is held open either way. const columns = await aprs.page.evaluate(() => [...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({ call: Math.round(row.querySelector(".aprs-call").getBoundingClientRect().x), summary: Math.round(row.querySelector(".decode-line-summary").getBoundingClientRect().x), symbol: !!row.querySelector(".aprs-symbol:not(.aprs-symbol-empty)"), }))); assert.ok(columns.some((column) => column.symbol) && columns.some((column) => !column.symbol), "the sample has to mix frames with and without a symbol to test this"); assert.equal(new Set(columns.map((column) => column.call)).size, 1, `callsigns start at ${JSON.stringify(columns.map((column) => column.call))}`); assert.equal(new Set(columns.map((column) => column.summary)).size, 1, `summaries start at ${JSON.stringify(columns.map((column) => column.summary))}`); // The frame as it arrived is still there, one click away. await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" }) .locator(".decode-line").first().click(); await aprs.page.waitForTimeout(200); const expanded = await aprs.page.evaluate(() => { const row = document.querySelector("#aprs-packets .aprs-packet[open]"); return { open: row.hasAttribute("open"), raw: row.querySelector(".decode-expanded-raw")?.textContent?.trim() ?? "", meta: row.querySelector(".decode-expanded-meta")?.textContent ?? "", }; }); assert.equal(expanded.open, true, "the frame did not open"); assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`); assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`); // AIS, on the same row. await aprs.page.locator('.sub-tab[data-subtab="ais"]').click(); await aprs.page.waitForTimeout(300); const aisRows = await aprs.page.evaluate(() => [...document.querySelectorAll("#ais-messages .ais-message")].map((row) => ({ tag: row.tagName, height: Math.round(row.getBoundingClientRect().height), name: row.querySelector(".ais-call")?.textContent?.trim() ?? "", summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "", }))); assert.equal(aisRows.length, AIS_MESSAGES.length, `rendered ${aisRows.length} messages`); for (const row of aisRows) { assert.equal(row.tag, "DETAILS", "an AIS message is not expandable in place"); assert.ok(row.height < 44, `an AIS message is ${row.height}px tall`); } const positionRow = aisRows.find((row) => row.name === "NEDERLAND"); const staticRow = aisRows.find((row) => row.name === "STENA SPIRIT"); assert.match(positionRow?.summary ?? "", /54\.3500, 18\.6500/, `position: ${positionRow?.summary}`); assert.match(positionRow?.summary ?? "", /8\.2 kn/, `position: ${positionRow?.summary}`); // A static report carries no fix, so it says where the vessel is going. assert.match(staticRow?.summary ?? "", /PBTX -> GDANSK/, `static: ${staticRow?.summary}`); await aprs.page.locator("#ais-messages .ais-message .decode-line").first().click(); await aprs.page.waitForTimeout(200); const aisExpanded = await aprs.page.evaluate(() => document.querySelector("#ais-messages .ais-message[open] .decode-expanded-meta")?.textContent ?? ""); assert.match(aisExpanded, /MMSI 2446600/, `expanded AIS: ${aisExpanded}`); assert.match(aisExpanded, /MHz/, `expanded AIS: ${aisExpanded}`); assert.deepEqual(aprs.runtimeErrors, []); } finally { await aprs.browser.close(); await aprsFixture.close(); }