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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user