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
+137
View File
@@ -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.',
);
});
});
+78
View File
@@ -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/);
});
});
+87
View File
@@ -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.06.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([]);
});
});