import { test, expect } from '../support/fixtures.js'; /** * The top bar fits the window it is in (#143). * * **This is measured per child, not on the shell**, which is #69's * lesson repeated one component over: `layout-overflow.spec.ts` asserts * the *document* needs no sideways scrolling, and clipping inside a * component is invisible to it — which is exactly why that spec was * green throughout this defect. What a user sees is a control rendered * past the edge of the bar it belongs to, so that is what is asserted. * * **And it is measured with a job running**, which is the half the * original report missed. `job-indicator` is `hidden` while idle and up * to 235px wide when it is not, so the bar was 611px inside 600 sitting * still and 862px during a scan — 171 to 262px of overflow, arriving * exactly when a user has reason to look at that bar. Nothing else in * this suite has ever measured a layout with work in flight; * `/__test/emit` stages it without staging the scan. */ type Page = import('@playwright/test').Page; /** * The widths this asks about. * * 600 is the bottom of the Compact band (#24) and where the defect * lands; 899 and 900 straddle `nav-history` appearing (68px more to * find, at the width that just gained the sidebar's labels); 800 is the * enforced minimum. * * **390 is kept, and what it asks changed with #57.** There is no bar * to fit below 600px any more — it is out of the grid and visually * hidden — so "nothing hangs out of it" is a claim about an element * with no row, and would pass on a build that had merely broken the * bar. Dropping the width would be dropping the one place this file * can still say something true about a phone, so it asserts the * *stronger* property instead, below: the bar is out of the layout * altogether, which is the thing #57 wanted and the thing that makes * fitting moot. */ const WIDTHS = [600, 800, 899, 900, 1440]; /** Where #57 leaves the bar, and where the desktop still has one. */ const PHONE_WIDTH = 390; /** * A scan whose title is as long as a real one gets. The label is capped * at 12rem by the component, so this is the widest the indicator can * be — measuring with "Scanning" instead reports a bar that fits and a * defect that is 100px smaller than it is. */ const LONG_JOB = { id: 'top-bar-fit', kind: 'library-scan', state: 'running', title: 'Scanning Music from the external drive', current: 40, total: 100, }; /** * Every child's right edge against the bar's own content box. * * The content box, not `clientWidth`: the bar has a 2em right gutter, * and a control sitting in the padding is already the failure — it is * simply one that `scrollWidth` under-reports, because `scrollWidth` * counts the left padding and not the right. */ const overflowingChildren = (page: Page) => page.evaluate(() => { const bar = document.querySelector('header.top-bar')!; const style = getComputedStyle(bar); const box = bar.getBoundingClientRect(); const left = box.left + parseFloat(style.paddingLeft); const right = box.right - parseFloat(style.paddingRight); return [...bar.children] .filter((child) => { const cs = getComputedStyle(child); // Out of flow is out of the question: a collapsed wordmark is // `position: absolute` and 1px wide precisely so it costs the // row nothing. if (cs.display === 'none' || cs.position === 'absolute') return false; const r = child.getBoundingClientRect(); return r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5); }) .map((child) => { const r = child.getBoundingClientRect(); return `${child.tagName.toLowerCase()}: ${Math.round(r.left)}..${Math.round(r.right)} outside ${Math.round(left)}..${Math.round(right)}`; }); }); /** What the fit pass gave up, read back off the DOM it changed. */ const collapsed = (page: Page) => page.evaluate(() => ({ wordmark: !!document.querySelector('header.top-bar hgroup.yj-collapsed'), jobLabel: !!document.querySelector('job-indicator[compact]'), })); test.describe('the top bar fits the window', () => { /** * The phone's answer, which is not "it fits" (#57). * * The bar has no grid row below 600px, so measuring its children * against its content box is measuring a 1px box that is already * invisible — a fit pass would collapse the wordmark every time and * report success about nothing, which is why `measureTopBarFit` * declines to run at all when the bar is out of flow. What is worth * asserting here is that the fit pass has not quietly started * *undoing* that: a rule that put the bar back in the layout would * pass every assertion in this file and cost a 439px screen 12% of * its height. */ test(`the bar is out of the layout at ${PHONE_WIDTH}px, with a job running`, async ({ app, testctl, }) => { await app.setViewportSize({ width: PHONE_WIDTH, height: 600 }); await testctl.emit('JobsChanged', [LONG_JOB]); // Not merely hidden: `display: none` on the header would satisfy // "invisible" and leave the 3.25em row exactly where it was. So // the assertion is that the content starts where the row above it // ends -- and with a job staged, the row above it is the jobs // band, which is the whole reason this row could go. await expect .poll(() => app.evaluate(() => { const bar = document.querySelector('header.top-bar')!; const main = document.querySelector('.main-panel')!; const band = document.querySelector('job-band')!; return { position: getComputedStyle(bar).position, gap: Math.round(main.getBoundingClientRect().top) - Math.round(band.getBoundingClientRect().bottom), }; }), ) .toEqual({ position: 'absolute', gap: 0 }); // And the work is still visible, in the band that replaced the // indicator (#62) — which is what made this row removable at all. await expect(app.locator('job-indicator')).toBeHidden(); await expect(app.locator('job-band').locator('job-row')).toHaveCount(1); await app.setViewportSize({ width: 1440, height: 900 }); }); for (const width of WIDTHS) { test(`no control sits outside the bar at ${width}px, idle`, async ({ app, }) => { await app.setViewportSize({ width, height: 600 }); await expect.poll(() => overflowingChildren(app)).toEqual([]); }); test(`no control sits outside the bar at ${width}px, with a job running`, async ({ app, testctl, }) => { await app.setViewportSize({ width, height: 600 }); await testctl.emit('JobsChanged', [LONG_JOB]); // The indicator has to actually be up, or this test passes by // measuring the idle case under another name. await expect(app.locator('job-indicator')).toBeVisible(); await expect.poll(() => overflowingChildren(app)).toEqual([]); }); } /** * The other half of "measured, never breakpointed": a rule that * collapses defensively at every narrow width fits just as well and * is a worse app. 1440 is roomy at any job title; 899 was measured to * fit with the longest one, because `nav-history` is not there yet. */ test('nothing is given up where there is room for it', async ({ app, testctl, }) => { await app.setViewportSize({ width: 1440, height: 900 }); await testctl.emit('JobsChanged', [LONG_JOB]); await expect(app.locator('job-indicator')).toBeVisible(); await expect.poll(() => collapsed(app)).toEqual({ wordmark: false, jobLabel: false, }); }); /** * And it gives them back. The pass starts from all-visible every * time, so this is the property that a rule which only ever *added* * to the collapsed set would fail — the wordmark would be gone for * the rest of the session after one narrow moment. */ test('the wordmark comes back when the window does', async ({ app, testctl, }) => { await testctl.emit('JobsChanged', [LONG_JOB]); await app.setViewportSize({ width: 600, height: 600 }); await expect.poll(() => collapsed(app)).toEqual({ wordmark: true, jobLabel: true, }); await app.setViewportSize({ width: 1440, height: 900 }); await expect.poll(() => collapsed(app)).toEqual({ wordmark: false, jobLabel: false, }); }); /** * The wordmark yields its width and not its existence: `display: * none` would take the document from one top-level heading to none. */ test('the collapsed wordmark is still the document heading', async ({ app, testctl, }) => { await testctl.emit('JobsChanged', [LONG_JOB]); await app.setViewportSize({ width: 600, height: 600 }); await expect.poll(() => collapsed(app)).toMatchObject({ wordmark: true }); await expect( app.getByRole('heading', { name: 'YellowJacket', level: 1 }), ).toHaveCount(1); }); /** * And the indicator keeps saying what it is doing after its visible * label goes — the `sr-only` live region is what announces the state, * which is the same argument the phone's own rule was written on. */ test('the job indicator still announces its state without its label', async ({ app, testctl, }) => { await testctl.emit('JobsChanged', [LONG_JOB]); await app.setViewportSize({ width: 600, height: 600 }); await expect.poll(() => collapsed(app)).toMatchObject({ jobLabel: true }); const spoken = await app .locator('job-indicator') .evaluate( (el) => el.shadowRoot?.querySelector('[aria-live]')?.textContent?.trim() ?? '', ); expect(spoken).toContain('Scanning Music from the external drive'); // Leave the app as the next spec expects to find it. await app.setViewportSize({ width: 1440, height: 900 }); }); });