Files
yellowjacket/frontend/test/components/notifications.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

127 lines
4.0 KiB
TypeScript

/**
* The surface itself: one component, four presentations.
*
* The assertions are on what a user (and Playwright) can see — the
* sentence, the action, the dismiss — rather than on which element
* happens to hold them, because the whole point of the shared notice is
* that a caller does not choose the markup.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/notifications/notification-host';
import '@components/notifications/inline-notice';
import { notificationStore } from '@store/notification-store';
import { flush } from '@test/support/harness';
import { fixture, shadow, shadowAll, text } from '@test/support/render';
import type { LitElement } from 'lit';
/** Let the store's microtask notification reach the component. */
async function settle(el: LitElement): Promise<void> {
await flush();
await el.updateComplete;
}
describe('<notification-host>', () => {
beforeEach(() => {
notificationStore.clear();
});
it('says nothing when there is nothing to say', async () => {
const el = await fixture('notification-host');
expect(shadow(el, '[data-testid="notification-stack"]')).toBeNull();
});
it('renders a persistent failure with the action it offers', async () => {
const el = await fixture<LitElement>('notification-host');
notificationStore.persistent({
text: 'The scan did not start.',
action: { label: 'Try again', run: () => undefined },
});
await settle(el);
expect([
text(el, '[data-testid="notification"]'),
text(el, '[data-testid="notification-action"]'),
]).toEqual([
expect.stringContaining('The scan did not start.'),
'Try again',
]);
});
it('stacks persistent above transient, so a toast never buries an answer', async () => {
const el = await fixture<LitElement>('notification-host');
notificationStore.transient({ text: 'That favourite was undone.' });
notificationStore.persistent({ text: 'The scan did not start.' });
await settle(el);
expect(
shadowAll(el, '[data-testid="notification"]').map((n) =>
n.getAttribute('data-level'),
),
).toEqual(['persistent', 'transient']);
});
it('dismisses on request', async () => {
const el = await fixture<LitElement>('notification-host');
notificationStore.transient({ text: 'That favourite was undone.' });
await settle(el);
shadow<HTMLButtonElement>(el, '.notice-dismiss')?.click();
await settle(el);
expect(shadow(el, '[data-testid="notification"]')).toBeNull();
});
it('puts a blocking failure in a dialog, which has to be answered', async () => {
const el = await fixture<LitElement>('notification-host');
notificationStore.blocking({
title: 'This folder was only partly retagged',
text: '3 of 9 tracks were written.',
});
await settle(el);
expect(shadow(el, '[data-testid="notification-blocking"]')).not.toBeNull();
});
it('leaves inline messages to the region that failed', async () => {
const el = await fixture<LitElement>('notification-host');
notificationStore.inline('player', { text: 'Could not seek.' });
await settle(el);
expect(shadow(el, '[data-testid="notification"]')).toBeNull();
});
});
describe('<inline-notice>', () => {
beforeEach(() => {
notificationStore.clear();
});
it('renders only its own region', async () => {
const el = await fixture<LitElement>('inline-notice', { region: 'player' });
notificationStore.inline('explore', { text: 'The search did not answer.' });
notificationStore.inline('player', { text: 'Could not seek.' });
await settle(el);
expect(text(el, '[data-testid="notification"]')).toContain(
'Could not seek.',
);
});
it('is a live region, because nothing moved focus to it', async () => {
const el = await fixture<LitElement>('inline-notice', { region: 'player' });
notificationStore.inline('player', { text: 'Could not seek.' });
await settle(el);
expect(shadow(el, '[aria-live="polite"]')).not.toBeNull();
});
});