Compare commits
2
Commits
84bdf2593c
...
c1899229a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1899229a0 | ||
|
|
84a99a3636 |
@@ -1689,7 +1689,24 @@ function estimateNoiseFloorDb(bins) {
|
||||
|
||||
// src/plugin-loader.ts
|
||||
var pluginGroups = {
|
||||
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
|
||||
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||
// with it. They used to come only with the map group, which left their
|
||||
// sub-tabs empty — decodes queueing in the runtime — until something opened
|
||||
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||
"digital-modes": [
|
||||
"/ft8.js",
|
||||
"/ft4.js",
|
||||
"/ft2.js",
|
||||
"/wspr.js",
|
||||
"/cw.js",
|
||||
"/background-decode.js",
|
||||
"/sat.js",
|
||||
"/wefax.js",
|
||||
"/ais.js",
|
||||
"/vdes.js",
|
||||
"/aprs.js",
|
||||
"/hf-aprs.js"
|
||||
],
|
||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||
statistics: ["/map-core.js"],
|
||||
@@ -2048,6 +2065,7 @@ var loadingSub = requiredElement("loading-sub");
|
||||
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||||
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||||
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||||
var decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
|
||||
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
||||
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
||||
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
||||
@@ -2197,10 +2215,19 @@ function syncTopBarAccess() {
|
||||
}
|
||||
}
|
||||
var overviewDrawPending = false;
|
||||
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") {
|
||||
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "", fraction = null) {
|
||||
if (!decodeHistoryOverlayEl) return;
|
||||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
||||
if (decodeHistoryProgressBarEl) {
|
||||
if (fraction == null) {
|
||||
decodeHistoryOverlayEl.dataset.phase = "fetching";
|
||||
decodeHistoryProgressBarEl.style.width = "";
|
||||
} else {
|
||||
delete decodeHistoryOverlayEl.dataset.phase;
|
||||
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
|
||||
}
|
||||
}
|
||||
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||||
}
|
||||
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
|
||||
@@ -7502,16 +7529,15 @@ function connectDecode() {
|
||||
let historySettled = false;
|
||||
let historyWorkerDone = false;
|
||||
let historyFallbackStarted = false;
|
||||
let historyRetried = false;
|
||||
let historyBatchDrainScheduled = false;
|
||||
let historyTotal = 0;
|
||||
let historyProcessed = 0;
|
||||
const historyGroupQueue = [];
|
||||
const liveBuffer = [];
|
||||
function flushLiveBuffer() {
|
||||
function releaseLiveBuffer() {
|
||||
if (historySettled) return;
|
||||
historySettled = true;
|
||||
terminateDecodeHistoryWorker();
|
||||
setDecodeHistoryReplayActive(false);
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
for (const msg of liveBuffer) {
|
||||
try {
|
||||
dispatchDecodeMessage(msg);
|
||||
@@ -7520,19 +7546,23 @@ function connectDecode() {
|
||||
}
|
||||
liveBuffer.length = 0;
|
||||
}
|
||||
function finishHistoryReplay() {
|
||||
clearTimeout(historyTimeout);
|
||||
releaseLiveBuffer();
|
||||
terminateDecodeHistoryWorker();
|
||||
setDecodeHistoryReplayActive(false);
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
}
|
||||
function updateHistoryReplayOverlay() {
|
||||
setDecodeHistoryOverlayVisible(
|
||||
true,
|
||||
"Loading decode history…",
|
||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
|
||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
|
||||
historyTotal > 0 ? historyProcessed / historyTotal : null
|
||||
);
|
||||
}
|
||||
function maybeFinishHistoryReplay() {
|
||||
if (historySettled) return;
|
||||
if (historyWorkerDone && historyGroupQueue.length === 0) {
|
||||
clearTimeout(historyTimeout);
|
||||
flushLiveBuffer();
|
||||
}
|
||||
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
|
||||
}
|
||||
function pumpDecodeHistoryGroupQueue() {
|
||||
historyBatchDrainScheduled = false;
|
||||
@@ -7586,17 +7616,25 @@ function connectDecode() {
|
||||
if (historyFallbackStarted || historySettled) return;
|
||||
historyFallbackStarted = true;
|
||||
loadDecodeHistoryOnMainThread((groups) => {
|
||||
clearTimeout(historyTimeout);
|
||||
const total = totalDecodeHistoryMessages(groups);
|
||||
if (total > 0) {
|
||||
enqueueDecodeHistoryGroups(groups);
|
||||
} else {
|
||||
flushLiveBuffer();
|
||||
finishHistoryReplay();
|
||||
}
|
||||
}, (err) => {
|
||||
console.error("Decode history fallback failed", err);
|
||||
clearTimeout(historyTimeout);
|
||||
flushLiveBuffer();
|
||||
if (historyRetried) {
|
||||
showHint("Decode history unavailable", 3e3);
|
||||
finishHistoryReplay();
|
||||
return;
|
||||
}
|
||||
historyRetried = true;
|
||||
historyFallbackStarted = false;
|
||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
|
||||
setTimeout(() => {
|
||||
startDecodeHistoryFallback();
|
||||
}, 2e3);
|
||||
});
|
||||
}
|
||||
function startDecodeHistoryWorkerReplay() {
|
||||
@@ -7657,10 +7695,9 @@ function connectDecode() {
|
||||
return true;
|
||||
}
|
||||
const historyTimeout = setTimeout(() => {
|
||||
if (!historySettled) {
|
||||
terminateDecodeHistoryWorker();
|
||||
flushLiveBuffer();
|
||||
}
|
||||
if (historySettled) return;
|
||||
releaseLiveBuffer();
|
||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
|
||||
}, 2e4);
|
||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||||
decodeSource = new EventSource("/decode");
|
||||
@@ -7682,7 +7719,7 @@ function connectDecode() {
|
||||
const wasClosed = source.readyState === 2;
|
||||
source.close();
|
||||
terminateDecodeHistoryWorker();
|
||||
if (!historySettled) flushLiveBuffer();
|
||||
if (!historySettled) releaseLiveBuffer();
|
||||
if (wasClosed) {
|
||||
updateDecodeStatus("Decode not available (check client audio config)");
|
||||
setTimeout(connectDecode, 1e4);
|
||||
|
||||
@@ -1660,11 +1660,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
|
||||
<div class="decode-history-overlay-card">
|
||||
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
|
||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
||||
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="history-progress-text">
|
||||
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
|
||||
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
|
||||
</div>
|
||||
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
|
||||
</div>
|
||||
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
||||
<script defer src="/vendor/leaflet.js"></script>
|
||||
|
||||
@@ -2209,6 +2209,74 @@ small { color: var(--text-muted); }
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Decode history loads in the corner, not over the page. It was a full-screen
|
||||
scrim: the operator could not read the waterfall, the decode panels or
|
||||
anything else while a large history replayed, and the replay is exactly when
|
||||
there is something to watch. */
|
||||
.history-progress {
|
||||
position: fixed;
|
||||
left: var(--space-4);
|
||||
bottom: var(--space-4);
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
width: min(20rem, calc(100vw - 2rem));
|
||||
padding: 0.6rem 0.75rem 0.7rem;
|
||||
border: 1px solid color-mix(in srgb, var(--border-light) 70%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--card-bg) 94%, transparent);
|
||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--bg) 35%, transparent);
|
||||
pointer-events: none;
|
||||
transition: opacity var(--dur-base) var(--ease-standard),
|
||||
visibility var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
.history-progress.is-hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.history-progress-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.history-progress-title {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
.history-progress-sub {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.history-progress-track {
|
||||
height: 0.28rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--border-light) 45%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.history-progress-bar {
|
||||
display: block;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent-green);
|
||||
transition: width var(--dur-base) var(--ease-out);
|
||||
}
|
||||
/* Indeterminate while the payload is still on the wire. */
|
||||
.history-progress[data-phase="fetching"] .history-progress-bar {
|
||||
width: 35%;
|
||||
animation: trx-history-sweep 1.1s var(--ease-standard) infinite;
|
||||
}
|
||||
@keyframes trx-history-sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(285%); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.history-progress { bottom: calc(5.9rem + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
.decode-history-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs",
|
||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -712,6 +712,7 @@ const loadingSub = requiredElement("loading-sub");
|
||||
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||||
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||||
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||||
const decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
|
||||
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
||||
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
||||
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
||||
@@ -880,10 +881,29 @@ function syncTopBarAccess() {
|
||||
}
|
||||
|
||||
let overviewDrawPending = false;
|
||||
function setDecodeHistoryOverlayVisible(visible: boolean, title = "", sub = "") {
|
||||
// Progress, in the corner. This used to dim the whole page behind a scrim,
|
||||
// which hid the waterfall and the decode panels for as long as the replay ran
|
||||
// — and a replay only happens when there is a backlog worth watching arrive.
|
||||
// `fraction` null leaves the bar indeterminate, for the part where the payload
|
||||
// is still on the wire and there is nothing to count yet.
|
||||
function setDecodeHistoryOverlayVisible(
|
||||
visible: boolean,
|
||||
title = "",
|
||||
sub = "",
|
||||
fraction: number | null = null,
|
||||
) {
|
||||
if (!decodeHistoryOverlayEl) return;
|
||||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
||||
if (decodeHistoryProgressBarEl) {
|
||||
if (fraction == null) {
|
||||
decodeHistoryOverlayEl.dataset.phase = "fetching";
|
||||
decodeHistoryProgressBarEl.style.width = "";
|
||||
} else {
|
||||
delete decodeHistoryOverlayEl.dataset.phase;
|
||||
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
|
||||
}
|
||||
}
|
||||
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||||
}
|
||||
|
||||
@@ -6494,36 +6514,44 @@ function connectDecode() {
|
||||
let historySettled = false;
|
||||
let historyWorkerDone = false;
|
||||
let historyFallbackStarted = false;
|
||||
let historyRetried = false;
|
||||
let historyBatchDrainScheduled = false;
|
||||
let historyTotal = 0;
|
||||
let historyProcessed = 0;
|
||||
const historyGroupQueue: DecodeHistoryGroup[] = [];
|
||||
const liveBuffer: DecodeMessage[] = [];
|
||||
function flushLiveBuffer() {
|
||||
// Live decodes wait behind the history so the panels stay in order. Letting
|
||||
// them through is not the same as being finished, and conflating the two is
|
||||
// what made a slow history disappear: the safety valve released the buffer
|
||||
// and tore the worker down with it, so whatever had not arrived never did.
|
||||
function releaseLiveBuffer() {
|
||||
if (historySettled) return;
|
||||
historySettled = true;
|
||||
terminateDecodeHistoryWorker();
|
||||
setDecodeHistoryReplayActive(false);
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
for (const msg of liveBuffer) {
|
||||
try { dispatchDecodeMessage(msg); } catch (_) {}
|
||||
}
|
||||
liveBuffer.length = 0;
|
||||
}
|
||||
|
||||
function finishHistoryReplay() {
|
||||
clearTimeout(historyTimeout);
|
||||
releaseLiveBuffer();
|
||||
terminateDecodeHistoryWorker();
|
||||
setDecodeHistoryReplayActive(false);
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
}
|
||||
|
||||
function updateHistoryReplayOverlay() {
|
||||
setDecodeHistoryOverlayVisible(
|
||||
true,
|
||||
"Loading decode history…",
|
||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
|
||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
|
||||
historyTotal > 0 ? historyProcessed / historyTotal : null,
|
||||
);
|
||||
}
|
||||
|
||||
function maybeFinishHistoryReplay() {
|
||||
if (historySettled) return;
|
||||
if (historyWorkerDone && historyGroupQueue.length === 0) {
|
||||
clearTimeout(historyTimeout);
|
||||
flushLiveBuffer();
|
||||
}
|
||||
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
|
||||
}
|
||||
|
||||
function pumpDecodeHistoryGroupQueue() {
|
||||
@@ -6585,17 +6613,26 @@ function connectDecode() {
|
||||
if (historyFallbackStarted || historySettled) return;
|
||||
historyFallbackStarted = true;
|
||||
loadDecodeHistoryOnMainThread((groups) => {
|
||||
clearTimeout(historyTimeout);
|
||||
const total = totalDecodeHistoryMessages(groups);
|
||||
if (total > 0) {
|
||||
enqueueDecodeHistoryGroups(groups);
|
||||
} else {
|
||||
flushLiveBuffer();
|
||||
finishHistoryReplay();
|
||||
}
|
||||
}, (err: unknown) => {
|
||||
console.error("Decode history fallback failed", err);
|
||||
clearTimeout(historyTimeout);
|
||||
flushLiveBuffer();
|
||||
// One retry, then say so. Failing silently here is why the history
|
||||
// sometimes only turned up on a second reload: nothing asked again and
|
||||
// nothing said anything was missing.
|
||||
if (historyRetried) {
|
||||
showHint("Decode history unavailable", 3000);
|
||||
finishHistoryReplay();
|
||||
return;
|
||||
}
|
||||
historyRetried = true;
|
||||
historyFallbackStarted = false;
|
||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
|
||||
setTimeout(() => { startDecodeHistoryFallback(); }, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6664,12 +6701,13 @@ function connectDecode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Safety valve: if the history fetch hangs, unblock after 20 s.
|
||||
// Safety valve: after 20 s, stop holding live decodes back — but keep
|
||||
// loading. The history is what the operator is waiting for, and dropping it
|
||||
// on the floor at the timeout is not something they can even see happen.
|
||||
const historyTimeout = setTimeout(() => {
|
||||
if (!historySettled) {
|
||||
terminateDecodeHistoryWorker();
|
||||
flushLiveBuffer();
|
||||
}
|
||||
if (historySettled) return;
|
||||
releaseLiveBuffer();
|
||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
|
||||
}, 20000);
|
||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||||
|
||||
@@ -6692,7 +6730,7 @@ function connectDecode() {
|
||||
const wasClosed = source.readyState === 2;
|
||||
source.close();
|
||||
terminateDecodeHistoryWorker();
|
||||
if (!historySettled) flushLiveBuffer();
|
||||
if (!historySettled) releaseLiveBuffer();
|
||||
if (wasClosed) {
|
||||
updateDecodeStatus("Decode not available (check client audio config)");
|
||||
setTimeout(connectDecode, 10000);
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
||||
|
||||
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
|
||||
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||
// with it. They used to come only with the map group, which left their
|
||||
// sub-tabs empty — decodes queueing in the runtime — until something opened
|
||||
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||
"digital-modes": [
|
||||
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
|
||||
"/sat.js", "/wefax.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
|
||||
],
|
||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||
statistics: ["/map-core.js"],
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// 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 */
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
// 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 { 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 ?? "",
|
||||
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})`);
|
||||
|
||||
// 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"),
|
||||
};
|
||||
});
|
||||
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");
|
||||
|
||||
// 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.ais > 0, "the vessel never reached the map");
|
||||
assert.ok(markers.stations > 0, "the APRS station never 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, lon: 18.6, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||
})),
|
||||
},
|
||||
});
|
||||
const replay = await startBrowser(chromium);
|
||||
|
||||
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.
|
||||
const samples = [];
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
samples.push(await replay.page.evaluate(() => {
|
||||
const element = document.getElementById("decode-history-overlay");
|
||||
if (!element || element.classList.contains("is-hidden")) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
width: Math.round(rect.width),
|
||||
coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
|
||||
};
|
||||
}));
|
||||
await replay.page.waitForTimeout(100);
|
||||
}
|
||||
const shown = samples.filter(Boolean);
|
||||
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");
|
||||
|
||||
assert.deepEqual(replay.runtimeErrors, []);
|
||||
} finally {
|
||||
await replay.browser.close();
|
||||
await historyFixture.close();
|
||||
}
|
||||
@@ -44,6 +44,68 @@ const CONTENT_TYPES = new Map([
|
||||
[".woff2", "font/woff2"],
|
||||
]);
|
||||
|
||||
// The history endpoint answers in CBOR (see api/decoder.rs), and the worker
|
||||
// that reads it takes the body as CBOR unconditionally. Serving JSON here left
|
||||
// every run exercising the client's retry path instead of its history path.
|
||||
function encodeCbor(value) {
|
||||
const chunks = [];
|
||||
const head = (major, length) => {
|
||||
if (length < 24) return Buffer.from([(major << 5) | length]);
|
||||
if (length < 0x100) return Buffer.from([(major << 5) | 24, length]);
|
||||
if (length < 0x10000) {
|
||||
const buffer = Buffer.alloc(3);
|
||||
buffer[0] = (major << 5) | 25;
|
||||
buffer.writeUInt16BE(length, 1);
|
||||
return buffer;
|
||||
}
|
||||
if (length < 0x1_0000_0000) {
|
||||
const buffer = Buffer.alloc(5);
|
||||
buffer[0] = (major << 5) | 26;
|
||||
buffer.writeUInt32BE(length, 1);
|
||||
return buffer;
|
||||
}
|
||||
// Timestamps are past 2^32 milliseconds, so the 64-bit form is needed.
|
||||
const buffer = Buffer.alloc(9);
|
||||
buffer[0] = (major << 5) | 27;
|
||||
buffer.writeBigUInt64BE(BigInt(length), 1);
|
||||
return buffer;
|
||||
};
|
||||
const write = (item) => {
|
||||
if (item === null || item === undefined) { chunks.push(Buffer.from([0xf6])); return; }
|
||||
if (typeof item === "boolean") { chunks.push(Buffer.from([item ? 0xf5 : 0xf4])); return; }
|
||||
if (typeof item === "number") {
|
||||
if (Number.isInteger(item) && item >= 0) { chunks.push(head(0, item)); return; }
|
||||
if (Number.isInteger(item) && item < 0) { chunks.push(head(1, -item - 1)); return; }
|
||||
const buffer = Buffer.alloc(9);
|
||||
buffer[0] = 0xfb;
|
||||
buffer.writeDoubleBE(item, 1);
|
||||
chunks.push(buffer);
|
||||
return;
|
||||
}
|
||||
if (typeof item === "string") {
|
||||
const bytes = Buffer.from(item, "utf8");
|
||||
chunks.push(head(3, bytes.length), bytes);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(item)) {
|
||||
chunks.push(head(4, item.length));
|
||||
item.forEach(write);
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(item);
|
||||
chunks.push(head(5, entries.length));
|
||||
for (const [key, entryValue] of entries) {
|
||||
const keyBytes = Buffer.from(key, "utf8");
|
||||
chunks.push(head(3, keyBytes.length), keyBytes);
|
||||
write(entryValue);
|
||||
}
|
||||
};
|
||||
write(value);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||
|
||||
function assetPath(urlPath) {
|
||||
// Every tab route has its own index handler on the server (see api/assets.rs),
|
||||
// so a deep link or a refresh serves the SPA shell, not a 404.
|
||||
@@ -65,6 +127,9 @@ export async function startWebFixture({
|
||||
spectrum = false,
|
||||
tx = false,
|
||||
meterDb = -70,
|
||||
decodes = [],
|
||||
mode = "FM",
|
||||
history = {},
|
||||
bookmarks = [],
|
||||
bandplan = {},
|
||||
bandplanEnabled = false,
|
||||
@@ -112,7 +177,7 @@ export async function startWebFixture({
|
||||
signal_meter: spectrum,
|
||||
},
|
||||
},
|
||||
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
|
||||
status: { freq: { hz: 100_000_000 }, mode, tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
|
||||
// Reported only by SDR backends, and what makes the client show the
|
||||
// squelch control at all.
|
||||
filter: spectrum
|
||||
@@ -228,6 +293,37 @@ export async function startWebFixture({
|
||||
request.on("close", () => clearInterval(timer));
|
||||
return;
|
||||
}
|
||||
// Decodes arrive on this stream in the server's own shape: a routing
|
||||
// "type" naming the decoder, snake_case fields inside. The mini views, the
|
||||
// map markers and the history all hang off it, and serving nothing left
|
||||
// every one of them untested.
|
||||
if (url.pathname === "/decode/history") {
|
||||
const payload = Object.fromEntries(HISTORY_GROUPS.map((group) => [group, history[group] ?? []]));
|
||||
response.writeHead(200, { "content-type": "application/cbor" });
|
||||
response.end(encodeCbor(payload));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/decode") {
|
||||
response.writeHead(200, {
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
"content-type": "text/event-stream",
|
||||
});
|
||||
response.write(": decode stream\n\n");
|
||||
// Repeats: a live decoder keeps producing, and the views that collapse
|
||||
// by station or vessel need more than one frame to behave like they do
|
||||
// in front of a radio.
|
||||
let sent = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (!decodes.length) return;
|
||||
const decode = decodes[sent++ % decodes.length];
|
||||
// Stamped as they leave: the client prunes anything older than the
|
||||
// retention window, so a fixed epoch would be dropped on arrival.
|
||||
response.write(`data: ${JSON.stringify({ ts_ms: Date.now(), ...decode })}\n\n`);
|
||||
}, 400);
|
||||
request.on("close", () => clearInterval(timer));
|
||||
return;
|
||||
}
|
||||
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
|
||||
response.writeHead(200, {
|
||||
"cache-control": "no-cache",
|
||||
|
||||
Reference in New Issue
Block a user