a11y.15 / WCAG 2.2.2: the bottom bar's title and artist scrolled for as long as a track played, re-armed in a loop by transitionend, with no pause mechanism and no reduced-motion guard. Reproduced under an emulated prefers-reduced-motion before the fix: the title still carried will-scroll with a 15s transition and the transform was still moving. That read landed in the snap-back half of the cycle, which is why a CSS-only 'transition: none' is the wrong fix -- it leaves the text translated off its own box and transitionend never fires to bring it back. The scroll is not armed at all instead, which is a decision shouldScroll() already owned, and it covers hover as well as always: reduce is a request about motion, not about autoplay. Two things came out of looking at the result rather than asserting on it. The non-scrolling fallback was hard-clipping, not ellipsising, in every mode including the default -- text-overflow was on the outer span while the overflowing box is the inline-block child. And moving it to the child then broke overflow *detection*, because the parent stops overflowing once the child hides its own; both measurements come from the child now. The second was caught by the new test's positive case, which is why it has one.
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
/**
|
|
* `<now-playing>` and `<queue-panel>` are the two components driven
|
|
* entirely by store state rather than by their own properties: feeding
|
|
* them backend events is how they are made to render anything at all.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest';
|
|
|
|
import '@components/now-playing/now-playing';
|
|
import '@components/queue-panel/queue-panel';
|
|
import { Events } from '../../src/events';
|
|
import { emit, calls, stub, flush, lastArgs } from '@test/support/harness';
|
|
import {
|
|
fixture,
|
|
shadow,
|
|
shadowAll,
|
|
text,
|
|
visual,
|
|
} from '@test/support/render';
|
|
import type { TrackInfo } from '@store/player-store';
|
|
import type { QueueTrack } from '@store/queue-store';
|
|
|
|
const TRACK: TrackInfo = {
|
|
fileName: 'ashes.mp3',
|
|
filePath: '/music/ashes.mp3',
|
|
trackLength: 215,
|
|
seekPosition: 0,
|
|
state: 'playing',
|
|
title: 'Ashes to Ashes',
|
|
artist: 'David Bowie',
|
|
album: 'Scary Monsters',
|
|
coverArt: '',
|
|
coverArtSmall: '',
|
|
coverArtMedium: '',
|
|
coverArtLarge: '',
|
|
trackChangeId: 1,
|
|
artistMbid: '',
|
|
releaseGroupMbid: '',
|
|
recordingMbid: '',
|
|
};
|
|
|
|
function queueTrack(n: number, title: string): QueueTrack {
|
|
return {
|
|
id: n,
|
|
audioFileId: n,
|
|
filePath: `/music/${n}.mp3`,
|
|
position: n,
|
|
title,
|
|
artist: 'Artist',
|
|
album: 'Album',
|
|
coverArtPath: '',
|
|
artistMbid: '',
|
|
releaseGroupMbid: '',
|
|
recordingMbid: '',
|
|
};
|
|
}
|
|
|
|
function setQueue(tracks: QueueTrack[], currentIndex = 0): void {
|
|
emit(Events.QueueChanged, {
|
|
tracks,
|
|
currentIndex,
|
|
shuffleMode: false,
|
|
repeatMode: 'off',
|
|
sourcePlaylistId: 0,
|
|
});
|
|
}
|
|
|
|
describe('<now-playing>', () => {
|
|
beforeEach(() => {
|
|
emit(Events.TrackChanged, null);
|
|
});
|
|
|
|
it('shows only a placeholder when nothing is loaded', async () => {
|
|
const el = await fixture('now-playing');
|
|
|
|
expect([
|
|
shadow(el, '.cover-placeholder'),
|
|
shadow(el, '[data-testid="now-playing-title"]'),
|
|
]).toEqual([expect.anything(), null]);
|
|
});
|
|
|
|
it('renders the title and artist of the loaded track', async () => {
|
|
const el = await fixture('now-playing');
|
|
|
|
emit(Events.TrackChanged, TRACK);
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
expect([
|
|
text(el, '[data-testid="now-playing-title"]'),
|
|
text(el, '[data-testid="now-playing-artist"]'),
|
|
]).toEqual(['Ashes to Ashes', 'David Bowie']);
|
|
});
|
|
|
|
it('names an artistless track rather than leaving the line blank', async () => {
|
|
const el = await fixture('now-playing');
|
|
|
|
emit(Events.TrackChanged, { ...TRACK, artist: '', trackChangeId: 2 });
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
expect(text(el, '[data-testid="now-playing-artist"]')).toBe(
|
|
'Unknown Artist',
|
|
);
|
|
});
|
|
|
|
it('prefers the small cover variant and falls back on error', async () => {
|
|
const el = await fixture('now-playing');
|
|
|
|
emit(Events.TrackChanged, {
|
|
...TRACK,
|
|
coverArt: '/covers/big.jpg',
|
|
coverArtSmall: '/covers/missing.jpg',
|
|
trackChangeId: 3,
|
|
});
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
const img = shadow<HTMLImageElement>(el, '.cover-art img');
|
|
const initial = img?.getAttribute('src');
|
|
|
|
img?.dispatchEvent(new Event('error'));
|
|
|
|
expect([initial, img?.src.endsWith('/covers/big.jpg')]).toEqual([
|
|
'/covers/missing.jpg',
|
|
true,
|
|
]);
|
|
});
|
|
|
|
it('offers a favourite toggle that names the target playlist', async () => {
|
|
stub('playlist.Service.GetDefaultPlaylistTrackPaths', []);
|
|
stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Loved' });
|
|
emit(Events.PlaylistRenamed, 1);
|
|
await flush();
|
|
|
|
const el = await fixture('now-playing');
|
|
|
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 4 });
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
expect(shadow(el, '.fav-btn')?.getAttribute('title')).toBe('Add to Loved');
|
|
});
|
|
|
|
it('toggles the favourite through the backend', async () => {
|
|
const el = await fixture('now-playing');
|
|
|
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 5 });
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
shadow<HTMLElement>(el, '.fav-btn')?.click();
|
|
await flush();
|
|
|
|
expect(lastArgs('playlist.Service.ToggleDefaultPlaylistTrack')).toEqual([
|
|
'/music/ashes.mp3',
|
|
]);
|
|
});
|
|
|
|
// perf.m5. `updated()` used to measure and rewrite the text geometry
|
|
// on every pass, and the player store notifies while playing — so a
|
|
// component whose DOM had not changed did six querySelectors and a
|
|
// read/write interleave several times a second. These two pin the
|
|
// guard from both sides: it has to skip the work when nothing it
|
|
// measures changed, and it has to *not* skip it when something did.
|
|
async function settle(el: HTMLElement & { updateComplete: Promise<unknown> }) {
|
|
for (let i = 0; i < 3; i++) {
|
|
await new Promise((r) => {
|
|
requestAnimationFrame(() => r(null));
|
|
});
|
|
await flush();
|
|
await el.updateComplete;
|
|
}
|
|
}
|
|
|
|
function countShadowQueries(el: HTMLElement): () => number {
|
|
const root = el.shadowRoot!;
|
|
const orig = root.querySelector.bind(root);
|
|
let n = 0;
|
|
|
|
root.querySelector = ((...args: [string]) => {
|
|
n++;
|
|
|
|
return orig(...args);
|
|
}) as typeof root.querySelector;
|
|
|
|
return () => n;
|
|
}
|
|
|
|
it('does not touch the DOM again when a position report changes nothing', async () => {
|
|
const el = await fixture('now-playing');
|
|
|
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 7 });
|
|
await flush();
|
|
await el.updateComplete;
|
|
// `observe()` delivers an initial callback of its own, which is one
|
|
// more legitimate re-measure. Let it land before counting, or the
|
|
// straggler reads as the thing this is asserting is gone.
|
|
await settle(el);
|
|
|
|
const queries = countShadowQueries(el);
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
emit(Events.PlaybackPositionChanged, {
|
|
positionSeconds: i + 1,
|
|
trackChangeId: 7,
|
|
seq: i,
|
|
});
|
|
await flush();
|
|
await el.updateComplete;
|
|
}
|
|
|
|
expect(queries()).toBe(0);
|
|
});
|
|
|
|
it('re-measures when the track changes', async () => {
|
|
const el = await fixture('now-playing');
|
|
|
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 8 });
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
const queries = countShadowQueries(el);
|
|
|
|
emit(Events.TrackChanged, {
|
|
...TRACK,
|
|
title: 'Teenage Wildlife',
|
|
trackChangeId: 9,
|
|
});
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
expect(queries()).toBeGreaterThan(0);
|
|
});
|
|
|
|
// a11y.15 (WCAG 2.2.2). Reproduced in the running app first: under an
|
|
// emulated `prefers-reduced-motion: reduce` the title still carried
|
|
// `will-scroll` with a 15s transition and the transform was still
|
|
// moving — the read landed in the *snap-back* half of the cycle,
|
|
// which is why suppressing the transition alone is not the fix.
|
|
//
|
|
// Both directions are asserted because a guard that suppresses
|
|
// everything passes the negative case for free, and a component that
|
|
// never scrolls at this width would too.
|
|
const LONG =
|
|
'An Exhaustively Overlong Track Title That Exists Solely To Find Out ' +
|
|
'Whether The Bottom Bar Truncates Or Overflows';
|
|
|
|
async function mountScrolling(reduce: boolean) {
|
|
const real = window.matchMedia.bind(window);
|
|
|
|
window.matchMedia = ((q: string) =>
|
|
q.includes('prefers-reduced-motion')
|
|
? {
|
|
matches: reduce,
|
|
media: q,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
}
|
|
: real(q)) as typeof window.matchMedia;
|
|
|
|
try {
|
|
localStorage.setItem('yj-now-playing-scroll-mode', 'always');
|
|
|
|
const el = await fixture('now-playing');
|
|
|
|
// The real host is sized by `--now-playing-width` on `.bottom-bar`,
|
|
// which the fixture does not have — so it is document-width here
|
|
// and nothing overflows, which made the positive case fail first.
|
|
el.style.width = '320px';
|
|
|
|
emit(Events.TrackChanged, { ...TRACK, title: LONG, trackChangeId: 10 });
|
|
await flush();
|
|
await settle(el);
|
|
|
|
return shadow(el, '.track-title')?.className ?? '';
|
|
} finally {
|
|
window.matchMedia = real;
|
|
localStorage.removeItem('yj-now-playing-scroll-mode');
|
|
}
|
|
}
|
|
|
|
it('scrolls an overflowing title when motion is not a problem', async () => {
|
|
expect(await mountScrolling(false)).toContain('will-scroll');
|
|
});
|
|
|
|
it('does not scroll at all under prefers-reduced-motion', async () => {
|
|
expect(await mountScrolling(true)).not.toContain('will-scroll');
|
|
});
|
|
|
|
it('looks the way it did last time', async () => {
|
|
const el = await fixture('now-playing');
|
|
|
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
await visual(el, 'now-playing');
|
|
expect(text(el, '[data-testid="now-playing-title"]')).toBe(
|
|
'Ashes to Ashes',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('<queue-panel>', () => {
|
|
beforeEach(() => {
|
|
setQueue([]);
|
|
});
|
|
|
|
// Every case here mounts the panel *open*. A closed panel renders no
|
|
// list at all (perf.m7) — `width: 0` used to hide a virtualizer that
|
|
// was still measuring its window on every queue change and still
|
|
// calling `scrollIntoView()` on an invisible element. These tests
|
|
// passed against a closed panel before that, which is the finding
|
|
// rather than a detail of the fixture.
|
|
|
|
it('says so when the queue is empty', async () => {
|
|
const el = await fixture('queue-panel', { open: true });
|
|
|
|
expect(text(el, '.empty-state p')).toBe('Queue is empty');
|
|
});
|
|
|
|
it('renders a row per queued track, tagged with its file path', async () => {
|
|
const el = await fixture('queue-panel', { open: true });
|
|
|
|
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')]);
|
|
await flush();
|
|
await el.updateComplete;
|
|
await new Promise((r) => {
|
|
requestAnimationFrame(() => r(null));
|
|
});
|
|
|
|
const rows = shadowAll(el, '[data-testid="queue-row"]');
|
|
|
|
expect(rows.map((r) => r.getAttribute('data-file-path'))).toEqual([
|
|
'/music/1.mp3',
|
|
'/music/2.mp3',
|
|
]);
|
|
});
|
|
|
|
it('marks the playing row as active', async () => {
|
|
const el = await fixture('queue-panel', { open: true });
|
|
|
|
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 1);
|
|
await flush();
|
|
await el.updateComplete;
|
|
await new Promise((r) => {
|
|
requestAnimationFrame(() => r(null));
|
|
});
|
|
|
|
const active = shadowAll(el, '[data-testid="queue-row"].active');
|
|
|
|
expect(active.map((r) => r.getAttribute('data-index'))).toEqual(['1']);
|
|
});
|
|
|
|
it('disables the clear button on an empty queue', async () => {
|
|
const el = await fixture('queue-panel', { open: true });
|
|
|
|
const button = shadow<HTMLButtonElement>(el, '.header-action-button');
|
|
|
|
expect(button?.disabled).toBe(true);
|
|
});
|
|
|
|
it('clears through the backend, not locally', async () => {
|
|
const el = await fixture('queue-panel', { open: true });
|
|
|
|
setQueue([queueTrack(1, 'First')]);
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
shadow<HTMLButtonElement>(el, '.header-action-button')?.click();
|
|
await flush();
|
|
|
|
expect(calls().map((c) => c.path)).toContain('queue.Queue.Clear');
|
|
});
|
|
|
|
// No screenshot for the queue panel: its list is a
|
|
// @lit-labs/virtualizer, which keeps re-measuring, so
|
|
// toMatchScreenshot never gets two identical frames and fails with
|
|
// "could not capture a stable screenshot" rather than a real diff.
|
|
it('keeps rendering rows after the virtualizer settles', async () => {
|
|
const el = await fixture('queue-panel', { open: true });
|
|
|
|
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 0);
|
|
await flush();
|
|
await el.updateComplete;
|
|
await new Promise((r) => {
|
|
requestAnimationFrame(() => r(null));
|
|
});
|
|
|
|
expect(shadowAll(el, '[data-testid="queue-row"]').length).toBe(2);
|
|
});
|
|
});
|