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