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>
349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// 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/);
|
|
});
|