From dee176c0f725a975de6c96942d46eb3648f772bb Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 21 Aug 2026 15:41:59 -0400 Subject: [PATCH] test(player): pin the art's shape and the short-screen arrangement Four of these eight fail on the build before the fix, with the numbers the issue is about: 263x39 at 424x439, 358x315 at 390x700, 300x36 at 900x500, and no row at all below 500px. The fifth viewport, 412x869, passes on both -- which is the boundary landing exactly where the arithmetic says it should, since the leftover only exceeds the width above ~843. Three of them cannot fail on the old build and are said to be guards rather than evidence: that the transport does not scroll off (the old build shrank the art instead, so it did not scroll either), that a tall phone keeps its column, and -- after a first draft that passed on the defect because the subtraction went negative -- a floor on the art at the device's own viewport instead of a comparison with a layout that is no longer there. The wait is on the arrangement rather than on a non-zero box: a previous test leaves the other layout on screen and a stale column satisfies "has a size" perfectly, which showed up as one test passing alone and failing in file order. What this tier cannot see is the device's engine. Nothing here depends on Chrome 113 behaviour -- the sizing rules were chosen by measuring that engine directly, and the numbers are on the issue. --- e2e/specs/phone-now-playing.spec.ts | 267 ++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 e2e/specs/phone-now-playing.spec.ts diff --git a/e2e/specs/phone-now-playing.spec.ts b/e2e/specs/phone-now-playing.spec.ts new file mode 100644 index 0000000..56545d5 --- /dev/null +++ b/e2e/specs/phone-now-playing.spec.ts @@ -0,0 +1,267 @@ +import { test, expect } from '../support/fixtures.js'; + +/** + * Now Playing on a short screen (#51). + * + * #51 asks for a layout that "survives" ~424x439 with the controls + * never scrolling off. #172 measured why it did not — the stacked + * layout's budget is fixed, so the art gets whatever is left, and that + * was 39px before #64 and 53px after it. + * + * **Two separate claims are asserted here, and only one of them is + * about the phone.** + * + * The first is that the art is *square*. It was not: `aspect-ratio` is + * specified not to re-derive the width when `max-height` clamps the + * height, so the art was drawn as a letterbox band and `object-fit: + * cover` cropped the cover to it — 264x53 on the reference device. The + * leftover only exceeds the width above ~843px of viewport, so this + * was every height from ~500 to ~843 as well: most phones, and any + * short window. That is ordinary CSS rather than a Chrome 113 quirk, + * so this tier can see it, and the heights below are chosen to cover + * the range rather than the one device. + * + * The second is the reflow: below 500px the art and the names sit side + * by side, which is what takes the art from 53px to 143px. That is + * asserted as a *relation between boxes* — the art beside the names, + * not above them — because the pixel count is a consequence of the + * arrangement and would pin this file to one device's chrome. + * + * **What this tier cannot see** is the device's engine: CI's Chromium + * and WebKit are current, and #60's clipping showed what that costs. + * Nothing here depends on Chrome 113 behaviour — the sizing rules were + * checked against the device itself, at column heights of 288, 300, + * 451, 600 and 800, and the numbers are on #51. + */ +type Page = import('@playwright/test').Page; + +/** The reference device's real viewport. */ +const DEVICE = { width: 424, height: 439 }; + +/** + * A tall phone, above the reflow's 500px. Roughly a Pixel 7, which is + * #51's other named device and was not attached — so what is checked + * here is the layout it *should* get, not that device. + */ +const TALL_PHONE = { width: 412, height: 869 }; + +/** Inside the crop's old range and above the reflow: a short window. */ +const SHORT_WINDOW = { width: 390, height: 700 }; + +/** + * The height the layout reflows at. Written down once here because the + * specs have to know which arrangement to *wait* for, not only which + * to assert. + */ +const REFLOW_AT = 500; + +/** Put a track in the player, so the view has art and names to lay out. */ +async function stageATrack(page: Page): Promise { + await page.evaluate(async () => { + const tracks = (await window.__yjEvents.call( + 'library.Library.GetTracks', + [0], + 10_000, + )) as { FilePath: string }[]; + + await window.__yjEvents.call( + 'queue.Queue.SetQueue', + [tracks.slice(0, 4).map((t) => t.FilePath), 0, false, { type: '', id: 0, label: '' }], + 10_000, + ); + }); +} + +/** Open the full-screen view and wait for the shell to say so. */ +async function openNowPlaying(page: Page): Promise { + await page.evaluate(() => { + document.dispatchEvent( + new CustomEvent('navigate', { + detail: { view: 'now-playing' }, + bubbles: true, + }), + ); + }); + + await expect(page.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'now-playing', + ); + + // The attribute is the shell's bookkeeping and lands before the view + // has a track, so measuring on it alone races the first layout -- + // which showed up as a 60x5 art on the first spec of a cold run. + // + // Waiting for a non-zero box is not enough on its own either: a + // previous test leaves the *other* arrangement on screen, and a + // stale column satisfies "has a size" perfectly. So the wait is for + // the arrangement this viewport should have, which is the thing + // every assertion below depends on. Found by this file passing one + // test at a time and failing in file order. + const wantRow = (page.viewportSize()?.height ?? 0) <= REFLOW_AT; + + await page.waitForFunction( + (row: boolean) => { + const v = document.querySelector('now-playing-view'); + const stack = v?.shadowRoot?.querySelector('.stack'); + const el = v?.shadowRoot?.querySelector('.art img, .art .placeholder'); + const t = v?.shadowRoot?.querySelector('.transport'); + + if (!stack || !el || !t) return false; + + const dir = getComputedStyle(stack).flexDirection; + + if (dir !== (row ? 'row' : 'column')) return false; + + const r = el.getBoundingClientRect(); + + return r.width > 0 && r.height > 0 && t.getBoundingClientRect().height > 0; + }, + wantRow, + ); +} + +/** + * The boxes this file reasons about, read in one evaluate. + * + * It reaches into the view's shadow root rather than using locators + * because the question is geometric — where these boxes are *relative + * to each other* — and a testid per edge would be four locators and + * four round trips to say one thing. + */ +async function boxes(page: Page) { + return page.evaluate(() => { + const v = document.querySelector('now-playing-view'); + + if (!v || !v.shadowRoot) return null; + + const rect = (sel: string) => { + const el = v.shadowRoot!.querySelector(sel); + + if (!el) return null; + + const r = el.getBoundingClientRect(); + + return { + left: r.left, right: r.right, top: r.top, bottom: r.bottom, + width: r.width, height: r.height, + }; + }; + + return { + // Whichever of the two the track has; both carry the sizing. + art: rect('.art img') ?? rect('.art .placeholder'), + artBox: rect('.art'), + meta: rect('.meta'), + transport: rect('.transport'), + scrollHeight: v.scrollHeight, + clientHeight: v.clientHeight, + }; + }); +} + +test.describe('Now Playing survives a short screen', () => { + test.beforeEach(async ({ app }) => { + await stageATrack(app); + }); + + /** + * The crop, at four heights spanning the range it covered. This is + * the assertion that fails on the build before this change: at + * 424x439 the art measured 264x53. + */ + for (const vp of [DEVICE, SHORT_WINDOW, TALL_PHONE, { width: 900, height: 500 }]) { + test(`draws the art square at ${vp.width}x${vp.height}`, async ({ app }) => { + await app.setViewportSize(vp); + await openNowPlaying(app); + + const b = await boxes(app); + + expect(b, 'now-playing-view did not mount').not.toBeNull(); + expect(b!.art, 'neither art nor placeholder rendered').not.toBeNull(); + + const { width, height } = b!.art!; + + expect(width, 'the art has no width').toBeGreaterThan(0); + // One pixel of slack for sub-pixel layout, and no more: the + // defect this guards was a 5:1 band. + expect( + Math.abs(width - height), + `art is ${Math.round(width)}x${Math.round(height)}, not square`, + ).toBeLessThanOrEqual(1); + }); + } + + /** + * The promise #51 states and plan 018's matrix repeats. A floor on + * the art with the block scrolling was the other option on #172 and + * this is why it was not taken. + */ + test('never scrolls the transport off the bottom', async ({ app }) => { + await app.setViewportSize(DEVICE); + await openNowPlaying(app); + + const b = await boxes(app); + + expect(b!.transport!.bottom).toBeLessThanOrEqual(DEVICE.height); + expect( + b!.scrollHeight, + 'the view scrolls, so the transport can be moved off screen', + ).toBeLessThanOrEqual(b!.clientHeight + 1); + }); + + /** + * The reflow itself, as a relation rather than a measurement: below + * 500px the names are *beside* the art, above it they are below. + */ + test('puts the names beside the art below 500px', async ({ app }) => { + await app.setViewportSize(DEVICE); + await openNowPlaying(app); + + const b = await boxes(app); + + expect( + b!.meta!.left, + 'the names are not to the right of the art', + ).toBeGreaterThanOrEqual(b!.artBox!.right - 1); + }); + + test('keeps the names below the art on a tall phone', async ({ app }) => { + await app.setViewportSize(TALL_PHONE); + await openNowPlaying(app); + + const b = await boxes(app); + + expect( + b!.meta!.top, + 'the names are not below the art', + ).toBeGreaterThanOrEqual(b!.artBox!.bottom - 1); + }); + + /** + * The reflow is only worth having if it buys something, and the + * honest form of that is a floor on the device's own viewport + * rather than a comparison with a layout that is no longer there. + * + * The first draft compared the art against the column's leftover + * computed from the boxes on screen, and it passed on the build + * before this change as well -- the subtraction goes negative when + * the names are taller than the art, which is precisely the broken + * state. A test that cannot fail on the defect is not evidence. + * + * 100 is a floor, not the measurement: the device draws 143 and CI's + * chrome differs by whatever the volume control adds, so pinning the + * exact number would make this a test about the runner. + */ + test('gives the art a real size on the reference device', async ({ app }) => { + await app.setViewportSize(DEVICE); + await openNowPlaying(app); + + const b = await boxes(app); + + expect( + b!.art!.height, + 'the art is still a sliver at the size #51 is about', + ).toBeGreaterThan(100); + }); +});