[feat](trx-frontend-http): put the tuned frequency in the address bar
CI / frontend (pull_request) Successful in 4m3s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m11s
CI / test (push) Successful in 7m20s
CI / frontend (push) Successful in 3m10s
CI / reuse (push) Successful in 2s
CI / frontend (pull_request) Successful in 4m3s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m11s
CI / test (push) Successful in 7m20s
CI / frontend (push) Successful in 3m10s
CI / reuse (push) Successful in 2s
A receiver spreads by being linked to, and there was nothing to link to: the routes carried the tab and nothing else, so "listen to this" could only ever mean a screenshot and a frequency typed out in a message. The query string now carries the dial -- rig, frequency, mode and bandwidth -- in both directions. Opening a link selects the rig, sets the mode, tunes, then applies the bandwidth: a mode change brings its own default bandwidth with it, so an explicit bw has to land after it. Frequencies are read the way someone writes them by hand (7074k, 14.074M) and written back as whole Hz, so what comes out of the address bar is the same link in canonical form. After that the address bar keeps up with the dial, which is what makes it copyable at any moment rather than only at load. It is rewritten with replaceState -- tuning is not navigation, and a swept dial would otherwise bury the back button. A link button in the top bar copies the current link; it folds into the overflow menu when the bar is tight. Applying a link changes the radio, so an rx session says so instead of failing control calls one at a time. A tab listening to a virtual channel leaves the address alone rather than publishing a frequency the rig is not on, and bw is skipped in both directions on rigs without filter control, which would only refuse it. The fixture pinned every state frame to 100 MHz plus jitter to keep frames distinct, so no test could observe tuning at all. The jitter moves to the S-meter and the fixture echoes set_freq/set_mode/ set_bandwidth, as it already did for squelch. Signed-off-by: Stan Grams <sjg@haxx.space>
This commit was merged in pull request #42.
This commit is contained in:
+54
-1
@@ -17,7 +17,8 @@ async function loadRoutes(window) {
|
||||
write: false,
|
||||
});
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window });
|
||||
// The sandbox is bare; the browser globals the module actually uses go in.
|
||||
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window, URLSearchParams });
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
@@ -38,3 +39,55 @@ test("all top-level tabs have stable round-trip routes", async () => {
|
||||
assert.equal(calls[0][0], "push");
|
||||
assert.equal(calls[0][3], "/statistics?rig=one#panel");
|
||||
});
|
||||
|
||||
// A tune link is what one operator sends another: it has to survive being
|
||||
// typed by hand, and it has to come back out as the frequency that went in.
|
||||
test("tune links parse the frequencies people write", async () => {
|
||||
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||
for (const [text, hz] of [
|
||||
["14074000", 14_074_000], ["14074000hz", 14_074_000],
|
||||
["7074k", 7_074_000], ["14.074M", 14_074_000], ["1.2G", 1_200_000_000],
|
||||
[" 145500k ", 145_500_000],
|
||||
]) {
|
||||
assert.equal(routes.parseFrequencyParam(text), hz, `${text} did not read as ${hz} Hz`);
|
||||
}
|
||||
for (const bad of ["", " ", "abc", "-7074", "0", "14,074", "1e6", null, undefined]) {
|
||||
assert.equal(routes.parseFrequencyParam(bad), null, `${bad} was read as a frequency`);
|
||||
}
|
||||
});
|
||||
|
||||
test("tune links carry rig, frequency, mode and bandwidth", async () => {
|
||||
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||
assert.deepEqual({ ...routes.parseTuneLink("?rig=sdr&f=14.074M&mode=usb&bw=3k") }, {
|
||||
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
|
||||
});
|
||||
// A link with nothing to say must not tune anything.
|
||||
assert.deepEqual({ ...routes.parseTuneLink("?tab=map") }, {
|
||||
rig: null, freqHz: null, mode: null, bandwidthHz: null,
|
||||
});
|
||||
// Junk in a parameter is not a frequency, and must not become one.
|
||||
assert.deepEqual({ ...routes.parseTuneLink("?f=&mode=NOTAMODE&bw=wide") }, {
|
||||
rig: null, freqHz: null, mode: null, bandwidthHz: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("writing a tune link canonicalises it and leaves other parameters alone", async () => {
|
||||
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||
const search = routes.tuneLinkSearch("?theme=dark&f=7074k", {
|
||||
rig: "sdr", freqHz: 14_074_000.4, mode: "usb", bandwidthHz: 3000,
|
||||
});
|
||||
const params = new URLSearchParams(search);
|
||||
assert.equal(params.get("theme"), "dark", "an unrelated parameter was dropped");
|
||||
assert.equal(params.get("f"), "14074000", "the frequency was not written as whole Hz");
|
||||
assert.equal(params.get("mode"), "USB");
|
||||
assert.equal(params.get("bw"), "3000");
|
||||
assert.equal(params.get("rig"), "sdr");
|
||||
// Round trip: what was written reads back as what went in.
|
||||
assert.deepEqual({ ...routes.parseTuneLink(search) }, {
|
||||
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
|
||||
});
|
||||
// Nothing known yet means no stale tune parameters left behind.
|
||||
const cleared = routes.tuneLinkSearch(search, { rig: null, freqHz: null, mode: null, bandwidthHz: null });
|
||||
assert.equal(cleared, "?theme=dark");
|
||||
assert.equal(routes.tuneLinkSearch("", { rig: null, freqHz: null, mode: null, bandwidthHz: null }), "");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// A link is how one operator tells another where to listen. Opening one has to
|
||||
// put the radio there, and the address bar has to keep up with the dial after
|
||||
// that — a link that goes stale the moment someone tunes is worse than none,
|
||||
// because it looks like it works.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document, window */
|
||||
|
||||
const LINK_HZ = 14_074_000;
|
||||
const LINK_BW = 2_800;
|
||||
const TUNED_HZ = 7_040_000;
|
||||
|
||||
const fixture = await startWebFixture({ spectrum: true });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
const dial = () => page.evaluate(() => ({
|
||||
freqHz: window.lastFreqHz,
|
||||
mode: document.getElementById("mode").value,
|
||||
search: window.location.search,
|
||||
path: window.location.pathname,
|
||||
}));
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1500, height: 950 });
|
||||
|
||||
// Opening the link tunes the radio: frequency, mode and bandwidth.
|
||||
await page.goto(`${fixture.origin}/?f=14.074M&mode=USB&bw=${LINK_BW}`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const opened = await dial();
|
||||
assert.equal(opened.freqHz, LINK_HZ, `the link left the radio on ${opened.freqHz} Hz`);
|
||||
assert.equal(opened.mode, "USB", `the link left the radio in ${opened.mode}`);
|
||||
const bandwidth = await page.evaluate(() => window.currentBandwidthHz);
|
||||
assert.equal(bandwidth, LINK_BW, `the link left the bandwidth at ${bandwidth} Hz`);
|
||||
|
||||
// Hand-written frequencies are canonicalised in place, so what the operator
|
||||
// copies back out is the same link in the form the app writes.
|
||||
const params = new URLSearchParams(opened.search);
|
||||
assert.equal(params.get("f"), String(LINK_HZ), `the URL kept "${params.get("f")}"`);
|
||||
assert.equal(params.get("mode"), "USB");
|
||||
|
||||
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||
// bar shareable at any moment rather than only at load.
|
||||
await page.locator("#freq").fill("7.040M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await page.waitForTimeout(1500);
|
||||
const tuned = await dial();
|
||||
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||
assert.equal(new URLSearchParams(tuned.search).get("f"), String(TUNED_HZ),
|
||||
`the URL still reads "${tuned.search}" after tuning`);
|
||||
|
||||
// Tuning is not navigation: a swept dial must not bury the back button.
|
||||
const historyLength = await page.evaluate(() => window.history.length);
|
||||
await page.locator("#freq").fill("7.100M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
||||
"tuning pushed a history entry instead of replacing one");
|
||||
|
||||
// Moving between tabs keeps the tune parameters: the path changes, the link
|
||||
// does not stop being a link.
|
||||
await page.locator('.tab[data-tab="map"]').click();
|
||||
await page.waitForTimeout(800);
|
||||
const onMap = await dial();
|
||||
assert.equal(onMap.path, "/map", `the map tab landed on ${onMap.path}`);
|
||||
assert.equal(new URLSearchParams(onMap.search).get("f"), "7100000",
|
||||
`the map tab dropped the tune parameters: "${onMap.search}"`);
|
||||
|
||||
// A link naming a rig selects it before applying the rest.
|
||||
await page.goto(`${fixture.origin}/?rig=rig-b&f=${TUNED_HZ}&mode=CW`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2500);
|
||||
const switched = await page.evaluate(() => ({
|
||||
rig: document.getElementById("header-rig-switch-select")?.value ?? null,
|
||||
selected: window.location.search,
|
||||
mode: document.getElementById("mode").value,
|
||||
}));
|
||||
assert.equal(switched.rig, "rig-b", `the link left the client on rig "${switched.rig}"`);
|
||||
assert.equal(switched.mode, "CW", `the link left the radio in ${switched.mode}`);
|
||||
|
||||
// A link with nothing to say tunes nothing, and the address bar fills in
|
||||
// with where the radio already is.
|
||||
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2000);
|
||||
const bare = await dial();
|
||||
assert.equal(new URLSearchParams(bare.search).get("f"), String(bare.freqHz),
|
||||
`a bare load wrote "${bare.search}" for a radio on ${bare.freqHz} Hz`);
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
@@ -249,6 +249,24 @@ export async function startWebFixture({
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/set_freq") {
|
||||
const hz = Number(url.searchParams.get("hz"));
|
||||
if (Number.isFinite(hz) && hz > 0) status.status.freq = { hz: Math.round(hz) };
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/set_mode") {
|
||||
const next = (url.searchParams.get("mode") || "").toUpperCase();
|
||||
if (next) status.status.mode = next;
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/set_bandwidth") {
|
||||
const hz = Number(url.searchParams.get("hz"));
|
||||
if (Number.isFinite(hz) && hz > 0 && status.filter) status.filter.bandwidth_hz = Math.round(hz);
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/select_rig" && request.method === "POST") {
|
||||
const remote = url.searchParams.get("remote");
|
||||
if (remote) {
|
||||
@@ -347,9 +365,11 @@ export async function startWebFixture({
|
||||
});
|
||||
// Varying, as a real one is: the client skips a frame identical to the
|
||||
// last, so a repeated payload exercises none of the state-update path.
|
||||
// The variation rides on the S-meter rather than the dial — a rig that
|
||||
// wandered a kilohertz every frame could never be told to tune.
|
||||
const frame = () => JSON.stringify({
|
||||
...status,
|
||||
status: { ...status.status, freq: { hz: 100_000_000 + (Date.now() % 1000) } },
|
||||
status: { ...status.status, rx: { ...status.status.rx, sig: meterDb + ((Date.now() % 3) - 1) } },
|
||||
});
|
||||
response.write(`data: ${frame()}\n\n`);
|
||||
const timer = setInterval(() => {
|
||||
|
||||
Reference in New Issue
Block a user