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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 artist’s 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', () => {
|
||||
|
||||
Reference in New Issue
Block a user