Files
yellowjacket/frontend/test/components/selection.test.ts
T
logan 5830b1ba17 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.
2026-08-12 01:20:03 -04:00

207 lines
6.5 KiB
TypeScript

/**
* 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]);
});
});