Files
yellowjacket/frontend/test/stores/notification-store.test.ts
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

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);
});
});