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:
2026-08-12 01:20:03 -04:00
parent 2518385330
commit 5830b1ba17
25 changed files with 2498 additions and 37 deletions
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,
);
});
});
+5 -3
View File
@@ -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.',
);
});
});
+71
View File
@@ -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();
});
});
+89 -6
View File
@@ -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);
});
});
+206
View File
@@ -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]);
});
});
+161 -2
View File
@@ -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 });
});
}
});