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.
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user