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:
@@ -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