// SPDX-FileCopyrightText: 2026 Stan Grams // // 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, })); // Typed, not filled. The app holds back its own refreshes of the frequency // field from the first keystroke until Enter, so that a state update arriving // mid-edit does not rewrite what is being typed. `fill()` sets the value // without a keystroke, leaving the field unguarded: on a slow machine a state // update could land between the fill and the Enter and put the old frequency // back, and the Enter would then re-apply the frequency the radio was already // on. Selecting first arms the guard before a single character changes. async function tuneByHand(text) { const field = page.locator("#freq"); await field.click(); await field.press("ControlOrMeta+a"); await field.pressSequentially(text); await field.press("Enter"); } 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 tuneByHand("7.040M"); 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 tuneByHand("7.100M"); 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(); }