Files
yellowjacket/frontend/test/components/now-playing-view.test.ts
T
logan 1b05dde382
Build & publish Arch package / arch-package (push) Successful in 2m27s
CI / check (push) Successful in 2m25s
Search index maintenance / maintain-index (push) Failing after 2m53s
CI / e2e (push) Successful in 5m55s
feat(ui): the full-screen now playing a phone needs
Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of
the phone's bottom bar -- 4px of height is not a thumb target, and a
phone's volume belongs to its hardware keys -- and promised them a
full-screen view. This is it, reached from a button over the mini
player's cover art.

**It composes the transport rather than reimplementing it.** The same
`seek-bar`, `player-controls` and `volume-control` the desktop bar
uses; a phone layout that copies them is a second transport to fix
every bug in, and the seek bar in particular carries interpolation
rules that took a plan of their own to get right. The seek bar
thickens its own track below the breakpoint, in its own stylesheet,
because the track size lives on a wa-slider inside its shadow root
where a custom property from the host cannot reach.

**It is a detail view, not a primary one.** It is somewhere you go and
come back from, so index.ts pushes the current view and Back pops it --
which is also why it is not a fifth tab: a tab you cannot leave by
pressing it again is not a tab.

Two things came from reading a screenshot rather than from a failing
test, and both were invisible to assertions that were individually
correct.

**The mini player was still under the full-screen view**, repeating it
in 4em of an 844px phone. index.css hides the bottom bar while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from index.ts, because the active view is
already published as an attribute. That takes the queue button with it,
so the view carries its own.

**And phase 1's shell rules had never applied.** A media query adds no
specificity, and the phone block sat above the plain rules it meant to
override, so at 390px the header kept its 2em gutters (32px), its 16px
gap and its 24px title, and the bottom bar kept a fixed 320px first
column. Nothing failed: the shell fits because of `min-width: 0` and
each component's own media query, which live in their own stylesheets
and have no later rule to lose to -- so what was dead was exactly the
cosmetic half no assertion looks at. The phone rules are one section at
the end of the file now, and it says why it is last. Measured after:
12px, 8px, 17.6px, `154px 187px 33px`.
2026-08-17 00:22:58 -04:00

127 lines
4.3 KiB
TypeScript

/**
* The full-screen now-playing view (plan 016 B2, phase 2).
*
* What is worth pinning here is not the layout but the *composition*:
* it renders the same `<seek-bar>`, `<player-controls>` and
* `<volume-control>` the desktop transport does, rather than its own.
* A phone layout that reimplements the transport is a second transport
* to fix every bug in — and the seek bar in particular carries
* interpolation rules that took a plan of their own to get right.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/now-playing-view/now-playing-view';
import { Events } from '../../src/events';
import { emit, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, text } from '@test/support/render';
import type { TrackInfo } from '@store/player-store';
const TRACK: TrackInfo = {
fileName: 'tideline.mp3',
filePath: '/music/tideline.mp3',
trackLength: 245,
seekPosition: 0,
state: 'playing',
title: 'Tideline',
artist: 'Sea Change',
album: 'Ebb',
coverArt: '/covers/ebb.jpg',
coverArtSmall: '/covers/ebb_sm.jpg',
coverArtMedium: '/covers/ebb_md.jpg',
coverArtLarge: '/covers/ebb_lg.jpg',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
describe('now-playing-view', () => {
beforeEach(() => {
resetHarness();
});
it('reuses the real transport components', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
for (const tag of ['seek-bar', 'player-controls', 'volume-control']) {
expect(shadow(el, tag), `${tag} is not rendered`).not.toBeNull();
}
});
it('shows the track, and the largest cover tier that is kept', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
expect(text(el, '[data-testid="npv-title"]')).toBe('Tideline');
// `saveCoverArt` records the largest *tier* as the path; there is
// no full-resolution original on disk to reach for.
expect(
shadow<HTMLImageElement>(el, '[data-testid="npv-art"]')?.getAttribute('src'),
).toBe('/covers/ebb_lg.jpg');
});
it('says so when nothing is playing, rather than rendering an empty frame', async () => {
// The player store is a singleton and outlives a test, so "no
// track" has to be stated rather than assumed from a fresh mount.
emit(Events.TrackChanged, null);
const el = await fixture('now-playing-view');
expect(shadow(el, '[data-testid="npv-empty"]')).not.toBeNull();
expect(shadow(el, '[data-testid="npv-art"]')).toBeNull();
// …and the way out is still there, which is the whole point of
// rendering the header in both branches.
expect(shadow(el, '[data-testid="npv-back"]')).not.toBeNull();
});
it('leaves by the nav stack, not by guessing where it came from', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
let backs = 0;
document.addEventListener('navigate-back', () => {
backs += 1;
});
shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]')?.click();
// `navigate-back` pops what index.ts pushed. Dispatching a
// `navigate` to a hardcoded view would strand anyone who arrived
// here from a detail page.
expect(backs).toBe(1);
});
it('gives the favourite button a target and a state', async () => {
stub('playlist.Service.ToggleFavorite', undefined);
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
const fav = shadow<HTMLButtonElement>(el, '[data-testid="npv-favorite"]');
expect(fav).not.toBeNull();
expect(fav?.getAttribute('aria-pressed')).toBe('false');
// A button that says only "heart" says nothing; the name carries
// the track and the playlist it goes to.
expect(fav?.getAttribute('aria-label')).toContain('Tideline');
expect(fav!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
});
it('gives the way out a thumb-sized target', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
const back = shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]');
expect(back!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
expect(back?.getAttribute('aria-label')).toBe('Back');
});
});