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:
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* A card grid repaints when the selection changes — and the thing that
|
||||||
|
* makes it repaint is not obvious.
|
||||||
|
*
|
||||||
|
* `perf.m1` says `artists-view` and `genres-view` should hoist their
|
||||||
|
* `.renderItem` / `.keyFunction` arrow functions to stable class fields
|
||||||
|
* the way `cover-grid` does, because `LitVirtualizer` declares both as
|
||||||
|
* plain properties and a fresh function marks them dirty on every host
|
||||||
|
* update. That is true, and the fix is a regression: a parent
|
||||||
|
* re-render only reaches the virtualizer's children *because* one of
|
||||||
|
* those properties changed. Hoist them, and `LitVirtualizer` sees no
|
||||||
|
* changed property, never re-renders, and the directive's `update()`
|
||||||
|
* never runs — so the cards keep the classes they had.
|
||||||
|
*
|
||||||
|
* Measured in the running app on the fixture library: 1 highlighted
|
||||||
|
* card before the change, 0 after. There was no compensating win to
|
||||||
|
* pay for it, so the closures stay.
|
||||||
|
*
|
||||||
|
* This test is the reason they stay. It fails if someone applies m1
|
||||||
|
* without also pushing an explicit `virtualizer.requestUpdate()` on
|
||||||
|
* every piece of host state a card reads — which is nearly every reason
|
||||||
|
* these views re-render, i.e. the same work under a longer name.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import '@components/artists-view/artists-view';
|
||||||
|
import '@components/genres-view/genres-view';
|
||||||
|
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import { fixture, shadowAll } from '@test/support/render';
|
||||||
|
|
||||||
|
const ARTISTS = [
|
||||||
|
{ ID: 1, Name: 'Alpha', AlbumCount: 2, TrackCount: 9 },
|
||||||
|
{ ID: 2, Name: 'Beta', AlbumCount: 1, TrackCount: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GENRES = [
|
||||||
|
{ name: 'Ambient', count: 12 },
|
||||||
|
{ name: 'Doom', count: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Give the virtualizer a viewport; a zero-height host renders nothing. */
|
||||||
|
function sized(el: HTMLElement): void {
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.height = '600px';
|
||||||
|
el.style.width = '900px';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle(el: LitElement): Promise<void> {
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a card grid shows its selection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub('library.Library.GetAllArtists', ARTISTS);
|
||||||
|
stub('library.Library.GetAllGenresWithCounts', GENRES);
|
||||||
|
stub('library.Library.GetAllTracks', []);
|
||||||
|
stub('library.Library.GetAllAlbums', []);
|
||||||
|
// The views read through LibraryController, whose cache is only
|
||||||
|
// primed by a scan-complete; without it they render nothing and the
|
||||||
|
// assertion below fails for the wrong reason.
|
||||||
|
emit(Events.LibraryScanComplete);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights an artist card that was ctrl-clicked', async () => {
|
||||||
|
const el = await fixture<LitElement>('artists-view');
|
||||||
|
|
||||||
|
sized(el);
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
const card = shadowAll(el, '.artist-card')[0];
|
||||||
|
|
||||||
|
expect(card, 'no artist cards rendered').toBeTruthy();
|
||||||
|
|
||||||
|
card!.dispatchEvent(
|
||||||
|
new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
ctrlKey: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
// The clicked card specifically, not a count: a view is free to
|
||||||
|
// arrive with something already selected, and the question here is
|
||||||
|
// only whether the click reached the DOM.
|
||||||
|
expect(shadowAll(el, '.artist-card')[0]?.classList.contains('selected')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights a genre card that was ctrl-clicked', async () => {
|
||||||
|
const el = await fixture<LitElement>('genres-view');
|
||||||
|
|
||||||
|
sized(el);
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
const card = shadowAll(el, '.genre-card')[0];
|
||||||
|
|
||||||
|
expect(card, 'no genre cards rendered').toBeTruthy();
|
||||||
|
|
||||||
|
card!.dispatchEvent(
|
||||||
|
new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
ctrlKey: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
// The clicked card specifically, not a count: a view is free to
|
||||||
|
// arrive with something already selected, and the question here is
|
||||||
|
// only whether the click reached the DOM.
|
||||||
|
expect(shadowAll(el, '.genre-card')[0]?.classList.contains('selected')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,9 @@ describe('<app-sidebar>', () => {
|
|||||||
const el = await fixture('app-sidebar');
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
shadowAll(el, 'li').map((li) => li.getAttribute('data-testid')),
|
shadowAll(el, 'li button').map((item) =>
|
||||||
|
item.getAttribute('data-testid'),
|
||||||
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
'nav-home',
|
'nav-home',
|
||||||
'nav-playlists',
|
'nav-playlists',
|
||||||
@@ -44,8 +46,8 @@ describe('<app-sidebar>', () => {
|
|||||||
it('marks exactly one item as the current page', async () => {
|
it('marks exactly one item as the current page', async () => {
|
||||||
const el = await fixture('app-sidebar');
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
const current = shadowAll(el, 'li').filter(
|
const current = shadowAll(el, 'li button').filter(
|
||||||
(li) => li.getAttribute('aria-current') === 'page',
|
(item) => item.getAttribute('aria-current') === 'page',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(current).toHaveLength(1);
|
expect(current).toHaveLength(1);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* The one "are you sure?".
|
||||||
|
*
|
||||||
|
* Three destructive actions had none at all — including a multi-select
|
||||||
|
* loop that deleted N playlists on one click (errors.M6, M7, m4) — so
|
||||||
|
* what matters here is that the promise a call site awaits cannot
|
||||||
|
* resolve true unless somebody said so.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
confirmAction,
|
||||||
|
type ConfirmDialog,
|
||||||
|
} from '@components/confirm-dialog/confirm-dialog';
|
||||||
|
|
||||||
|
/** The singleton the helper attaches to the document on first use. */
|
||||||
|
function host(): ConfirmDialog {
|
||||||
|
const el = document.querySelector<ConfirmDialog>('confirm-dialog');
|
||||||
|
|
||||||
|
if (!el) throw new Error('confirm-dialog did not mount itself');
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function press(testid: string): Promise<void> {
|
||||||
|
const el = host();
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
el.shadowRoot?.querySelector<HTMLButtonElement>(`[data-testid="${testid}"]`)
|
||||||
|
?.click();
|
||||||
|
await el.updateComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('confirmAction', () => {
|
||||||
|
it('resolves true only when the user accepts', async () => {
|
||||||
|
const answer = confirmAction({
|
||||||
|
title: 'Delete “Chill”?',
|
||||||
|
message: 'The playlist is deleted; the audio files are not.',
|
||||||
|
confirmLabel: 'Delete playlist',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await press('confirm-accept');
|
||||||
|
|
||||||
|
await expect(answer).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false on cancel, which is the default answer', async () => {
|
||||||
|
const answer = confirmAction({ title: 'Delete?', message: 'Gone for good.' });
|
||||||
|
|
||||||
|
await press('confirm-cancel');
|
||||||
|
|
||||||
|
await expect(answer).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what will happen before asking', async () => {
|
||||||
|
const answer = confirmAction({
|
||||||
|
title: 'Remove “Lidarr”?',
|
||||||
|
message: 'YellowJacket will stop using this client.',
|
||||||
|
impact: 'Its stored credentials are deleted and cannot be recovered.',
|
||||||
|
});
|
||||||
|
const el = host();
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.shadowRoot?.textContent).toContain('cannot be recovered');
|
||||||
|
|
||||||
|
await press('confirm-cancel');
|
||||||
|
await answer;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leave a second question hanging', async () => {
|
||||||
|
const first = confirmAction({ title: 'One?', message: 'First.' });
|
||||||
|
const second = confirmAction({ title: 'Two?', message: 'Second.' });
|
||||||
|
|
||||||
|
await press('confirm-accept');
|
||||||
|
|
||||||
|
// The first was superseded and answered "no" rather than never
|
||||||
|
// settling — a call site awaiting it would otherwise hang forever.
|
||||||
|
await expect(Promise.all([first, second])).resolves.toEqual([false, true]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* "Loading tracks…" used to be what the track list said when it was
|
||||||
|
* loading, when it was empty, and when the query had failed — including
|
||||||
|
* on the first screen a new user ever sees, behind the first-run wizard
|
||||||
|
* (errors.M2, H-12). Three situations, three different things to say.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import '@components/track-list/track-list';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import { emit, stub, stubFailure, flush, resetHarness } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, text } from '@test/support/render';
|
||||||
|
|
||||||
|
/** Drop the library store's cache so the list has to fetch. */
|
||||||
|
async function emptyLibrary(): Promise<void> {
|
||||||
|
resetHarness();
|
||||||
|
stub('library.Library.GetAllTracks', []);
|
||||||
|
stub('library.Library.GetAllAlbums', []);
|
||||||
|
stub('library.Library.GetAllArtists', []);
|
||||||
|
stub('library.Library.GetAllGenresWithCounts', []);
|
||||||
|
emit(Events.LibraryScanComplete);
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('<track-list> empty, loading and failed', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await emptyLibrary();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the library is empty when it is empty', async () => {
|
||||||
|
const el = await fixture<LitElement>('track-list');
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(text(el, '[data-testid="track-list-empty"]')).toContain(
|
||||||
|
'No tracks yet',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the query failed, and offers to try again', async () => {
|
||||||
|
stubFailure('library.Library.GetAllTracks', 'sql: database is locked');
|
||||||
|
emit(Events.LibraryScanComplete);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const el = await fixture<LitElement>('track-list');
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect([
|
||||||
|
text(el, '[data-testid="track-list-error"]'),
|
||||||
|
shadow(el, '[data-testid="track-list-loading"]'),
|
||||||
|
]).toEqual([expect.stringContaining('busy'), null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not claim to be loading a list it was handed', async () => {
|
||||||
|
const el = await fixture<LitElement>('track-list', { externalTracks: [] });
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(text(el, '[data-testid="track-list-empty"]')).toBe(
|
||||||
|
'Nothing here yet.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* The icons are bundled, and stay bundled.
|
||||||
|
*
|
||||||
|
* `e2e/specs/offline-icons.spec.ts` is the reproduction — it closes the
|
||||||
|
* network and looks at the screen. This is the cheap guard that runs
|
||||||
|
* on every change: that the library the app registers resolves every
|
||||||
|
* name it claims to, and resolves none of them to a URL somebody else
|
||||||
|
* has to be reachable to serve.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
// A bare <wa-icon> is an unknown element unless something pulls the
|
||||||
|
// component in; in the app `index.ts` does it, and here nothing else in
|
||||||
|
// this module would.
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
|
||||||
|
import { bundledIconNames, registerBundledIcons } from '../../src/icons';
|
||||||
|
import { fixture } from '../support/render';
|
||||||
|
|
||||||
|
// A name from each of the ways a call site produces one: a literal in a
|
||||||
|
// template, a sidebar table entry, a value computed from player state,
|
||||||
|
// and the notification tone map. Not exhaustive on purpose — the
|
||||||
|
// exhaustive check is the e2e sweep, which can see what state produces.
|
||||||
|
const REPRESENTATIVE = [
|
||||||
|
'house', 'compact-disc', 'play', 'pause', 'shuffle', 'repeat',
|
||||||
|
'volume-high', 'volume-xmark', 'triangle-exclamation', 'circle-check',
|
||||||
|
'magnifying-glass', 'gear', 'heart', 'regular/heart',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('bundled icons', () => {
|
||||||
|
it('resolves every name the app is known to use', () => {
|
||||||
|
const names = new Set(bundledIconNames());
|
||||||
|
|
||||||
|
for (const name of REPRESENTATIVE) {
|
||||||
|
expect(names, `icon '${name}' is not bundled`).toContain(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves nothing to a remote origin', async () => {
|
||||||
|
// Re-registering is what the app does on every boot; it must be
|
||||||
|
// idempotent, and the assertion needs the resolver itself
|
||||||
|
// rather than the name list.
|
||||||
|
registerBundledIcons();
|
||||||
|
|
||||||
|
// Attributes, not properties: `regular/heart` is a library key
|
||||||
|
// rather than an icon name, and `wa-icon` takes its name from
|
||||||
|
// the attribute.
|
||||||
|
const icons = await Promise.all(
|
||||||
|
REPRESENTATIVE.filter((n) => !n.includes('/')).map(async (n) => {
|
||||||
|
const el = await fixture('wa-icon');
|
||||||
|
el.setAttribute('name', n);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Give the icon components a turn to fetch and inline their SVG.
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
expect(icons.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const icon of icons) {
|
||||||
|
const svg = icon.shadowRoot?.querySelector('svg');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
svg,
|
||||||
|
`icon '${icon.getAttribute('name')}' rendered nothing`,
|
||||||
|
).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Keyboard reachability.
|
||||||
|
*
|
||||||
|
* Tabbing through the whole app used to yield fourteen stops, every one
|
||||||
|
* of them chrome: the sidebar was a list of `<li @click>`, a track list
|
||||||
|
* had no tab stop at all, and the *closed* queue panel still had two
|
||||||
|
* (`.planning/audits/2026-08-11-ui/hands-on.md`, H-5).
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/sidebar/app-sidebar';
|
||||||
|
import '@components/queue-panel/queue-panel';
|
||||||
|
import '@components/track-list/track-list';
|
||||||
|
import { stub } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||||
|
|
||||||
|
/** Two fixture tracks, enough to move a focus ring between. */
|
||||||
|
const TRACKS = [
|
||||||
|
{
|
||||||
|
FilePath: '/music/a.mp3',
|
||||||
|
TrackName: 'Alpha',
|
||||||
|
ArtistName: 'One',
|
||||||
|
Album: 'First',
|
||||||
|
Duration: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FilePath: '/music/b.mp3',
|
||||||
|
TrackName: 'Beta',
|
||||||
|
ArtistName: 'Two',
|
||||||
|
Album: 'First',
|
||||||
|
Duration: 120,
|
||||||
|
},
|
||||||
|
] as never[];
|
||||||
|
|
||||||
|
describe('<app-sidebar> is reachable', () => {
|
||||||
|
it('renders every destination as a button, not a bare list item', async () => {
|
||||||
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
|
const items = shadowAll(el, 'li button');
|
||||||
|
|
||||||
|
expect(items).toHaveLength(11);
|
||||||
|
expect(items.every((item) => item.tagName === 'BUTTON')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the nav in a landmark, so it can be jumped to', async () => {
|
||||||
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
|
expect(shadow(el, 'nav')?.getAttribute('aria-label')).toBe('Main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<queue-panel> when closed', () => {
|
||||||
|
it('is inert, so its buttons are not tab stops', async () => {
|
||||||
|
const el = await fixture('queue-panel');
|
||||||
|
|
||||||
|
expect(el.inert).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not inert once opened', async () => {
|
||||||
|
const el = await fixture('queue-panel');
|
||||||
|
|
||||||
|
await update(el, { open: true });
|
||||||
|
|
||||||
|
expect(el.inert).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<track-list> roving tabindex', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stub('library.Library.GetAllTracks', TRACKS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers exactly one tab stop, however many rows there are', async () => {
|
||||||
|
const el = await fixture('track-list', { externalTracks: TRACKS });
|
||||||
|
|
||||||
|
const stops = shadowAll(el, '.track-row[tabindex="0"]');
|
||||||
|
|
||||||
|
expect(stops).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the rows grid semantics rather than none', async () => {
|
||||||
|
const el = await fixture('track-list', { externalTracks: TRACKS });
|
||||||
|
|
||||||
|
const row = shadow(el, '.track-row');
|
||||||
|
|
||||||
|
expect({
|
||||||
|
row: row?.getAttribute('role'),
|
||||||
|
grid: shadow(el, '.table-container')?.getAttribute('role'),
|
||||||
|
cell: shadow(el, '.track-row .cell')?.getAttribute('role'),
|
||||||
|
}).toEqual({ row: 'row', grid: 'grid', cell: 'gridcell' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the tab stop with the arrow keys', async () => {
|
||||||
|
const el = await fixture('track-list', { externalTracks: TRACKS });
|
||||||
|
|
||||||
|
shadow(el, '.table-container')?.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'ArrowDown',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shadow(el, '.track-row[tabindex="0"]')?.getAttribute('data-index'),
|
||||||
|
).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claims the tracklist shortcut scope while it is on screen', async () => {
|
||||||
|
const el = await fixture('track-list', { externalTracks: TRACKS });
|
||||||
|
|
||||||
|
expect(el.dataset['shortcutScope']).toBe('tracklist');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* `track-details` is 42 kB and must not ride in the startup chunk.
|
||||||
|
*
|
||||||
|
* Five components open it — `track-list`, `cover-grid`, `queue-panel`,
|
||||||
|
* `playlist-details` and `smart-playlist-details` — and all five used
|
||||||
|
* to `import` it for side effect, so it was eagerly evaluated before
|
||||||
|
* first paint however `index.ts` split the routes. Measured: 814.5 kB
|
||||||
|
* of JS evaluated before first paint, against 772.9 kB after.
|
||||||
|
*
|
||||||
|
* What keeps it out is the *absence* of those imports, which is
|
||||||
|
* invisible: adding one back costs nothing anybody would notice, and
|
||||||
|
* the dialog carries on working because the chunk is also warmed on
|
||||||
|
* idle. So the first half of this file reads the five sources and
|
||||||
|
* fails if one of them reaches for it statically again. A `import
|
||||||
|
* type` is fine — types are erased and pull in no chunk.
|
||||||
|
*
|
||||||
|
* The second half is the reason that is safe: `loadTrackDetails()`
|
||||||
|
* really does define the element, so an opener that awaits it can then
|
||||||
|
* use the `<track-details>` its template already rendered. Before the
|
||||||
|
* chunk lands that element exists but is not upgraded — an inert
|
||||||
|
* `HTMLElement` with no `show()` — which is the same trap `index.ts`'s
|
||||||
|
* `VIEW_LOADERS` exists for, and why every opener awaits.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import trackListSource from '@components/track-list/track-list.ts?raw';
|
||||||
|
import coverGridSource from '@components/cover-grid/cover-grid.ts?raw';
|
||||||
|
import queuePanelSource from '@components/queue-panel/queue-panel.ts?raw';
|
||||||
|
import playlistDetailsSource from '@components/playlist-details/playlist-details.ts?raw';
|
||||||
|
import smartPlaylistDetailsSource from '@components/smart-playlist-details/smart-playlist-details.ts?raw';
|
||||||
|
|
||||||
|
import { loadTrackDetails } from '@utils/lazy-track-details';
|
||||||
|
|
||||||
|
const OPENERS: Array<[string, string]> = [
|
||||||
|
['track-list', trackListSource],
|
||||||
|
['cover-grid', coverGridSource],
|
||||||
|
['queue-panel', queuePanelSource],
|
||||||
|
['playlist-details', playlistDetailsSource],
|
||||||
|
['smart-playlist-details', smartPlaylistDetailsSource],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A side-effect import: `import '…track-details…'`, no bindings. */
|
||||||
|
const SIDE_EFFECT_IMPORT =
|
||||||
|
/^\s*import\s+['"][^'"]*track-details[^'"]*['"]\s*;?\s*$/m;
|
||||||
|
|
||||||
|
describe('track-details stays out of the startup chunk', () => {
|
||||||
|
it.each(OPENERS)(
|
||||||
|
'%s does not import track-details for side effect',
|
||||||
|
(_name, source) => {
|
||||||
|
expect(SIDE_EFFECT_IMPORT.test(source)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(OPENERS)('%s loads it at the point of use', (_name, source) => {
|
||||||
|
expect(source).toContain('loadTrackDetails');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadTrackDetails', () => {
|
||||||
|
it('defines the element and reports success', async () => {
|
||||||
|
await expect(loadTrackDetails()).resolves.toBe(true);
|
||||||
|
expect(customElements.get('track-details')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a rendered element usable', async () => {
|
||||||
|
// The invariant every opener depends on: after the await, the
|
||||||
|
// `<track-details>` its template already rendered has a
|
||||||
|
// `show()` on it. (This cannot observe the *un*-upgraded state
|
||||||
|
// — the suite above has already defined the element, and a
|
||||||
|
// custom element cannot be undefined again — so it asserts the
|
||||||
|
// postcondition rather than the transition.)
|
||||||
|
const el = document.createElement('track-details');
|
||||||
|
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadTrackDetails();
|
||||||
|
expect(typeof (el as { show?: unknown }).show).toBe('function');
|
||||||
|
} finally {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is memoised, so a second open does not refetch', async () => {
|
||||||
|
const first = loadTrackDetails();
|
||||||
|
|
||||||
|
expect(loadTrackDetails()).toBe(first);
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* `perf.M3` and `perf.M4` are both per-row costs paid only while a
|
||||||
|
* virtualizer is recycling rows, which makes them invisible to every
|
||||||
|
* other tier: nothing renders differently, nothing fails, and the app
|
||||||
|
* is merely slower to scroll.
|
||||||
|
*
|
||||||
|
* The measurement that found them lives in `e2e/perf/measure.mjs` and
|
||||||
|
* needs a 50 000-track library. These are the cheap guards that keep
|
||||||
|
* the fixes from being undone by someone reading the call site alone,
|
||||||
|
* and they assert the *mechanism* rather than a duration — a timing
|
||||||
|
* assertion in a component test is a flake, not a regression test.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { COLUMN_DEFS } from '@components/track-list/columns';
|
||||||
|
import { render } from 'lit';
|
||||||
|
|
||||||
|
/** Render a column's cell into a detached element and read the HTML. */
|
||||||
|
function cell(columnId: string, track: Record<string, unknown>): HTMLElement {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
|
||||||
|
render(COLUMN_DEFS[columnId]?.renderCell?.(track as never), host);
|
||||||
|
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the track list Art column', () => {
|
||||||
|
const track = {
|
||||||
|
CoverArtPath: '/covers/abc.jpg',
|
||||||
|
CoverArtSmall: '/covers/abc_sm.jpg',
|
||||||
|
CoverArtMedium: '/covers/abc_md.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('asks for the 100 px tier, not the original, for a 24 px box', () => {
|
||||||
|
// The original is commonly 1500×1500 and several hundred kB, and was
|
||||||
|
// being decoded in full to draw 576 pixels.
|
||||||
|
const img = cell('albumArt', track).querySelector('img');
|
||||||
|
|
||||||
|
expect(img?.getAttribute('src')).toBe('/covers/abc_sm.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the decode off the scroll path', () => {
|
||||||
|
const img = cell('albumArt', track).querySelector('img');
|
||||||
|
|
||||||
|
expect([
|
||||||
|
img?.getAttribute('loading'),
|
||||||
|
img?.getAttribute('decoding'),
|
||||||
|
]).toEqual(['lazy', 'async']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back through the tiers rather than rendering nothing', () => {
|
||||||
|
const onlyOriginal = cell('albumArt', {
|
||||||
|
CoverArtPath: '/covers/abc.jpg',
|
||||||
|
CoverArtSmall: '',
|
||||||
|
CoverArtMedium: '',
|
||||||
|
}).querySelector('img');
|
||||||
|
|
||||||
|
expect(onlyOriginal?.getAttribute('src')).toBe('/covers/abc.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing at all when there is no art', () => {
|
||||||
|
const none = cell('albumArt', {
|
||||||
|
CoverArtPath: '',
|
||||||
|
CoverArtSmall: '',
|
||||||
|
CoverArtMedium: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(none.querySelector('img')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* The surface itself: one component, four presentations.
|
||||||
|
*
|
||||||
|
* The assertions are on what a user (and Playwright) can see — the
|
||||||
|
* sentence, the action, the dismiss — rather than on which element
|
||||||
|
* happens to hold them, because the whole point of the shared notice is
|
||||||
|
* that a caller does not choose the markup.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/notifications/notification-host';
|
||||||
|
import '@components/notifications/inline-notice';
|
||||||
|
import { notificationStore } from '@store/notification-store';
|
||||||
|
import { flush } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, shadowAll, text } from '@test/support/render';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
/** Let the store's microtask notification reach the component. */
|
||||||
|
async function settle(el: LitElement): Promise<void> {
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('<notification-host>', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
notificationStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing when there is nothing to say', async () => {
|
||||||
|
const el = await fixture('notification-host');
|
||||||
|
|
||||||
|
expect(shadow(el, '[data-testid="notification-stack"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a persistent failure with the action it offers', async () => {
|
||||||
|
const el = await fixture<LitElement>('notification-host');
|
||||||
|
|
||||||
|
notificationStore.persistent({
|
||||||
|
text: 'The scan did not start.',
|
||||||
|
action: { label: 'Try again', run: () => undefined },
|
||||||
|
});
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
expect([
|
||||||
|
text(el, '[data-testid="notification"]'),
|
||||||
|
text(el, '[data-testid="notification-action"]'),
|
||||||
|
]).toEqual([
|
||||||
|
expect.stringContaining('The scan did not start.'),
|
||||||
|
'Try again',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks persistent above transient, so a toast never buries an answer', async () => {
|
||||||
|
const el = await fixture<LitElement>('notification-host');
|
||||||
|
|
||||||
|
notificationStore.transient({ text: 'That favourite was undone.' });
|
||||||
|
notificationStore.persistent({ text: 'The scan did not start.' });
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shadowAll(el, '[data-testid="notification"]').map((n) =>
|
||||||
|
n.getAttribute('data-level'),
|
||||||
|
),
|
||||||
|
).toEqual(['persistent', 'transient']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses on request', async () => {
|
||||||
|
const el = await fixture<LitElement>('notification-host');
|
||||||
|
|
||||||
|
notificationStore.transient({ text: 'That favourite was undone.' });
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(el, '.notice-dismiss')?.click();
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
expect(shadow(el, '[data-testid="notification"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts a blocking failure in a dialog, which has to be answered', async () => {
|
||||||
|
const el = await fixture<LitElement>('notification-host');
|
||||||
|
|
||||||
|
notificationStore.blocking({
|
||||||
|
title: 'This folder was only partly retagged',
|
||||||
|
text: '3 of 9 tracks were written.',
|
||||||
|
});
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
expect(shadow(el, '[data-testid="notification-blocking"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves inline messages to the region that failed', async () => {
|
||||||
|
const el = await fixture<LitElement>('notification-host');
|
||||||
|
|
||||||
|
notificationStore.inline('player', { text: 'Could not seek.' });
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
expect(shadow(el, '[data-testid="notification"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<inline-notice>', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
notificationStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only its own region', async () => {
|
||||||
|
const el = await fixture<LitElement>('inline-notice', { region: 'player' });
|
||||||
|
|
||||||
|
notificationStore.inline('explore', { text: 'The search did not answer.' });
|
||||||
|
notificationStore.inline('player', { text: 'Could not seek.' });
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
expect(text(el, '[data-testid="notification"]')).toContain(
|
||||||
|
'Could not seek.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a live region, because nothing moved focus to it', async () => {
|
||||||
|
const el = await fixture<LitElement>('inline-notice', { region: 'player' });
|
||||||
|
|
||||||
|
notificationStore.inline('player', { text: 'Could not seek.' });
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
expect(shadow(el, '[aria-live="polite"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -156,6 +156,82 @@ describe('<now-playing>', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// perf.m5. `updated()` used to measure and rewrite the text geometry
|
||||||
|
// on every pass, and the player store notifies while playing — so a
|
||||||
|
// component whose DOM had not changed did six querySelectors and a
|
||||||
|
// read/write interleave several times a second. These two pin the
|
||||||
|
// guard from both sides: it has to skip the work when nothing it
|
||||||
|
// measures changed, and it has to *not* skip it when something did.
|
||||||
|
async function settle(el: HTMLElement & { updateComplete: Promise<unknown> }) {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await new Promise((r) => {
|
||||||
|
requestAnimationFrame(() => r(null));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countShadowQueries(el: HTMLElement): () => number {
|
||||||
|
const root = el.shadowRoot!;
|
||||||
|
const orig = root.querySelector.bind(root);
|
||||||
|
let n = 0;
|
||||||
|
|
||||||
|
root.querySelector = ((...args: [string]) => {
|
||||||
|
n++;
|
||||||
|
|
||||||
|
return orig(...args);
|
||||||
|
}) as typeof root.querySelector;
|
||||||
|
|
||||||
|
return () => n;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not touch the DOM again when a position report changes nothing', async () => {
|
||||||
|
const el = await fixture('now-playing');
|
||||||
|
|
||||||
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 7 });
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
// `observe()` delivers an initial callback of its own, which is one
|
||||||
|
// more legitimate re-measure. Let it land before counting, or the
|
||||||
|
// straggler reads as the thing this is asserting is gone.
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
const queries = countShadowQueries(el);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
emit(Events.PlaybackPositionChanged, {
|
||||||
|
positionSeconds: i + 1,
|
||||||
|
trackChangeId: 7,
|
||||||
|
seq: i,
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(queries()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-measures when the track changes', async () => {
|
||||||
|
const el = await fixture('now-playing');
|
||||||
|
|
||||||
|
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 8 });
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const queries = countShadowQueries(el);
|
||||||
|
|
||||||
|
emit(Events.TrackChanged, {
|
||||||
|
...TRACK,
|
||||||
|
title: 'Teenage Wildlife',
|
||||||
|
trackChangeId: 9,
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(queries()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('looks the way it did last time', async () => {
|
it('looks the way it did last time', async () => {
|
||||||
const el = await fixture('now-playing');
|
const el = await fixture('now-playing');
|
||||||
|
|
||||||
@@ -175,14 +251,21 @@ describe('<queue-panel>', () => {
|
|||||||
setQueue([]);
|
setQueue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Every case here mounts the panel *open*. A closed panel renders no
|
||||||
|
// list at all (perf.m7) — `width: 0` used to hide a virtualizer that
|
||||||
|
// was still measuring its window on every queue change and still
|
||||||
|
// calling `scrollIntoView()` on an invisible element. These tests
|
||||||
|
// passed against a closed panel before that, which is the finding
|
||||||
|
// rather than a detail of the fixture.
|
||||||
|
|
||||||
it('says so when the queue is empty', async () => {
|
it('says so when the queue is empty', async () => {
|
||||||
const el = await fixture('queue-panel');
|
const el = await fixture('queue-panel', { open: true });
|
||||||
|
|
||||||
expect(text(el, '.empty-state p')).toBe('Queue is empty');
|
expect(text(el, '.empty-state p')).toBe('Queue is empty');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a row per queued track, tagged with its file path', async () => {
|
it('renders a row per queued track, tagged with its file path', async () => {
|
||||||
const el = await fixture('queue-panel');
|
const el = await fixture('queue-panel', { open: true });
|
||||||
|
|
||||||
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')]);
|
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')]);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -200,7 +283,7 @@ describe('<queue-panel>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('marks the playing row as active', async () => {
|
it('marks the playing row as active', async () => {
|
||||||
const el = await fixture('queue-panel');
|
const el = await fixture('queue-panel', { open: true });
|
||||||
|
|
||||||
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 1);
|
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 1);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -215,7 +298,7 @@ describe('<queue-panel>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('disables the clear button on an empty queue', async () => {
|
it('disables the clear button on an empty queue', async () => {
|
||||||
const el = await fixture('queue-panel');
|
const el = await fixture('queue-panel', { open: true });
|
||||||
|
|
||||||
const button = shadow<HTMLButtonElement>(el, '.header-action-button');
|
const button = shadow<HTMLButtonElement>(el, '.header-action-button');
|
||||||
|
|
||||||
@@ -223,7 +306,7 @@ describe('<queue-panel>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clears through the backend, not locally', async () => {
|
it('clears through the backend, not locally', async () => {
|
||||||
const el = await fixture('queue-panel');
|
const el = await fixture('queue-panel', { open: true });
|
||||||
|
|
||||||
setQueue([queueTrack(1, 'First')]);
|
setQueue([queueTrack(1, 'First')]);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -240,7 +323,7 @@ describe('<queue-panel>', () => {
|
|||||||
// toMatchScreenshot never gets two identical frames and fails with
|
// toMatchScreenshot never gets two identical frames and fails with
|
||||||
// "could not capture a stable screenshot" rather than a real diff.
|
// "could not capture a stable screenshot" rather than a real diff.
|
||||||
it('keeps rendering rows after the virtualizer settles', async () => {
|
it('keeps rendering rows after the virtualizer settles', async () => {
|
||||||
const el = await fixture('queue-panel');
|
const el = await fixture('queue-panel', { open: true });
|
||||||
|
|
||||||
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 0);
|
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 0);
|
||||||
await flush();
|
await flush();
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* `perf.M5`: the two playlist detail views rendered every track with a
|
||||||
|
* plain `.map()`. Measured at 2 000 tracks: 22 090 elements retained in
|
||||||
|
* the shadow root and 2 000 eager cover requests on open, against the
|
||||||
|
* 487 and 0 a virtualizer costs.
|
||||||
|
*
|
||||||
|
* The magnitude belongs to `e2e/perf/measure.mjs` and a 50 000-track
|
||||||
|
* library. These are the cheap guards for the *mechanism*, and in
|
||||||
|
* particular for the one thing that broke while fixing it: the row
|
||||||
|
* templates now live inside the virtualizer, so a host re-render alone
|
||||||
|
* no longer repaints them. Selection went silently dead — the
|
||||||
|
* controller held the right keys and no row ever showed it. Nothing but
|
||||||
|
* a click in the real app caught that, which is precisely why it is
|
||||||
|
* pinned here.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import '@components/playlist-details/playlist-details';
|
||||||
|
import '@components/smart-playlist-details/smart-playlist-details';
|
||||||
|
import { stub, flush, resetHarness } from '@test/support/harness';
|
||||||
|
import { fixture, shadowAll } from '@test/support/render';
|
||||||
|
|
||||||
|
const TRACKS = 500;
|
||||||
|
|
||||||
|
function tracks(n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
ID: i + 1,
|
||||||
|
FilePath: `/music/track-${i}.mp3`,
|
||||||
|
Title: `Track ${i}`,
|
||||||
|
Artist: 'An Artist',
|
||||||
|
Album: 'An Album',
|
||||||
|
Duration: 180000,
|
||||||
|
CoverArtSmall: `/covers/${i}_sm.jpg`,
|
||||||
|
CoverArtMedium: `/covers/${i}_md.jpg`,
|
||||||
|
CoverArtPath: `/covers/${i}.jpg`,
|
||||||
|
Phantom: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give the virtualizer a viewport; a zero-height host renders no rows. */
|
||||||
|
function sized(el: HTMLElement): void {
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.height = '600px';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('<playlist-details> at length', () => {
|
||||||
|
let el: LitElement;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetHarness();
|
||||||
|
stub('playlist.Service.GetPlaylistTracks', tracks(TRACKS));
|
||||||
|
stub('playlist.Service.GetAllPlaylists', []);
|
||||||
|
|
||||||
|
el = await fixture<LitElement>('playlist-details', {
|
||||||
|
playlistId: 1,
|
||||||
|
playlistName: 'A playlist',
|
||||||
|
});
|
||||||
|
sized(el);
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders through a virtualizer, not one row per track', () => {
|
||||||
|
const rows = shadowAll(el, '.track-item');
|
||||||
|
|
||||||
|
// A screenful and its overscan, not the playlist. The exact number
|
||||||
|
// depends on the viewport, so the assertion is the *order of
|
||||||
|
// magnitude* — 500 rows would be the bug.
|
||||||
|
expect([
|
||||||
|
shadowAll(el, 'lit-virtualizer').length,
|
||||||
|
rows.length > 0,
|
||||||
|
rows.length < TRACKS / 4,
|
||||||
|
]).toEqual([1, true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never asks for a cover it has not scrolled to', () => {
|
||||||
|
const imgs = shadowAll<HTMLImageElement>(el, '.track-item img');
|
||||||
|
|
||||||
|
expect(imgs.every((i) => i.getAttribute('loading') === 'lazy')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for the small tier, not the original', () => {
|
||||||
|
const img = shadowAll<HTMLImageElement>(el, '.track-item img')[0];
|
||||||
|
|
||||||
|
expect(img?.getAttribute('src')).toBe('/covers/0_sm.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still shows a selection, though the rows are the virtualizer\u2019s', async () => {
|
||||||
|
const row = shadowAll(el, '.track-item')[0]!;
|
||||||
|
|
||||||
|
row.dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||||
|
);
|
||||||
|
await el.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shadowAll(el, '.track-item').filter((r) =>
|
||||||
|
r.classList.contains('selected'),
|
||||||
|
).length,
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<smart-playlist-details> at length', () => {
|
||||||
|
let el: LitElement;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetHarness();
|
||||||
|
stub('playlist.Service.GetSmartPlaylistTracks', tracks(TRACKS));
|
||||||
|
stub('playlist.Service.GetSmartPlaylistRules', '{"rules":[]}');
|
||||||
|
stub('playlist.Service.GetAllPlaylists', []);
|
||||||
|
|
||||||
|
el = await fixture<LitElement>('smart-playlist-details', {
|
||||||
|
playlistId: 1,
|
||||||
|
playlistName: 'A smart playlist',
|
||||||
|
});
|
||||||
|
sized(el);
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders through a virtualizer, not one row per track', () => {
|
||||||
|
const rows = shadowAll(el, '.track-item');
|
||||||
|
|
||||||
|
expect([
|
||||||
|
shadowAll(el, 'lit-virtualizer').length,
|
||||||
|
rows.length > 0,
|
||||||
|
rows.length < TRACKS / 4,
|
||||||
|
]).toEqual([1, true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never asks for a cover it has not scrolled to', () => {
|
||||||
|
const imgs = shadowAll<HTMLImageElement>(el, '.track-item img');
|
||||||
|
|
||||||
|
expect(imgs.every((i) => i.getAttribute('loading') === 'lazy')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* A refetch is not a deselection.
|
||||||
|
*
|
||||||
|
* Selecting forty tracks to drag into a playlist was impossible while
|
||||||
|
* music was playing: every finished track invalidated the library
|
||||||
|
* cache, `track-list` answered the new array by calling `loadTracks()`,
|
||||||
|
* and that cleared the selection (audit perf.C2).
|
||||||
|
*
|
||||||
|
* Half of that is fixed in the backend — a play count no longer
|
||||||
|
* invalidates anything (perf.C1) — but the other half has to hold on
|
||||||
|
* its own, because a rescan, a retag or a library switch still deliver
|
||||||
|
* a new array, and none of those should throw away a selection whose
|
||||||
|
* items are still in the list.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import { SelectionController } from '../../src/utils/selection-controller';
|
||||||
|
|
||||||
|
class FakeHost {
|
||||||
|
items: string[] = [];
|
||||||
|
|
||||||
|
updates = 0;
|
||||||
|
|
||||||
|
changes = 0;
|
||||||
|
|
||||||
|
// ReactiveControllerHost, minus the parts a selection never uses.
|
||||||
|
addController(): void {
|
||||||
|
/* registration; nothing to do here. */
|
||||||
|
}
|
||||||
|
|
||||||
|
removeController(): void {
|
||||||
|
/* ditto. */
|
||||||
|
}
|
||||||
|
|
||||||
|
updateComplete = Promise.resolve(true);
|
||||||
|
|
||||||
|
requestUpdate(): void {
|
||||||
|
this.updates++;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectionChanged(): void {
|
||||||
|
this.changes++;
|
||||||
|
}
|
||||||
|
|
||||||
|
getItemCount(): number {
|
||||||
|
return this.items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getItemKey(index: number): string | undefined {
|
||||||
|
return this.items[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(
|
||||||
|
selection: SelectionController,
|
||||||
|
host: FakeHost,
|
||||||
|
index: number,
|
||||||
|
modifiers: MouseEventInit = {},
|
||||||
|
): void {
|
||||||
|
selection.handleItemClick(
|
||||||
|
new MouseEvent('click', modifiers),
|
||||||
|
host.items[index] as string,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selection across a refetch', () => {
|
||||||
|
let host: FakeHost;
|
||||||
|
let selection: SelectionController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
host = new FakeHost();
|
||||||
|
host.items = ['/a.mp3', '/b.mp3', '/c.mp3', '/d.mp3'];
|
||||||
|
selection = new SelectionController(host);
|
||||||
|
|
||||||
|
click(selection, host, 0);
|
||||||
|
click(selection, host, 2, { ctrlKey: true });
|
||||||
|
click(selection, host, 3, { ctrlKey: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a selection whose items all survive', () => {
|
||||||
|
const present = new Set(host.items);
|
||||||
|
|
||||||
|
selection.retain((key) => present.has(key));
|
||||||
|
|
||||||
|
expect([...selection.selectedItems].sort()).toEqual([
|
||||||
|
'/a.mp3', '/c.mp3', '/d.mp3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops only the items that are gone', () => {
|
||||||
|
host.items = ['/a.mp3', '/b.mp3', '/c.mp3'];
|
||||||
|
const present = new Set(host.items);
|
||||||
|
|
||||||
|
selection.retain((key) => present.has(key));
|
||||||
|
|
||||||
|
expect([...selection.selectedItems].sort()).toEqual([
|
||||||
|
'/a.mp3', '/c.mp3',
|
||||||
|
]);
|
||||||
|
expect(host.changes).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify when nothing changed', () => {
|
||||||
|
const present = new Set(host.items);
|
||||||
|
const before = host.changes;
|
||||||
|
|
||||||
|
selection.retain((key) => present.has(key));
|
||||||
|
|
||||||
|
expect(host.changes).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgets the shift-click anchor, which indexes the old list', () => {
|
||||||
|
host.items = ['/d.mp3', '/a.mp3', '/c.mp3'];
|
||||||
|
const present = new Set(host.items);
|
||||||
|
|
||||||
|
selection.retain((key) => present.has(key));
|
||||||
|
|
||||||
|
// A shift-click after a refetch extends from the clicked item
|
||||||
|
// alone rather than from a row that has since moved.
|
||||||
|
click(selection, host, 2, { shiftKey: true });
|
||||||
|
|
||||||
|
expect(selection.selectedItems.has('/c.mp3')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects all even when the count matches the previous size', () => {
|
||||||
|
// perf.p5: the guard compared cardinalities, so a selection
|
||||||
|
// that happened to be the same size as the list short-circuited
|
||||||
|
// Select All into a no-op.
|
||||||
|
selection.clear();
|
||||||
|
click(selection, host, 0);
|
||||||
|
click(selection, host, 1, { ctrlKey: true });
|
||||||
|
click(selection, host, 2, { ctrlKey: true });
|
||||||
|
click(selection, host, 3, { ctrlKey: true });
|
||||||
|
|
||||||
|
host.items = ['/w.mp3', '/x.mp3', '/y.mp3', '/z.mp3'];
|
||||||
|
selection.selectAll();
|
||||||
|
|
||||||
|
expect([...selection.selectedItems].sort()).toEqual([
|
||||||
|
'/w.mp3', '/x.mp3', '/y.mp3', '/z.mp3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `getSelectedKeysOrdered()` / `getSelectedIndices()` walk the list
|
||||||
|
* rather than the selection, which is `perf.m6`. Measured at 50 000
|
||||||
|
* tracks that walk is 3 ms — a fifth of a frame — so it stays, and the
|
||||||
|
* only change is that it stops once it has found everything.
|
||||||
|
*
|
||||||
|
* That early exit has one way to be wrong, and it is the case these
|
||||||
|
* tests exist for: a selected key that is *not* in the list must not
|
||||||
|
* end the walk early and truncate the answer. It is reachable —
|
||||||
|
* `retain()` deliberately keeps keys across a list it has not
|
||||||
|
* re-checked, and Ctrl-clicking builds a selection in any order.
|
||||||
|
*/
|
||||||
|
describe('ordered selection accessors', () => {
|
||||||
|
let host: FakeHost;
|
||||||
|
let selection: SelectionController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
host = new FakeHost();
|
||||||
|
host.items = ['/a.mp3', '/b.mp3', '/c.mp3', '/d.mp3'];
|
||||||
|
selection = new SelectionController(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns keys in list order, not selection order', () => {
|
||||||
|
click(selection, host, 3);
|
||||||
|
click(selection, host, 1, { ctrlKey: true });
|
||||||
|
click(selection, host, 0, { ctrlKey: true });
|
||||||
|
|
||||||
|
expect(selection.getSelectedKeysOrdered())
|
||||||
|
.toEqual(['/a.mp3', '/b.mp3', '/d.mp3']);
|
||||||
|
expect(selection.getSelectedIndices()).toEqual([0, 1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty for an empty selection', () => {
|
||||||
|
expect(selection.getSelectedKeysOrdered()).toEqual([]);
|
||||||
|
expect(selection.getSelectedIndices()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a selection at the end of the list', () => {
|
||||||
|
click(selection, host, 3);
|
||||||
|
|
||||||
|
expect(selection.getSelectedKeysOrdered()).toEqual(['/d.mp3']);
|
||||||
|
expect(selection.getSelectedIndices()).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stop early when a selected key is missing', () => {
|
||||||
|
click(selection, host, 0);
|
||||||
|
click(selection, host, 3, { ctrlKey: true });
|
||||||
|
|
||||||
|
// The list loses the first selected item; the selection still
|
||||||
|
// holds its key, which is exactly what `retain()` allows.
|
||||||
|
host.items = ['/b.mp3', '/c.mp3', '/d.mp3'];
|
||||||
|
|
||||||
|
expect(selection.getSelectedKeysOrdered()).toEqual(['/d.mp3']);
|
||||||
|
expect(selection.getSelectedIndices()).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns every selected key when all are selected', () => {
|
||||||
|
selection.selectAll();
|
||||||
|
|
||||||
|
expect(selection.getSelectedKeysOrdered()).toEqual(host.items);
|
||||||
|
expect(selection.getSelectedIndices()).toEqual([0, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
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/controls/player-controls';
|
||||||
import '@components/audio-player/seekbar/seek-bar';
|
import '@components/audio-player/seekbar/seek-bar';
|
||||||
import '@components/audio-player/volume-control/volume-control';
|
import '@components/audio-player/volume-control/volume-control';
|
||||||
@@ -15,11 +16,14 @@ import {
|
|||||||
fixture,
|
fixture,
|
||||||
shadow,
|
shadow,
|
||||||
shadowAll,
|
shadowAll,
|
||||||
|
deepShadow,
|
||||||
|
deepText,
|
||||||
text,
|
text,
|
||||||
click,
|
click,
|
||||||
visual,
|
visual,
|
||||||
} from '@test/support/render';
|
} 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 = {
|
const TRACK: TrackInfo = {
|
||||||
fileName: 'long.mp3',
|
fileName: 'long.mp3',
|
||||||
@@ -205,7 +209,81 @@ describe('<seek-bar>', () => {
|
|||||||
expect([
|
expect([
|
||||||
text(el, '[data-testid="elapsed-time"]'),
|
text(el, '[data-testid="elapsed-time"]'),
|
||||||
text(el, '[data-testid="remaining-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 () => {
|
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');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* The half of the lifecycle the view cache never had.
|
||||||
|
*
|
||||||
|
* `index.ts` keeps every primary view mounted so that `scrollTop`
|
||||||
|
* survives navigation, which means `disconnectedCallback` never fires
|
||||||
|
* and a view that is off-screen keeps listening. The tests here assert
|
||||||
|
* the property that fixes: a deactivated view has removed everything it
|
||||||
|
* registered, and a reactivated one has it back — measured by counting
|
||||||
|
* what actually reaches `document`, since that is where the damage was
|
||||||
|
* (`.planning/audits/2026-08-11-ui/hands-on.md`, H-1).
|
||||||
|
*/
|
||||||
|
import { LitElement, html } from 'lit';
|
||||||
|
import { customElement } from 'lit/decorators.js';
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
||||||
|
import {
|
||||||
|
ambientShortcutScope,
|
||||||
|
resetShortcutScopes,
|
||||||
|
} from '../../src/services/shortcut-scope';
|
||||||
|
import '@components/autotag-view/autotag-view';
|
||||||
|
import '@components/track-list/track-list';
|
||||||
|
import '@components/cover-grid/cover-grid';
|
||||||
|
import '@components/artists-view/artists-view';
|
||||||
|
import '@components/genres-view/genres-view';
|
||||||
|
import '@components/explore-view/explore-view';
|
||||||
|
import '@components/home-view/home-view';
|
||||||
|
import '@components/downloads-view/downloads-view';
|
||||||
|
|
||||||
|
import '@components/jobs/jobs-view';
|
||||||
|
import '@components/playlist-view/playlist-view';
|
||||||
|
import { fixture } from '@test/support/render';
|
||||||
|
import { stub, flush } from '@test/support/harness';
|
||||||
|
|
||||||
|
/** Count of keydown-ish document listeners, by proxy: every test view
|
||||||
|
* below registers on `document`, so a counting stand-in for
|
||||||
|
* `addEventListener` is the only honest measure available in a
|
||||||
|
* browser. */
|
||||||
|
let added = 0;
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
const realAdd = document.addEventListener.bind(document);
|
||||||
|
const realRemove = document.removeEventListener.bind(document);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
added = 0;
|
||||||
|
removed = 0;
|
||||||
|
document.addEventListener = ((...args: Parameters<typeof realAdd>) => {
|
||||||
|
added += 1;
|
||||||
|
|
||||||
|
return realAdd(...args);
|
||||||
|
}) as typeof document.addEventListener;
|
||||||
|
document.removeEventListener = ((
|
||||||
|
...args: Parameters<typeof realRemove>
|
||||||
|
) => {
|
||||||
|
removed += 1;
|
||||||
|
|
||||||
|
return realRemove(...args);
|
||||||
|
}) as typeof document.removeEventListener;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.addEventListener = realAdd;
|
||||||
|
document.removeEventListener = realRemove;
|
||||||
|
resetShortcutScopes();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
@customElement('lifecycle-probe')
|
||||||
|
class LifecycleProbe extends ViewLifecycleMixin(LitElement) {
|
||||||
|
ticks = 0;
|
||||||
|
keypresses = 0;
|
||||||
|
activations = 0;
|
||||||
|
deactivations = 0;
|
||||||
|
|
||||||
|
protected override onViewActivate(): void {
|
||||||
|
this.activations += 1;
|
||||||
|
this.listenWhileActive(document, 'keydown', () => {
|
||||||
|
this.keypresses += 1;
|
||||||
|
});
|
||||||
|
this.intervalWhileActive(() => {
|
||||||
|
this.ticks += 1;
|
||||||
|
}, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override onViewDeactivate(): void {
|
||||||
|
this.deactivations += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
return html`<p>probe</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One navigation away and back, as `index.ts` performs it. */
|
||||||
|
function navigateAway(el: LifecycleProbe): void {
|
||||||
|
el.classList.add('view-hidden');
|
||||||
|
el.viewDeactivated();
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateBack(el: LifecycleProbe): void {
|
||||||
|
el.classList.remove('view-hidden');
|
||||||
|
el.viewActivated();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('view lifecycle', () => {
|
||||||
|
it('activates on connection when it is not created hidden', async () => {
|
||||||
|
const el = await fixture<LifecycleProbe>('lifecycle-probe');
|
||||||
|
|
||||||
|
expect(el.viewActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops listening once deactivated', async () => {
|
||||||
|
const el = await fixture<LifecycleProbe>('lifecycle-probe');
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||||
|
navigateAway(el);
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||||
|
|
||||||
|
expect(el.keypresses).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens again on the way back, and only once', async () => {
|
||||||
|
const el = await fixture<LifecycleProbe>('lifecycle-probe');
|
||||||
|
|
||||||
|
navigateAway(el);
|
||||||
|
navigateBack(el);
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||||
|
|
||||||
|
expect(el.keypresses).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accumulate document listeners across navigations', async () => {
|
||||||
|
const el = await fixture<LifecycleProbe>('lifecycle-probe');
|
||||||
|
const afterFirst = added - removed;
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
navigateAway(el);
|
||||||
|
navigateBack(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(added - removed).toBe(afterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops its timers while off screen', async () => {
|
||||||
|
const el = await fixture<LifecycleProbe>('lifecycle-probe');
|
||||||
|
|
||||||
|
navigateAway(el);
|
||||||
|
|
||||||
|
const at = el.ticks;
|
||||||
|
|
||||||
|
await new Promise<void>((r) => {
|
||||||
|
setTimeout(r, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(el.ticks).toBe(at);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent in both directions', async () => {
|
||||||
|
const el = await fixture<LifecycleProbe>('lifecycle-probe');
|
||||||
|
|
||||||
|
el.viewActivated();
|
||||||
|
el.viewActivated();
|
||||||
|
navigateAway(el);
|
||||||
|
el.viewDeactivated();
|
||||||
|
|
||||||
|
expect([el.activations, el.deactivations]).toEqual([1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivates when it is genuinely removed', async () => {
|
||||||
|
const el = await fixture<LifecycleProbe>('lifecycle-probe');
|
||||||
|
|
||||||
|
el.remove();
|
||||||
|
|
||||||
|
expect(el.viewActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render while off screen, and catches up on return', async () => {
|
||||||
|
const el = await fixture<LifecycleProbe>('lifecycle-probe');
|
||||||
|
|
||||||
|
navigateAway(el);
|
||||||
|
el.requestUpdate();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.hasUpdated).toBe(true);
|
||||||
|
|
||||||
|
// The update was withheld, not lost: reactivation replays it.
|
||||||
|
navigateBack(el);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('shortcut scope claims', () => {
|
||||||
|
it('is claimed by the view on screen and released when it leaves', async () => {
|
||||||
|
const list = await fixture('track-list');
|
||||||
|
|
||||||
|
expect(ambientShortcutScope()).toBe('tracklist');
|
||||||
|
|
||||||
|
(list as unknown as { viewDeactivated(): void }).viewDeactivated();
|
||||||
|
|
||||||
|
expect(ambientShortcutScope()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes the scope as the attribute the service reads', async () => {
|
||||||
|
const list = await fixture('track-list');
|
||||||
|
|
||||||
|
expect(list.dataset['shortcutScope']).toBe('tracklist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/** The views `index.ts` caches, all of which must take part. */
|
||||||
|
const CACHED_VIEWS = [
|
||||||
|
'autotag-view',
|
||||||
|
'track-list',
|
||||||
|
'cover-grid',
|
||||||
|
'artists-view',
|
||||||
|
'genres-view',
|
||||||
|
'downloads-view',
|
||||||
|
'jobs-view',
|
||||||
|
'playlist-view',
|
||||||
|
'explore-view',
|
||||||
|
'home-view',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** An empty-but-valid backend, as smoke.test.ts does: an unstubbed
|
||||||
|
* binding resolves undefined, which is not what Go sends. */
|
||||||
|
function stubEmptyBackend(): void {
|
||||||
|
for (const path of [
|
||||||
|
'library.Library.GetAllTracks',
|
||||||
|
'library.Library.GetAllAlbums',
|
||||||
|
'library.Library.GetAllArtists',
|
||||||
|
'library.Library.GetAllGenresWithCounts',
|
||||||
|
'library.Library.GetAllLibrariesWithTrackCounts',
|
||||||
|
'playlist.Service.GetAllPlaylists',
|
||||||
|
'playlist.Service.GetAllPlaylistsWithTracks',
|
||||||
|
'playlist.Service.GetDefaultPlaylistTrackPaths',
|
||||||
|
'jobs.Service.GetJobs',
|
||||||
|
'download.Service.ListProviders',
|
||||||
|
'download.Service.ListDownloads',
|
||||||
|
'download.Service.ListRequests',
|
||||||
|
'download.Service.ProviderKinds',
|
||||||
|
'autotagservice.Service.ListPendingFolders',
|
||||||
|
'home.Service.GetShelves',
|
||||||
|
]) {
|
||||||
|
stub(path, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('every cached view', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubEmptyBackend();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tag of CACHED_VIEWS) {
|
||||||
|
it(`${tag} holds no document listeners while off screen`, async () => {
|
||||||
|
const before = added - removed;
|
||||||
|
const el = await fixture(tag);
|
||||||
|
const view = el as unknown as {
|
||||||
|
viewActivated(): void;
|
||||||
|
viewDeactivated(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const active = added - removed;
|
||||||
|
|
||||||
|
view.viewDeactivated();
|
||||||
|
|
||||||
|
expect({ tag, held: added - removed - before }).toEqual({
|
||||||
|
tag,
|
||||||
|
held: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// …and gets them all back, exactly once, on the way in again.
|
||||||
|
view.viewActivated();
|
||||||
|
view.viewDeactivated();
|
||||||
|
view.viewActivated();
|
||||||
|
|
||||||
|
expect({ tag, net: added - removed }).toEqual({ tag, net: active });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
+15
-5
@@ -4,12 +4,16 @@
|
|||||||
* 1. Install the Wails fake. Store singletons call `EventsOn` and load
|
* 1. Install the Wails fake. Store singletons call `EventsOn` and load
|
||||||
* from the backend *in their constructors*, which run when a test
|
* from the backend *in their constructors*, which run when a test
|
||||||
* module imports them — so the globals have to exist first.
|
* module imports them — so the globals have to exist first.
|
||||||
* 2. Point Web Awesome at its assets. Without this every `<wa-icon>`
|
* 2. Point Web Awesome at its assets, and register the *bundled* icon
|
||||||
* silently 404s and screenshots come out with holes in them.
|
* library — the same call `index.ts` makes. Without the second
|
||||||
|
* one this tier renders icons from fontawesome.com, so a green
|
||||||
|
* `make ui-test` would depend on the network and the screenshot
|
||||||
|
* baselines would be of something the app no longer ships.
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach } from 'vitest';
|
import { afterEach, beforeEach } from 'vitest';
|
||||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||||
|
import { registerBundledIcons } from '../src/icons';
|
||||||
import { installWailsFake, wails } from './support/wails-fake';
|
import { installWailsFake, wails } from './support/wails-fake';
|
||||||
import { resetHarness } from './support/harness';
|
import { resetHarness } from './support/harness';
|
||||||
import { cleanupFixtures } from './support/render';
|
import { cleanupFixtures } from './support/render';
|
||||||
@@ -43,10 +47,16 @@ for (const [path, value] of importTimeDefaults) {
|
|||||||
wails.stub(path, value);
|
wails.stub(path, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vite serves the dependency's own directory, so icons resolve from
|
// Vite serves the dependency's own directory, so Web Awesome's own
|
||||||
// node_modules rather than from the built `dist/webawesome` copy the
|
// assets resolve from node_modules rather than from the built
|
||||||
// app uses.
|
// `dist/webawesome` copy the app uses.
|
||||||
|
//
|
||||||
|
// Note this does *not* cover icons: `getBasePath` is read only by the
|
||||||
|
// component autoloader, never by the icon resolver, which is the
|
||||||
|
// original half of audit finding H-4 and the reason the line below
|
||||||
|
// exists rather than being implied by this one.
|
||||||
setBasePath('/node_modules/@awesome.me/webawesome/dist');
|
setBasePath('/node_modules/@awesome.me/webawesome/dist');
|
||||||
|
registerBundledIcons();
|
||||||
|
|
||||||
// index.ts imports the theme store for its side effect: it derives the
|
// index.ts imports the theme store for its side effect: it derives the
|
||||||
// --yj-* custom properties and applies them to :root, where every
|
// --yj-* custom properties and applies them to :root, where every
|
||||||
|
|||||||
@@ -197,14 +197,74 @@ describe('shortcut dispatch: scope', () => {
|
|||||||
expect(calls('queue.Queue.Play')).toHaveLength(0);
|
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'));
|
const input = mount(document.createElement('input'));
|
||||||
|
|
||||||
input.type = 'checkbox';
|
input.type = 'checkbox';
|
||||||
input.focus();
|
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(' ');
|
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', () => {
|
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', () => {
|
it('resolves a panel scope from a data-shortcut-scope ancestor', () => {
|
||||||
const panel = mount(document.createElement('div'));
|
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.dataset['shortcutScope'] = 'tracklist';
|
||||||
panel.append(button);
|
panel.append(button);
|
||||||
button.focus();
|
button.focus();
|
||||||
@@ -256,8 +319,9 @@ describe('shortcut dispatch: scope', () => {
|
|||||||
const panel = mount(document.createElement('div'));
|
const panel = mount(document.createElement('div'));
|
||||||
const inner = document.createElement('div');
|
const inner = document.createElement('div');
|
||||||
const root = inner.attachShadow({ mode: 'open' });
|
const root = inner.attachShadow({ mode: 'open' });
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('div');
|
||||||
|
|
||||||
|
button.tabIndex = 0;
|
||||||
panel.dataset['shortcutScope'] = 'tracklist';
|
panel.dataset['shortcutScope'] = 'tracklist';
|
||||||
panel.append(inner);
|
panel.append(inner);
|
||||||
root.append(button);
|
root.append(button);
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ import {
|
|||||||
emit,
|
emit,
|
||||||
calls,
|
calls,
|
||||||
stub,
|
stub,
|
||||||
|
stubFailure,
|
||||||
flush,
|
flush,
|
||||||
lastArgs,
|
lastArgs,
|
||||||
resetHarness,
|
resetHarness,
|
||||||
} from '@test/support/harness';
|
} 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 ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }];
|
||||||
const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }];
|
const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }];
|
||||||
const ARTISTS = [{ ID: 1, Name: 'Artist' }];
|
const ARTISTS = [{ ID: 1, Name: 'Artist' }];
|
||||||
@@ -110,6 +114,67 @@ describe('library store: caching', () => {
|
|||||||
expect(calls('library.Library.GetAllTracks')).toHaveLength(1);
|
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 () => {
|
it('resets scroll positions on invalidation, so a shorter list is not scrolled past its end', async () => {
|
||||||
libraryStore.setScrollPosition('albums', 4200);
|
libraryStore.setScrollPosition('albums', 4200);
|
||||||
emit(Events.LibraryScanComplete);
|
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', () => {
|
describe('library store: default library id', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
libraryStore.setSelectedLibrary(null);
|
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
|
* 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
|
* `refetch`: one drops the cache (consumers render empty until the
|
||||||
* fetch lands), the other holds the stale list until the new one
|
* 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';
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
@@ -11,17 +16,42 @@ import { playlistStore } from '@store/playlist-store';
|
|||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { emit, calls, stub, flush, resetHarness } from '@test/support/harness';
|
import { emit, calls, stub, flush, resetHarness } from '@test/support/harness';
|
||||||
|
|
||||||
|
/** The real shape: `WithTracks` is `{ Summary, Tracks }`. */
|
||||||
const PLAYLISTS = [
|
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.GetAllPlaylistsWithTracks', PLAYLISTS);
|
||||||
|
stub('playlist.Service.GetAllPlaylists', SUMMARIES);
|
||||||
|
stub('playlist.Service.GetPlaylistTracks', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload(): Promise<void> {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = playlistStore.subscribe(() => {});
|
||||||
|
stubReads();
|
||||||
playlistStore.invalidate();
|
playlistStore.invalidate();
|
||||||
await flush();
|
await flush();
|
||||||
resetHarness();
|
resetHarness();
|
||||||
stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS);
|
stubReads();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('playlist store: caching', () => {
|
describe('playlist store: caching', () => {
|
||||||
@@ -86,12 +116,11 @@ describe('playlist store: invalidating events', () => {
|
|||||||
await reload();
|
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 = [
|
const events = [
|
||||||
Events.PlaylistCreated,
|
Events.PlaylistCreated,
|
||||||
Events.PlaylistDeleted,
|
Events.PlaylistDeleted,
|
||||||
Events.PlaylistRenamed,
|
Events.PlaylistRenamed,
|
||||||
Events.PlaylistTracksChanged,
|
|
||||||
Events.PlaylistsRestored,
|
Events.PlaylistsRestored,
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
];
|
];
|
||||||
@@ -105,4 +134,103 @@ describe('playlist store: invalidating events', () => {
|
|||||||
calls('playlist.Service.GetAllPlaylistsWithTracks'),
|
calls('playlist.Service.GetAllPlaylistsWithTracks'),
|
||||||
).toHaveLength(events.length);
|
).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 { searchStore } from '@store/search-store';
|
||||||
import { trackListStore } from '@store/tracklist-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 { Events } from '../../src/events';
|
||||||
import { emit, lastCall, flush } from '@test/support/harness';
|
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');
|
expect(exploreCache.getArtist('mbid-2')?.imageURL).toBe('http://x/eno.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caches an artist’s release groups and top tracks separately', () => {
|
// `perf.M8`. An artist entry holds the artist photo's base64 data URL
|
||||||
exploreCache.setArtistAlbums('mbid-3', [{ mbid: 'rg-1' }] as never);
|
// — ~128 kB measured — so an unbounded map on a view that never
|
||||||
exploreCache.setArtistTopTracks('mbid-3', [{ mbid: 'rec-1' }] as 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([
|
for (let i = 0; i < over; i++) {
|
||||||
exploreCache.getArtistAlbums('mbid-3')?.length,
|
exploreCache.setArtist(`cap-${i}`, { mbid: `cap-${i}`, name: `A${i}` });
|
||||||
exploreCache.getArtistTopTracks('mbid-3')?.length,
|
}
|
||||||
]).toEqual([1, 1]);
|
|
||||||
|
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', () => {
|
it('populates artists and albums from one search result', () => {
|
||||||
|
|||||||
@@ -67,6 +67,39 @@ export function shadowAll<E extends Element = Element>(
|
|||||||
return [...(host.shadowRoot?.querySelectorAll<E>(selector) ?? [])];
|
return [...(host.shadowRoot?.querySelectorAll<E>(selector) ?? [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query through nested shadow roots.
|
||||||
|
*
|
||||||
|
* A component that composes another component is still one thing to the
|
||||||
|
* user, and to Playwright — `shadow()` stops at the first boundary,
|
||||||
|
* which makes an assertion depend on which component happens to own the
|
||||||
|
* markup today.
|
||||||
|
*/
|
||||||
|
export function deepShadow<E extends Element = Element>(
|
||||||
|
root: Element,
|
||||||
|
selector: string,
|
||||||
|
): E | null {
|
||||||
|
const queue: Array<Element | ShadowRoot> = [root.shadowRoot ?? root];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const node = queue.shift()!;
|
||||||
|
const hit = node.querySelector<E>(selector);
|
||||||
|
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
for (const el of node.querySelectorAll('*')) {
|
||||||
|
if (el.shadowRoot) queue.push(el.shadowRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trimmed text content of the first deep match, or null if absent. */
|
||||||
|
export function deepText(host: Element, selector: string): string | null {
|
||||||
|
return deepShadow(host, selector)?.textContent?.trim() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Trimmed text content of the first match, or null if absent. */
|
/** Trimmed text content of the first match, or null if absent. */
|
||||||
export function text(host: Element, selector: string): string | null {
|
export function text(host: Element, selector: string): string | null {
|
||||||
return shadow(host, selector)?.textContent?.trim() ?? null;
|
return shadow(host, selector)?.textContent?.trim() ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Eight places in this app render a Go error verbatim, so a person is
|
||||||
|
* shown `Get "https://musicbrainz.org/ws/2/…": context deadline
|
||||||
|
* exceeded` and asked to make something of it (errors.M9).
|
||||||
|
*
|
||||||
|
* `describeError` is the one map from those strings to a sentence. It
|
||||||
|
* is deliberately conservative: it recognises the handful of causes a
|
||||||
|
* user can act on and says something generic about everything else,
|
||||||
|
* because a wrong guess about a cause is worse than no guess.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { describeError, explainError } from '@utils/describe-error';
|
||||||
|
|
||||||
|
describe('describeError', () => {
|
||||||
|
const cases: Array<[label: string, raw: string, expected: RegExp]> = [
|
||||||
|
[
|
||||||
|
'a timed-out MusicBrainz lookup',
|
||||||
|
'Get "https://musicbrainz.org/ws/2/artist": context deadline exceeded',
|
||||||
|
/took too long/i,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'an http client timeout',
|
||||||
|
'Get "https://example.com": net/http: request canceled (Client.Timeout exceeded while awaiting headers)',
|
||||||
|
/took too long/i,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a name that does not resolve',
|
||||||
|
'Get "https://musicbrainz.org": dial tcp: lookup musicbrainz.org: no such host',
|
||||||
|
/connection|offline|reach/i,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a refused connection',
|
||||||
|
'Post "http://localhost:8080/api": dial tcp 127.0.0.1:8080: connect: connection refused',
|
||||||
|
/connection|offline|reach/i,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a locked database',
|
||||||
|
"failed to remove 'Music': sql: database is locked",
|
||||||
|
/busy|in use/i,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a file that moved',
|
||||||
|
'open /music/gone.mp3: no such file or directory',
|
||||||
|
/not be found|moved/i,
|
||||||
|
],
|
||||||
|
['a 404 from a provider', 'unexpected status 404 Not Found', /not be found/i],
|
||||||
|
[
|
||||||
|
'a file the app may not read',
|
||||||
|
'open /music/locked.flac: permission denied',
|
||||||
|
/permission/i,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a folder Windows will not open',
|
||||||
|
'CreateFile C:\\Music: Access is denied.',
|
||||||
|
/permission/i,
|
||||||
|
],
|
||||||
|
['a cancelled operation', 'context canceled', /cancelled|canceled|stopped/i],
|
||||||
|
[
|
||||||
|
'a disk with nothing left',
|
||||||
|
'write /music/a.mp3: no space left on device',
|
||||||
|
/space/i,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, raw, expected] of cases) {
|
||||||
|
it(`describes ${label}`, () => {
|
||||||
|
expect(describeError(new Error(raw))).toMatch(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('never leaks the raw Go string into the sentence', () => {
|
||||||
|
const raw =
|
||||||
|
'Get "https://musicbrainz.org/ws/2/artist": context deadline exceeded';
|
||||||
|
|
||||||
|
expect(describeError(new Error(raw))).not.toContain('context deadline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to something generic rather than guessing', () => {
|
||||||
|
const described = describeError(new Error('build plan: 7 of 9 rejected'));
|
||||||
|
|
||||||
|
expect([described.length > 0, described.includes('build plan')]).toEqual([
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the shapes a rejected binding actually produces', () => {
|
||||||
|
// Wails rejects with whatever the Go error marshalled to, which is
|
||||||
|
// often a bare string and occasionally not a string at all.
|
||||||
|
expect([
|
||||||
|
describeError('sql: database is locked'),
|
||||||
|
describeError(null),
|
||||||
|
describeError({ message: 'permission denied' }),
|
||||||
|
]).toEqual([
|
||||||
|
describeError(new Error('sql: database is locked')),
|
||||||
|
describeError(new Error('')),
|
||||||
|
describeError(new Error('permission denied')),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a caller-supplied fallback for the unrecognised case', () => {
|
||||||
|
expect(
|
||||||
|
describeError(new Error('build plan: 7 of 9 rejected'), 'Nothing was written.'),
|
||||||
|
).toBe('Nothing was written.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some backend errors are already sentences — the sentinels this app
|
||||||
|
* writes for conditions it defined. Those are the most useful thing to
|
||||||
|
* show, and dropping them for a generic line would be a regression.
|
||||||
|
*/
|
||||||
|
describe('explainError', () => {
|
||||||
|
it('repeats a sentinel the backend wrote for a person', () => {
|
||||||
|
expect(
|
||||||
|
explainError(new Error('a library with that name already exists: "Decoy"')),
|
||||||
|
).toContain('already exists');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not repeat a wrapped runtime error', () => {
|
||||||
|
const described = explainError(
|
||||||
|
new Error('could not rename library: sql: database is locked'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([described.includes('sql:'), /busy/i.test(described)]).toEqual([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('punctuates what it repeats', () => {
|
||||||
|
expect(explainError(new Error('no candidate selected'))).toBe(
|
||||||
|
'no candidate selected.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* `perf.M7`/`M8`: the two Explore art caches were unbounded on a view
|
||||||
|
* that never unmounts. Measured at twelve searches, a session retained
|
||||||
|
* **8.48 MB** and was still climbing 0.7 MB per search, because a cover
|
||||||
|
* thumbnail is a ~27 kB base64 data URL and an artist photo is ~128 kB.
|
||||||
|
*
|
||||||
|
* `LRUMap` is the bound. The behaviours below are the ones the call
|
||||||
|
* sites actually depend on — in particular that a *read* is what keeps
|
||||||
|
* an entry alive, since the entry being rendered must never be the one
|
||||||
|
* evicted, and that `has()` is not a read, because both caches use
|
||||||
|
* `has()` to test a negative "already tried, no art" marker.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { LRUMap } from '@utils/lru-map';
|
||||||
|
|
||||||
|
describe('LRUMap', () => {
|
||||||
|
it('behaves like a Map below its cap', () => {
|
||||||
|
const m = new LRUMap<string, number>(4);
|
||||||
|
|
||||||
|
m.set('a', 1).set('b', 2);
|
||||||
|
|
||||||
|
expect([m.get('a'), m.get('b'), m.get('c'), m.size]).toEqual([
|
||||||
|
1, 2, undefined, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never exceeds its cap', () => {
|
||||||
|
const m = new LRUMap<number, number>(10);
|
||||||
|
|
||||||
|
for (let i = 0; i < 1000; i++) m.set(i, i);
|
||||||
|
|
||||||
|
expect(m.size).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts the oldest entry first', () => {
|
||||||
|
const m = new LRUMap<string, number>(2);
|
||||||
|
|
||||||
|
m.set('a', 1).set('b', 2).set('c', 3);
|
||||||
|
|
||||||
|
expect([m.get('a'), m.get('b'), m.get('c')]).toEqual([undefined, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a read renews an entry, so the rendered one survives', () => {
|
||||||
|
const m = new LRUMap<string, number>(2);
|
||||||
|
|
||||||
|
m.set('a', 1).set('b', 2);
|
||||||
|
m.get('a');
|
||||||
|
m.set('c', 3);
|
||||||
|
|
||||||
|
// 'b' was the least recently *used*, even though 'a' was older.
|
||||||
|
expect([m.get('a'), m.get('b'), m.get('c')]).toEqual([1, undefined, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has() does not renew, so a negative marker cannot outrank real art', () => {
|
||||||
|
const m = new LRUMap<string, string>(2);
|
||||||
|
|
||||||
|
// '' is the "already attempted, no art" marker both caches store.
|
||||||
|
m.set('miss', '').set('art', 'data:…');
|
||||||
|
m.has('miss');
|
||||||
|
m.set('new', 'data:…');
|
||||||
|
|
||||||
|
expect(m.has('miss')).toBe(false);
|
||||||
|
expect(m.get('art')).toBe('data:…');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwriting an existing key does not grow the map or evict', () => {
|
||||||
|
const m = new LRUMap<string, number>(2);
|
||||||
|
|
||||||
|
m.set('a', 1).set('b', 2).set('a', 99);
|
||||||
|
|
||||||
|
expect([m.size, m.get('a'), m.get('b')]).toEqual([2, 99, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a cap that cannot hold anything', () => {
|
||||||
|
expect(() => new LRUMap<string, number>(0)).toThrow(/at least 1/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* `tracksByFilePath` is a cache keyed on an array's identity, which is
|
||||||
|
* only safe because the stores replace the array whenever its contents
|
||||||
|
* change. These tests pin both halves of that: the cache is reused for
|
||||||
|
* the same array, and a new array gets a new map.
|
||||||
|
*
|
||||||
|
* The reason it exists is `perf.m6`: five components resolved selected
|
||||||
|
* file paths back to tracks with `filePaths.map(fp => tracks.find(…))`,
|
||||||
|
* O(selection × total). Measured through the real opener at 50 000
|
||||||
|
* tracks, "Select all → Edit tags" blocked the main thread for
|
||||||
|
* 3.0–6.3 s; with the map, 68 ms.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { tracksByFilePath, tracksForPaths } from '@utils/track-index';
|
||||||
|
|
||||||
|
type Track = { FilePath: string; Title: string };
|
||||||
|
|
||||||
|
const track = (n: number): Track => ({
|
||||||
|
FilePath: `/music/${n}.mp3`,
|
||||||
|
Title: `Track ${n}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The util is typed against the generated `library.Track`; these
|
||||||
|
// fixtures carry only the fields it reads.
|
||||||
|
const asTracks = (t: Track[]) => t as unknown as Parameters<
|
||||||
|
typeof tracksByFilePath
|
||||||
|
>[0];
|
||||||
|
|
||||||
|
describe('tracksByFilePath', () => {
|
||||||
|
it('indexes by file path', () => {
|
||||||
|
const tracks = asTracks([track(1), track(2), track(3)]);
|
||||||
|
const map = tracksByFilePath(tracks);
|
||||||
|
|
||||||
|
expect(map.size).toBe(3);
|
||||||
|
expect(map.get('/music/2.mp3')).toBe(tracks[1]);
|
||||||
|
expect(map.get('/music/nope.mp3')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the map for the same array', () => {
|
||||||
|
const tracks = asTracks([track(1), track(2)]);
|
||||||
|
|
||||||
|
expect(tracksByFilePath(tracks)).toBe(tracksByFilePath(tracks));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a fresh map for a replaced array', () => {
|
||||||
|
// The store shares unchanged members and replaces the array,
|
||||||
|
// so identity is the invalidation signal.
|
||||||
|
const first = asTracks([track(1)]);
|
||||||
|
const second = asTracks([track(1), track(2)]);
|
||||||
|
|
||||||
|
expect(tracksByFilePath(second)).not.toBe(tracksByFilePath(first));
|
||||||
|
expect(tracksByFilePath(second).size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first of a duplicated path, as find() did', () => {
|
||||||
|
const a = { FilePath: '/music/1.mp3', Title: 'first' };
|
||||||
|
const b = { FilePath: '/music/1.mp3', Title: 'second' };
|
||||||
|
|
||||||
|
expect(tracksByFilePath(asTracks([a, b])).get('/music/1.mp3'))
|
||||||
|
.toBe(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tracksForPaths', () => {
|
||||||
|
it('resolves in the order asked for, not list order', () => {
|
||||||
|
const tracks = asTracks([track(1), track(2), track(3)]);
|
||||||
|
const got = tracksForPaths(
|
||||||
|
tracks,
|
||||||
|
['/music/3.mp3', '/music/1.mp3'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(got.map((t) => t.FilePath))
|
||||||
|
.toEqual(['/music/3.mp3', '/music/1.mp3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops paths that are not in the list', () => {
|
||||||
|
const tracks = asTracks([track(1)]);
|
||||||
|
|
||||||
|
expect(tracksForPaths(tracks, ['/music/1.mp3', '/gone.mp3']))
|
||||||
|
.toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty for an empty selection', () => {
|
||||||
|
expect(tracksForPaths(asTracks([track(1)]), [])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user