CI still put the tab strip into the controls, now at 1100px — the narrowest bar in the app, since the bookmark gutters take 9.5rem a side above that width and leave 756px against 871px at 900px. With the controls already in the overflow menu and the tabs already down to icons, nothing else could give, and what gives by default is the strip: it is the one item allowed to shrink below its content, so its tabs keep full width and slide under the controls, out of reach. The identity block takes the squeeze instead, ellipsised. A clipped station name is still readable; a destination hidden underneath the controls is not. The guard that was supposed to catch this scaled only the tabs and the controls, not the title and subtitles — which is exactly what runs out of room — and skipped 900px. It now scales every piece of text in the bar and checks all four widths. Measured across text scales from 1.0 to 3.0 at each width, the bar keeps its 16px allowance everywhere; before this, 1.6 and above overlapped at 1100px. Signed-off-by: Stan Grams <sjg@haxx.space>
250 lines
14 KiB
JavaScript
250 lines
14 KiB
JavaScript
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import assert from "node:assert/strict";
|
|
import { chromium } from "playwright-core";
|
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
|
|
|
// page.evaluate callbacks run in the browser, not in this Node process.
|
|
/* global document, getComputedStyle */
|
|
|
|
const fixture = await startWebFixture();
|
|
const { selectedRigs } = fixture;
|
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
|
|
|
try {
|
|
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(500);
|
|
assert.deepEqual(runtimeErrors, []);
|
|
await page.locator("#content").waitFor({ state: "visible" });
|
|
assert.equal(await page.locator("#auth-gate").isVisible(), false);
|
|
assert.equal(await page.locator("#tab-main").isVisible(), true);
|
|
await page.locator("summary", { hasText: "Audio controls" }).click();
|
|
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
|
|
|
|
// Mode is a button group over a hidden <select>, which stays the value a
|
|
// dozen call sites and several plugins read. The click has to reach it, and
|
|
// the select must not take part in layout while it does.
|
|
const modeBefore = await page.evaluate(() => ({
|
|
buttons: document.querySelectorAll("#mode-picker button").length,
|
|
value: document.getElementById("mode").value,
|
|
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
|
|
}));
|
|
assert.ok(modeBefore.buttons > 1, `mode picker rendered ${modeBefore.buttons} buttons`);
|
|
assert.equal(modeBefore.active, modeBefore.value, "mode picker disagrees with the select");
|
|
const target = await page.evaluate(() => {
|
|
const other = [...document.querySelectorAll("#mode-picker button")]
|
|
.find((btn) => btn.dataset.mode !== document.getElementById("mode").value);
|
|
return other?.dataset.mode;
|
|
});
|
|
await page.locator(`#mode-picker button[data-mode="${target}"]`).click();
|
|
await page.waitForTimeout(200);
|
|
const modeAfter = await page.evaluate(() => ({
|
|
value: document.getElementById("mode").value,
|
|
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
|
|
selectWidth: Math.round(document.getElementById("mode").getBoundingClientRect().width),
|
|
}));
|
|
assert.equal(modeAfter.value, target, `clicking ${target} left the select at ${modeAfter.value}`);
|
|
assert.equal(modeAfter.active, target, "the clicked mode is not the marked one");
|
|
assert.ok(modeAfter.selectWidth <= 2, `the hidden select still occupies ${modeAfter.selectWidth}px`);
|
|
|
|
// Mode-specific controls live on their own row, which has to leave with them:
|
|
// an empty one would still take a track and a gap in the tray and draw its
|
|
// divider under the controls every rig has.
|
|
const modeRowState = async () => page.evaluate(() => {
|
|
const row = document.getElementById("mode-controls-row");
|
|
return { display: getComputedStyle(row).display, height: Math.round(row.getBoundingClientRect().height) };
|
|
});
|
|
await page.locator('#mode-picker button[data-mode="WFM"]').click();
|
|
await page.waitForTimeout(250);
|
|
const withWfm = await modeRowState();
|
|
assert.notEqual(withWfm.display, "none", "WFM controls did not bring their row up");
|
|
assert.ok(withWfm.height > 0, `WFM row has no height (${withWfm.height}px)`);
|
|
await page.locator('#mode-picker button[data-mode="FM"]').click();
|
|
await page.waitForTimeout(250);
|
|
const withoutWfm = await modeRowState();
|
|
assert.equal(withoutWfm.display, "none", "the mode row stayed behind with nothing in it");
|
|
|
|
// Scheduler controls read left to right: step, hand back, then the entry on
|
|
// air. The separator is drawn by the current-entry block, so it can only sit
|
|
// in the right place if that block is last.
|
|
const schedulerRow = await page.evaluate(() => [...document.querySelectorAll(".scheduler-action-row > *")]
|
|
.map((el) => el.id || [...el.children].map((c) => c.id).join("+")));
|
|
assert.deepEqual(schedulerRow,
|
|
["scheduler-prev-btn+scheduler-next-btn", "scheduler-release-btn", "scheduler-cycle-status"],
|
|
`scheduler control order is ${JSON.stringify(schedulerRow)}`);
|
|
|
|
// The footer status pill colours its dot from data-state, so a hint written
|
|
// straight to textContent would leave the dot stuck on the previous state.
|
|
const hint = await page.evaluate(() => {
|
|
const element = document.getElementById("power-hint");
|
|
return { state: element.dataset.state, text: element.textContent.trim() };
|
|
});
|
|
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
|
|
assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);
|
|
|
|
const rigPicker = page.locator("#header-rig-switch-select");
|
|
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
|
|
await rigPicker.selectOption("rig-b");
|
|
await page.waitForTimeout(100);
|
|
assert.deepEqual(selectedRigs, ["rig-b"]);
|
|
|
|
await page.locator('.tab[data-tab="map"]').click();
|
|
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
|
assert.equal(new URL(page.url()).pathname, "/map");
|
|
|
|
// The selected destination is marked by a box on all four sides, so a rule
|
|
// that drops one edge (the mobile nav used to lose its bottom border) is a
|
|
// regression even though the tab still reads as "active".
|
|
const activeTab = await page.evaluate(() => {
|
|
const style = getComputedStyle(document.querySelector(".tab-bar-nav .tab.active"));
|
|
return ["Top", "Right", "Bottom", "Left"].map((side) => ({
|
|
width: style.getPropertyValue(`border-${side.toLowerCase()}-width`),
|
|
color: style.getPropertyValue(`border-${side.toLowerCase()}-color`),
|
|
}));
|
|
});
|
|
for (const edge of activeTab) {
|
|
assert.notEqual(edge.width, "0px", `active tab border: ${JSON.stringify(activeTab)}`);
|
|
assert.ok(!/rgba\(0, 0, 0, 0\)|transparent/.test(edge.color), `active tab border: ${JSON.stringify(activeTab)}`);
|
|
}
|
|
|
|
// The map is full-bleed: it breaks out of the centred .card column and
|
|
// reaches both viewport edges, without pushing the page sideways.
|
|
const stage = await page.evaluate(() => {
|
|
const rect = document.getElementById("map-stage").getBoundingClientRect();
|
|
return {
|
|
left: Math.round(rect.left),
|
|
right: Math.round(rect.right),
|
|
viewport: document.documentElement.clientWidth,
|
|
sideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
|
gapToFooter: Math.round(document.querySelector(".footer").getBoundingClientRect().top - rect.bottom),
|
|
pageScrolls: document.documentElement.scrollHeight > document.documentElement.clientHeight + 1,
|
|
};
|
|
});
|
|
assert.equal(stage.left, 0, `map stage starts at ${stage.left}px, not the viewport edge`);
|
|
assert.equal(stage.right, stage.viewport, `map stage ends at ${stage.right}px, not ${stage.viewport}px`);
|
|
assert.equal(stage.sideways, false, "full-bleed map makes the page scroll sideways");
|
|
// ...and fills the column down to the footer. Capping the height at a
|
|
// fraction of the viewport left a dead band that grew with the window.
|
|
assert.ok(stage.gapToFooter <= 16,
|
|
`${stage.gapToFooter}px of dead space between the map and the footer`);
|
|
assert.equal(stage.pageScrolls, false, "the map grew past the viewport");
|
|
|
|
await page.locator('.tab[data-tab="main"]').click();
|
|
assert.equal(new URL(page.url()).pathname, "/");
|
|
|
|
// About is an occasional destination, so it lives behind More at every
|
|
// width rather than in the operating tab strip.
|
|
await page.locator("#mobile-more-btn").click();
|
|
await page.locator('[data-navigate-tab="about"]').click();
|
|
await page.locator("#tab-about").waitFor({ state: "visible" });
|
|
assert.equal(new URL(page.url()).pathname, "/about");
|
|
|
|
await page.goBack();
|
|
await page.locator("#tab-main").waitFor({ state: "visible" });
|
|
assert.equal(new URL(page.url()).pathname, "/");
|
|
assert.deepEqual(runtimeErrors, []);
|
|
|
|
// Refreshing or deep-linking must mark the destination, not Tools. The first
|
|
// route navigation runs while the card is still behind the loading state, so
|
|
// a test that asked whether the tab was displayed saw "none" for every tab
|
|
// and lit Tools up on every refresh of every page.
|
|
for (const [route, tab, toolsLit] of [["/map", "map", false], ["/about", "about", true]]) {
|
|
await page.goto(`${fixture.origin}${route}`, { waitUntil: "domcontentloaded" });
|
|
await page.locator(`#tab-${tab}`).waitFor({ state: "visible" });
|
|
const marked = await page.evaluate(() => ({
|
|
actives: [...document.querySelectorAll(".tab-bar .tab.active")].map((t) => t.dataset.tab || t.id),
|
|
tools: document.getElementById("mobile-more-btn").classList.contains("active"),
|
|
}));
|
|
assert.ok(marked.actives.includes(tab), `${route} marks ${JSON.stringify(marked.actives)}`);
|
|
assert.equal(marked.tools, toolsLit, `${route}: Tools active is ${marked.tools}`);
|
|
}
|
|
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
|
|
await page.locator("#tab-main").waitFor({ state: "visible" });
|
|
assert.deepEqual(runtimeErrors, []);
|
|
|
|
// --- Layout regressions -------------------------------------------------
|
|
// Every fault below shipped at some point while the rest of this file
|
|
// passed, because nothing here looked at geometry: a header whose height
|
|
// tracked the viewport, controls that stretched, a tab strip that ran under
|
|
// the controls, and a dropdown that opened underneath the spectrum.
|
|
for (const width of [1440, 1280, 1100, 900]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.waitForTimeout(250);
|
|
const header = await page.evaluate(() => {
|
|
const bar = document.querySelector(".tab-bar");
|
|
const nav = document.querySelector(".tab-bar-nav");
|
|
const actions = document.querySelector(".top-bar-actions");
|
|
const controls = [...actions.children]
|
|
.filter((el) => !el.hidden && el.getBoundingClientRect().height > 0)
|
|
.map((el) => Math.round(el.getBoundingClientRect().height));
|
|
return {
|
|
barHeight: Math.round(bar.getBoundingClientRect().height),
|
|
// The tabs, not the strip: with the strip allowed to overflow its box
|
|
// shrinks while its content paints across the controls, so the
|
|
// container's own rect never registers the collision.
|
|
overlap: Math.round(Math.max(...[...nav.querySelectorAll(".tab")]
|
|
.filter((tab) => tab.offsetParent !== null)
|
|
.map((tab) => tab.getBoundingClientRect().right))
|
|
- actions.getBoundingClientRect().left),
|
|
heights: [...new Set(controls)],
|
|
pageScrollsSideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
|
};
|
|
});
|
|
assert.ok(header.barHeight <= 96, `header is ${header.barHeight}px at ${width}px; it should stay one row`);
|
|
assert.ok(header.overlap <= 0, `tab strip overlaps the controls by ${header.overlap}px at ${width}px`);
|
|
assert.ok(header.heights.length <= 2, `controls have heights ${header.heights.join(", ")} at ${width}px`);
|
|
assert.equal(header.pageScrollsSideways, false, `page scrolls sideways at ${width}px`);
|
|
}
|
|
|
|
// A dropdown must paint over the page, not inside the header: fixed
|
|
// positioning escapes clipping but not the header's stacking context.
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await page.waitForTimeout(250);
|
|
const menu = await page.evaluate(() => {
|
|
document.getElementById("mobile-more-btn").click();
|
|
const element = document.getElementById("mobile-more-menu");
|
|
const rect = element.getBoundingClientRect();
|
|
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
return { width: Math.round(rect.width), height: Math.round(rect.height), onTop: element.contains(hit) };
|
|
});
|
|
assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
|
|
assert.ok(menu.onTop, "menu is painted underneath the page");
|
|
|
|
// Wider text than this machine renders. The checks above passed on macOS
|
|
// while CI, whose system font is wider, put the tab strip into the controls:
|
|
// the bar had run out of moves and the tabs kept their full width anyway.
|
|
// Every piece of text in the bar is scaled, not just the tabs — the identity
|
|
// block is what runs out of room first at 1100px, where the bookmark gutters
|
|
// make the card narrower than it is at 900px.
|
|
const scale = 1.9;
|
|
await page.addStyleTag({ content: `
|
|
.tab-bar .title { font-size: ${1.05 * scale}rem !important; }
|
|
.tab-bar .subtitle { font-size: ${0.78 * scale}rem !important; }
|
|
.tab-bar .tab { font-size: ${0.95 * scale}rem !important; }
|
|
.tab-bar select, .tab-bar button { font-size: ${0.95 * scale}rem !important; }
|
|
` });
|
|
for (const width of [1440, 1280, 1100, 900]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.waitForTimeout(250);
|
|
const crowded = await page.evaluate(() => {
|
|
const nav = document.querySelector(".tab-bar-nav");
|
|
const actions = document.querySelector(".top-bar-actions");
|
|
const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null);
|
|
return {
|
|
overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right))
|
|
- actions.getBoundingClientRect().left),
|
|
iconsOnly: nav.classList.contains("nav-icons-only"),
|
|
};
|
|
});
|
|
assert.ok(crowded.overlap <= 0,
|
|
`with wider text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
|
|
assert.equal(crowded.iconsOnly, true, `the strip kept its labels at ${width}px with no room for them`);
|
|
}
|
|
|
|
} finally {
|
|
await browser.close();
|
|
await fixture.close();
|
|
}
|