[fix](trx-frontend-http): restore bookmark host contract
The TypeScript migration turned app.js from a classic script into an ES module, so its top-level declarations stopped being shared globals. bookmarks.ts was converted verbatim and kept reading them as window properties, which app.ts no longer publishes. Every bookmark interaction read undefined: the Add Bookmark and Select All buttons stayed hidden because the auth check saw no authEnabled or authRole, per-rig scopes were missing from the scope picker and the move target, decoder checkboxes were never built, and Tune threw on bridge.postPath before issuing a single request. Extend the typed window.trx host contract instead of restoring globals, as docs/frontend-architecture.md closes the standalone window property list. trx.state publishes authEnabled; trx.core publishes setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency, syncBandwidthInput, scheduleSpectrumDraw, and onDecoderRegistryReady. Replace the vchan setRigFrequency wrapper with an interceptFrequency service method, matching interceptMode and interceptBandwidth. The wrapper captured an undefined original and silently dropped every tune; routing interception through setRigFrequency also restores virtual channel redirection for the application's own tuning. Read registry-built elements through bmOptionalEl, since bmEl throws and the decoder checkboxes and decode toggle buttons are legitimately absent until the registry arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -26,6 +26,48 @@ class ElementFixture {
|
||||
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 };
|
||||
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; },
|
||||
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 readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8");
|
||||
|
||||
test("bookmarks register an explicit typed service for application consumers", async () => {
|
||||
const elements = new Map();
|
||||
const element = (id) => {
|
||||
@@ -36,33 +78,15 @@ test("bookmarks register an explicit typed service for application consumers", a
|
||||
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 = {
|
||||
trx: { modules: {} },
|
||||
trxUi: { confirm: async () => true },
|
||||
decoderRegistry: [],
|
||||
};
|
||||
const { window } = hostFixture();
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
document: {
|
||||
getElementById: element,
|
||||
querySelector: () => new ElementFixture(),
|
||||
querySelectorAll: () => [],
|
||||
createElement: () => new ElementFixture(),
|
||||
createTextNode: (text) => ({ textContent: text }),
|
||||
addEventListener() {},
|
||||
},
|
||||
document: documentFixture(element),
|
||||
fetch: async () => ({ ok: true, json: async () => bookmarks }),
|
||||
CSS: { escape: (value) => value },
|
||||
Element: ElementFixture,
|
||||
Set,
|
||||
Map,
|
||||
Array,
|
||||
Number,
|
||||
String,
|
||||
Promise,
|
||||
console,
|
||||
});
|
||||
const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8");
|
||||
new vm.Script(source).runInContext(context);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
@@ -73,3 +97,80 @@ test("bookmarks register an explicit typed service for application consumers", a
|
||||
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, "");
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ test("virtual channels expose typed SSE and interception boundaries", async () =
|
||||
assert.equal(typeof service.applyCapabilities, "function");
|
||||
assert.equal(await service.interceptMode("USB"), false);
|
||||
assert.equal(await service.interceptBandwidth(2400), false);
|
||||
// No virtual channel is active, so tuning stays with the physical rig.
|
||||
assert.equal(service.interceptFrequency(14_074_000), false);
|
||||
assert.equal(window.vchanHandleSession, undefined);
|
||||
assert.equal(window.vchanInterceptBandwidth, undefined);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user