// SPDX-FileCopyrightText: 2026 Stan Grams // // SPDX-License-Identifier: GPL-2.0-or-later // The radio page and the digital modes page both describe one rig: the one the // operator selected. Every rig a client is connected to feeds the same decode // stream, so both the mini views over the waterfall and the panels behind them // filter on the rig that heard each decode. The map is the exception and keeps // all of them — it has its own rig filter — which is why the histories these // views read from are never pruned by rig. import assert from "node:assert/strict"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; import { createHost } from "./host-fixture.mjs"; class ElementFixture { constructor(value = "") { this.children = []; this.innerHTML = ""; this.textContent = ""; this.value = value; this.style = {}; this.dataset = {}; this.classList = { add() {}, remove() {}, toggle() {} }; } appendChild(child) { this.children.push(child); return child; } removeChild(child) { this.children.splice(this.children.indexOf(child), 1); } replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); } addEventListener() {} setAttribute() {} querySelector() { return null; } querySelectorAll() { return []; } get firstChild() { return this.children[0] ?? null; } get lastElementChild() { return this.children.at(-1) ?? null; } get scrollHeight() { return this.children.length; } } /** A document whose named elements exist and whose unknown ones do not. */ function createDocument(elements) { return { documentElement: {}, getElementById: (id) => elements.get(id) ?? null, querySelector: () => null, querySelectorAll: () => [], createElement: () => new ElementFixture(), createDocumentFragment: () => new ElementFixture(), }; } async function runPlugin(entry, { window, document: doc, extras = {} }) { const context = vm.createContext({ window, document: doc, navigator: {}, requestAnimationFrame(callback) { callback(); return 1; }, getComputedStyle: () => ({ getPropertyValue: () => "" }), Date, Number, String, Math, Array, Map, Set, Reflect, console, ...extras, }); const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url)); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); return context; } test("the APRS mini view and panel both keep to the active rig", async () => { const overlay = new ElementFixture(); const packets = new ElementFixture(); const totals = new ElementFixture(); const elements = new Map([ ["aprs-bar-overlay", overlay], ["aprs-packets", packets], ["aprs-total-count", totals], ["mode", new ElementFixture("PKT")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), trxUi: { confirm: async () => true }, aprsMapAddStation: () => {}, }; await runPlugin("aprs", { window, document: createDocument(elements) }); const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" }; window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" }); assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view"); assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view"); assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame"); assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`); }); test("the APRS mini view shows every frame until a rig is known", async () => { const overlay = new ElementFixture(); const elements = new Map([ ["aprs-bar-overlay", overlay], ["mode", new ElementFixture("PKT")], ]); const window = { ...createHost(), trxUi: { confirm: async () => true }, aprsMapAddStation: () => {}, }; await runPlugin("aprs", { window, document: createDocument(elements) }); const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" }; window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB" }); assert.match(overlay.innerHTML, /SP1AAA/); assert.match(overlay.innerHTML, /SP2BBB/); }); test("the AIS mini view keeps to the active rig", async () => { const overlay = new ElementFixture(); const elements = new Map([ ["ais-bar-overlay", overlay], ["mode", new ElementFixture("AIS")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), trxUi: { confirm: async () => true }, aisMapAddVessel: () => {}, }; await runPlugin("ais", { window, document: createDocument(elements) }); const now = Date.now(); window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" }); assert.match(overlay.innerHTML, /NEARBY/); assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view"); }); test("the VDES mini view keeps to the active rig", async () => { const overlay = new ElementFixture(); const elements = new Map([ ["vdes-bar-overlay", overlay], ["mode", new ElementFixture("VDES")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), trxUi: { confirm: async () => true }, vdesMapAddPoint: () => {}, }; await runPlugin("vdes", { window, document: createDocument(elements) }); const now = Date.now(); window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" }); assert.match(overlay.innerHTML, /SP1AAA/); assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view"); }); test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => { const overlay = new ElementFixture(); const elements = new Map([ ["cw-bar-overlay", overlay], ["cw-output", new ElementFixture()], ["mode", new ElementFixture("CW")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), trxUi: { confirm: async () => true }, addEventListener() {}, }; await runPlugin("cw", { window, document: createDocument(elements) }); window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("cw", { text: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" }); window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" }); assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost"); assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view"); }); test("the FT8 mini view keeps to the active rig", async () => { const overlay = new ElementFixture(); const elements = new Map([ ["ft8-bar-overlay", overlay], ["mode", new ElementFixture("DIG")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), ft8BaseHz: 7_074_000, trxUi: { confirm: async () => true }, mapAddLocator: () => {}, setInterval() { return 1; }, }; await runPlugin("ft8", { window, document: createDocument(elements) }); const now = Date.now(); window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" }); assert.match(overlay.innerHTML, /SP1AAA/); assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view"); }); // ── The digital modes panels ─────────────────────────────────────────────── // The same rule one page over: a panel lists the selected rig's decodes only. test("the AIS panel lists the active rig's vessels only", async () => { const messages = new ElementFixture(); const count = new ElementFixture(); const elements = new Map([ ["ais-messages", messages], ["ais-vessel-count", count], ["mode", new ElementFixture("AIS")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), trxUi: { confirm: async () => true }, aisMapAddVessel: () => {}, }; await runPlugin("ais", { window, document: createDocument(elements) }); const now = Date.now(); window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" }); assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel"); assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`); }); test("the VDES panel lists the active rig's bursts only", async () => { const messages = new ElementFixture(); const count = new ElementFixture(); const elements = new Map([ ["vdes-messages", messages], ["vdes-frame-count", count], ["mode", new ElementFixture("VDES")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), trxUi: { confirm: async () => true }, vdesMapAddPoint: () => {}, }; await runPlugin("vdes", { window, document: createDocument(elements) }); const now = Date.now(); window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" }); assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst"); assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`); }); test("the FT8 panel lists the active rig's decodes only", async () => { const messages = new ElementFixture(); const elements = new Map([ ["ft8-messages", messages], ["mode", new ElementFixture("DIG")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), ft8BaseHz: 7_074_000, trxUi: { confirm: async () => true }, mapAddLocator: () => {}, setInterval() { return 1; }, }; await runPlugin("ft8", { window, document: createDocument(elements) }); const now = Date.now(); window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" }); assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode"); }); test("the WSPR panel lists the active rig's spots only", async () => { const messages = new ElementFixture(); const elements = new Map([ ["wspr-messages", messages], ["mode", new ElementFixture("DIG")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), ft8BaseHz: 14_095_600, trxUi: { confirm: async () => true }, mapAddLocator: () => {}, }; await runPlugin("wspr", { window, document: createDocument(elements), extras: { setInterval: () => 1 }, }); const now = Date.now(); window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" }); assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot"); }); test("the CW pane copies the active rig only", async () => { const output = new ElementFixture(); const elements = new Map([ ["cw-output", output], ["cw-bar-overlay", new ElementFixture()], ["mode", new ElementFixture("CW")], ]); const window = { ...createHost({ state: { lastActiveRigId: "rig-a" } }), trxUi: { confirm: async () => true }, addEventListener() {}, }; await runPlugin("cw", { window, document: createDocument(elements) }); window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" }); window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" }); const copied = output.children.map((line) => line.textContent).join(""); assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`); }); test("switching rigs repaints the panels through the runtime", async () => { const packets = new ElementFixture(); const elements = new Map([ ["aprs-packets", packets], ["aprs-bar-overlay", new ElementFixture()], ["mode", new ElementFixture("PKT")], ]); // A mutable host: the rig picker writes lastActiveRigId, and the panels read // it as they paint. const host = createHost({ state: { lastActiveRigId: "rig-a" } }); let activeRigId = "rig-a"; Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId }); const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} }; await runPlugin("aprs", { window, document: createDocument(elements) }); const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" }; window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" }); window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" }); assert.equal(packets.children.length, 1); activeRigId = "rig-b"; window.trxPluginRuntime.rerenderAll(); assert.equal(packets.children.length, 1, "the panel did not follow the switch"); const shown = packets.children.map((row) => row.innerHTML).join(""); assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from"); assert.doesNotMatch(shown, /SP1AAA/); });