test(frontend): cover the lifecycle, the voice and the repaints
Component and store cases for everything in this series, several of which exist because the thing they pin is invisible everywhere else: - `view-lifecycle` and `keyboard-reach` — a document listener count that does not grow across a simulated navigate cycle, and a tab sequence that reaches the sidebar and plays a row without a mouse. - `notifications`, `notification-store`, `confirm-dialog`, `empty-states` — the four levels, the (level, region, key) coalescing window, and loading/failed/empty as three states. - `card-grid-repaint` — fails if `artists-view`'s or `genres-view`'s per-render arrow functions are hoisted to stable fields, which is the audit's own recommendation and takes the cards from 1 highlighted to 0. It exists for no other reason. - `lazy-track-details` — reads the five sources and fails on a returning static import, the same shape as `TestNoDirectRuntimeEmits` and for the same reason: the invariant is about what the code does *not* say. - `now-playing` — a position report that changes nothing must not touch the DOM again, and a track change must. The first fails against the old unconditional `updated()`. - `playlist-virtualization`, `list-render-cost`, `selection`, `icons`, and the store cases for the library-filter race, the never-settling waiter and the per-playlist patch.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
import '@components/audio-player/audio-player';
|
||||
import '@components/audio-player/controls/player-controls';
|
||||
import '@components/audio-player/seekbar/seek-bar';
|
||||
import '@components/audio-player/volume-control/volume-control';
|
||||
@@ -15,11 +16,14 @@ import {
|
||||
fixture,
|
||||
shadow,
|
||||
shadowAll,
|
||||
deepShadow,
|
||||
deepText,
|
||||
text,
|
||||
click,
|
||||
visual,
|
||||
} from '@test/support/render';
|
||||
import type { TrackInfo } from '@store/player-store';
|
||||
import { PlayerRegion, type TrackInfo } from '@store/player-store';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
|
||||
const TRACK: TrackInfo = {
|
||||
fileName: 'long.mp3',
|
||||
@@ -205,7 +209,81 @@ describe('<seek-bar>', () => {
|
||||
expect([
|
||||
text(el, '[data-testid="elapsed-time"]'),
|
||||
text(el, '[data-testid="remaining-time"]'),
|
||||
]).toEqual(['00:00', '01:30']);
|
||||
]).toEqual(['00:00', '-01:30']);
|
||||
});
|
||||
|
||||
it('says which number the right-hand clock is, and swaps it on click', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 21 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
// H-16: it read `01:21` next to a track the list called `01:30`,
|
||||
// with no minus sign, no label and no way to see the duration.
|
||||
await click(el, '[data-testid="remaining-time"]');
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="remaining-time"]')).toBe('01:30');
|
||||
});
|
||||
|
||||
it('renders the position the backend reports rather than its own count', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 11 });
|
||||
emit(Events.PlaybackPositionChanged, {
|
||||
positionSeconds: 42,
|
||||
trackLength: 90,
|
||||
trackChangeId: 11,
|
||||
seq: 1,
|
||||
playing: true,
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:42');
|
||||
});
|
||||
|
||||
it('resets its interpolation on every report, so a seek cannot desync it', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 12 });
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await el.updateComplete;
|
||||
|
||||
// The user seeks; the backend lands somewhere else entirely and
|
||||
// says so. The local counter must be discarded, not added to.
|
||||
emit(Events.PlaybackPositionChanged, {
|
||||
positionSeconds: 40,
|
||||
trackLength: 90,
|
||||
trackChangeId: 12,
|
||||
seq: 2,
|
||||
playing: true,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:41');
|
||||
});
|
||||
|
||||
it('ignores a report belonging to a track that is no longer loaded', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 13 });
|
||||
emit(Events.PlaybackPositionChanged, {
|
||||
positionSeconds: 60,
|
||||
trackLength: 90,
|
||||
trackChangeId: 12,
|
||||
seq: 3,
|
||||
playing: true,
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:00');
|
||||
});
|
||||
|
||||
it('resumes mid-track from the position the backend reported', async () => {
|
||||
@@ -359,3 +437,84 @@ describe('volume control: mute', () => {
|
||||
expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The player says when it could not do what it was told.
|
||||
*
|
||||
* This is the Inline level of plan 007's notification table, and it is
|
||||
* deliberately local to the bottom bar rather than an app-wide surface:
|
||||
* the useful response to a track that will not play is to keep playing,
|
||||
* which the backend already does by skipping it.
|
||||
*/
|
||||
describe('<audio-player> messages', () => {
|
||||
beforeEach(() => {
|
||||
notificationStore.dismissRegion(PlayerRegion);
|
||||
});
|
||||
|
||||
it('names the track that could not be played', async () => {
|
||||
const el = await fixture('audio-player');
|
||||
|
||||
emit(Events.PlaybackFailed, {
|
||||
filePath: '/music/gone.mp3',
|
||||
title: 'Tideline',
|
||||
artist: 'Aurora Fields',
|
||||
reason: 'no such file or directory',
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(deepText(el, '[data-testid="player-message"]')).toContain(
|
||||
'Tideline',
|
||||
);
|
||||
});
|
||||
|
||||
it('coalesces a disconnected drive into one message with a count', async () => {
|
||||
const el = await fixture('audio-player');
|
||||
|
||||
for (const title of ['One', 'Two', 'Three']) {
|
||||
emit(Events.PlaybackFailed, {
|
||||
filePath: `/music/${title}.mp3`,
|
||||
title,
|
||||
artist: '',
|
||||
reason: 'no such file or directory',
|
||||
});
|
||||
}
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
// Not three messages, and not one message about the last file.
|
||||
expect(deepText(el, '[data-testid="player-message"]')).toContain(
|
||||
'Skipped 3 tracks',
|
||||
);
|
||||
});
|
||||
|
||||
it('explains a failed seek, which used to be emitted into the void', async () => {
|
||||
const el = await fixture('audio-player');
|
||||
|
||||
emit(Events.SeekFailed);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(deepText(el, '[data-testid="player-message"]')).toContain(
|
||||
'Could not seek',
|
||||
);
|
||||
});
|
||||
|
||||
it('can be dismissed', async () => {
|
||||
const el = await fixture('audio-player');
|
||||
|
||||
emit(Events.SeekFailed);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
deepShadow<HTMLButtonElement>(
|
||||
el,
|
||||
'[data-testid="player-message"] .notice-dismiss',
|
||||
)!.click();
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(deepShadow(el, '[data-testid="player-message"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user