// SPDX-FileCopyrightText: 2026 Stan Grams // // SPDX-License-Identifier: GPL-2.0-or-later // Geometry of the spectrum area while tuning. Everything above the spectrum is // driven by what happens to be in the visible range — band plan allocations, // bookmarks — and the strips that show them used to appear and disappear with // it, so tuning across a band edge moved the whole page under the operator's // cursor. Nothing else in the suite serves a rig with a spectrum. import assert from "node:assert/strict"; import { chromium } from "playwright-core"; import { startBrowser, startWebFixture } from "./web-fixture.mjs"; /* global document, getComputedStyle */ const BOOKMARKS = [ { id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" }, { id: "b2", name: "40m CW", freq_hz: 7030000, mode: "CW", category: "", comment: "", locator: "" }, ]; const BANDPLAN = { iaru1: { bands: [{ name: "40m", low_hz: 7000000, high_hz: 7200000, segments: [ { low_hz: 7000000, high_hz: 7040000, mode: "CW", label: "CW" }, { low_hz: 7040000, high_hz: 7200000, mode: "All", label: "All modes" }, ], }], }, }; // 40m has both bookmarks and allocations; 20m has neither. const BAND_WITH_CONTENT = 7074000; const BAND_WITHOUT_CONTENT = 14074000; // The meter sits well away from the spectrum's noise floor, so a squelch that // took its level from the plot would land somewhere else entirely. const METER_DB = -70; const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, bandplan: BANDPLAN, bandplanEnabled: true, meterDb: METER_DB, }); const { browser, page, runtimeErrors } = await startBrowser(chromium); function readGeometry() { const top = (selector) => { const el = document.querySelector(selector); return el ? Math.round(el.getBoundingClientRect().top) : null; }; const axis = document.getElementById("spectrum-bookmark-axis"); const strip = document.getElementById("spectrum-bandplan-strip"); return { chips: axis.querySelectorAll(".spectrum-bookmark-chip").length, axisEmpty: axis.classList.contains("bm-axis-empty"), stripReserved: strip.classList.contains("bp-visible"), stripEmpty: strip.classList.contains("bp-empty"), overviewTop: top(".overview-strip"), spectrumTop: top("#spectrum-panel"), controlsTop: top(".controls-row"), footerTop: top(".footer"), docHeight: document.documentElement.scrollHeight, }; } const layoutOf = (geometry) => ({ overviewTop: geometry.overviewTop, spectrumTop: geometry.spectrumTop, controlsTop: geometry.controlsTop, footerTop: geometry.footerTop, docHeight: geometry.docHeight, }); async function tuneTo(hz) { fixture.setCenterHz(hz); await page.waitForTimeout(900); return page.evaluate(readGeometry); } try { await page.setViewportSize({ width: 1600, height: 950 }); await page.goto(fixture.origin, { waitUntil: "domcontentloaded" }); await page.locator("#content").waitFor({ state: "visible" }); await page.locator("#spectrum-panel").waitFor({ state: "visible" }); await page.waitForTimeout(1500); const populated = await tuneTo(BAND_WITH_CONTENT); assert.equal(populated.chips, BOOKMARKS.length, `expected both bookmarks, saw ${populated.chips}`); assert.equal(populated.axisEmpty, false, "bookmark rail claims to be empty with chips in it"); assert.equal(populated.stripReserved, true, "band plan strip is missing on a band with allocations"); assert.equal(populated.stripEmpty, false, "band plan strip claims to be empty with segments in it"); const bare = await tuneTo(BAND_WITHOUT_CONTENT); assert.equal(bare.chips, 0, `expected no bookmarks on ${BAND_WITHOUT_CONTENT}Hz, saw ${bare.chips}`); assert.equal(bare.axisEmpty, true, "bookmark rail should show its placeholder"); assert.equal(bare.stripReserved, true, "band plan strip gave its height back"); assert.equal(bare.stripEmpty, true, "band plan strip should be marked empty"); // The point of both placeholders: tuning must not move anything. assert.deepEqual(layoutOf(bare), layoutOf(populated), `tuning off the band moved the page: ${JSON.stringify(populated)} -> ${JSON.stringify(bare)}`); const back = await tuneTo(BAND_WITH_CONTENT); assert.equal(back.chips, BOOKMARKS.length, "bookmarks did not come back"); assert.deepEqual(layoutOf(back), layoutOf(populated), "tuning back moved the page"); // The rail covers the top of the overview, so only the chips may take // pointer events — the rest has to fall through to the plot behind it. const hits = await page.evaluate(() => { const chip = document.querySelector("#spectrum-bookmark-axis .spectrum-bookmark-chip"); const chipRect = chip.getBoundingClientRect(); const axisRect = document.getElementById("spectrum-bookmark-axis").getBoundingClientRect(); const onChip = document.elementFromPoint(chipRect.left + chipRect.width / 2, chipRect.top + chipRect.height / 2); const besideChip = document.elementFromPoint(axisRect.right - 30, axisRect.top + 10); return { chip: onChip?.closest(".spectrum-bookmark-chip") ? "chip" : (onChip?.id || onChip?.tagName), besideChip: besideChip?.id || besideChip?.tagName, }; }); assert.equal(hits.chip, "chip", `chip is not clickable, hit ${hits.chip}`); assert.equal(hits.besideChip, "overview-canvas", `rail swallows events, hit ${hits.besideChip}`); // Squelch: the threshold is in the dB the spectrum axis is labelled in, so the // line is the control. It used to be a percentage on a slider in the audio // row, with nothing on screen to relate the number to. await page.locator("summary", { hasText: "Audio controls" }).click(); await page.locator("#sdr-squelch-toggle").click(); // Auto parks it just above the noise the meter reports — not above the // spectrum's noise floor, which sits anywhere from 1 dB below to 22 dB above // the meter depending on span, bandwidth and decimation. await page.locator("#sdr-squelch-auto").click(); await page.waitForTimeout(400); const auto = await page.evaluate(() => Number(document.getElementById("sdr-squelch-db").value)); assert.ok(Math.abs(auto - (METER_DB + 5)) <= 1, `auto put the threshold at ${auto} dB with the meter at ${METER_DB} dB`); const squelchOn = await page.evaluate(() => { const line = document.getElementById("spectrum-squelch-line"); return { shown: getComputedStyle(line).display !== "none", db: Number(document.getElementById("sdr-squelch-db").value), label: Number(document.getElementById("spectrum-squelch-label").textContent), toggle: document.getElementById("sdr-squelch-toggle").getAttribute("aria-pressed"), top: Math.round(line.getBoundingClientRect().top), }; }); assert.equal(squelchOn.shown, true, "the threshold line did not appear with the squelch on"); assert.equal(squelchOn.toggle, "true", "the SQL switch did not follow the squelch state"); assert.equal(squelchOn.label, squelchOn.db, "the line and the readout disagree on the threshold"); // Dragging the line down lowers the threshold and tells the server. const submitted = []; page.on("request", (request) => { if (request.url().includes("/set_sdr_squelch")) { submitted.push(Number(new URL(request.url()).searchParams.get("threshold_db"))); } }); const grip = await page.locator("#spectrum-squelch-grip").boundingBox(); await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); await page.mouse.down(); await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 + 60, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(400); const dragged = await page.evaluate(() => ({ db: Number(document.getElementById("sdr-squelch-db").value), label: Number(document.getElementById("spectrum-squelch-label").textContent), top: Math.round(document.getElementById("spectrum-squelch-line").getBoundingClientRect().top), })); assert.ok(dragged.db < squelchOn.db, `dragging down left the threshold at ${dragged.db} dB (was ${squelchOn.db})`); assert.equal(dragged.label, dragged.db, "the line label did not follow the drag"); assert.ok(dragged.top > squelchOn.top, "the line did not move with the drag"); assert.ok(submitted.includes(dragged.db), `the server was never told about ${dragged.db} dB (saw ${JSON.stringify(submitted)})`); // Turning it off leaves the threshold alone — the old control conflated the // two, so dropping to zero to listen threw the setting away. await page.locator("#sdr-squelch-toggle").click(); await page.waitForTimeout(300); const squelchOff = await page.evaluate(() => ({ db: Number(document.getElementById("sdr-squelch-db").value), shown: getComputedStyle(document.getElementById("spectrum-squelch-line")).display !== "none", dot: document.getElementById("sdr-squelch-state").dataset.state, })); assert.equal(squelchOff.db, dragged.db, "turning the squelch off discarded the threshold"); assert.equal(squelchOff.shown, false, "the line stayed up with the squelch off"); assert.equal(squelchOff.dot, "off", "the indicator did not follow the squelch off"); assert.deepEqual(runtimeErrors, []); } finally { await browser.close(); await fixture.close(); } // The band plan is fetched once at startup, which can land before the session // exists. It used to fail silently and never retry, so the allocations only // turned up if the operator reloaded the page by hand. const retryFixture = await startWebFixture({ spectrum: true, bandplan: BANDPLAN, bandplanEnabled: true, bandplanUnauthorizedFirst: true, }); const retry = await startBrowser(chromium); try { await retry.page.setViewportSize({ width: 1600, height: 950 }); await retry.page.goto(retryFixture.origin, { waitUntil: "domcontentloaded" }); await retry.page.locator("#spectrum-panel").waitFor({ state: "visible" }); await retry.page.waitForTimeout(2000); const strip = await retry.page.evaluate(() => { const element = document.getElementById("spectrum-bandplan-strip"); return { segments: element.children.length, empty: element.classList.contains("bp-empty") }; }); assert.ok(strip.segments > 0, "the band plan never arrived after its first request was refused"); assert.equal(strip.empty, false, "the strip is still showing its placeholder"); } finally { await retry.browser.close(); await retryFixture.close(); }