CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m34s
CI / test (push) Successful in 7m47s
CI / frontend (pull_request) Successful in 4m28s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m21s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 6s
A client connected to several rigs decodes all of them at once, and the decode stream carries every rig's traffic to every browser. The decoder panels listed all of it: a station a background rig copied on another band appeared in the APRS list next to the selected rig's, the vessel counts and the "latest seen" lines counted both, the status lines said "Receiving" because some other rig was, and the CW pane interleaved two rigs into one stream of text that read as neither. The page is about the rig the operator selected — the one whose spectrum is on screen and whose audio is playing — so each panel now shows what that rig heard: rows, counts, latest-seen, status, the live picture a WEFAX or SSTV frame is painting, and the CW pane. Nothing is dropped on the way in. The map is the whole station's view, has its own rig filter, and would empty out if the plugins stopped feeding it, so the histories still hold every rig and the map still plots them. That also means a switch loses nothing: the runtime gained a rerender hook, which the rig switch calls, and switching back brings the other rig's traffic up again. The CW pane is the exception — a running stream of text cannot be unpicked after the fact — so it starts empty on the rig switched to. 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>
352 lines
18 KiB
JavaScript
352 lines
18 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 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,
|
|
})),
|
|
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`);
|
|
|
|
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();
|
|
}
|