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.
159 lines
5.0 KiB
TypeScript
159 lines
5.0 KiB
TypeScript
/**
|
|
* The app's one notification surface.
|
|
*
|
|
* Two behaviours here are the reason it exists as a store rather than
|
|
* as a component: the caller picks a *level* and nothing else, and
|
|
* coalescing happens once, here, so a queue of 200 unplayable files
|
|
* produces one message rather than 200 and no future caller has to
|
|
* remember that.
|
|
*/
|
|
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
|
|
|
import { notificationStore } from '@store/notification-store';
|
|
|
|
describe('notification store', () => {
|
|
beforeEach(() => {
|
|
notificationStore.clear();
|
|
});
|
|
|
|
it('keeps a notification per level', () => {
|
|
notificationStore.blocking({ text: 'Half the folder was retagged.' });
|
|
notificationStore.persistent({ text: 'The scan did not start.' });
|
|
notificationStore.transient({ text: 'That favourite was undone.' });
|
|
notificationStore.inline('player', { text: 'Could not seek.' });
|
|
|
|
expect(notificationStore.getAll().map((n) => n.level)).toEqual([
|
|
'blocking',
|
|
'persistent',
|
|
'transient',
|
|
'inline',
|
|
]);
|
|
});
|
|
|
|
it('routes an inline message to its region and nowhere else', () => {
|
|
notificationStore.inline('player', { text: 'Could not seek.' });
|
|
|
|
expect([
|
|
notificationStore.forRegion('player').length,
|
|
notificationStore.forRegion('explore').length,
|
|
notificationStore.byLevel('transient').length,
|
|
]).toEqual([1, 0, 0]);
|
|
});
|
|
|
|
it('folds a repeat into one message with a count', () => {
|
|
for (const title of ['One', 'Two', 'Three']) {
|
|
notificationStore.inline('player', {
|
|
key: 'playback-failed',
|
|
text: `Could not play “${title}”.`,
|
|
coalescedText: (count) => `Skipped ${count} tracks.`,
|
|
});
|
|
}
|
|
|
|
const [only] = notificationStore.forRegion('player');
|
|
|
|
expect([notificationStore.getAll().length, only?.count, only?.text]).toEqual(
|
|
[1, 3, 'Skipped 3 tracks.'],
|
|
);
|
|
});
|
|
|
|
it('does not fold two different failures together', () => {
|
|
notificationStore.transient({ key: 'a', text: 'One thing failed.' });
|
|
notificationStore.transient({ key: 'b', text: 'Another thing failed.' });
|
|
|
|
expect(notificationStore.getAll()).toHaveLength(2);
|
|
});
|
|
|
|
it('does not fold the same key across levels', () => {
|
|
notificationStore.transient({ key: 'scan', text: 'Scan failed.' });
|
|
notificationStore.persistent({ key: 'scan', text: 'Scan failed.' });
|
|
|
|
expect(notificationStore.getAll()).toHaveLength(2);
|
|
});
|
|
|
|
it('runs an action and takes the message away with it', () => {
|
|
const retry = vi.fn();
|
|
const id = notificationStore.persistent({
|
|
text: 'The scan did not start.',
|
|
action: { label: 'Try again', run: retry },
|
|
});
|
|
|
|
notificationStore.runAction(id);
|
|
|
|
expect([retry.mock.calls.length, notificationStore.getAll().length]).toEqual(
|
|
[1, 0],
|
|
);
|
|
});
|
|
|
|
it('notifies subscribers once per batch', async () => {
|
|
let notifications = 0;
|
|
const off = notificationStore.subscribe(() => {
|
|
notifications += 1;
|
|
});
|
|
|
|
notificationStore.transient({ text: 'One.' });
|
|
notificationStore.transient({ text: 'Two.' });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
off();
|
|
|
|
expect(notifications).toBe(1);
|
|
});
|
|
|
|
it('keeps the stack readable, and never drops a modal to do it', () => {
|
|
notificationStore.blocking({ text: 'Half the folder was retagged.' });
|
|
|
|
for (let i = 0; i < 8; i += 1) {
|
|
notificationStore.persistent({ key: `k${i}`, text: `Failure ${i}.` });
|
|
}
|
|
|
|
const levels = notificationStore.getAll().map((n) => n.level);
|
|
|
|
expect([levels.length, levels.includes('blocking')]).toEqual([5, true]);
|
|
});
|
|
});
|
|
|
|
describe('notification store: self-dismissal', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
notificationStore.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('takes a toast away by itself', () => {
|
|
notificationStore.transient({ text: 'That favourite was undone.' });
|
|
vi.advanceTimersByTime(6000);
|
|
|
|
expect(notificationStore.getAll()).toHaveLength(0);
|
|
});
|
|
|
|
it('leaves the levels that are waiting for an answer', () => {
|
|
notificationStore.persistent({ text: 'The scan did not start.' });
|
|
notificationStore.blocking({ text: 'Half the folder was retagged.' });
|
|
vi.advanceTimersByTime(60_000);
|
|
|
|
expect(notificationStore.getAll()).toHaveLength(2);
|
|
});
|
|
|
|
it('restarts the clock when a message repeats', () => {
|
|
notificationStore.transient({ key: 'fav', text: 'Undone.' });
|
|
vi.advanceTimersByTime(4000);
|
|
notificationStore.transient({ key: 'fav', text: 'Undone.' });
|
|
vi.advanceTimersByTime(4000);
|
|
|
|
// Still there: the second occurrence bought it another window.
|
|
expect(notificationStore.getAll()).toHaveLength(1);
|
|
});
|
|
|
|
it('starts a new message once the coalescing window has passed', () => {
|
|
notificationStore.inline('player', { key: 'seek', text: 'Could not seek.' });
|
|
vi.advanceTimersByTime(20_000);
|
|
notificationStore.inline('player', { key: 'seek', text: 'Could not seek.' });
|
|
|
|
const [only] = notificationStore.forRegion('player');
|
|
|
|
expect(only?.count).toBe(1);
|
|
});
|
|
});
|