Files
yellowjacket/e2e/specs/page-header.spec.ts
T
logan b7dc368d7c
Build & publish Arch package / arch-package (push) Successful in 2m0s
CI / check (push) Canceled after 14s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
feat(ui): give every primary view the same page header
Four views had a heading and four did not, two had a sort control and
none showed a count, so the app changed shape as you moved through it
and "how many albums have I got" could only be answered by counting.
The reason they disagreed is that each had written its own arrangement:
the sort toolbar existed three times, in track-list, cover-grid and
playlist-view, as the same twenty lines with different bugs.

<page-header> is that arrangement once - title, count, sort, actions -
and nine views adopt it. Artists and Genres gain the sort control they
never had; Artists sorts by name only, because library.Artist carries
nothing countable, so the header renders a label and a direction button
rather than a select with one option in it. The header keeps its place
while a view loads: a heading that appears only once the data does is
the shifting layout this is meant to stop. The count is omitted, not
zero, until the view has an answer.

The header search box keeps its slot on every view instead of vanishing
on the ones it cannot serve - which is what moved the library filter
and the job indicator on every navigation. It is view-scoped by
decision and now says so: "Search albums" in the placeholder, the scope
named in the header ("Showing artists matching 'tide'"), and disabled
with a reason where there is nothing to search or the page has a search
of its own.

Also fixes an e2e trap this uncovered: the view-lifecycle spec toggled
shuffle and never toggled it back, so a second run against the same app
failed playback.spec's shuffle assertion - a failure that reads exactly
like a regression in whatever you are holding.
2026-08-12 02:35:35 -04:00

143 lines
4.7 KiB
TypeScript

import { test, expect } from '../support/fixtures.js';
/**
* H-19: Playlists, Downloads, Jobs, Settings and Home had a page
* title; Artists, Genres, Albums and Tracks had none, none of the nine
* showed a count, and two of them had a sort control that the other
* seven had written out (or not) for themselves.
*
* This is the spec that stops the tenth view inventing a tenth
* arrangement: every primary view is asked for its heading, and the
* ones that show a list are asked for a count as well.
*/
/** view id -> [heading, has a count] */
const VIEWS: [string, string, boolean][] = [
['home', 'Home', false],
['playlists', 'Playlists', true],
['artists', 'Artists', true],
['genres', 'Genres', true],
['albums', 'Albums', true],
['tracks', 'Tracks', true],
['explore', 'Explore', false],
['downloads', 'Downloads', false],
['jobs', 'Background jobs', false],
];
/** The header lives in the view's shadow root, inside its own. */
const header = (page: import('@playwright/test').Page, view: string) =>
page.evaluate((v) => {
const el = document.querySelector(`[data-testid="main-content"] ${v}`);
const ph = el?.shadowRoot?.querySelector('page-header');
const root = ph?.shadowRoot;
return {
heading:
root
?.querySelector('[data-testid="page-heading"]')
?.textContent?.trim() ?? null,
count:
root
?.querySelector('[data-testid="page-count"]')
?.textContent?.trim() ?? null,
};
}, view);
/** Which element a view id renders as. */
const TAGS: Record<string, string> = {
home: 'home-view',
playlists: 'playlist-view',
artists: 'artists-view',
genres: 'genres-view',
albums: 'cover-grid',
tracks: 'track-list',
explore: 'explore-view',
downloads: 'downloads-view',
jobs: 'jobs-view',
};
test.describe('every primary view says what it is', () => {
test('each one has the shared header, with a heading', async ({ app }) => {
for (const [view, heading, hasCount] of VIEWS) {
await app.getByTestId(`nav-${view}`).click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
view,
);
// Poll: a view is a chunk, so the first visit awaits an import.
await expect
.poll(async () => (await header(app, TAGS[view]!)).heading)
.toBe(heading);
if (hasCount) {
// Polled, not read: the count is deliberately absent until the
// view has an answer — "0 albums" while loading is a lie that
// corrects itself, which is worse than saying nothing.
await expect
.poll(async () => (await header(app, TAGS[view]!)).count)
.toMatch(/^[\d,]+ \w+$/);
} else {
expect((await header(app, TAGS[view]!)).count).toBeNull();
}
}
// Leave the app where the next spec expects it.
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test('the count is of what is on screen, not of the library', async ({
app,
}) => {
await app.getByTestId('nav-artists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'artists',
);
// Polled, and cleared first: the term survives navigation (by
// decision), and the count is absent until the view has an answer
// — so reading either one straight after a click can capture null
// and make every assertion after it meaningless.
await app.getByTestId('search-input').fill('');
await expect
.poll(async () => (await header(app, 'artists-view')).count)
.toMatch(/^[\d,]+ \w+$/);
const all = (await header(app, 'artists-view')).count;
// The header search is view-scoped by decision (Decisions, 2), and
// the header is where that scope is finally admitted to.
await app.getByTestId('search-input').fill('aurora');
await expect
.poll(async () => (await header(app, 'artists-view')).count)
.not.toBe(all);
const scope = await app.evaluate(() =>
document
.querySelector('[data-testid="main-content"] artists-view')
?.shadowRoot?.querySelector('page-header')
?.shadowRoot?.querySelector('[data-testid="page-search-scope"]')
?.textContent?.trim(),
);
expect(scope).toContain('artists matching');
await app.getByTestId('search-input').fill('');
await expect
.poll(async () => (await header(app, 'artists-view')).count)
.toBe(all);
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
});