[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:
@@ -3625,12 +3625,19 @@ async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardB
|
||||
}
|
||||
var _freqOptimisticHz = null;
|
||||
var _freqOptimisticSeq = 0;
|
||||
function armOptimisticFrequency(freqHz) {
|
||||
if (!isFiniteNumber(freqHz)) return;
|
||||
_freqOptimisticSeq += 1;
|
||||
_freqOptimisticHz = Math.round(freqHz);
|
||||
}
|
||||
function setRigFrequency(freqHz) {
|
||||
const targetHz = Math.round(freqHz);
|
||||
if (!freqAllowed(targetHz)) {
|
||||
showUnsupportedFreqPopup(targetHz);
|
||||
throw new Error(`Unsupported frequency: ${targetHz}`);
|
||||
}
|
||||
if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return;
|
||||
void window.trx?.modules.vchan?.takeSchedulerControl();
|
||||
const prevFreqHz = lastFreqHz;
|
||||
const seq = ++_freqOptimisticSeq;
|
||||
_freqOptimisticHz = targetHz;
|
||||
@@ -5713,6 +5720,9 @@ Object.defineProperties(trxState, {
|
||||
decodeHistoryRetentionMin: { get() {
|
||||
return decodeHistoryRetentionMin;
|
||||
} },
|
||||
authEnabled: { get() {
|
||||
return authEnabled;
|
||||
} },
|
||||
authRole: { get() {
|
||||
return authRole;
|
||||
} },
|
||||
@@ -5816,6 +5826,12 @@ var trxCore = Object.freeze({
|
||||
scheduleUiFrameJob,
|
||||
navigateToTab,
|
||||
rigBadgeColor,
|
||||
setRigFrequency,
|
||||
applyLocalTunedFrequency,
|
||||
armOptimisticFrequency,
|
||||
syncBandwidthInput,
|
||||
scheduleSpectrumDraw,
|
||||
onDecoderRegistryReady,
|
||||
latLonToMaidenhead,
|
||||
locatorToLatLon,
|
||||
haversineKm,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// src/plugins/bookmarks.ts
|
||||
var bridge = window;
|
||||
var trxState = bridge.trx.state;
|
||||
var trxCore = bridge.trx.core;
|
||||
function bmEl(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
||||
return element;
|
||||
}
|
||||
function bmOptionalEl(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
function errorMessage(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -34,7 +39,7 @@ function bmEsc(str) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
function bmCanControl() {
|
||||
return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control";
|
||||
return !trxState.authEnabled || trxState.authRole === "control";
|
||||
}
|
||||
function bmSyncAccess() {
|
||||
const canCtrl = bmCanControl();
|
||||
@@ -44,8 +49,7 @@ function bmSyncAccess() {
|
||||
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||
}
|
||||
function bmListScope() {
|
||||
const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null;
|
||||
return rig || "general";
|
||||
return trxState.lastActiveRigId || "general";
|
||||
}
|
||||
async function bmFetchOverlay() {
|
||||
const overlayScope = bmListScope();
|
||||
@@ -61,7 +65,7 @@ async function bmFetchOverlay() {
|
||||
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
||||
bridge.syncBookmarkMapLocators(bmOverlayList);
|
||||
}
|
||||
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
|
||||
trxCore.scheduleSpectrumDraw();
|
||||
}
|
||||
async function bmFetch(categoryFilter) {
|
||||
let url = "/bookmarks";
|
||||
@@ -184,12 +188,12 @@ function bmChangePage(delta) {
|
||||
bmRender(bmFilteredList);
|
||||
}
|
||||
function bmReadDecoders() {
|
||||
return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
||||
return trxState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
||||
}
|
||||
function bmWriteDecoders(decoders) {
|
||||
const set = new Set(decoders || []);
|
||||
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||
const el = bmEl("bm-dec-" + d.id);
|
||||
trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||
const el = bmOptionalEl("bm-dec-" + d.id);
|
||||
if (el) el.checked = set.has(d.id);
|
||||
});
|
||||
}
|
||||
@@ -197,7 +201,7 @@ function bmBuildDecoderCheckboxes() {
|
||||
const container = bmEl("bm-decoder-checkboxes");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||
trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "bm-decoder-check";
|
||||
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
||||
@@ -227,19 +231,17 @@ function bmCloseForm() {
|
||||
if (wrap) wrap.style.display = "none";
|
||||
}
|
||||
function bmPrefillFromStatus() {
|
||||
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
|
||||
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
|
||||
const freqHz = trxState.lastFreqHz;
|
||||
if (freqHz != null && Number.isFinite(freqHz)) {
|
||||
bmEl("bm-freq").value = String(Math.round(freqHz));
|
||||
}
|
||||
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
|
||||
bmEl("bm-mode").value = bridge.lastModeName;
|
||||
if (trxState.lastModeName) {
|
||||
bmEl("bm-mode").value = trxState.lastModeName;
|
||||
}
|
||||
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
|
||||
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
|
||||
if (trxState.currentBandwidthHz > 0) {
|
||||
bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz));
|
||||
}
|
||||
const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => {
|
||||
const btn = bmEl(d.id + "-decode-toggle-btn");
|
||||
return btn && btn.dataset.enabled === "true";
|
||||
}).map((d) => d.id);
|
||||
const activeDecoders = trxState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id);
|
||||
bmWriteDecoders(activeDecoders);
|
||||
}
|
||||
async function bmSave(e) {
|
||||
@@ -320,55 +322,43 @@ async function bmDelete(id) {
|
||||
}
|
||||
function bmApply(bm) {
|
||||
try {
|
||||
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
|
||||
bridge.modeEl.value = (bm.mode || "").toUpperCase();
|
||||
const modeEl = document.getElementById("mode");
|
||||
if (modeEl) {
|
||||
modeEl.value = (bm.mode || "").toUpperCase();
|
||||
}
|
||||
if (bm.bandwidth_hz) {
|
||||
if (typeof bridge.currentBandwidthHz !== "undefined") {
|
||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
||||
}
|
||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
||||
if (typeof bridge.syncBandwidthInput === "function") {
|
||||
bridge.syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
trxState.currentBandwidthHz = bm.bandwidth_hz;
|
||||
trxCore.syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
if (typeof bridge.applyLocalTunedFrequency === "function") {
|
||||
if (typeof bridge._freqOptimisticSeq !== "undefined") {
|
||||
++bridge._freqOptimisticSeq;
|
||||
bridge._freqOptimisticHz = bm.freq_hz;
|
||||
}
|
||||
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
}
|
||||
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
|
||||
bridge.scheduleSpectrumDraw();
|
||||
trxCore.armOptimisticFrequency(bm.freq_hz);
|
||||
trxCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
if (trxState.lastSpectrumData) {
|
||||
trxCore.scheduleSpectrumDraw();
|
||||
}
|
||||
const tunePromise = (async () => {
|
||||
await bridge.trx?.modules?.vchan?.takeSchedulerControl();
|
||||
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false;
|
||||
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
||||
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||
if (!onVirtual) {
|
||||
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
}
|
||||
if (bm.bandwidth_hz) {
|
||||
const bwHandledByVchan = await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||
const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||
if (!bwHandledByVchan) {
|
||||
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||
await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||
}
|
||||
}
|
||||
if (typeof bridge.setRigFrequency === "function") {
|
||||
await bridge.setRigFrequency(bm.freq_hz);
|
||||
} else {
|
||||
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
|
||||
}
|
||||
trxCore.setRigFrequency(bm.freq_hz);
|
||||
})();
|
||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||
const modeUp = (bm.mode || "").toUpperCase();
|
||||
const allToggleDecoders = (bridge.decoderRegistry || []).filter(
|
||||
const allToggleDecoders = trxState.decoderRegistry.filter(
|
||||
(d) => d.activation === "toggle"
|
||||
);
|
||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||
let statusUrl = "/status";
|
||||
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
|
||||
const rigId = trxState.lastActiveRigId;
|
||||
if (rigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
||||
}
|
||||
const statusResp = await fetch(statusUrl);
|
||||
if (!statusResp.ok) return;
|
||||
@@ -387,7 +377,7 @@ function bmApply(bm) {
|
||||
wanted = currentlyOn;
|
||||
}
|
||||
if (wanted !== currentlyOn) {
|
||||
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
}
|
||||
}
|
||||
if (toggles.length) await Promise.all(toggles);
|
||||
@@ -399,8 +389,6 @@ function bmApply(bm) {
|
||||
console.error("Failed to apply bookmark:", err);
|
||||
}
|
||||
}
|
||||
bridge.trx ??= {};
|
||||
bridge.trx.modules ??= {};
|
||||
bridge.trx.modules.bookmarks = {
|
||||
get overlayList() {
|
||||
return bmOverlayList;
|
||||
@@ -439,8 +427,8 @@ function bmUpdateSelectionUi() {
|
||||
function bmPopulateMoveTarget() {
|
||||
const sel = bmEl("bm-move-target");
|
||||
if (!sel) return;
|
||||
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
|
||||
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
|
||||
const rigIds = trxState.lastRigIds;
|
||||
const displayNames = trxState.lastRigDisplayNames;
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = "";
|
||||
if (bmScope !== "general") {
|
||||
@@ -545,8 +533,8 @@ async function bmDeleteSelected() {
|
||||
function bmPopulateScopePicker() {
|
||||
const picker = bmEl("bm-scope-picker");
|
||||
if (!picker) return;
|
||||
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
|
||||
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
|
||||
const rigIds = trxState.lastRigIds;
|
||||
const displayNames = trxState.lastRigDisplayNames;
|
||||
const prev = picker.value;
|
||||
while (picker.options.length > 1) picker.remove(1);
|
||||
rigIds.forEach((id) => {
|
||||
@@ -565,9 +553,7 @@ function bmPopulateScopePicker() {
|
||||
(function initBookmarks() {
|
||||
bmSyncAccess();
|
||||
bmBuildDecoderCheckboxes();
|
||||
if (typeof bridge.onDecoderRegistryReady === "function") {
|
||||
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
}
|
||||
trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
bmPopulateScopePicker();
|
||||
const scopePicker = bmEl("bm-scope-picker");
|
||||
if (scopePicker) {
|
||||
|
||||
@@ -411,6 +411,15 @@ async function vchanInterceptBandwidth(bwHz) {
|
||||
await vchanSetChannelBandwidth(bwHz);
|
||||
return true;
|
||||
}
|
||||
function vchanInterceptFrequency(freqHz) {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
const core = vchanWindow.trx?.core;
|
||||
const targetHz = Math.round(freqHz);
|
||||
core?.armOptimisticFrequency(targetHz);
|
||||
core?.applyLocalTunedFrequency(targetHz);
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return true;
|
||||
}
|
||||
vchanWindow.trx ??= {};
|
||||
vchanWindow.trx.modules ??= {};
|
||||
vchanWindow.trx.modules.vchan = {
|
||||
@@ -427,27 +436,10 @@ vchanWindow.trx.modules.vchan = {
|
||||
isOnVirtual: vchanIsOnVirtual,
|
||||
interceptMode: vchanInterceptMode,
|
||||
interceptBandwidth: vchanInterceptBandwidth,
|
||||
interceptFrequency: vchanInterceptFrequency,
|
||||
takeSchedulerControl: vchanTakeSchedulerControl,
|
||||
releaseToScheduler: vchanToggleSchedulerRelease
|
||||
};
|
||||
(function() {
|
||||
const original = vchanWindow.setRigFrequency;
|
||||
vchanWindow.setRigFrequency = function(freqHz) {
|
||||
if (vchanIsOnVirtual()) {
|
||||
if (vchanWindow.applyLocalTunedFrequency) {
|
||||
if (typeof vchanWindow._freqOptimisticSeq === "number") {
|
||||
vchanWindow._freqOptimisticSeq += 1;
|
||||
vchanWindow._freqOptimisticHz = Math.round(freqHz);
|
||||
}
|
||||
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
|
||||
}
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return;
|
||||
}
|
||||
void vchanTakeSchedulerControl();
|
||||
original?.(freqHz);
|
||||
};
|
||||
})();
|
||||
(function initSchedulerReleaseControl() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
if (btn) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
applyDecoderRegistryVisibility,
|
||||
decoderRegistry,
|
||||
loadDecoderRegistry,
|
||||
onDecoderRegistryReady,
|
||||
} from "./core/decoder-registry.js";
|
||||
import {
|
||||
fetchAuthSession,
|
||||
@@ -201,6 +202,7 @@ interface TrxModules {
|
||||
handleChannels(data: string): void;
|
||||
handleSession(data: string): void;
|
||||
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
||||
interceptFrequency(frequencyHz: number): boolean;
|
||||
interceptMode(mode: string): Promise<boolean>;
|
||||
isOnVirtual(): boolean;
|
||||
takeSchedulerControl(): Promise<void>;
|
||||
@@ -217,6 +219,7 @@ interface TrxState {
|
||||
readonly lastRigDisplayNames: Record<string, string>;
|
||||
readonly initialMapZoom: number;
|
||||
readonly decodeHistoryRetentionMin: number;
|
||||
readonly authEnabled: boolean;
|
||||
readonly authRole: AuthRole | null;
|
||||
readonly decoderRegistry: typeof decoderRegistry;
|
||||
readonly sseSessionId: string | null;
|
||||
@@ -2476,12 +2479,26 @@ async function ensureTunedBandwidthCoverage(freqHz: number, bandwidthHz = covera
|
||||
let _freqOptimisticHz: number | null = null;
|
||||
let _freqOptimisticSeq = 0;
|
||||
|
||||
// Lazy features paint their own optimistic frequency before starting the tune
|
||||
// round-trip (a bookmark updates the marker before mode and bandwidth settle).
|
||||
// Arming the guard from there keeps SSE from snapping back to the stale value.
|
||||
function armOptimisticFrequency(freqHz: number) {
|
||||
if (!isFiniteNumber(freqHz)) return;
|
||||
_freqOptimisticSeq += 1;
|
||||
_freqOptimisticHz = Math.round(freqHz);
|
||||
}
|
||||
|
||||
function setRigFrequency(freqHz: number) {
|
||||
const targetHz = Math.round(freqHz);
|
||||
if (!freqAllowed(targetHz)) {
|
||||
showUnsupportedFreqPopup(targetHz);
|
||||
throw new Error(`Unsupported frequency: ${targetHz}`);
|
||||
}
|
||||
// A virtual channel owns its own tuning path: the plugin applies the
|
||||
// optimistic update and posts to the channel API instead of the rig.
|
||||
if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return;
|
||||
// Scheduler control is fire-and-forget — don't block the freq change.
|
||||
void window.trx?.modules.vchan?.takeSchedulerControl();
|
||||
// Optimistic local update — visual is instant via CSS overlay + guard.
|
||||
const prevFreqHz = lastFreqHz;
|
||||
const seq = ++_freqOptimisticSeq;
|
||||
@@ -4822,6 +4839,7 @@ Object.defineProperties(trxState, {
|
||||
lastRigDisplayNames: { get() { return lastRigDisplayNames; } },
|
||||
initialMapZoom: { get() { return initialMapZoom; } },
|
||||
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
|
||||
authEnabled: { get() { return authEnabled; } },
|
||||
authRole: { get() { return authRole; } },
|
||||
decoderRegistry: { get() { return decoderRegistry; } },
|
||||
sseSessionId: { get() { return sseSessionId; } },
|
||||
@@ -4855,6 +4873,8 @@ const trxCore = Object.freeze({
|
||||
saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
|
||||
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
|
||||
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
|
||||
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
|
||||
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady,
|
||||
latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm,
|
||||
formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText,
|
||||
nearestBookmarkForHz, currentDecodeHistoryRetentionMs,
|
||||
|
||||
@@ -43,42 +43,60 @@ interface VirtualChannelService {
|
||||
takeSchedulerControl(): Promise<void>;
|
||||
}
|
||||
|
||||
/* Application state read through the `window.trx` host contract. This plugin
|
||||
* is a separate bundle, so it cannot import the application module directly. */
|
||||
interface BookmarkHostState {
|
||||
readonly authEnabled: boolean;
|
||||
readonly authRole: string | null;
|
||||
readonly lastActiveRigId: string | null;
|
||||
readonly lastRigIds: string[];
|
||||
readonly lastRigDisplayNames: Record<string, string>;
|
||||
readonly lastFreqHz: number | null;
|
||||
readonly lastModeName: string;
|
||||
readonly lastSpectrumData: unknown;
|
||||
currentBandwidthHz: number;
|
||||
readonly decoderRegistry: readonly DecoderDescriptor[];
|
||||
}
|
||||
|
||||
interface BookmarkHostCore {
|
||||
postPath(path: string): Promise<unknown>;
|
||||
setRigFrequency(frequencyHz: number): void;
|
||||
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
|
||||
armOptimisticFrequency(frequencyHz: number): void;
|
||||
syncBandwidthInput(bandwidthHz: number): void;
|
||||
scheduleSpectrumDraw(): void;
|
||||
onDecoderRegistryReady(callback: () => void): void;
|
||||
}
|
||||
|
||||
interface BookmarkBridge {
|
||||
authEnabled?: boolean;
|
||||
authRole?: string | null;
|
||||
lastActiveRigId?: string | null;
|
||||
lastRigIds?: string[];
|
||||
lastRigDisplayNames?: Record<string, string>;
|
||||
lastFreqHz?: number;
|
||||
lastModeName?: string;
|
||||
lastSpectrumData?: unknown;
|
||||
currentBandwidthHz?: number;
|
||||
modeEl?: HTMLSelectElement | null;
|
||||
decoderRegistry?: DecoderDescriptor[];
|
||||
trx?: { modules?: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
|
||||
trx: {
|
||||
state: BookmarkHostState;
|
||||
core: BookmarkHostCore;
|
||||
modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService };
|
||||
};
|
||||
trxUi: {
|
||||
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
|
||||
notify?(message: string, options: { kind: "error" }): void;
|
||||
};
|
||||
syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
|
||||
scheduleSpectrumDraw?(): void;
|
||||
syncBandwidthInput?(bandwidthHz: number): void;
|
||||
applyLocalTunedFrequency?(frequencyHz: number, force?: boolean): void;
|
||||
setRigFrequency?(frequencyHz: number): Promise<unknown>;
|
||||
postPath(path: string): Promise<unknown>;
|
||||
onDecoderRegistryReady?(callback: () => void): void;
|
||||
_freqOptimisticSeq?: number;
|
||||
_freqOptimisticHz?: number;
|
||||
}
|
||||
|
||||
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
|
||||
const bridge = window as unknown as BookmarkBridge;
|
||||
const trxState = bridge.trx.state;
|
||||
const trxCore = bridge.trx.core;
|
||||
function bmEl(id: string): BookmarkElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
||||
return element as BookmarkElement;
|
||||
}
|
||||
|
||||
/* Decoder checkboxes and decoder toggle buttons are built from the runtime
|
||||
* registry, so their elements are legitimately absent for unbuilt decoders. */
|
||||
function bmOptionalEl(id: string): BookmarkElement | null {
|
||||
return document.getElementById(id) as BookmarkElement | null;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -119,10 +137,7 @@ function bmEsc(str: unknown): string {
|
||||
}
|
||||
|
||||
function bmCanControl() {
|
||||
return (
|
||||
(typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) ||
|
||||
(typeof bridge.authRole !== "undefined" && bridge.authRole === "control")
|
||||
);
|
||||
return !trxState.authEnabled || trxState.authRole === "control";
|
||||
}
|
||||
|
||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||
@@ -136,8 +151,7 @@ function bmSyncAccess() {
|
||||
|
||||
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
||||
function bmListScope() {
|
||||
const rig = (typeof bridge.lastActiveRigId !== "undefined") ? bridge.lastActiveRigId : null;
|
||||
return rig || "general";
|
||||
return trxState.lastActiveRigId || "general";
|
||||
}
|
||||
|
||||
async function bmFetchOverlay() {
|
||||
@@ -154,7 +168,7 @@ async function bmFetchOverlay() {
|
||||
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
||||
bridge.syncBookmarkMapLocators(bmOverlayList);
|
||||
}
|
||||
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
|
||||
trxCore.scheduleSpectrumDraw();
|
||||
}
|
||||
|
||||
async function bmFetch(categoryFilter: string): Promise<void> {
|
||||
@@ -310,19 +324,19 @@ function bmChangePage(delta: number): void {
|
||||
|
||||
// Read decoder checkboxes and return an array of selected decoder names.
|
||||
function bmReadDecoders(): string[] {
|
||||
return (bridge.decoderRegistry || [])
|
||||
return trxState.decoderRegistry
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.filter(d => bmEl("bm-dec-" + d.id)?.checked)
|
||||
.filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
|
||||
.map(d => d.id);
|
||||
}
|
||||
|
||||
// Set decoder checkboxes to match the given array.
|
||||
function bmWriteDecoders(decoders: readonly string[]): void {
|
||||
const set = new Set(decoders || []);
|
||||
(bridge.decoderRegistry || [])
|
||||
trxState.decoderRegistry
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.forEach(d => {
|
||||
const el = bmEl("bm-dec-" + d.id);
|
||||
const el = bmOptionalEl("bm-dec-" + d.id);
|
||||
if (el) el.checked = set.has(d.id);
|
||||
});
|
||||
}
|
||||
@@ -332,7 +346,7 @@ function bmBuildDecoderCheckboxes() {
|
||||
const container = bmEl("bm-decoder-checkboxes");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
(bridge.decoderRegistry || [])
|
||||
trxState.decoderRegistry
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.forEach(d => {
|
||||
const label = document.createElement("label");
|
||||
@@ -372,23 +386,21 @@ function bmCloseForm() {
|
||||
}
|
||||
|
||||
function bmPrefillFromStatus() {
|
||||
// Use globals maintained by app.js (updated by SSE stream)
|
||||
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
|
||||
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
|
||||
// Read live rig state from the host contract (updated by the SSE stream).
|
||||
const freqHz = trxState.lastFreqHz;
|
||||
if (freqHz != null && Number.isFinite(freqHz)) {
|
||||
bmEl("bm-freq").value = String(Math.round(freqHz));
|
||||
}
|
||||
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
|
||||
bmEl("bm-mode").value = bridge.lastModeName;
|
||||
if (trxState.lastModeName) {
|
||||
bmEl("bm-mode").value = trxState.lastModeName;
|
||||
}
|
||||
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
|
||||
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
|
||||
if (trxState.currentBandwidthHz > 0) {
|
||||
bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz));
|
||||
}
|
||||
// Prefill decoder checkboxes from current toggle button state.
|
||||
const activeDecoders = (bridge.decoderRegistry || [])
|
||||
const activeDecoders = trxState.decoderRegistry
|
||||
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
||||
.filter(d => {
|
||||
const btn = bmEl(d.id + "-decode-toggle-btn");
|
||||
return btn && btn.dataset.enabled === "true";
|
||||
})
|
||||
.filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
|
||||
.map(d => d.id);
|
||||
bmWriteDecoders(activeDecoders);
|
||||
}
|
||||
@@ -478,58 +490,44 @@ async function bmDelete(id: string): Promise<void> {
|
||||
function bmApply(bm: Bookmark): void {
|
||||
try {
|
||||
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
||||
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
|
||||
bridge.modeEl.value = (bm.mode || "").toUpperCase();
|
||||
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
||||
if (modeEl) {
|
||||
modeEl.value = (bm.mode || "").toUpperCase();
|
||||
}
|
||||
if (bm.bandwidth_hz) {
|
||||
if (typeof bridge.currentBandwidthHz !== "undefined") {
|
||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
||||
}
|
||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
||||
if (typeof bridge.syncBandwidthInput === "function") {
|
||||
bridge.syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
trxState.currentBandwidthHz = bm.bandwidth_hz;
|
||||
trxCore.syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
if (typeof bridge.applyLocalTunedFrequency === "function") {
|
||||
// Set optimistic guard before applying so SSE cannot snap back.
|
||||
if (typeof bridge._freqOptimisticSeq !== "undefined") {
|
||||
++bridge._freqOptimisticSeq;
|
||||
bridge._freqOptimisticHz = bm.freq_hz;
|
||||
}
|
||||
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
}
|
||||
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
|
||||
bridge.scheduleSpectrumDraw();
|
||||
// Set optimistic guard before applying so SSE cannot snap back.
|
||||
trxCore.armOptimisticFrequency(bm.freq_hz);
|
||||
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||
trxCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
if (trxState.lastSpectrumData) {
|
||||
trxCore.scheduleSpectrumDraw();
|
||||
}
|
||||
|
||||
// Take scheduler control up front, then apply mode before bandwidth so a
|
||||
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
||||
const tunePromise = (async () => {
|
||||
await bridge.trx?.modules?.vchan?.takeSchedulerControl();
|
||||
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
||||
|
||||
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false;
|
||||
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||
if (!onVirtual) {
|
||||
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
}
|
||||
|
||||
if (bm.bandwidth_hz) {
|
||||
const bwHandledByVchan =
|
||||
await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||
await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||
if (!bwHandledByVchan) {
|
||||
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||
await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||
}
|
||||
}
|
||||
|
||||
// bridge.setRigFrequency is wrapped by vchan.js to redirect to the channel API
|
||||
// when on a virtual channel, so this call works correctly in both cases.
|
||||
// It also does its own optimistic update (bridge.applyLocalTunedFrequency) but
|
||||
// that's a no-op since we already set the same value above.
|
||||
if (typeof bridge.setRigFrequency === "function") {
|
||||
await bridge.setRigFrequency(bm.freq_hz);
|
||||
} else {
|
||||
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
|
||||
}
|
||||
// setRigFrequency redirects to the channel API when a virtual channel is
|
||||
// active. It repeats the optimistic update applied above, which is a
|
||||
// no-op because the value is unchanged.
|
||||
trxCore.setRigFrequency(bm.freq_hz);
|
||||
})();
|
||||
// Decoder toggles — fire-and-forget.
|
||||
// - Decoders incompatible with the new mode are always turned off
|
||||
@@ -539,13 +537,14 @@ function bmApply(bm: Bookmark): void {
|
||||
// alone.
|
||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||
const modeUp = (bm.mode || "").toUpperCase();
|
||||
const allToggleDecoders = (bridge.decoderRegistry || []).filter(d =>
|
||||
const allToggleDecoders = trxState.decoderRegistry.filter(d =>
|
||||
d.activation === "toggle"
|
||||
);
|
||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||
let statusUrl = "/status";
|
||||
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
|
||||
const rigId = trxState.lastActiveRigId;
|
||||
if (rigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
||||
}
|
||||
const statusResp = await fetch(statusUrl);
|
||||
if (!statusResp.ok) return;
|
||||
@@ -567,7 +566,7 @@ function bmApply(bm: Bookmark): void {
|
||||
wanted = currentlyOn;
|
||||
}
|
||||
if (wanted !== currentlyOn) {
|
||||
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
}
|
||||
}
|
||||
if (toggles.length) await Promise.all(toggles);
|
||||
@@ -582,8 +581,6 @@ function bmApply(bm: Bookmark): void {
|
||||
}
|
||||
}
|
||||
|
||||
bridge.trx ??= {};
|
||||
bridge.trx.modules ??= {};
|
||||
bridge.trx.modules.bookmarks = {
|
||||
get overlayList() { return bmOverlayList; },
|
||||
get overlayRevision() { return bmOverlayRevision; },
|
||||
@@ -619,8 +616,8 @@ function bmUpdateSelectionUi() {
|
||||
function bmPopulateMoveTarget() {
|
||||
const sel = bmEl("bm-move-target");
|
||||
if (!sel) return;
|
||||
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
|
||||
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
|
||||
const rigIds = trxState.lastRigIds;
|
||||
const displayNames = trxState.lastRigDisplayNames;
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = "";
|
||||
if (bmScope !== "general") {
|
||||
@@ -728,8 +725,8 @@ async function bmDeleteSelected() {
|
||||
function bmPopulateScopePicker() {
|
||||
const picker = bmEl("bm-scope-picker");
|
||||
if (!picker) return;
|
||||
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
|
||||
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
|
||||
const rigIds = trxState.lastRigIds;
|
||||
const displayNames = trxState.lastRigDisplayNames;
|
||||
// Preserve current selection if still valid.
|
||||
const prev = picker.value;
|
||||
while (picker.options.length > 1) picker.remove(1);
|
||||
@@ -756,9 +753,7 @@ function bmPopulateScopePicker() {
|
||||
// Build decoder checkboxes from registry. The registry is fetched async
|
||||
// so we rebuild once it arrives to ensure checkboxes are present.
|
||||
bmBuildDecoderCheckboxes();
|
||||
if (typeof bridge.onDecoderRegistryReady === "function") {
|
||||
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
}
|
||||
trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
|
||||
// Scope picker
|
||||
bmPopulateScopePicker();
|
||||
|
||||
@@ -31,8 +31,6 @@ interface VirtualChannelBridge {
|
||||
jogUnit?: number;
|
||||
rxActive?: boolean;
|
||||
_audioChannelOverride?: string | null;
|
||||
_freqOptimisticSeq?: number;
|
||||
_freqOptimisticHz?: number;
|
||||
renderRdsOverlays?: () => void;
|
||||
updateDocumentTitle?: (rds: unknown) => void;
|
||||
activeChannelRds?: () => unknown;
|
||||
@@ -47,10 +45,16 @@ interface VirtualChannelBridge {
|
||||
positionRdsPsOverlay?: () => void;
|
||||
mwDefaultsForMode?: (mode: string) => [number, ...unknown[]];
|
||||
showHint?: (message: string, durationMs: number) => void;
|
||||
applyLocalTunedFrequency?: (frequencyHz: number) => void;
|
||||
setRigFrequency?: (frequencyHz: number) => void;
|
||||
refreshFreqDisplay?: () => void;
|
||||
trx?: { modules?: { vchan?: VirtualChannelService } };
|
||||
trx?: {
|
||||
core?: VirtualChannelCoreServices;
|
||||
modules?: { vchan?: VirtualChannelService };
|
||||
};
|
||||
}
|
||||
|
||||
interface VirtualChannelCoreServices {
|
||||
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
|
||||
armOptimisticFrequency(frequencyHz: number): void;
|
||||
}
|
||||
|
||||
interface VirtualChannelService {
|
||||
@@ -63,6 +67,7 @@ interface VirtualChannelService {
|
||||
isOnVirtual(): boolean;
|
||||
interceptMode(mode: string): Promise<boolean>;
|
||||
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
||||
interceptFrequency(frequencyHz: number): boolean;
|
||||
takeSchedulerControl(): Promise<void>;
|
||||
releaseToScheduler(): Promise<void>;
|
||||
}
|
||||
@@ -571,6 +576,19 @@ async function vchanInterceptBandwidth(bwHz: number): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Called by setRigFrequency before it posts /set_freq. When a non-primary
|
||||
// channel is active the change belongs to the channel API instead of the rig,
|
||||
// so this applies the optimistic local update and reports the tune as handled.
|
||||
function vchanInterceptFrequency(freqHz: number): boolean {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
const core = vchanWindow.trx?.core;
|
||||
const targetHz = Math.round(freqHz);
|
||||
core?.armOptimisticFrequency(targetHz);
|
||||
core?.applyLocalTunedFrequency(targetHz);
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return true;
|
||||
}
|
||||
|
||||
vchanWindow.trx ??= {};
|
||||
vchanWindow.trx.modules ??= {};
|
||||
vchanWindow.trx.modules.vchan = {
|
||||
@@ -583,34 +601,11 @@ vchanWindow.trx.modules.vchan = {
|
||||
isOnVirtual: vchanIsOnVirtual,
|
||||
interceptMode: vchanInterceptMode,
|
||||
interceptBandwidth: vchanInterceptBandwidth,
|
||||
interceptFrequency: vchanInterceptFrequency,
|
||||
takeSchedulerControl: vchanTakeSchedulerControl,
|
||||
releaseToScheduler: vchanToggleSchedulerRelease,
|
||||
};
|
||||
|
||||
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
|
||||
// frequency changes are redirected to the active virtual channel instead of
|
||||
// the server when on a non-primary channel.
|
||||
(function() {
|
||||
const original = vchanWindow.setRigFrequency;
|
||||
vchanWindow.setRigFrequency = function(freqHz: number) {
|
||||
if (vchanIsOnVirtual()) {
|
||||
// Optimistic local update first, then fire-and-forget channel API.
|
||||
if (vchanWindow.applyLocalTunedFrequency) {
|
||||
if (typeof vchanWindow._freqOptimisticSeq === "number") {
|
||||
vchanWindow._freqOptimisticSeq += 1;
|
||||
vchanWindow._freqOptimisticHz = Math.round(freqHz);
|
||||
}
|
||||
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
|
||||
}
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return;
|
||||
}
|
||||
// Scheduler control is fire-and-forget — don't block the freq change.
|
||||
void vchanTakeSchedulerControl();
|
||||
original?.(freqHz);
|
||||
};
|
||||
})();
|
||||
|
||||
(function initSchedulerReleaseControl() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
if (btn) {
|
||||
|
||||
@@ -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