// 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, window */ 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 strip of controls under the plot. Its two clusters — the receiver's // bandwidth and the display's levels — used to size themselves independently: // four control heights on one line, and between about 1100 and 1400 px the // left cluster wrapped to two lines while the right one did not, leaving the // two at heights that matched neither each other nor anything else. const stripFixture = await startWebFixture({ spectrum: true }); const strip = await startBrowser(chromium); try { for (const width of [1600, 1200, 900]) { await strip.page.setViewportSize({ width, height: 950 }); await strip.page.goto(stripFixture.origin, { waitUntil: "domcontentloaded" }); await strip.page.locator("#spectrum-panel").waitFor({ state: "visible" }); await strip.page.waitForTimeout(1600); const measured = await strip.page.evaluate(() => { const controls = document.getElementById("spectrum-controls"); const box = (element) => element.getBoundingClientRect(); const clusters = [...controls.children] .filter((child) => child.id) .map((child) => ({ id: child.id, top: Math.round(box(child).top) })); const parts = [...controls.querySelectorAll(".spectrum-field, .spectrum-btn")]; return { heights: [...new Set(parts.map((part) => Math.round(box(part).height)))], count: parts.length, clusters, // Rows are lines of the strip: clusters sharing a top are on one line. lines: new Set(clusters.map((cluster) => cluster.top)).size, overflows: controls.scrollWidth > controls.clientWidth + 1, insidePanel: box(controls).right <= box(document.getElementById("spectrum-panel")).right + 1, }; }); // Five fields and four buttons: bandwidth, Set, Auto BW, Sweet-spot, peak // hold, floor, range, Auto, contrast. assert.equal(measured.count, 9, `the strip has ${measured.count} controls at ${width}px`); assert.deepEqual(measured.heights.length, 1, `controls are ${measured.heights.join(", ")}px tall at ${width}px`); assert.equal(measured.overflows, false, `the strip overflows at ${width}px`); assert.equal(measured.insidePanel, true, `the strip runs past the plot at ${width}px`); // Either both clusters share a line, or each has one to itself. What must // never happen is one cluster floating against the middle of the other. const tops = measured.clusters.map((cluster) => cluster.top); assert.ok( measured.lines === 1 || measured.lines === measured.clusters.length, `clusters sit at ${tops.join(", ")} at ${width}px`, ); } assert.deepEqual(strip.runtimeErrors, []); } finally { await strip.browser.close(); await stripFixture.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(); } // The map's filter panel is a bar across the top of the map, not a window // sitting on it: it has to stay one or two rows tall, span most of the width, // and keep clear of what shares the map's corners — Leaflet's zoom buttons and // the band legend. A panel that grew a column would cover the map it filters. // Fullscreen and the filter toggle ride at the bar's right-hand end, so only // the filters collapse: the bar itself has to survive Hide Filters, or there // is nothing left to click to bring them back. const mapFixture = await startWebFixture({ spectrum: true }); const mapView = await startBrowser(chromium); try { for (const width of [1600, 1200]) { await mapView.page.setViewportSize({ width, height: 950 }); await mapView.page.goto(`${mapFixture.origin}/map`, { waitUntil: "domcontentloaded" }); await mapView.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" }); await mapView.page.waitForTimeout(1200); const bar = await mapView.page.evaluate(() => { const box = (element) => (element ? element.getBoundingClientRect() : null); const panel = document.querySelector(".map-overlay-panel"); const stage = box(document.getElementById("map-stage")); const zoom = box(document.querySelector("#aprs-map .leaflet-control-zoom")); const legend = box(document.getElementById("map-band-legend")); const panelBox = box(panel); const hits = (a, b) => !!a && !!b && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; return { widthPct: Math.round((panelBox.width / stage.width) * 100), height: Math.round(panelBox.height), outsideStage: panelBox.right > stage.right + 1 || panelBox.bottom > stage.bottom + 1, hitsZoom: hits(panelBox, zoom), hitsLegend: hits(panelBox, legend), // Both controls belong to the bar now, not to a floating corner block. actionsInBar: [...panel.querySelectorAll(".map-overlay-actions button")] .map((button) => button.id).join(","), // The rule dividing them from the filters is drawn on this block's // edge, so the block has to run the height of the filters beside it — // centred, it left the rule floating as a stub against a two-row bar. actionsShort: Math.round(box(panel.querySelector(".map-overlay-filters")).height - box(panel.querySelector(".map-overlay-actions")).height), // Every label sits in a gutter of one width, so whichever group leads // a row leads it from the same place: with the labels at their natural // widths, a row starting "Search" began 10px off one starting "Filter". rowStarts: (() => { const leaders = new Map(); for (const group of panel.querySelectorAll(".map-overlay-filters .map-locator-filter-group")) { const rect = box(group); // Groups of a row differ in height, so bucket them by their middle. const row = Math.round((rect.top + rect.height / 2) / 20); if (!leaders.has(row) || rect.left < box(leaders.get(row)).left) leaders.set(row, group); } return [...leaders.values()].map((group) => Math.round(box(group.children[1]).left)); })(), clipped: panel.scrollWidth > panel.clientWidth + 1 || panel.scrollHeight > panel.clientHeight + 1, }; }); assert.ok(bar.widthPct >= 70, `the filter bar covers ${bar.widthPct}% of the map at ${width}px`); assert.ok(bar.height <= 140, `the filter bar is ${bar.height}px tall at ${width}px, not a bar`); assert.equal(bar.outsideStage, false, `the filter bar runs off the map at ${width}px`); assert.equal(bar.hitsZoom, false, `the filter bar covers the zoom buttons at ${width}px`); assert.equal(bar.actionsInBar, "map-fullscreen-btn,map-overlay-toggle-btn", `the bar carries "${bar.actionsInBar}" at ${width}px`); assert.ok(bar.actionsShort <= 1, `the buttons' divider falls ${bar.actionsShort}px short of the bar at ${width}px`); assert.equal(new Set(bar.rowStarts).size, 1, `the bar's rows start at ${bar.rowStarts.join(", ")}px at ${width}px`); assert.equal(bar.hitsLegend, false, `the filter bar covers the band legend at ${width}px`); assert.equal(bar.clipped, false, `the filter bar is clipping its own controls at ${width}px`); } // Band chips only exist once something has been heard on a band. The bar // used to explain "all bands visible by default" in a line of prose wedged // between the chips and the next group, which is neither what a toolbar is // for nor a width it can spare: an "All" chip says it and undoes a // selection. Nothing is dimmed while nothing is filtered out, either — every // chip used to come up greyed at the very moment all of them were showing. await mapView.page.evaluate(() => { const ts = Date.now(); for (const [grid, hz] of [["JO94", 14_074_000], ["JN48", 7_074_000], ["FN42", 21_074_000]]) { window.trxPluginRuntime.dispatch("ft8", { message: `CQ TEST ${grid}`, grid, freq_hz: hz, snr_db: -8, ts_ms: ts, rig_id: "rig-a" }); } }); await mapView.page.waitForTimeout(1500); const chipRow = () => mapView.page.evaluate(() => { const row = document.getElementById("map-locator-choice-filter"); const chips = [...row.querySelectorAll(".map-locator-chip")]; const all = row.querySelector(".map-locator-chip-all"); return { bands: chips.filter((chip) => chip !== all).map((chip) => chip.textContent.trim()), prose: row.querySelector(".map-locator-empty")?.textContent ?? null, allActive: all?.classList.contains("is-active") ?? null, dimmed: chips.filter((chip) => chip.classList.contains("is-inactive")) .map((chip) => chip.textContent.trim()), selected: chips.filter((chip) => chip.getAttribute("aria-pressed") === "true" && chip !== all).map((chip) => chip.textContent.trim()), }; }); const unfiltered = await chipRow(); assert.ok(unfiltered.bands.includes("20m") && unfiltered.bands.includes("40m"), `the chip row is showing ${unfiltered.bands.join(",")}`); assert.equal(unfiltered.prose, null, `the bar is explaining itself in prose: "${unfiltered.prose}"`); assert.equal(unfiltered.allActive, true, "All is not lit while every band is on the map"); assert.deepEqual(unfiltered.dimmed, [], `${unfiltered.dimmed.join(",")} came up dimmed with no filter set`); await mapView.page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="20m"]').click(); await mapView.page.waitForTimeout(300); const filtered = await chipRow(); assert.deepEqual(filtered.selected, ["20m"], `picking 20m selected ${filtered.selected.join(",")}`); assert.equal(filtered.allActive, false, "All stayed lit with a band picked out"); assert.ok(filtered.dimmed.includes("40m"), "the bands now filtered out are not shown as such"); // And back: All is how a selection is undone without hunting for the chips // that are in it. await mapView.page.locator('#map-locator-choice-filter .map-locator-chip-all').click(); await mapView.page.waitForTimeout(300); const cleared = await chipRow(); assert.equal(cleared.allActive, true, "All did not clear the band selection"); assert.deepEqual(cleared.selected, [], `${cleared.selected.join(",")} survived All`); assert.deepEqual(cleared.dimmed, [], `${cleared.dimmed.join(",")} stayed dimmed after All`); // Hiding gives the map back, but leaves the bar itself — collapsed to its // two controls at the right-hand edge — so the filters can be brought back. await mapView.page.locator("#map-overlay-toggle-btn").click(); await mapView.page.waitForTimeout(400); const toggled = await mapView.page.evaluate(() => { const panel = document.querySelector(".map-overlay-panel"); const stage = document.getElementById("map-stage").getBoundingClientRect(); const panelBox = panel.getBoundingClientRect(); const visible = (id) => document.getElementById(id).getBoundingClientRect().width > 0; return { filtersHidden: panel.querySelector(".map-overlay-filters").classList.contains("is-hidden"), widthPct: Math.round((panelBox.width / stage.width) * 100), rightGap: Math.round(stage.right - panelBox.right), label: document.getElementById("map-overlay-toggle-btn").textContent.trim(), togglesVisible: visible("map-fullscreen-btn") && visible("map-overlay-toggle-btn"), }; }); assert.equal(toggled.filtersHidden, true, "the filters stayed up after Hide Filters"); assert.equal(toggled.togglesVisible, true, "Hide Filters took its own button down with it"); assert.ok(toggled.widthPct < 30, `the collapsed bar still covers ${toggled.widthPct}% of the map`); assert.ok(toggled.rightGap < 30, `the collapsed bar sits ${toggled.rightGap}px from the map's edge`); assert.equal(toggled.label, "Show Filters", `the toggle still reads "${toggled.label}"`); await mapView.page.locator("#map-overlay-toggle-btn").click(); await mapView.page.waitForTimeout(400); const restored = await mapView.page.evaluate(() => !document.querySelector(".map-overlay-filters").classList.contains("is-hidden")); assert.equal(restored, true, "Show Filters did not bring the filters back"); assert.deepEqual(mapView.runtimeErrors, []); } finally { await mapView.browser.close(); await mapFixture.close(); }