// SPDX-FileCopyrightText: 2026 Stan Grams // // 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"); });