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.
237 lines
7.3 KiB
TypeScript
237 lines
7.3 KiB
TypeScript
/**
|
|
* The playlist store caches one list and invalidates it on six
|
|
* 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.
|
|
*
|
|
* 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';
|
|
|
|
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 = [
|
|
{
|
|
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' }],
|
|
},
|
|
];
|
|
|
|
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();
|
|
stubReads();
|
|
}
|
|
|
|
describe('playlist store: caching', () => {
|
|
beforeEach(async () => {
|
|
await reload();
|
|
});
|
|
|
|
it('serves a second read from cache', async () => {
|
|
await playlistStore.getPlaylists();
|
|
|
|
expect(calls()).toEqual([]);
|
|
});
|
|
|
|
it('deduplicates concurrent first reads', async () => {
|
|
playlistStore.invalidate();
|
|
await Promise.all([
|
|
playlistStore.getPlaylists(),
|
|
playlistStore.getPlaylists(),
|
|
]);
|
|
|
|
expect(calls('playlist.Service.GetAllPlaylistsWithTracks')).toHaveLength(1);
|
|
});
|
|
|
|
it('exposes the cache synchronously for render', () => {
|
|
expect(playlistStore.getCachedPlaylists()).toEqual(PLAYLISTS);
|
|
});
|
|
|
|
it('normalises a null list to an empty one', async () => {
|
|
stub('playlist.Service.GetAllPlaylistsWithTracks', null);
|
|
playlistStore.invalidate();
|
|
await flush();
|
|
|
|
expect(playlistStore.getCachedPlaylists()).toEqual([]);
|
|
});
|
|
|
|
it('holds the stale list across a refetch, so the view does not flash empty', async () => {
|
|
const pending = playlistStore.refetch();
|
|
const during = playlistStore.getCachedPlaylists();
|
|
|
|
await pending;
|
|
|
|
expect(during).toEqual(PLAYLISTS);
|
|
});
|
|
|
|
it('drops the cache on invalidate, which is the difference from refetch', () => {
|
|
playlistStore.invalidate();
|
|
|
|
expect(playlistStore.getCachedPlaylists()).toBeNull();
|
|
});
|
|
|
|
it('resets the scroll position when the list is invalidated', async () => {
|
|
playlistStore.setScrollPosition(900);
|
|
playlistStore.invalidate();
|
|
await flush();
|
|
|
|
expect(playlistStore.getScrollPosition()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('playlist store: invalidating events', () => {
|
|
beforeEach(async () => {
|
|
await reload();
|
|
});
|
|
|
|
it('refetches everything for every event that can restructure the list', async () => {
|
|
const events = [
|
|
Events.PlaylistCreated,
|
|
Events.PlaylistDeleted,
|
|
Events.PlaylistRenamed,
|
|
Events.PlaylistsRestored,
|
|
Events.LibraryScanComplete,
|
|
];
|
|
|
|
for (const name of events) {
|
|
emit(name, 1);
|
|
await flush();
|
|
}
|
|
|
|
expect(
|
|
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);
|
|
});
|
|
});
|