CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m23s
CI / frontend (pull_request) Successful in 4m20s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m35s
CI / frontend (push) Successful in 3m29s
CI / reuse (push) Successful in 2s
CI cut "Bookmarks" off at 360 px where this machine had ten pixels to spare. How wide a platform draws a word varies by more than ten per cent, so a fixed 0.6rem label is a bet on the machine it was measured on -- the same bet ui-core's own comment warns about where it explains why the tab strip reflows by measurement rather than at a width. The long labels are sized with `clamp(0.46rem, 2.2vw, 0.62rem)` now. A tab is a fifth of the viewport, so what fits in it follows the viewport; tying the label to the same thing leaves better than a fifth of the tab spare at every phone width, and holds with the text drawn 30% wider than it is here. The test asked the wrong question too. "Does the label fit" is a question about font metrics, and it will keep answering differently on different machines. It now asserts what actually matters: a label stays inside its own tab, and if it is too long for it, it ends in an ellipsis rather than being cut through a letter. Both hold whatever width the platform draws the words at. Signed-off-by: Stan Grams <sjg@haxx.space>
150 lines
7.0 KiB
JavaScript
150 lines
7.0 KiB
JavaScript
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
// What a phone gets. A page that scrolls sideways is a page with something on
|
|
// it nobody will find: the transmit controls spent this whole period laid out
|
|
// at x=400 on a 390 px screen, off the side of a tray 354 px wide, reachable
|
|
// only by a horizontal scroll with nothing to say it was there.
|
|
|
|
import assert from "node:assert/strict";
|
|
import { chromium } from "playwright-core";
|
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
|
|
|
/* global document, getComputedStyle */
|
|
|
|
const PHONES = [430, 390, 360];
|
|
|
|
const fixture = await startWebFixture({ spectrum: true, tx: true });
|
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
|
|
|
try {
|
|
for (const width of PHONES) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
|
await page.locator("#content").waitFor({ state: "visible" });
|
|
await page.waitForTimeout(1800);
|
|
|
|
const layout = await page.evaluate((viewport) => {
|
|
const box = (element) => element.getBoundingClientRect();
|
|
// Anything laid out past the right edge of the screen.
|
|
const past = [];
|
|
const walk = (element) => {
|
|
const rect = box(element);
|
|
if (rect.width > 0 && rect.right > viewport + 1) {
|
|
past.push(`${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}`);
|
|
}
|
|
for (const child of element.children) walk(child);
|
|
};
|
|
walk(document.getElementById("content"));
|
|
const tx = document.getElementById("tx-power-col");
|
|
return {
|
|
documentScroll: document.documentElement.scrollWidth,
|
|
past: past.slice(0, 6),
|
|
txRight: tx ? Math.round(box(tx).right) : null,
|
|
txVisible: tx ? getComputedStyle(tx).display !== "none" : false,
|
|
rigSelect: Math.round(box(document.getElementById("header-rig-switch-select")).width),
|
|
};
|
|
}, width);
|
|
|
|
assert.equal(layout.documentScroll, width,
|
|
`the page scrolls sideways at ${width}px (${layout.documentScroll}px wide)`);
|
|
assert.deepEqual(layout.past, [],
|
|
`laid out past the screen at ${width}px: ${layout.past.join(", ")}`);
|
|
if (layout.txVisible) {
|
|
assert.ok(layout.txRight <= width + 1,
|
|
`the transmit controls end at ${layout.txRight}px on a ${width}px screen`);
|
|
}
|
|
// The rig's name was taking 139px of a 338px bar and pushing everything
|
|
// else into the overflow menu.
|
|
assert.ok(layout.rigSelect <= 112,
|
|
`the rig picker is ${layout.rigSelect}px wide at ${width}px`);
|
|
|
|
// The bottom nav keeps its labels — that is what makes it navigation
|
|
// rather than five glyphs — and a label used to overflow its tab and run
|
|
// into the next one: "Bookmarks igital mode".
|
|
//
|
|
// What is asserted is that a label stays inside its own tab and, if it is
|
|
// too long for it, ends in an ellipsis. Not that it fits: how wide a
|
|
// platform draws a word varies by more than ten per cent, so "it fits"
|
|
// passes on the machine it was written on and fails on the next one —
|
|
// which is what a fixed font size did here.
|
|
const nav = await page.evaluate(() => {
|
|
const bar = document.querySelector(".tab-bar-nav");
|
|
return [...bar.querySelectorAll(".tab")]
|
|
.filter((tab) => tab.getBoundingClientRect().width > 0)
|
|
.map((tab) => {
|
|
const label = [...tab.querySelectorAll(".tab-label, .tab-label-short")]
|
|
.find((span) => getComputedStyle(span).display !== "none");
|
|
const style = label ? getComputedStyle(label) : null;
|
|
const labelBox = label?.getBoundingClientRect();
|
|
const tabBox = tab.getBoundingClientRect();
|
|
return {
|
|
text: (label?.textContent ?? "").trim(),
|
|
escapes: labelBox
|
|
? labelBox.left < tabBox.left - 1 || labelBox.right > tabBox.right + 1
|
|
: false,
|
|
truncates: style?.textOverflow === "ellipsis" && style?.overflow !== "visible",
|
|
empty: !labelBox || labelBox.width < 1,
|
|
};
|
|
});
|
|
});
|
|
assert.ok(nav.length >= 4, `the bottom nav has ${nav.length} destinations at ${width}px`);
|
|
const escaped = nav.filter((tab) => tab.escapes).map((tab) => tab.text);
|
|
assert.deepEqual(escaped, [],
|
|
`labels overflowing their tab at ${width}px: ${escaped.join(", ")}`);
|
|
const untruncatable = nav.filter((tab) => !tab.truncates).map((tab) => tab.text);
|
|
assert.deepEqual(untruncatable, [],
|
|
`labels that would be cut rather than ellipsised at ${width}px: ${untruncatable.join(", ")}`);
|
|
const blank = nav.filter((tab) => tab.empty).map((tab, index) => tab.text || `#${index}`);
|
|
assert.deepEqual(blank, [], `destinations with no label at ${width}px: ${blank.join(", ")}`);
|
|
}
|
|
|
|
// A tab whose visible label is shortened for the nav still has to say what
|
|
// it is to anything that reads the page rather than looks at it.
|
|
const named = await page.evaluate(() => {
|
|
const tab = document.querySelector('.tab[data-tab="digital-modes"]');
|
|
const short = tab.querySelector(".tab-label-short");
|
|
return {
|
|
label: tab.getAttribute("aria-label"),
|
|
shortHidden: short?.getAttribute("aria-hidden"),
|
|
};
|
|
});
|
|
assert.equal(named.label, "Digital modes", "the shortened tab lost its full name");
|
|
assert.equal(named.shortHidden, "true", "the short label is read out as well as shown");
|
|
|
|
// Hiding the map's filters leaves the bar, and the bar has to still be
|
|
// there to bring them back — it carries the only button that does.
|
|
await page.setViewportSize({ width: 1400, height: 900 });
|
|
await page.goto(`${fixture.origin}/map`, { waitUntil: "domcontentloaded" });
|
|
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
|
await page.waitForTimeout(1200);
|
|
await page.locator("#map-overlay-toggle-btn").click();
|
|
await page.waitForTimeout(400);
|
|
const collapsed = await page.evaluate(() => {
|
|
const panel = document.querySelector(".map-overlay-panel");
|
|
const style = getComputedStyle(panel);
|
|
const rect = panel.getBoundingClientRect();
|
|
const button = document.getElementById("map-overlay-toggle-btn").getBoundingClientRect();
|
|
return {
|
|
width: Math.round(rect.width),
|
|
height: Math.round(rect.height),
|
|
opacity: Number(style.opacity),
|
|
visibility: style.visibility,
|
|
display: style.display,
|
|
buttonWidth: Math.round(button.width),
|
|
};
|
|
});
|
|
assert.ok(collapsed.width > 0 && collapsed.height > 0,
|
|
`the collapsed bar measures ${collapsed.width}x${collapsed.height}`);
|
|
assert.equal(collapsed.visibility, "visible", "the collapsed bar is not visible");
|
|
assert.notEqual(collapsed.display, "none", "the collapsed bar is display:none");
|
|
assert.equal(collapsed.opacity, 1, `the collapsed bar is at opacity ${collapsed.opacity}`);
|
|
assert.ok(collapsed.buttonWidth > 0, "Show Filters has no size to click");
|
|
|
|
assert.deepEqual(runtimeErrors, []);
|
|
} finally {
|
|
await browser.close();
|
|
await fixture.close();
|
|
}
|