The track list shared out its whole clientWidth across the resizable columns while every row spends 24px on the favourite column and 2x8px on its own padding before the first one starts, so the grid was always exactly 40px wider than the box holding it and the last column was clipped at every size (scrollWidth 1280 vs clientWidth 1240, measured). Both numbers now live in one place and are read by the two call sites that had written them out separately, which is how they came to disagree. The enforced minimum was 512x384, which the layout had never supported: at 700x480 the eleven sidebar items needed 406px of a 352px pane, overflow:hidden cut the last two off with nothing to scroll, and Settings and Jobs could not be reached at all. The pane scrolls now, the sidebar collapses to icons below 900px (its .collapsed mode existed and only a manual drag ever reached it), the subtitle hides at the same breakpoint so the title stops wrapping out of the 4em bar, and the minimum is 800x600 - measured as where the shell still works rather than picked as a round number.
138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
import { test, expect } from '../support/fixtures.js';
|
||
|
||
/**
|
||
* H-7 and H-11, which are one finding seen from two distances: the app
|
||
* did not fit in its own window.
|
||
*
|
||
* `computeDefaultWidths` shared out the track list's whole
|
||
* `clientWidth` and never subtracted the 24 px favourite column or the
|
||
* 2×8 px row padding that `colBoundaryPositions` already knew about, so
|
||
* every row was 40 px wider than the box holding it (`scrollWidth 1280`
|
||
* against `clientWidth 1240`) and Duration was clipped at every size.
|
||
*
|
||
* And the enforced minimum window was 512×384, which the layout had
|
||
* never supported: at 700×480 the eleven sidebar items needed 406 px of
|
||
* a 352 px pane, `overflow: hidden` cut the last two off, nothing
|
||
* scrolled, and **Settings and Jobs could not be reached at all**.
|
||
*
|
||
* The three viewports are the two common ones and the new enforced
|
||
* minimum (`backend/config/window.go`). The minimum is the one that
|
||
* matters: it is the only size the app is *promising* to work at.
|
||
*/
|
||
|
||
/** Keep in step with `MinWidth`/`MinHeight` in backend/config/window.go. */
|
||
const MIN_VIEWPORT = { width: 800, height: 600 };
|
||
|
||
const VIEWPORTS = [
|
||
{ name: '1440×900', width: 1440, height: 900 },
|
||
{ name: '1024×768', width: 1024, height: 768 },
|
||
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
|
||
];
|
||
|
||
/**
|
||
* Rows report their own overflow, which is the symptom a user sees: a
|
||
* column rendered past the edge of the row it belongs to.
|
||
*/
|
||
const rowOverflow = (page: import('@playwright/test').Page) =>
|
||
page.evaluate(() => {
|
||
const list = document.querySelector('track-list');
|
||
const root = list?.shadowRoot;
|
||
|
||
if (!root) return { rows: 0, overflowing: [] as string[] };
|
||
|
||
const boxes = [
|
||
...root.querySelectorAll<HTMLElement>('.header-row, .track-row'),
|
||
];
|
||
|
||
return {
|
||
rows: boxes.length,
|
||
overflowing: boxes
|
||
.filter((b) => b.scrollWidth > b.clientWidth)
|
||
.map((b) => `${b.className}: ${b.scrollWidth} > ${b.clientWidth}`),
|
||
};
|
||
});
|
||
|
||
test.describe('the app fits in its own window', () => {
|
||
for (const vp of VIEWPORTS) {
|
||
test(`no track row overflows at ${vp.name}`, async ({ app }) => {
|
||
await app.setViewportSize({ width: vp.width, height: vp.height });
|
||
await app.getByTestId('nav-tracks').click();
|
||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||
'data-active-view',
|
||
'tracks',
|
||
);
|
||
|
||
// The columns are recomputed by a ResizeObserver, so poll rather
|
||
// than read once: a single read races the resize and passes on
|
||
// the widths from the previous viewport.
|
||
await expect.poll(async () => (await rowOverflow(app)).rows).toBeGreaterThan(1);
|
||
await expect
|
||
.poll(async () => (await rowOverflow(app)).overflowing)
|
||
.toEqual([]);
|
||
});
|
||
}
|
||
|
||
test('every destination stays reachable at the minimum size', async ({
|
||
app,
|
||
}) => {
|
||
await app.setViewportSize(MIN_VIEWPORT);
|
||
|
||
// Below the breakpoint the sidebar collapses to icons; the labels
|
||
// go, the destinations do not.
|
||
const sidebar = app.locator('app-sidebar');
|
||
|
||
await expect
|
||
.poll(() =>
|
||
sidebar.evaluate((el) => el.classList.contains('collapsed')),
|
||
)
|
||
.toBe(true);
|
||
|
||
// Settings and Jobs are the two that were unreachable: they are
|
||
// last in the nav, and the pane used to clip rather than scroll.
|
||
for (const view of ['jobs', 'settings'] as const) {
|
||
const item = app.getByTestId(`nav-${view}`);
|
||
|
||
await item.scrollIntoViewIfNeeded();
|
||
await item.click();
|
||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||
'data-active-view',
|
||
view,
|
||
);
|
||
}
|
||
|
||
// Leave the app where the next spec expects to find it.
|
||
await app.getByTestId('nav-tracks').click();
|
||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||
'data-active-view',
|
||
'tracks',
|
||
);
|
||
});
|
||
|
||
test('the sidebar scrolls rather than hiding what does not fit', async ({
|
||
app,
|
||
}) => {
|
||
await app.setViewportSize({ width: 700, height: 480 });
|
||
|
||
// Smaller than the enforced minimum on purpose: the window cannot
|
||
// be dragged here, but a scaled display or a large system font can
|
||
// still land the layout in it, and clipping the nav with no scroll
|
||
// is the failure that made Settings unreachable.
|
||
const reachable = await app.locator('app-sidebar').evaluate((el) => {
|
||
const settings = el.shadowRoot?.querySelector<HTMLElement>(
|
||
'[data-testid="nav-settings"]',
|
||
);
|
||
|
||
if (!settings) return null;
|
||
|
||
el.scrollTop = el.scrollHeight;
|
||
|
||
const item = settings.getBoundingClientRect();
|
||
const pane = el.getBoundingClientRect();
|
||
|
||
return item.bottom <= Math.ceil(pane.bottom) && item.top >= Math.floor(pane.top);
|
||
});
|
||
|
||
expect(reachable).toBe(true);
|
||
});
|
||
});
|