Mode was a full-width select: 483px of the row to display "FM". The modes are three or four characters and there are at most twelve, so they become a segmented group like the Unit and Step Scale pickers beside them — a third of the width, and one click instead of two. The <select> stays as the mode's value. A dozen call sites and several plugins read #mode.value, so replacing it outright would have reached much further than a layout change should; it is hidden from sight and from assistive tech, the buttons write to it, and everything downstream runs unchanged. Every writer re-syncs the buttons, the plugins through a new trxCore.syncModePicker. The row itself was a grid with a track per column, but the WFM, SAM and transmit columns are hidden on most rigs, so it ended in some 500px of hole. It packs left now. Same fault one level down: the power buttons sat in three fixed tracks, so a rig with neither transmit nor lock kept two empty ones and left its label chip stranded at the far edge. Unit and Step Scale move out of the frequency row and in beside the wheel and the +/- they modify, which were some 600px away. Signed-off-by: Stan Grams <sjg@haxx.space>
178 lines
6.8 KiB
JavaScript
178 lines
6.8 KiB
JavaScript
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// 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, "");
|
|
});
|