[feat](trx-frontend-http): make the APRS list a log, and read the payloads
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s

A frame was a card five lines tall — timestamp, a meta line, the
information field as it arrived on the air, three buttons, and a Details
panel repeating the four things already on the row — so five frames
filled the panel and the payload was left to be decoded by eye.

A frame is one line now: time, station, type, and what the frame says.
It opens in place for the path, the CRC, the raw field, its bytes and
the actions.  Twenty-one frames fit where five did.

And the information field is read rather than echoed.  Weather reports
give temperature, wind, humidity and pressure; telemetry gives its
sequence and channels; a message gives its addressee and text; a
position gives the fix, course and speed, and the comment the station
wrote.  Anything that cannot be summarised falls back to the raw field,
which is in the expanded view either way.

HF APRS had a copy of the same forty lines of markup, differing by one
badge, so both now build their rows from one function in the shared
module — the CSS is shared between them and this would have broken it
otherwise.  Both headers lose their three summary cards for a line of
counts beside the filters, which frees another fifth of the panel.

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-04 23:12:02 +02:00
parent 67a7bace4e
commit 37987b2779
11 changed files with 661 additions and 479 deletions
@@ -164,3 +164,71 @@ try {
await replay.browser.close();
await historyFixture.close();
}
// The APRS list: one line per frame, with the information field read for the
// operator rather than shown as it arrives on the air.
const APRS_FRAMES = [
{ packet_type: "weather", info: "_10090556c220s004g005t077r000p000P000h50b09900" },
{ packet_type: "message", info: ":SP2SJG-9 :Hello from the field{01" },
{ packet_type: "telemetry", info: "T#005,199,000,255,073,123,01101001" },
{ packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 },
].map((frame, index) => ({
src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a", ts_ms: Date.now() - index * 1000,
...frame,
}));
const aprsFixture = await startWebFixture({ spectrum: true, history: { aprs: APRS_FRAMES } });
const aprs = await startBrowser(chromium);
try {
await aprs.page.setViewportSize({ width: 1400, height: 900 });
await aprs.page.goto(`${aprsFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await aprs.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await aprs.page.waitForTimeout(2000);
await aprs.page.locator('.sub-tab[data-subtab="aprs"]').click();
await aprs.page.waitForTimeout(400);
const rows = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
tag: row.tagName,
height: Math.round(row.getBoundingClientRect().height),
type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "",
summary: row.querySelector(".aprs-line-summary")?.textContent?.trim() ?? "",
})));
// Newest first, so find each by the type it carries rather than by position.
const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? "";
assert.equal(rows.length, APRS_FRAMES.length, `rendered ${rows.length} frames`);
for (const row of rows) {
assert.equal(row.tag, "DETAILS", "a frame is not expandable in place");
assert.ok(row.height < 44, `a frame is ${row.height}px tall; it used to be a card of about 140`);
}
// Each payload read rather than echoed: 25 °C from t077, the addressee from
// a message, the sequence from telemetry, the fix from a position report.
assert.match(summaryOf("Weather"), /25 °C/, `weather summary: ${summaryOf("Weather")}`);
assert.match(summaryOf("Weather"), /990\.0 hPa/, `weather summary: ${summaryOf("Weather")}`);
assert.match(summaryOf("Message"), /→ SP2SJG-9: Hello from the field/, `message summary: ${summaryOf("Message")}`);
assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`);
assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`);
// The frame as it arrived is still there, one click away.
await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" })
.locator(".aprs-line").first().click();
await aprs.page.waitForTimeout(200);
const expanded = await aprs.page.evaluate(() => {
const row = document.querySelector("#aprs-packets .aprs-packet[open]");
return {
open: row.hasAttribute("open"),
raw: row.querySelector(".aprs-expanded-raw")?.textContent?.trim() ?? "",
meta: row.querySelector(".aprs-expanded-meta")?.textContent ?? "",
};
});
assert.equal(expanded.open, true, "the frame did not open");
assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`);
assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`);
assert.deepEqual(aprs.runtimeErrors, []);
} finally {
await aprs.browser.close();
await aprsFixture.close();
}