Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs
T
sjgandClaude Opus 5 ae7df31d91
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 8m36s
CI / frontend (pull_request) Failing after 1m23s
CI / reuse (pull_request) Successful in 5s
[fix](trx-frontend-http): replay what arrived before a lazy view loaded
Opening the Map or Statistics page showed only what had been decoded since
the moment it was opened, and a reload — landing straight on the tab, so its
module loads at startup ahead of the history — was the only way to see the
rest.  Two things were being thrown away.

The decode log the Statistics page counts lives in the map module, which is
lazy.  Recording into a module that is not loaded yet is a no-op, and unlike
the map markers nothing replayed the log when it finally arrived, so every
decode heard before the first visit was simply never counted.  Hold those
records in the client and hand them over when the module attaches.

The map's own replay covered APRS, AIS and VDES, whose plugins implement
syncMap, but not the grid squares: the FTx family and WSPR plotted locators
as decodes arrived and had no replay at all, so everything they heard before
the map loaded was lost, and the unique-grid counter with it.  Both plot
through a helper now, which their syncMap replays oldest first.  A replayed
WSPR spot carries the frequency it was heard on rather than one worked out
against wherever the dial has moved to since.

Pinned in decode-flow, whose history fixture gains FT8 and WSPR spots: after
a first visit the statistics count every stored decode and every grid square,
which before this change were 0 and 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 08:56:06 +02:00

380 lines
20 KiB
JavaScript

// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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();
}