Files
yellowjacket/frontend/test/components/home-view.test.ts
T
logan f967916550
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m36s
CI / e2e (pull_request) Successful in 7m17s
fix(page-header): collapse the actions that do not fit into a menu
Playlists slotted three buttons totalling 390px into a header that gets
700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px
with the queue closed, and 158 of 162 at the 800x600 enforced minimum.
On a phone none of the three could be reached at all, which is what the
Android report said. Plan 018's size matrix promises the opposite: no
action is ever unreachable at any supported size.

The header could not fix that for slotted markup, and that is a fact
about the API rather than an effort estimate — a component cannot move
another component's light-DOM children into a dropdown and keep their
behaviour, and arbitrary markup offers nothing generic to render as a
menu item. So a host passes `PageAction[]` and the header chooses the
rendering; the slot survives for markup a data list cannot express, at
the stated cost that a slotted action does not collapse.

All three hosts that slot actions migrated, which also normalises the
plain-<button>/<wa-button> split between them onto one shape the header
styles — and lets it measure a button that has already upgraded, rather
than a wa-button whose shadow DOM arrives in its own first update.

Four things in it are load-bearing:

- Every measuring pass starts from all-visible, so the collapsed set is
  a pure function of the current width and an action comes back when
  the window grows. It flips `hidden` imperatively rather than
  re-rendering between steps, or the intermediate state paints and the
  fix flashes the overflow it exists to prevent.
- "Fits" means nothing is clipped, not that the header does not
  overflow. Once the title can ellipsis it absorbs the pressure and
  scrollWidth reports a perfect fit while the heading reads "Playlis…"
  — this bug moved from the button to the title, and invisible to the
  same measurement that missed it the first time.
- New Playlist has the highest priority because it is the drop target
  and a closed menu cannot be one. `PageAction.drop` therefore carries
  the host's own handlers; the affordance is absent from the overflow
  rather than approximated there.
- The overflow trigger is a named button with aria-expanded and an
  aria-controls naming a panel that is always in the DOM, and the
  keyboard model is the shared `MenuKeyboard`.

`layout-overflow.spec.ts` passes on the broken build — it asserts the
shell needs no sideways scrolling, and clipping inside a component is
invisible to it, which is why this defect survived a spec named for it.
The new spec measures each button against its own header at four
viewports and asserts buttons plus menu account for every declared
action, without which it would pass vacuously on a build rendering none.

Closes #69
2026-08-19 15:08:57 -04:00

186 lines
5.6 KiB
TypeScript

/**
* The home page renders whatever `backend/home` decided, and nothing
* else: the shelves, their stated reasons, and two things a cover can
* do. So these tests are about the contract between the two — that a
* shelf's reason is displayed rather than swallowed, that a card opens
* the album it names, and that a play button plays it instead of
* opening it.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/home-view/home-view';
import { stub, calls, lastArgs, stubFailure } from '@test/support/harness';
import { fixture, shadow, shadowAll, texts, text } from '@test/support/render';
function album(id: number, name: string, artist = 'Artist') {
return {
ID: id,
Name: name,
ArtistName: artist,
MBID: '',
Year: 2000,
ReleaseYear: 2000,
CoverArtPath: '',
CoverArtSmall: '',
CoverArtMedium: '',
CoverArtLarge: '',
ArtistMBID: '',
};
}
const SHELVES = [
{
id: 'recently-played',
kind: 'recently-played',
title: 'Pick up where you left off',
subtitle: 'The last albums you played',
albums: [album(1, 'Kid A'), album(2, 'Amnesiac')],
},
{
id: 'genre-Doom Jazz',
kind: 'genre',
title: 'Doom Jazz',
subtitle: 'Because your library is full of it',
albums: [album(3, 'Black Ships')],
},
];
describe('home view', () => {
beforeEach(() => {
stub('home.Service.GetShelves', SHELVES);
stub('library.Library.GetAlbumTracks', [
{ FilePath: '/music/1.mp3' },
{ FilePath: '/music/2.mp3' },
]);
});
it('renders a row per shelf, each with the reason it exists', async () => {
const el = await fixture('home-view');
await el.updateComplete;
expect(texts(el, '.shelf-title')).toEqual([
'Pick up where you left off',
'Doom Jazz',
]);
// The subtitle is the whole difference between a shelf and a grid.
expect(texts(el, '.shelf-sub')).toEqual([
'The last albums you played',
'Because your library is full of it',
]);
});
it('draws a letter tile for an album with no cover, not a hole', async () => {
// H-9: the missing-art placeholder was a small dim icon on a
// surface the same colour as the page, so a shelf read as having
// gaps in it — while the Albums and Artists grids both drew a
// letter tile. Plainly visible in any Home screenshot, and
// invisible to every assertion in this file until now.
const el = await fixture('home-view');
await el.updateComplete;
await new Promise((r) => setTimeout(r, 20));
const placeholders = shadowAll(el, '.art .placeholder');
expect(placeholders.length, 'no placeholders for coverless albums').toBeGreaterThan(
0,
);
// The initial of the album's own name, as cover-grid does it.
expect(placeholders[0]?.textContent?.trim()).toBe('K');
// And it is a tile: it fills the art box rather than sitting in it.
expect(getComputedStyle(placeholders[0]!).backgroundImage).not.toBe('none');
});
it('keys each row by the kind the backend assigned', async () => {
const el = await fixture('home-view');
await el.updateComplete;
expect(
shadowAll(el, '.shelf').map((s) => s.getAttribute('data-kind')),
).toEqual(['recently-played', 'genre']);
});
it('opens the album a card names, by local id', async () => {
const el = await fixture('home-view');
await el.updateComplete;
const seen: unknown[] = [];
el.addEventListener('navigate', (e) => seen.push((e as CustomEvent).detail));
shadow<HTMLElement>(el, '.card')!.click();
expect(seen).toEqual([
{
view: 'explore-album-details',
releaseGroupMBID: '',
albumName: 'Kid A',
artistName: 'Artist',
localAlbumId: 1,
},
]);
});
it('plays the album from the play button without navigating', async () => {
const el = await fixture('home-view');
await el.updateComplete;
const seen: unknown[] = [];
el.addEventListener('navigate', (e) => seen.push(e));
shadow<HTMLElement>(el, '.play')!.click();
await new Promise((r) => setTimeout(r, 0));
expect(seen).toEqual([]);
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1, 0]);
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
['/music/1.mp3', '/music/2.mp3'],
0,
true,
{ type: 'album', id: 1, label: 'Kid A' },
]);
});
it('says so rather than rendering an empty page when there is nothing', async () => {
stub('home.Service.GetShelves', []);
const el = await fixture('home-view');
await el.updateComplete;
expect(text(el, '.empty')).toContain('Nothing to suggest yet');
});
it('reports a backend failure instead of pretending the library is empty', async () => {
stubFailure('home.Service.GetShelves');
const el = await fixture('home-view');
await el.updateComplete;
await el.updateComplete;
expect(text(el, '.empty')).toContain('Could not read your library');
});
it('rebuilds on demand', async () => {
const el = await fixture('home-view');
await el.updateComplete;
const before = calls('home.Service.GetShelves').length;
// The action is declared to `page-header` rather than slotted as
// markup (#69), so it is a button in *that* shadow root now.
const header = shadow<HTMLElement>(el, 'page-header')!;
await (header as LitElement).updateComplete;
header.shadowRoot!
.querySelector<HTMLButtonElement>(
'[data-testid="page-action-shuffle-suggestions"]',
)!
.click();
await el.updateComplete;
expect(calls('home.Service.GetShelves').length).toBe(before + 1);
});
});