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:
2026-08-12 01:20:03 -04:00
parent 2518385330
commit 5830b1ba17
25 changed files with 2498 additions and 37 deletions
@@ -197,14 +197,74 @@ describe('shortcut dispatch: scope', () => {
expect(calls('queue.Queue.Play')).toHaveLength(0);
});
it('lets a checkbox through — it is not a text input', () => {
it('leaves Space to a focused checkbox, which owns it', () => {
// The global bindings are unmodified single keys, so the service has
// to yield to a control that means something by the key itself —
// otherwise the checkbox you tabbed to cannot be ticked (H-6).
const input = mount(document.createElement('input'));
input.type = 'checkbox';
input.focus();
expect(press(' ').defaultPrevented).toBe(false);
expect(calls('queue.Queue.Play')).toHaveLength(0);
});
it('still fires a key the focused control does not own', () => {
bindings({ 'player.playPause': 'Space', 'player.next': 'N' });
const button = mount(document.createElement('button'));
button.focus();
press('n');
expect(calls('queue.Queue.Next')).toHaveLength(1);
});
it('leaves the arrow keys to a focused slider', () => {
bindings({ 'player.volumeUp': 'Up' });
const input = mount(document.createElement('input'));
input.type = 'range';
input.focus();
press('ArrowUp');
expect(calls('player.Player.ChangeVolume')).toHaveLength(0);
});
it('leaves every unmodified key to an open dialog', () => {
const dialog = mount(document.createElement('div'));
const button = document.createElement('button');
dialog.setAttribute('role', 'dialog');
dialog.append(button);
button.focus();
press(' ');
expect(calls('queue.Queue.Play')).toHaveLength(1);
expect(calls('queue.Queue.Play')).toHaveLength(0);
});
it('keeps a modified binding even inside a dialog', () => {
bindings({ 'app.selectAll': 'Ctrl+A' });
const dialog = mount(document.createElement('div'));
const button = document.createElement('button');
dialog.setAttribute('role', 'dialog');
dialog.append(button);
button.focus();
let fired = 0;
const listener = (): void => {
fired += 1;
};
document.addEventListener('shortcut:select-all', listener);
press('a', { ctrlKey: true });
document.removeEventListener('shortcut:select-all', listener);
expect(fired).toBe(1);
});
it('blurs the input on Escape, and only on Escape', () => {
@@ -234,8 +294,11 @@ describe('shortcut dispatch: scope', () => {
it('resolves a panel scope from a data-shortcut-scope ancestor', () => {
const panel = mount(document.createElement('div'));
const button = document.createElement('button');
const button = document.createElement('div');
// Focused on a plain focusable, not a button: a button owns Enter
// itself and is meant to keep it.
button.tabIndex = 0;
panel.dataset['shortcutScope'] = 'tracklist';
panel.append(button);
button.focus();
@@ -256,8 +319,9 @@ describe('shortcut dispatch: scope', () => {
const panel = mount(document.createElement('div'));
const inner = document.createElement('div');
const root = inner.attachShadow({ mode: 'open' });
const button = document.createElement('button');
const button = document.createElement('div');
button.tabIndex = 0;
panel.dataset['shortcutScope'] = 'tracklist';
panel.append(inner);
root.append(button);
+123 -1
View File
@@ -14,12 +14,16 @@ import {
emit,
calls,
stub,
stubFailure,
flush,
lastArgs,
resetHarness,
} from '@test/support/harness';
const TRACKS = [{ ID: 1, Title: 'One' }];
const TRACKS = [
{ ID: 1, Title: 'One', FilePath: '/a.mp3', PlayCount: 0, LastPlayed: '' },
{ ID: 2, Title: 'Two', FilePath: '/b.mp3', PlayCount: 4, LastPlayed: 'x' },
];
const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }];
const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }];
const ARTISTS = [{ ID: 1, Name: 'Artist' }];
@@ -110,6 +114,67 @@ describe('library store: caching', () => {
expect(calls('library.Library.GetAllTracks')).toHaveLength(1);
});
/*
* A finished track used to arrive as TrackMetadataChanged, so every
* song refetched the whole library: ~37 MB across the IPC and ~0.8 s
* of blocked main thread per track at 50 000 tracks (perf.C1).
*
* The assertion that matters is the negative one. Patching the track
* in place is only a fix if nothing is refetched as well.
*/
describe('a play count arriving', () => {
beforeEach(async () => {
emit(Events.TrackPlayCountChanged, {
audioFileId: 1,
filePath: '/a.mp3',
playCount: 9,
lastPlayed: '2026-08-11 10:00:00',
});
await flush();
});
it('refetches nothing', () => {
expect(calls().map((c) => c.path)).toEqual([]);
});
it('patches the one track it names', () => {
const tracks = libraryStore.getCachedTracks();
expect(tracks?.[0]).toMatchObject({
FilePath: '/a.mp3',
PlayCount: 9,
LastPlayed: '2026-08-11 10:00:00',
});
});
it('leaves every other track alone', () => {
expect(libraryStore.getCachedTracks()?.[1]).toMatchObject({
FilePath: '/b.mp3',
PlayCount: 4,
});
});
it('replaces the array, so memoized consumers notice', () => {
// `track-list` keys its filter/sort caches on the array identity;
// mutating in place would be invisible to every one of them.
expect(libraryStore.getCachedTracks()).not.toBe(TRACKS);
expect(libraryStore.changeGeneration).toBeGreaterThan(0);
});
});
it('ignores a play count for a track it has never heard of', async () => {
const before = libraryStore.getCachedTracks();
emit(Events.TrackPlayCountChanged, {
filePath: '/not-in-this-library.mp3',
playCount: 1,
});
await flush();
expect(libraryStore.getCachedTracks()).toBe(before);
expect(calls()).toEqual([]);
});
it('resets scroll positions on invalidation, so a shorter list is not scrolled past its end', async () => {
libraryStore.setScrollPosition('albums', 4200);
emit(Events.LibraryScanComplete);
@@ -178,6 +243,63 @@ describe('library store: library filter', () => {
});
});
/**
* The reproduction for errors.C4 and errors.M1: two bugs that are the
* same bug seen from either end of an in-flight fetch.
*/
describe('library store: a fetch that is overtaken', () => {
beforeEach(async () => {
libraryStore.setSelectedLibrary(null);
await reload();
});
it('serves the library that is selected, not the one that was in flight', async () => {
const pending: Array<{ id: number; resolve: (v: unknown) => void }> = [];
const byLibrary = (id: number) => [{ ID: id, Title: `Library ${id}` }];
// Only the track fetch is held open; the other three settle at once,
// so the test is about the overtaking and nothing else.
stub(
'library.Library.GetAllTracksByLibrary',
(id: number) =>
new Promise((resolve) => {
pending.push({ id, resolve });
}),
);
libraryStore.setSelectedLibrary(7);
await flush();
libraryStore.setSelectedLibrary(8);
await flush();
// Library 7's answer lands after the user has already moved on.
pending.find((p) => p.id === 7)?.resolve(byLibrary(7));
await flush();
pending.find((p) => p.id === 8)?.resolve(byLibrary(8));
await flush();
expect(libraryStore.getCachedTracks()).toEqual(byLibrary(8));
});
it('settles the waiters when the fetch they are waiting on fails', async () => {
stubFailure('library.Library.GetAllTracks', 'sql: database is locked');
// Invalidation drops the cache and starts the fetch that fails.
emit(Events.LibraryScanComplete);
// Arrives while that fetch is in flight, so it waits on it rather
// than issuing a second one.
const waiter = libraryStore.getTracks().then(
() => 'resolved',
() => 'rejected',
);
const timeout = new Promise((resolve) => {
setTimeout(() => resolve('never settled'), 500);
});
await expect(Promise.race([waiter, timeout])).resolves.toBe('rejected');
});
});
describe('library store: default library id', () => {
beforeEach(async () => {
libraryStore.setSelectedLibrary(null);
@@ -0,0 +1,158 @@
/**
* The app's one notification surface.
*
* Two behaviours here are the reason it exists as a store rather than
* as a component: the caller picks a *level* and nothing else, and
* coalescing happens once, here, so a queue of 200 unplayable files
* produces one message rather than 200 and no future caller has to
* remember that.
*/
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
import { notificationStore } from '@store/notification-store';
describe('notification store', () => {
beforeEach(() => {
notificationStore.clear();
});
it('keeps a notification per level', () => {
notificationStore.blocking({ text: 'Half the folder was retagged.' });
notificationStore.persistent({ text: 'The scan did not start.' });
notificationStore.transient({ text: 'That favourite was undone.' });
notificationStore.inline('player', { text: 'Could not seek.' });
expect(notificationStore.getAll().map((n) => n.level)).toEqual([
'blocking',
'persistent',
'transient',
'inline',
]);
});
it('routes an inline message to its region and nowhere else', () => {
notificationStore.inline('player', { text: 'Could not seek.' });
expect([
notificationStore.forRegion('player').length,
notificationStore.forRegion('explore').length,
notificationStore.byLevel('transient').length,
]).toEqual([1, 0, 0]);
});
it('folds a repeat into one message with a count', () => {
for (const title of ['One', 'Two', 'Three']) {
notificationStore.inline('player', {
key: 'playback-failed',
text: `Could not play “${title}”.`,
coalescedText: (count) => `Skipped ${count} tracks.`,
});
}
const [only] = notificationStore.forRegion('player');
expect([notificationStore.getAll().length, only?.count, only?.text]).toEqual(
[1, 3, 'Skipped 3 tracks.'],
);
});
it('does not fold two different failures together', () => {
notificationStore.transient({ key: 'a', text: 'One thing failed.' });
notificationStore.transient({ key: 'b', text: 'Another thing failed.' });
expect(notificationStore.getAll()).toHaveLength(2);
});
it('does not fold the same key across levels', () => {
notificationStore.transient({ key: 'scan', text: 'Scan failed.' });
notificationStore.persistent({ key: 'scan', text: 'Scan failed.' });
expect(notificationStore.getAll()).toHaveLength(2);
});
it('runs an action and takes the message away with it', () => {
const retry = vi.fn();
const id = notificationStore.persistent({
text: 'The scan did not start.',
action: { label: 'Try again', run: retry },
});
notificationStore.runAction(id);
expect([retry.mock.calls.length, notificationStore.getAll().length]).toEqual(
[1, 0],
);
});
it('notifies subscribers once per batch', async () => {
let notifications = 0;
const off = notificationStore.subscribe(() => {
notifications += 1;
});
notificationStore.transient({ text: 'One.' });
notificationStore.transient({ text: 'Two.' });
await new Promise((resolve) => setTimeout(resolve, 0));
off();
expect(notifications).toBe(1);
});
it('keeps the stack readable, and never drops a modal to do it', () => {
notificationStore.blocking({ text: 'Half the folder was retagged.' });
for (let i = 0; i < 8; i += 1) {
notificationStore.persistent({ key: `k${i}`, text: `Failure ${i}.` });
}
const levels = notificationStore.getAll().map((n) => n.level);
expect([levels.length, levels.includes('blocking')]).toEqual([5, true]);
});
});
describe('notification store: self-dismissal', () => {
beforeEach(() => {
vi.useFakeTimers();
notificationStore.clear();
});
afterEach(() => {
vi.useRealTimers();
});
it('takes a toast away by itself', () => {
notificationStore.transient({ text: 'That favourite was undone.' });
vi.advanceTimersByTime(6000);
expect(notificationStore.getAll()).toHaveLength(0);
});
it('leaves the levels that are waiting for an answer', () => {
notificationStore.persistent({ text: 'The scan did not start.' });
notificationStore.blocking({ text: 'Half the folder was retagged.' });
vi.advanceTimersByTime(60_000);
expect(notificationStore.getAll()).toHaveLength(2);
});
it('restarts the clock when a message repeats', () => {
notificationStore.transient({ key: 'fav', text: 'Undone.' });
vi.advanceTimersByTime(4000);
notificationStore.transient({ key: 'fav', text: 'Undone.' });
vi.advanceTimersByTime(4000);
// Still there: the second occurrence bought it another window.
expect(notificationStore.getAll()).toHaveLength(1);
});
it('starts a new message once the coalescing window has passed', () => {
notificationStore.inline('player', { key: 'seek', text: 'Could not seek.' });
vi.advanceTimersByTime(20_000);
notificationStore.inline('player', { key: 'seek', text: 'Could not seek.' });
const [only] = notificationStore.forRegion('player');
expect(only?.count).toBe(1);
});
});
+136 -8
View File
@@ -1,9 +1,14 @@
/**
* The playlist store caches one list and invalidates it on six
* different events. The distinction worth testing is `invalidate` vs
* different events. Two distinctions are worth testing. `invalidate` vs
* `refetch`: one drops the cache (consumers render empty until the
* fetch lands), the other holds the stale list until the new one
* arrives. Using the wrong one shows up as a flash of empty list.
* arrives — using the wrong one shows up as a flash of empty list.
*
* And `invalidate` vs *patch*: `GetAllPlaylistsWithTracks` returns every
* row of every playlist with full track metadata, which is the wrong
* answer to "one track was added to playlist 2" and was measured at
* 2.61 MB for one heart toggle (`perf.C5`). The event carries the id.
*/
import { describe, expect, it, beforeEach } from 'vitest';
@@ -11,17 +16,42 @@ import { playlistStore } from '@store/playlist-store';
import { Events } from '../../src/events';
import { emit, calls, stub, flush, resetHarness } from '@test/support/harness';
/** The real shape: `WithTracks` is `{ Summary, Tracks }`. */
const PLAYLISTS = [
{ ID: 1, Name: 'Morning', Tracks: [] },
{ ID: 2, Name: 'Evening', Tracks: [] },
{
Summary: { ID: 1, Name: 'Morning', UpdatedAt: '2026-01-01T00:00:00Z' },
Tracks: [{ FilePath: '/a.mp3', Title: 'One' }],
},
{
Summary: { ID: 2, Name: 'Evening', UpdatedAt: '2026-01-01T00:00:00Z' },
Tracks: [{ FilePath: '/b.mp3', Title: 'Two' }],
},
];
async function reload(): Promise<void> {
const SUMMARIES = PLAYLISTS.map((p) => p.Summary);
/**
* The store only refetches eagerly when something is subscribed —
* before `playlist-view` has ever been opened there is no reader and
* nothing to refresh. Tests that assert on a refetch therefore need a
* subscriber, exactly as the running app does.
*/
let unsubscribe: (() => void) | null = null;
function stubReads(): void {
stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS);
stub('playlist.Service.GetAllPlaylists', SUMMARIES);
stub('playlist.Service.GetPlaylistTracks', []);
}
async function reload(): Promise<void> {
unsubscribe?.();
unsubscribe = playlistStore.subscribe(() => {});
stubReads();
playlistStore.invalidate();
await flush();
resetHarness();
stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS);
stubReads();
}
describe('playlist store: caching', () => {
@@ -86,12 +116,11 @@ describe('playlist store: invalidating events', () => {
await reload();
});
it('refetches for every event that can change a playlist', async () => {
it('refetches everything for every event that can restructure the list', async () => {
const events = [
Events.PlaylistCreated,
Events.PlaylistDeleted,
Events.PlaylistRenamed,
Events.PlaylistTracksChanged,
Events.PlaylistsRestored,
Events.LibraryScanComplete,
];
@@ -105,4 +134,103 @@ describe('playlist store: invalidating events', () => {
calls('playlist.Service.GetAllPlaylistsWithTracks'),
).toHaveLength(events.length);
});
it('does not refetch when nothing is subscribed', async () => {
unsubscribe?.();
unsubscribe = null;
emit(Events.PlaylistCreated, 1);
await flush();
expect(calls('playlist.Service.GetAllPlaylistsWithTracks')).toEqual([]);
// Still dropped, so the next reader fetches rather than serving a
// list the backend has moved on from.
expect(playlistStore.getCachedPlaylists()).toBeNull();
});
});
describe('playlist store: patching one playlist (perf.C5)', () => {
beforeEach(async () => {
await reload();
});
it('refetches only the playlist the event names', async () => {
stub('playlist.Service.GetPlaylistTracks', [
{ FilePath: '/b.mp3', Title: 'Two' },
{ FilePath: '/c.mp3', Title: 'Three' },
]);
emit(Events.PlaylistTracksChanged, 2);
await flush();
expect(calls('playlist.Service.GetAllPlaylistsWithTracks')).toEqual([]);
expect(calls('playlist.Service.GetPlaylistTracks')).toHaveLength(1);
const cached = playlistStore.getCachedPlaylists() ?? [];
expect(cached).toHaveLength(2);
expect(cached[1]?.Tracks).toHaveLength(2);
});
it('shares the tracks of every playlist that did not change', async () => {
const before = playlistStore.getCachedPlaylists() ?? [];
emit(Events.PlaylistTracksChanged, 2);
await flush();
const after = playlistStore.getCachedPlaylists() ?? [];
// A new array identity, because `playlist-view` keys its reload off
// it — but the untouched playlist's tracks are the same objects.
// Asserted non-empty first, or two `undefined`s would pass this.
expect(before[0]?.Tracks).toBeDefined();
expect(after).not.toBe(before);
expect(after[0]?.Tracks).toBe(before[0]?.Tracks);
});
it('refreshes summaries, which carry the sort key', async () => {
stub('playlist.Service.GetAllPlaylists', [
SUMMARIES[0],
{ ...SUMMARIES[1], UpdatedAt: '2026-06-01T00:00:00Z' },
]);
emit(Events.PlaylistTracksChanged, 2);
await flush();
expect(
(playlistStore.getCachedPlaylists() ?? [])[1]?.Summary.UpdatedAt,
).toBe('2026-06-01T00:00:00Z');
});
it('falls back to a full refetch when the event carries no id', async () => {
// The bulk restore and reorder paths emit a nil id, which says
// "something changed" without saying what.
emit(Events.PlaylistTracksChanged, null);
await flush();
expect(
calls('playlist.Service.GetAllPlaylistsWithTracks'),
).toHaveLength(1);
});
it('falls back to a full refetch for a playlist it has never seen', async () => {
emit(Events.PlaylistTracksChanged, 99);
await flush();
expect(
calls('playlist.Service.GetAllPlaylistsWithTracks'),
).toHaveLength(1);
});
it('does not patch a cold cache, or one with a fetch already in flight', async () => {
// Both cases arrive as "there is nothing here to patch, and a full
// fetch either is happening or is about to" — patching would race
// that fetch and be overwritten by it.
playlistStore.invalidate();
emit(Events.PlaylistTracksChanged, 2);
await flush();
expect(calls('playlist.Service.GetPlaylistTracks')).toEqual([]);
expect(playlistStore.getCachedPlaylists()).toEqual(PLAYLISTS);
});
});
+28 -8
View File
@@ -7,7 +7,7 @@ import { describe, expect, it, beforeEach } from 'vitest';
import { searchStore } from '@store/search-store';
import { trackListStore } from '@store/tracklist-store';
import { exploreCache } from '@store/explore-cache';
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '@store/explore-cache';
import { Events } from '../../src/events';
import { emit, lastCall, flush } from '@test/support/harness';
@@ -141,14 +141,34 @@ describe('explore cache', () => {
expect(exploreCache.getArtist('mbid-2')?.imageURL).toBe('http://x/eno.jpg');
});
it('caches an artists release groups and top tracks separately', () => {
exploreCache.setArtistAlbums('mbid-3', [{ mbid: 'rg-1' }] as never);
exploreCache.setArtistTopTracks('mbid-3', [{ mbid: 'rec-1' }] as never);
// `perf.M8`. An artist entry holds the artist photo's base64 data URL
// — ~128 kB measured — so an unbounded map on a view that never
// unmounts grows for the life of the process.
it('evicts the least recently used artist past its cap', () => {
const over = ARTIST_IMAGE_CACHE_LIMIT + 10;
expect([
exploreCache.getArtistAlbums('mbid-3')?.length,
exploreCache.getArtistTopTracks('mbid-3')?.length,
]).toEqual([1, 1]);
for (let i = 0; i < over; i++) {
exploreCache.setArtist(`cap-${i}`, { mbid: `cap-${i}`, name: `A${i}` });
}
expect(exploreCache.stats().artists.entries).toBe(ARTIST_IMAGE_CACHE_LIMIT);
// The first inserted is gone; the last is not.
expect(exploreCache.getArtist('cap-0')).toBeUndefined();
expect(exploreCache.getArtist(`cap-${over - 1}`)?.name).toBe(`A${over - 1}`);
});
it('keeps an artist alive by reading it', () => {
// Recency is what makes the cap safe: the entry being rendered must
// not be the one evicted, or the render refetches it immediately.
for (let i = 0; i < ARTIST_IMAGE_CACHE_LIMIT; i++) {
exploreCache.setArtist(`lru-${i}`, { mbid: `lru-${i}`, name: `A${i}` });
}
exploreCache.getArtist('lru-0');
exploreCache.setArtist('lru-new', { mbid: 'lru-new', name: 'New' });
expect(exploreCache.getArtist('lru-0')?.name).toBe('A0');
expect(exploreCache.getArtist('lru-1')).toBeUndefined();
});
it('populates artists and albums from one search result', () => {