On a 390 px screen the transmit controls were laid out at x=400, off the side of a tray 354 px wide: present, invisible, and reachable only by a horizontal scroll with nothing to say it was there. The page scrolled sideways by a dozen pixels as well. Three causes, each in a different place. A container query at the end of the stylesheet re-imposes `flex-wrap: wrap` on a narrow tray's rows. That is right while a row runs left to right; below the phone breakpoint the row is a column, and wrapping a column starts a *second column* — which is what put the transmit controls beside the tray rather than under it. The rule outranks the phone one, two classes to its one, and sits later in the file, so it now excludes itself below that breakpoint rather than being overridden. The tray is a grid, and a grid column sizes to its content. One row wider than the screen — the mode picker, six buttons across — dragged the whole tray out with it. `minmax(0, 1fr)` lets the column be as narrow as the phone and the rows wrap inside it. The wavelength and signal-strength readouts are given the width of their column on narrow screens, but with padding on a content box that is the column's width plus the padding. Those dozen pixels were the page's sideways scroll. They are border-box now. Also: the rig picker was taking 139 px of a 338 px bar, pushing the rest of the top bar into the overflow menu. It is capped and ellipsised on phones, with the full name still in the menu it opens. The map's filter bar, collapsed, keeps both its anchors and shrinks inside them rather than dropping `left` to be sized by shrink-to-fit, and no longer asks for a compositing layer it does not need. An absolutely positioned, backdrop-filtered, composited box sizing itself from its content is the shape of thing that renders as nothing on an engine other than the one it was written against — which is what Edge does with it. Unverified there: this machine has no Edge to test with. tests/mobile-layout.mjs holds the page to it at 430, 390 and 360 px: no sideways scroll, nothing laid out past the right edge, the transmit controls inside the screen, the rig picker within its cap — and the collapsed filter bar still on screen with a button to bring the filters back. The SSTV panel test stamped its picture with a fixed date, which the panel's own retention window dropped once that date was a day old. It uses a recent stamp now. Signed-off-by: Stan Grams <sjg@haxx.space>
187 lines
7.4 KiB
JavaScript
187 lines
7.4 KiB
JavaScript
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||
//
|
||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||
|
||
// The SSTV panel: a picture arriving row by row, and what is kept once it has.
|
||
// Watching the image build up is the point of the mode, so rows have to reach
|
||
// the canvas as they arrive rather than at the end of a two-minute frame.
|
||
|
||
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";
|
||
|
||
/** A DOM stub with only what the plugin reaches for. */
|
||
function makeElement(id) {
|
||
const listeners = new Map();
|
||
return {
|
||
id,
|
||
textContent: "",
|
||
innerHTML: "",
|
||
value: "",
|
||
style: {},
|
||
attributes: {},
|
||
classList: {
|
||
classes: new Set(),
|
||
add(name) { this.classes.add(name); },
|
||
remove(name) { this.classes.delete(name); },
|
||
toggle(name, on) { if (on) this.classes.add(name); else this.classes.delete(name); },
|
||
contains(name) { return this.classes.has(name); },
|
||
},
|
||
addEventListener(type, handler) { listeners.set(type, handler); },
|
||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||
getAttribute(name) { return this.attributes[name] ?? null; },
|
||
click() { listeners.get("click")?.(); },
|
||
fire(type, event) { listeners.get(type)?.(event); },
|
||
};
|
||
}
|
||
|
||
function makeCanvas(id) {
|
||
const element = makeElement(id);
|
||
element.width = 0;
|
||
element.height = 0;
|
||
const painted = [];
|
||
const fills = [];
|
||
element.painted = painted;
|
||
element.fills = fills;
|
||
element.getContext = () => ({
|
||
fillStyle: "",
|
||
fillRect: (...args) => { fills.push(args); },
|
||
createImageData: (width, height) => ({
|
||
width, height, data: new Uint8ClampedArray(width * height * 4),
|
||
}),
|
||
putImageData: (image, x, y) => { painted.push({ x, y, data: image.data }); },
|
||
});
|
||
return element;
|
||
}
|
||
|
||
async function loadPanel() {
|
||
const elements = new Map();
|
||
const element = (id) => {
|
||
if (!elements.has(id)) {
|
||
elements.set(id, id.endsWith("canvas") ? makeCanvas(id) : makeElement(id));
|
||
}
|
||
return elements.get(id);
|
||
};
|
||
// Touch every id the panel defines, so the plugin caches real stubs.
|
||
for (const id of [
|
||
"sstv-status", "sstv-live-view", "sstv-history-view", "sstv-live-container",
|
||
"sstv-live-info", "sstv-live-canvas", "sstv-live-latest", "sstv-history-list",
|
||
"sstv-history-count", "sstv-filter", "sstv-sort", "sstv-decode-toggle-btn",
|
||
"sstv-clear-btn", "sstv-view-live", "sstv-view-history",
|
||
]) element(id);
|
||
|
||
const window = { ...createHost() };
|
||
const context = vm.createContext({
|
||
window,
|
||
document: { getElementById: (id) => elements.get(id) ?? null },
|
||
atob: (data) => Buffer.from(data, "base64").toString("binary"),
|
||
Date, Number, String, Math, Set, Uint8Array, Uint8ClampedArray, JSON, console,
|
||
});
|
||
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
|
||
const source = await bundleEntry(new URL("../src/plugins/sstv.ts", import.meta.url));
|
||
new vm.Script(runtime).runInContext(context);
|
||
new vm.Script(source).runInContext(context);
|
||
return { window, runtime: window.trxPluginRuntime, element };
|
||
}
|
||
|
||
/** One row of RGB triples, base64 as the server sends it. */
|
||
function rowData(width, [r, g, b]) {
|
||
const bytes = new Uint8Array(width * 3);
|
||
for (let x = 0; x < width; x += 1) {
|
||
bytes[x * 3] = r;
|
||
bytes[x * 3 + 1] = g;
|
||
bytes[x * 3 + 2] = b;
|
||
}
|
||
return Buffer.from(bytes).toString("base64");
|
||
}
|
||
|
||
test("a picture arriving paints its rows as they come", async () => {
|
||
const { runtime, element } = await loadPanel();
|
||
|
||
// The header names the mode and the geometry; the canvas takes both.
|
||
runtime.dispatch("sstv_progress", {
|
||
state: "Receiving Martin M1", mode: "Martin M1", width: 320, height: 256, line: 0,
|
||
});
|
||
const canvas = element("sstv-live-canvas");
|
||
assert.equal(canvas.width, 320, "the canvas did not take the mode's width");
|
||
assert.equal(canvas.height, 256, "the canvas did not take the mode's height");
|
||
assert.equal(element("sstv-live-container").style.display, "",
|
||
"the live view stayed hidden while a picture was arriving");
|
||
assert.match(element("sstv-status").textContent, /Martin M1/);
|
||
|
||
// Rows land at the line number they carry, not in arrival order: a decoder
|
||
// that painted them in sequence would shear a picture with a dropped line.
|
||
runtime.dispatch("sstv_progress", { line: 4, line_data: rowData(320, [255, 0, 0]) });
|
||
runtime.dispatch("sstv_progress", { line: 2, line_data: rowData(320, [0, 0, 255]) });
|
||
assert.deepEqual(canvas.painted.map((p) => p.y), [4, 2],
|
||
`rows painted at ${canvas.painted.map((p) => p.y).join(",")}`);
|
||
assert.deepEqual([...canvas.painted[0].data.slice(0, 4)], [255, 0, 0, 255], "row 4 is not red");
|
||
assert.deepEqual([...canvas.painted[1].data.slice(0, 4)], [0, 0, 255, 255], "row 2 is not blue");
|
||
assert.match(element("sstv-live-info").textContent, /Martin M1/);
|
||
});
|
||
|
||
test("a received picture is kept, shown, and linked by file name alone", async () => {
|
||
const { runtime, element } = await loadPanel();
|
||
|
||
// Recent, not a fixed date: the panel drops anything older than the history
|
||
// retention window, so a picture stamped with the day the test was written
|
||
// passes until that day is a day ago.
|
||
runtime.dispatch("sstv", {
|
||
ts_ms: Date.now() - 60_000,
|
||
vis: 44, mode: "Martin M1", width: 320, height: 256, lines: 256, complete: true,
|
||
path: "/home/op/.cache/trx-rs/sstv/SSTV_20260805T120000Z_14230000_Martin-M1.png",
|
||
});
|
||
|
||
const latest = element("sstv-live-latest").innerHTML;
|
||
assert.match(latest, /Martin M1/);
|
||
assert.match(latest, /complete/);
|
||
// The server serves pictures by file name; the path it stored is its own.
|
||
assert.match(latest, /\/sstv-images\/SSTV_20260805T120000Z_14230000_Martin-M1\.png/);
|
||
assert.doesNotMatch(latest, /home\/op/, "the server's filesystem path reached the page");
|
||
|
||
element("sstv-view-history").click();
|
||
const history = element("sstv-history-list").innerHTML;
|
||
assert.match(history, /Martin M1/);
|
||
assert.match(history, /320×256/);
|
||
assert.match(element("sstv-history-count").textContent, /1 picture/);
|
||
});
|
||
|
||
test("a picture cut short is kept, and says so", async () => {
|
||
const { runtime, element } = await loadPanel();
|
||
|
||
runtime.dispatch("sstv", {
|
||
ts_ms: Date.now(), vis: 60, mode: "Scottie S1", width: 320, height: 256,
|
||
lines: 91, complete: false, path: "/cache/SSTV_x_Scottie-S1.png",
|
||
});
|
||
|
||
assert.match(element("sstv-status").textContent, /Partial/);
|
||
element("sstv-view-history").click();
|
||
assert.match(element("sstv-history-list").innerHTML, /91 \(partial\)/);
|
||
});
|
||
|
||
test("clearing empties the panel", async () => {
|
||
const { runtime, element } = await loadPanel();
|
||
runtime.dispatch("sstv", { ts_ms: Date.now(), mode: "PD120", lines: 496, complete: true });
|
||
assert.match(element("sstv-live-latest").innerHTML, /PD120/);
|
||
|
||
runtime.reset("sstv");
|
||
assert.equal(element("sstv-live-latest").innerHTML, "");
|
||
assert.equal(element("sstv-status").textContent, "Idle");
|
||
assert.equal(element("sstv-live-container").style.display, "none");
|
||
});
|
||
|
||
test("the toggle button follows the rig state", async () => {
|
||
const { window, element } = await loadPanel();
|
||
const button = element("sstv-decode-toggle-btn");
|
||
|
||
window.syncSstvToggle(true);
|
||
assert.equal(button.textContent, "Disable SSTV");
|
||
assert.equal(button.getAttribute("aria-pressed"), "true");
|
||
|
||
window.syncSstvToggle(false);
|
||
assert.equal(button.textContent, "Enable SSTV");
|
||
assert.equal(button.getAttribute("aria-pressed"), "false");
|
||
});
|