From 15ff6865426a9707a14fe358dca91004dbebc079 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Fri, 7 Aug 2026 00:46:26 +0200 Subject: [PATCH 1/2] [fix](trx-frontend-http): keep the mini views to the rig on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decode SSE stream and the history behind it are not rig-scoped: every rig's decodes reach the browser, each carrying the rig that heard it. The panels on the decoder tabs want that — they aggregate the whole station — but the mini views over the waterfall caption the spectrum underneath, and they were reading the same unfiltered histories. A background rig copying APRS on another band put its frames over the active rig's waterfall. The mode gate did not help: it reads the mode of the rig on screen, so those frames appeared whenever that rig happened to be in PKT. Filter each overlay on the rig it belongs to, through one shared predicate that compares a decode's rig_id with the per-tab active rig already driving the spectrum and the audio. A decode that names no rig, and a session that has not learnt its rig list yet, still show everything. The FTx normalizer was dropping rig_id on the floor, so it now keeps it. CW needed more than a filter: its lines accumulate character by character, so two rigs copying at once braided their text into one unreadable line. Lines in progress are now kept per rig. The bar repaints in render() move into refreshDecodeBars(), which the rig switch calls as well — otherwise the outgoing rig's frames stayed on screen until the next state update — and which finally includes the CW bar. Closes #49 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- .../assets/web/generated/ais.js | 7 +- .../assets/web/generated/app.js | 13 +- .../assets/web/generated/aprs.js | 7 +- .../assets/web/generated/chunk-BQQXSNLC.js | 14 ++ .../{chunk-O2Y7YEVQ.js => chunk-K3D6FOP5.js} | 8 +- .../assets/web/generated/cw.js | 41 ++-- .../assets/web/generated/ft2.js | 3 +- .../assets/web/generated/ft4.js | 3 +- .../assets/web/generated/ft8.js | 3 +- .../assets/web/generated/vdes.js | 5 +- .../trx-frontend-http/frontend/src/app.ts | 22 +- .../frontend/src/plugins/active-rig.ts | 20 ++ .../frontend/src/plugins/ais.ts | 5 +- .../frontend/src/plugins/aprs.ts | 5 +- .../frontend/src/plugins/cw.ts | 53 +++-- .../frontend/src/plugins/ftx-family.ts | 8 +- .../frontend/src/plugins/vdes.ts | 5 +- .../frontend/tests/decode-flow.mjs | 15 +- .../frontend/tests/mini-view-rig.test.mjs | 198 ++++++++++++++++++ 19 files changed, 380 insertions(+), 55 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BQQXSNLC.js rename src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/{chunk-O2Y7YEVQ.js => chunk-K3D6FOP5.js} (97%) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/active-rig.ts create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mini-view-rig.test.mjs diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js index 9fda5775..093be46c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js @@ -1,3 +1,6 @@ +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore, hostState @@ -222,7 +225,9 @@ function updateAisBar() { updateAisSummary(); const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS"; const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS; - const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs); + const recent = aisMessageHistory.filter( + (msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id) + ); const messages = aisLatestByVessel(recent).slice(0, 8); if (!isAis || messages.length === 0) { aisBarOverlay.style.display = "none"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 6f891962..a598cc0a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -3555,6 +3555,13 @@ function positionRdsPsOverlay() { function resetRdsDisplay() { updateRdsPsOverlay(primaryRds); } +function refreshDecodeBars() { + window.updateAisBar?.(); + window.updateVdesBar?.(); + window.updateAprsBar?.(); + window.updateFt8Bar?.(); + window.updateCwBar?.(); +} function resetDecoderStateOnRigSwitch() { primaryRds = null; vchanRdsById = /* @__PURE__ */ new Map(); @@ -3569,6 +3576,7 @@ function resetDecoderStateOnRigSwitch() { const el = document.getElementById(id); if (el) el.textContent = "--"; }); + refreshDecodeBars(); } function resetWfmStereoIndicator() { if (!wfmStFlagEl) return; @@ -4788,10 +4796,7 @@ function render(update) { const connText = _decodeConnectedText[d.id] || "Connected, listening for packets"; setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); } - if (window.updateAisBar) window.updateAisBar(); - if (window.updateVdesBar) window.updateVdesBar(); - if (window.updateAprsBar) window.updateAprsBar(); - if (window.updateFt8Bar) window.updateFt8Bar(); + refreshDecodeBars(); for (const d of decoderRegistry) { if (d.activation !== "toggle") continue; const key = d.id.replace(/-/g, "_") + "_decode_enabled"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index 0436b5df..2eba9eba 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -5,6 +5,9 @@ import { normalizeAprsPacket, renderAprsPacketRow } from "./chunk-OPEIVJGD.js"; +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore, hostState @@ -153,7 +156,9 @@ function updateAprsBar() { if (!aprsBarOverlay) return; const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT"; const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; - const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs); + const okFrames = aprsPacketHistory.filter( + (p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id) + ); const frames = collapseAprsDuplicates(okFrames).slice(0, 8); const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0); if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BQQXSNLC.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BQQXSNLC.js new file mode 100644 index 00000000..14d8f60f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BQQXSNLC.js @@ -0,0 +1,14 @@ +import { + hostState +} from "./chunk-KL66PICH.js"; + +// src/plugins/active-rig.ts +function isActiveRigDecode(rigId) { + const activeRigId = hostState.lastActiveRigId; + if (!activeRigId || !rigId) return true; + return rigId === activeRigId; +} + +export { + isActiveRigDecode +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-O2Y7YEVQ.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-K3D6FOP5.js similarity index 97% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-O2Y7YEVQ.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-K3D6FOP5.js index bbb4b0db..19c67d0f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-O2Y7YEVQ.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-K3D6FOP5.js @@ -1,3 +1,6 @@ +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -177,6 +180,9 @@ function initializeFtxDecoder(config) { }); } return { + // The rig that heard it, kept so the mini view can tell a decode of the + // rig on screen from one a background rig made on another band. + rig_id: message.rig_id ?? null, receiver: bridge.getDecodeRigMeta?.() ?? null, ts_ms: message.ts_ms, snr_db: message.snr_db, @@ -202,7 +208,7 @@ function initializeFtxDecoder(config) { bridge.clearMapMarkersByType?.(id); }; const barFrames = () => { - const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8); + const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8); let html = ""; for (const message of recent) { const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js index 0a7e7a3e..1564ab69 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js @@ -1,3 +1,6 @@ +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3; var cwLastAppendTime = 0; var cwTonePickerRaf = null; var cwBarHistory = []; -var cwBarCurrentLine = null; +var cwBarCurrentLines = /* @__PURE__ */ new Map(); var cwBarDismissedAtMs = 0; var cwAutoLocalOverride = null; function escapeCwHtml(input) { @@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) { if (cwAutoLocalOverride !== null) return; applyCwAutoUi(enabled); }; -function cwBarFlushCurrentLine() { - if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { - cwBarHistory.unshift(cwBarCurrentLine); +function cwBarFlushCurrentLine(key) { + const line = cwBarCurrentLines.get(key); + cwBarCurrentLines.delete(key); + if (line?.text.trim()) { + cwBarHistory.unshift(line); if (cwBarHistory.length > 50) cwBarHistory.length = 50; } - cwBarCurrentLine = null; } function updateCwBar() { if (!cwBarOverlay) return; const mode = (document.getElementById("mode")?.value || "").toUpperCase(); const isCw = mode === "CW" || mode === "CWR"; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; - const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); - const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; + const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId)); + const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs); + const liveLines = [...inProgress, ...recent]; const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { cwBarOverlay.style.display = "none"; @@ -308,7 +313,7 @@ function resetCwHistoryView() { if (cwOutputEl) cwOutputEl.innerHTML = ""; cwLastAppendTime = 0; cwBarHistory = []; - cwBarCurrentLine = null; + cwBarCurrentLines.clear(); updateCwBar(); drawCwTonePicker(); } @@ -346,18 +351,22 @@ function onServerCw(evt) { } if (evt.text) { const now = Date.now(); + const rigId = evt.rig_id ?? null; + const key = rigId ?? ""; if (evt.text === "\n") { - cwBarFlushCurrentLine(); + cwBarFlushCurrentLine(key); } else { - if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { - cwBarFlushCurrentLine(); + let line = cwBarCurrentLines.get(key); + if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) { + cwBarFlushCurrentLine(key); const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + cwBarCurrentLines.set(key, line); } - cwBarCurrentLine.text += evt.text; - cwBarCurrentLine.lastMs = now; - if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); - if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); + line.text += evt.text; + line.lastMs = now; + if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm); + if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz)); } updateCwBar(); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js index 9b1997d8..db3311bb 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -1,6 +1,7 @@ import { initializeFtxDecoder -} from "./chunk-O2Y7YEVQ.js"; +} from "./chunk-K3D6FOP5.js"; +import "./chunk-BQQXSNLC.js"; import "./chunk-KL66PICH.js"; // src/plugins/ft2.ts diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js index 9c7839bf..d4fdf8a8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -1,6 +1,7 @@ import { initializeFtxDecoder -} from "./chunk-O2Y7YEVQ.js"; +} from "./chunk-K3D6FOP5.js"; +import "./chunk-BQQXSNLC.js"; import "./chunk-KL66PICH.js"; // src/plugins/ft4.ts diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js index d18bcde6..85570b26 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -2,7 +2,8 @@ import { initializeFt8FamilyBar, initializeFtxDecoder, installFtxCompatibilityHelpers -} from "./chunk-O2Y7YEVQ.js"; +} from "./chunk-K3D6FOP5.js"; +import "./chunk-BQQXSNLC.js"; import "./chunk-KL66PICH.js"; // src/plugins/ft8.ts diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js index ff9cf002..6bc119d2 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js @@ -1,3 +1,6 @@ +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -137,7 +140,7 @@ function updateVdesBar() { updateVdesSummary(); const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES"; const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS; - const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6); + const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6); if (!isVdes || messages.length === 0) { vdesBarOverlay.style.display = "none"; vdesBarOverlay.innerHTML = ""; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index 4a0c707e..aaf7ce58 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -392,6 +392,7 @@ declare global { updateVdesBar?(value?: number): void; updateAprsBar?(value?: number): void; updateFt8Bar?(value?: number): void; + updateCwBar?(value?: number): void; updateSatLiveState?(value: unknown): void; applyCwAutoUi?(enabled: boolean): void; applyCwAutoUiFromServer?(enabled: boolean): void; @@ -2207,6 +2208,17 @@ function resetRdsDisplay() { updateRdsPsOverlay(primaryRds); } +// The mini views over the waterfall show only what the rig on screen heard, so +// they have to be repainted whenever that rig — or its mode — changes, not just +// when the next decode happens to arrive. +function refreshDecodeBars() { + window.updateAisBar?.(); + window.updateVdesBar?.(); + window.updateAprsBar?.(); + window.updateFt8Bar?.(); + window.updateCwBar?.(); +} + function resetDecoderStateOnRigSwitch() { // RDS primaryRds = null; @@ -2226,6 +2238,11 @@ function resetDecoderStateOnRigSwitch() { const el = document.getElementById(id); if (el) el.textContent = "--"; }); + + // The decode stream is not rig-scoped, so the histories behind the mini views + // survive the switch: drop the outgoing rig's frames from them now instead of + // leaving them on screen until the next state update. + refreshDecodeBars(); } function resetWfmStereoIndicator() { @@ -3676,10 +3693,7 @@ function render(update: AppUpdate) { const connText = _decodeConnectedText[d.id] || "Connected, listening for packets"; setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); } - if (window.updateAisBar) window.updateAisBar(); - if (window.updateVdesBar) window.updateVdesBar(); - if (window.updateAprsBar) window.updateAprsBar(); - if (window.updateFt8Bar) window.updateFt8Bar(); + refreshDecodeBars(); // Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong. for (const d of decoderRegistry) { if (d.activation !== "toggle") continue; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/active-rig.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/active-rig.ts new file mode 100644 index 00000000..499ae127 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/active-rig.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import { hostState } from "./host.js"; + +// The decode panels are aggregate views on purpose: they list what every rig +// heard, and the history they restore from is not rig-scoped either. The mini +// views over the waterfall are not aggregates — they read as a caption for the +// spectrum underneath, so a frame a background rig copied on another band +// belongs to a different picture entirely. Each decode names the rig that +// heard it (the client stamps `rig_id` as the frame leaves the audio +// connection), which is all the overlays need to keep to the rig on screen. +export function isActiveRigDecode(rigId: string | null | undefined): boolean { + const activeRigId = hostState.lastActiveRigId; + // Before the rig list has arrived, and for a decode that reached the browser + // without a rig of its own, there is nothing to disagree with. + if (!activeRigId || !rigId) return true; + return rigId === activeRigId; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts index b08c0939..182fbbe7 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts @@ -6,6 +6,7 @@ import { hostCore, hostState } from "./host.js"; export {}; +import { isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract"; interface AisMessage { @@ -314,7 +315,9 @@ function updateAisBar() { const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS"; const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS; - const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs); + const recent = aisMessageHistory.filter( + (msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id), + ); const messages = aisLatestByVessel(recent).slice(0, 8); if (!isAis || messages.length === 0) { aisBarOverlay.style.display = "none"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts index 307e394e..5e14d0a6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts @@ -4,6 +4,7 @@ import { hostCore, hostState } from "./host.js"; +import { isActiveRigDecode } from "./active-rig.js"; import { aprsAgeText, aprsPacketCategory, @@ -179,7 +180,9 @@ function updateAprsBar() { if (!aprsBarOverlay) return; const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT"; const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; - const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs); + const okFrames = aprsPacketHistory.filter( + (p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id), + ); const frames = collapseAprsDuplicates(okFrames).slice(0, 8); const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0); if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts index 8ca86646..3028dcc6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts @@ -4,6 +4,7 @@ import { hostCore } from "./host.js"; +import { isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js"; export {}; @@ -20,8 +21,11 @@ interface CwRenderer { drawPoints(points: number[], size: number, color: Rgba): void; } interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number } -interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean } -interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number } +interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean } +interface CwLine { + rigId: string | null; tsMs: number; ts: string; text: string; + wpm: number | null; tone_hz: number | null; lastMs: number; +} interface CwToneRange { tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number; toneSpanHz: number; lowerSideband: boolean; mode: string; @@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000; let cwLastAppendTime = 0; let cwTonePickerRaf: number | null = null; let cwBarHistory: CwLine[] = []; -let cwBarCurrentLine: CwLine | null = null; +// One line in progress per rig, keyed by rig id ("" for a decode that names no +// rig). Two rigs copying at the same time each fill their own line instead of +// braiding their characters into one. +const cwBarCurrentLines = new Map(); let cwBarDismissedAtMs = 0; // Tracks a user-initiated auto toggle that is in-flight (POST not yet // acknowledged). While set, server-state updates must not override the @@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) { applyCwAutoUi(enabled); }; -function cwBarFlushCurrentLine(): void { - if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { - cwBarHistory.unshift(cwBarCurrentLine); +function cwBarFlushCurrentLine(key: string): void { + const line = cwBarCurrentLines.get(key); + cwBarCurrentLines.delete(key); + if (line?.text.trim()) { + cwBarHistory.unshift(line); if (cwBarHistory.length > 50) cwBarHistory.length = 50; } - cwBarCurrentLine = null; } function updateCwBar(): void { @@ -114,9 +122,12 @@ function updateCwBar(): void { const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase(); const isCw = mode === "CW" || mode === "CWR"; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; - const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); - // Prepend the in-progress line so characters appear immediately - const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; + const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId)); + // Prepend the in-progress lines so characters appear immediately + const inProgress = [...cwBarCurrentLines.values()] + .filter((l) => l.text && isActiveRigDecode(l.rigId)) + .sort((a, b) => b.tsMs - a.tsMs); + const liveLines = [...inProgress, ...recent]; const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { cwBarOverlay.style.display = "none"; @@ -402,7 +413,7 @@ function resetCwHistoryView(): void { if (cwOutputEl) cwOutputEl.innerHTML = ""; cwLastAppendTime = 0; cwBarHistory = []; - cwBarCurrentLine = null; + cwBarCurrentLines.clear(); updateCwBar(); drawCwTonePicker(); } @@ -445,18 +456,22 @@ function onServerCw(evt: CwEvent): void { // Bar history accumulation (regardless of pause state) if (evt.text) { const now = Date.now(); + const rigId = evt.rig_id ?? null; + const key = rigId ?? ""; if (evt.text === "\n") { - cwBarFlushCurrentLine(); + cwBarFlushCurrentLine(key); } else { - if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { - cwBarFlushCurrentLine(); + let line = cwBarCurrentLines.get(key); + if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) { + cwBarFlushCurrentLine(key); const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + cwBarCurrentLines.set(key, line); } - cwBarCurrentLine.text += evt.text; - cwBarCurrentLine.lastMs = now; - if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); - if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); + line.text += evt.text; + line.lastMs = now; + if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm); + if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz)); } updateCwBar(); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts index 5a533914..8f4a77fe 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts @@ -4,6 +4,7 @@ import { hostCore } from "./host.js"; +import { isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js"; export type FtxDecoderId = "ft2" | "ft4" | "ft8"; @@ -15,6 +16,7 @@ export interface FtxMessage { snr_db?: number | undefined; dt_s?: number | undefined; freq_hz?: number | undefined; + rig_id?: string | null | undefined; receiver?: unknown; [key: string]: unknown; } @@ -251,6 +253,9 @@ export function initializeFtxDecoder(config: FtxConfig): void { }); } return { + // The rig that heard it, kept so the mini view can tell a decode of the + // rig on screen from one a background rig made on another band. + rig_id: message.rig_id ?? null, receiver: bridge.getDecodeRigMeta?.() ?? null, ts_ms: message.ts_ms, snr_db: message.snr_db, @@ -277,7 +282,8 @@ export function initializeFtxDecoder(config: FtxConfig): void { }; const barFrames = (): BarFrames => { const recent = history - .filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000) + .filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000 + && isActiveRigDecode(message.rig_id)) .slice(0, 8); let html = ""; for (const message of recent) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts index 0e6b46ce..fd083801 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts @@ -6,6 +6,7 @@ import { hostCore } from "./host.js"; export {}; +import { isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract"; interface VdesMessage { @@ -228,7 +229,9 @@ function updateVdesBar() { updateVdesSummary(); const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES"; const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS; - const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6); + const messages = vdesMessageHistory + .filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)) + .slice(0, 6); if (!isVdes || messages.length === 0) { vdesBarOverlay.style.display = "none"; vdesBarOverlay.innerHTML = ""; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs index e0d0cf00..6159bc13 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs @@ -22,9 +22,18 @@ const 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. Its traffic +// belongs in the panels, which aggregate every rig, but not in the mini view, +// which captions the spectrum of the rig on screen. +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], mode: "AIS" }); +const fixture = await startWebFixture({ + spectrum: true, decodes: [VESSEL, BEACON, OTHER_RIG_VESSEL], mode: "AIS", +}); const { browser, page, runtimeErrors } = await startBrowser(chromium); try { @@ -41,11 +50,13 @@ try { 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, 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, true, "the AIS panel dropped the background rig's vessel"); // The mini view rides over the waterfall on the radio page. await page.locator('.tab[data-tab="main"]').click(); @@ -56,11 +67,13 @@ try { 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 diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mini-view-rig.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mini-view-rig.test.mjs new file mode 100644 index 00000000..6bdf0e13 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mini-view-rig.test.mjs @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +// The mini views over the waterfall caption the spectrum below them, so they +// show only what the rig on screen heard. The panels on the decoder tabs stay +// aggregate — every rig's traffic lands there — which is the distinction these +// tests pin down: a decode from a background rig belongs in the list and not in +// the overlay. + +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 keeps to the active rig while the panel lists both", async () => { + const overlay = new ElementFixture(); + const packets = new ElementFixture(); + const elements = new Map([ + ["aprs-bar-overlay", overlay], + ["aprs-packets", packets], + ["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, 2, "the APRS panel dropped a frame it should still list"); +}); + +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"); +}); -- 2.55.0 From 86dd36312e61730d83e75a37910cef040a295188 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Fri, 7 Aug 2026 02:47:04 +0200 Subject: [PATCH 2/2] [test](trx-frontend-http): type the frequency instead of filling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tune-links drove the dial with Playwright's fill(), which writes a value into the field without a keystroke. The app arms its guard against its own refreshes on the first keydown, so a filled field stays unguarded: any state update landing between the fill and the Enter rewrites the field with the frequency the radio is already on, and the Enter then re-applies that. The window is a few milliseconds wide on a developer's machine and wide enough to lose on a loaded CI runner, where the test failed claiming the tuning had landed on the frequency it started from. Type it the way an operator does: select the field, then send the characters as keystrokes. The select arms the guard before a single character changes. Under CPU throttling that reproduced the failure — 1 in 6 runs with fill(), on this branch and on main alike — typing came through 8 runs clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- .../frontend/tests/tune-links.mjs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs index 85ab0535..55546f0b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs @@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({ path: window.location.pathname, })); +// Typed, not filled. The app holds back its own refreshes of the frequency +// field from the first keystroke until Enter, so that a state update arriving +// mid-edit does not rewrite what is being typed. `fill()` sets the value +// without a keystroke, leaving the field unguarded: on a slow machine a state +// update could land between the fill and the Enter and put the old frequency +// back, and the Enter would then re-apply the frequency the radio was already +// on. Selecting first arms the guard before a single character changes. +async function tuneByHand(text) { + const field = page.locator("#freq"); + await field.click(); + await field.press("ControlOrMeta+a"); + await field.pressSequentially(text); + await field.press("Enter"); +} + try { await page.setViewportSize({ width: 1500, height: 950 }); @@ -49,8 +64,7 @@ try { // Tuning by hand rewrites the link. This is the part that makes the address // bar shareable at any moment rather than only at load. - await page.locator("#freq").fill("7.040M"); - await page.locator("#freq").press("Enter"); + await tuneByHand("7.040M"); await page.waitForTimeout(1500); const tuned = await dial(); assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`); @@ -59,8 +73,7 @@ try { // Tuning is not navigation: a swept dial must not bury the back button. const historyLength = await page.evaluate(() => window.history.length); - await page.locator("#freq").fill("7.100M"); - await page.locator("#freq").press("Enter"); + await tuneByHand("7.100M"); await page.waitForTimeout(1200); assert.equal(await page.evaluate(() => window.history.length), historyLength, "tuning pushed a history entry instead of replacing one"); -- 2.55.0