import { test, expect, openTheQueue } from '../support/fixtures.js'; /** * #55 — the queue is a *place* while it covers the content, and a * *control* while it sits beside it. * * #24 already made the pixels right: measured at the reference device's * 424×439, the overlaid panel is 424×318, which is `.main-panel`'s rect * exactly. What was missing was the navigation model, and the defect was * measurable in one line — opening the queue on Artists and pressing * back moved the page *underneath* to Albums and left the queue up. A * back press that changes something the user cannot see, and costs them * their place, is the whole of "it does not flow". * * **These assert the entry, not the attribute.** The temptation is to * check `#queue-button[aria-expanded]` and stop, which is the shell's * own bookkeeping and was right throughout the bug: what has to be true * is that *one* back press closes the queue and the *next* one * navigates. Asserting only the first would pass on a build that * orphans the entry, which is the defect moved one press later — the * same trap `back-navigation.spec.ts` documents about `data-active-view` * and `layout-overflow.spec.ts` set for #69. * * **Three of these nine fail on the build before #55**, and the other * six cannot, which is worth knowing before trusting them: "the entry * is not orphaned" and "the column is not in the stack" are both * vacuously true of a build that pushes no entry at all, and the * containment assertion pins the mount that was *not* taken. They guard * the next change rather than reproducing this one — the three that * reproduce it are the two back-press tests and the touch target. */ type Page = import('@playwright/test').Page; /** The reference device's real viewport, not a resized desktop. */ const DEVICE = { width: 424, height: 439 }; /** Wide enough that the queue is a column: 1280 − 200 − 320 ≥ 480. */ const DESKTOP = { width: 1280, height: 800 }; /** * The Compact band, where the queue is a *screen* (644 − 320 < 480) and * the bottom bar still carries its button. * * Two of these tests need both facts at once and only this band has * them: below 600px #59 takes the button off the bar, so there is no * toggle to re-press and the queue is opened from Now Playing — which * is itself a detail view, so "the destination stays lit" is vacuously * true there rather than tested. */ const COMPACT = { width: 700, height: 600 }; const activeView = (page: Page) => page.getByTestId('main-content'); const queue = (page: Page) => page.locator('#queue-panel'); const toggle = (page: Page) => page.locator('#queue-button'); /** * Whether the queue is up. * * The panel's own attribute rather than the toggle's `aria-expanded`, * because below 600px there is no toggle to ask (#59) — and the panel * is the one fact both of them reflect anyway. */ async function expectQueue(page: Page, open: boolean): Promise { const panel = queue(page); if (open) { await expect(panel).toHaveAttribute('open', ''); } else { await expect(panel).not.toHaveAttribute('open', ''); } } test.describe('the queue is a screen where it covers the content', () => { test.beforeEach(async ({ app }) => { await app.setViewportSize(DEVICE); await app.getByTestId('tab-albums').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); }); // On a phone the queue is opened from Now Playing (#59), so the page // *underneath* it is `now-playing` and the journey is two entries // deep: albums -> now-playing -> queue. That is the real route a user // takes, which is why these do not reach for the shortcut. test('back closes the queue and leaves the page where it was', async ({ app, }) => { await expect(queue(app)).toHaveAttribute('overlay', ''); await openTheQueue(app); await expectQueue(app, true); await app.goBack(); await expectQueue(app, false); // The page underneath is untouched. Before #55 this was the // *previous* view, because the queue was not in the stack at all // and back spent an entry navigating something nobody could see. await expect(activeView(app)).toHaveAttribute( 'data-active-view', 'now-playing', ); }); test('costs exactly one entry, so the next press navigates', async ({ app, }) => { await openTheQueue(app); await expectQueue(app, true); await app.goBack(); await expectQueue(app, false); await expect(activeView(app)).toHaveAttribute( 'data-active-view', 'now-playing', ); await app.goBack(); // Exactly one entry each: the second press leaves Now Playing for // the page it was opened from, rather than being swallowed by a // queue that had already closed. await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); }); /** * Every route out unwinds the entry, and they do it through the * panel's own `open` attribute rather than each knowing about * history — which is why a fourth route added later gets this free. * * The failure this pins is silent: close by button, and if the entry * is orphaned the app looks correct until the next back press does * nothing at all. It is a guard rather than a reproduction — a build * with no entry to orphan passes it — and it is paired with the two * above, which do reproduce. */ for (const [name, dismiss] of [ [ 'the close button', async (app: Page) => { await app.getByRole('button', { name: 'Close queue' }).click(); }, ], [ 'Escape', async (app: Page) => { await app.keyboard.press('Escape'); }, ], ] as Array<[string, (app: Page) => Promise]>) { test(`${name} leaves no entry behind`, async ({ app }) => { await openTheQueue(app); await expectQueue(app, true); await dismiss(app); await expectQueue(app, false); await app.goBack(); // One press, one screen: Now Playing is what the queue was opened // from, so leaving it lands on Albums. An orphaned entry would // have spent this press on nothing and left it here. await expect(activeView(app)).toHaveAttribute( 'data-active-view', 'albums', ); }); } /** * A detail view leaves the destination it was opened from lit * (`active-view-store`, #72), and the queue inherits that — it is * published with `isPrimary: false`, so `isActive('albums')` is still * true underneath it. * * `aria-current` rather than a class, for the reason * `back-navigation.spec.ts` gives: the class was right throughout the * bug that rule exists for. */ /** * With the panel spanning the whole width there is no scrim here at * all (#171), so the close button is the only pointer route out of a * full-screen surface. Measured at 424×439 before #55: **25×21px**. */ test('offers a way out a thumb can hit', async ({ app }) => { await openTheQueue(app); const box = await app .getByRole('button', { name: 'Close queue' }) .boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeGreaterThanOrEqual(44); expect(box!.height).toBeGreaterThanOrEqual(44); }); /** * #171 — and it draws no scrim, because there is nowhere to tap. * * `.panel-content` is `width: 100%` here, so the scrim sat entirely * underneath it: measured at 424×439, host, panel and scrim all * 424×318. #24's tap-outside-to-close cannot exist on a surface with * no outside, and a `cursor: pointer` layer nobody can reach is a * claim the component cannot keep. * * Asserted as absence rather than by clicking, for the reason the * issue gives: a naive phone case clicks the scrim's centre and hits * the panel, so it passes on the build this exists to fail. The scrim * is still real between 600 and 899px, which `queue-overlay.spec.ts` * asserts at 900×600 by clicking it. */ test('draws no scrim, because a screen has no outside to tap', async ({ app, }) => { await openTheQueue(app); const scrim = await queue(app).evaluate( (el) => el.shadowRoot!.querySelector('.scrim') !== null, ); expect(scrim).toBe(false); }); }); /** * **The mechanism, because no tier here can see the consequence.** * * #55's Direction asked for a `DETAIL_LOADERS` mount, which would put * the panel inside `.main-panel > *`. That box is paint-contained under * a `.main-panel` that is too, and `contain: paint` makes an element a * containing block for fixed descendants *and clips them* — which is * what a `wa-popup` falls back to on the reference device's Chrome 113, * where the Popover API does not exist (#60, `.planning/NOTES.md`). * `queue-panel` has a context menu, so that mount would have broken a * working menu on the one device this issue is about. * * CI's Chromium and WebKit both *have* the Popover API, so the menu is * top-layered and correct here either way: a spec asserting "the menu is * not clipped" is green on the broken build. What a browser can answer * honestly is where the element is, so that is what this asks. */ test('the panel stays out of the paint-contained region', async ({ app }) => { await app.setViewportSize(DEVICE); // Open, because that is the only state in which a menu can be opened // from it — and because the host drops `paint` from its own // containment deliberately in overlay mode, so a closed panel answers // a different question. await openTheQueue(app); await expectQueue(app, true); const ancestry = await app.evaluate(() => { const chain: Array<{ tag: string; contain: string }> = []; for ( let el = document.getElementById('queue-panel'); el && el !== document.documentElement; el = el.parentElement ) { chain.push({ tag: el.tagName.toLowerCase(), contain: getComputedStyle(el).contain, }); } return chain; }); expect(ancestry.length).toBeGreaterThan(1); expect(ancestry.some((a) => a.tag === 'main')).toBe(false); for (const { tag, contain } of ancestry) { expect( `${tag}: ${contain}`, 'a paint-contained ancestor clips a fixed-positioned popup on Chrome 113', ).not.toMatch(/paint|content|strict/); } }); /** * Two properties need the queue to be a *screen* and the bar to still * have its button, and only the Compact band has both — below 600px #59 * takes the button off the bar. */ test.describe('a screen opened from the bar', () => { test.beforeEach(async ({ app }) => { await app.setViewportSize(COMPACT); await app.getByTestId('nav-albums').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); await expect(queue(app)).toHaveAttribute('overlay', ''); }); /** * A detail view leaves the destination it was opened from lit * (`active-view-store`, #72), and the queue inherits that — it is * published with `isPrimary: false`, so `isActive('albums')` is still * true underneath it. * * `aria-current` rather than a class, for the reason * `back-navigation.spec.ts` gives: the class was right throughout the * bug that rule exists for. */ test('leaves the destination it was opened from highlighted', async ({ app, }) => { // By testid, not by role: at 700px the sidebar is in icon mode, so // what the item is *named* is a different question from which item // it is. The assertion is still `aria-current`, which is the // accessible fact. const albums = app.getByTestId('nav-albums'); await expect(albums).toHaveAttribute('aria-current', 'page'); await toggle(app).click(); await expectQueue(app, true); await expect(albums).toHaveAttribute('aria-current', 'page'); }); /** The toggle is a fourth way out, and it unwinds the entry like the * other three — through the panel's attribute, not its own handler. */ test('closes from the same toggle, leaving no entry behind', async ({ app, }) => { await toggle(app).click(); await expectQueue(app, true); await toggle(app).click(); await expectQueue(app, false); await app.goBack(); await expect(activeView(app)).not.toHaveAttribute( 'data-active-view', 'albums', ); }); }); /** * The column is not a place. Somebody docked it; back must not undock * it, and navigating to another view must not take it away. * * This is the half a viewport breakpoint would get wrong: the mode is * computed from the panel's own drag-resizable width, so the queue * becomes a screen exactly when it stops being affordable as a column. */ test.describe('a docked queue is not in the back stack', () => { test.beforeEach(async ({ app }) => { await app.setViewportSize(DESKTOP); }); test('survives a navigation, and back navigates the page', async ({ app, }) => { await app.getByTestId('nav-albums').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); await toggle(app).click(); await expectQueue(app, true); await expect(queue(app)).not.toHaveAttribute('overlay', ''); await app.getByTestId('nav-artists').click(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists'); await expectQueue(app, true); await app.goBack(); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); await expectQueue(app, true); }); });