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.
123 lines
4.1 KiB
TypeScript
123 lines
4.1 KiB
TypeScript
/**
|
|
* 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,
|
|
);
|
|
});
|
|
});
|