// SPDX-FileCopyrightText: 2026 Stan Grams // // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; class ElementFixture { constructor() { this.value = ""; this.textContent = ""; this.innerHTML = ""; this.style = {}; this.dataset = {}; this.options = []; this.selectedOptions = []; this.children = []; } addEventListener() {} appendChild(child) { this.children.push(child); return child; } add(child) { this.options.push(child); } remove(index) { this.options.splice(index, 1); } focus() {} querySelector() { return null; } } // Mirrors the `window.trx` host contract published by app.ts. The plugin is a // separate bundle, so every application service it uses arrives this way. function hostFixture(overrides = {}) { const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 }; const state = { authEnabled: false, authRole: "control", lastActiveRigId: null, lastRigIds: [], lastRigDisplayNames: {}, lastFreqHz: 14_074_000, lastModeName: "USB", lastSpectrumData: null, currentBandwidthHz: 2400, decoderRegistry: [], ...overrides, }; const core = { postPath: async (path) => { calls.postPath.push(path); }, setRigFrequency: (hz) => { calls.setRigFrequency.push(hz); }, applyLocalTunedFrequency: (hz, force) => { calls.applyLocalTunedFrequency.push([hz, force]); }, armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); }, syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); }, scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; }, syncModePicker: () => { calls.syncModePicker += 1; }, onDecoderRegistryReady: () => {}, }; return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls }; } function documentFixture(element) { return { getElementById: element, querySelector: () => new ElementFixture(), querySelectorAll: () => [], createElement: () => new ElementFixture(), createTextNode: (text) => ({ textContent: text }), addEventListener() {}, }; } const source = await bundleEntry(new URL("../src/plugins/bookmarks.ts", import.meta.url)); test("bookmarks register an explicit typed service for application consumers", async () => { const elements = new Map(); const element = (id) => { if (!elements.has(id)) elements.set(id, new ElementFixture()); return elements.get(id); }; element("bm-scope-picker").value = "general"; element("bm-category-filter").options.push({ value: "" }); element("bm-mode-filter").options.push({ value: "" }); const bookmarks = [{ id: "one", name: "Local", freq_hz: 145_500_000, mode: "FM", scope: "general" }]; const { window } = hostFixture(); const context = vm.createContext({ window, document: documentFixture(element), fetch: async () => ({ ok: true, json: async () => bookmarks }), CSS: { escape: (value) => value }, Element: ElementFixture, console, }); new vm.Script(source).runInContext(context); await new Promise((resolve) => setImmediate(resolve)); assert.equal(window.trx.modules.bookmarks.overlayList.length, 1); assert.equal(window.trx.modules.bookmarks.overlayList[0].id, "one"); assert.equal(typeof window.trx.modules.bookmarks.apply, "function"); assert.equal(typeof window.trx.modules.bookmarks.formatFrequency, "function"); assert.equal(typeof window.trx.modules.bookmarks.populateScopePicker, "function"); assert.equal(globalThis.bmOverlayList, undefined); }); test("applying a bookmark drives tuning through the host services", async () => { const elements = new Map(); const element = (id) => { if (!elements.has(id)) elements.set(id, new ElementFixture()); return elements.get(id); }; const { window, calls } = hostFixture({ lastActiveRigId: "sdr", decoderRegistry: [ { id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"], bookmark_selectable: true }, { id: "cw", label: "CW", activation: "toggle", active_modes: ["CW"], bookmark_selectable: true }, ], }); const context = vm.createContext({ window, document: documentFixture(element), fetch: async (url) => ({ ok: true, json: async () => (url.startsWith("/status") ? { ft8_decode_enabled: false, cw_decode_enabled: true } : []), }), CSS: { escape: (value) => value }, Element: ElementFixture, console, }); new vm.Script(source).runInContext(context); await new Promise((resolve) => setImmediate(resolve)); calls.postPath.length = 0; window.trx.modules.bookmarks.apply({ id: "ft8-20m", name: "FT8 20m", freq_hz: 14_074_000, mode: "USB", bandwidth_hz: 3000, decoders: ["ft8"], }); await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); // Optimistic UI: the guard is armed before the display update so a stale SSE // frame cannot snap the marker back while the tune is in flight. assert.deepEqual(calls.armOptimisticFrequency, [14_074_000]); assert.deepEqual(calls.applyLocalTunedFrequency, [[14_074_000, true]]); assert.deepEqual(calls.syncBandwidthInput, [3000]); assert.equal(window.trx.state.currentBandwidthHz, 3000); assert.equal(element("mode").value, "USB"); // Rig commands: mode and bandwidth over postPath, frequency via the core // tuning service so virtual-channel redirection still applies. assert.deepEqual(calls.setRigFrequency, [14_074_000]); assert.ok(calls.postPath.includes("/set_mode?mode=USB")); assert.ok(calls.postPath.includes("/set_bandwidth?hz=3000")); // ft8 is selected and off; cw is on but incompatible with USB. assert.ok(calls.postPath.includes("/toggle_ft8_decode")); assert.ok(calls.postPath.includes("/toggle_cw_decode")); }); test("bookmark controls follow the host authentication state", async () => { const elements = new Map(); const element = (id) => { if (!elements.has(id)) elements.set(id, new ElementFixture()); return elements.get(id); }; const { window } = hostFixture({ authEnabled: true, authRole: "rx" }); const context = vm.createContext({ window, document: documentFixture(element), fetch: async () => ({ ok: true, json: async () => [] }), CSS: { escape: (value) => value }, Element: ElementFixture, console, }); new vm.Script(source).runInContext(context); await new Promise((resolve) => setImmediate(resolve)); assert.equal(element("bm-add-btn").style.display, "none"); window.trx.state.authRole = "control"; await window.trx.modules.bookmarks.fetch(""); assert.equal(element("bm-add-btn").style.display, ""); });