import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { useNotificationStore, type NotificationKind } from '../stores/NotificationStore'; import { Toaster, TOAST_EXIT_MS } from './Toaster'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let container: HTMLDivElement; let root: Root; beforeEach(() => { useNotificationStore.getState().clear(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); useNotificationStore.getState().clear(); }); const KINDS: NotificationKind[] = ['error', 'warning', 'success', 'info']; describe('Toaster status glyphs', () => { // The point of the status icon is a non-colour severity channel (WCAG 1.4.1) — so // every kind must render a leading glyph, not lean on the border colour alone. test.each(KINDS)('a %s toast renders a leading status glyph', (kind) => { act(() => { useNotificationStore.getState().notify({ kind, title: `${kind} title`, message: 'body' }); root.render(); }); const toast = container.querySelector('[role="alert"], [role="status"]'); expect(toast).not.toBeNull(); // The status glyph is a direct-child of the toast (the close button's svg // is nested inside the button, so :scope > svg isolates the status icon). expect(toast!.querySelector(':scope > svg')).not.toBeNull(); }); test('error and warning are assertive; success and info are polite', () => { act(() => { useNotificationStore.getState().notify({ kind: 'error', title: 'e', message: 'b' }); useNotificationStore.getState().notify({ kind: 'success', title: 's', message: 'b' }); root.render(); }); expect(container.querySelector('[role="alert"]')).not.toBeNull(); expect(container.querySelector('[role="status"]')).not.toBeNull(); }); }); describe('Toaster exit animation (two-phase dismiss)', () => { test('close button applies the exiting class immediately and keeps the toast visible', () => { vi.useFakeTimers(); let id: string; act(() => { id = useNotificationStore.getState().notify({ kind: 'error', title: 'T', message: 'M' }); root.render(); }); // Toast is visible. const closeBtn = container.querySelector('button[aria-label="Dismiss notification"]'); expect(closeBtn).not.toBeNull(); // Click close — begins the exit phase. act(() => { closeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); // Still in the DOM (store not yet cleared), but carrying the exiting class. const toast = container.querySelector('[role="alert"]'); expect(toast).not.toBeNull(); expect(toast!.className).toMatch(/exiting/); // Notification still in the store during the animation window. expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true); vi.useRealTimers(); }); test('toast is removed from the store after TOAST_EXIT_MS', () => { vi.useFakeTimers(); let id: string; act(() => { id = useNotificationStore.getState().notify({ kind: 'error', title: 'T', message: 'M' }); root.render(); }); const closeBtn = container.querySelector('button[aria-label="Dismiss notification"]'); act(() => { closeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); // Just before the timeout fires — still in the store. act(() => { vi.advanceTimersByTime(TOAST_EXIT_MS - 1); }); expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true); // After the timeout — removed from the store. act(() => { vi.advanceTimersByTime(1); }); expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(false); vi.useRealTimers(); }); test('auto-dismiss (success) also plays the exit animation before removing', () => { vi.useFakeTimers(); const AUTO_DISMISS_MS = 6000; let id: string; act(() => { id = useNotificationStore.getState().notify({ kind: 'success', title: 'T', message: 'M' }); root.render(); }); // Before auto-dismiss fires — toast is visible and not exiting. expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true); const toastBefore = container.querySelector('[role="status"]'); expect(toastBefore).not.toBeNull(); expect(toastBefore!.className).not.toMatch(/exiting/); // At the auto-dismiss threshold — exit phase begins. act(() => { vi.advanceTimersByTime(AUTO_DISMISS_MS); }); const toastMid = container.querySelector('[role="status"]'); expect(toastMid).not.toBeNull(); expect(toastMid!.className).toMatch(/exiting/); // Still in store during animation. expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true); // After the exit animation — removed from store. act(() => { vi.advanceTimersByTime(TOAST_EXIT_MS); }); expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(false); vi.useRealTimers(); }); test('multiple toasts can be in the exiting state simultaneously', () => { vi.useFakeTimers(); act(() => { useNotificationStore.getState().notify({ kind: 'error', title: 'A', message: 'a' }); useNotificationStore.getState().notify({ kind: 'warning', title: 'B', message: 'b' }); root.render(); }); // Click close on both toasts. const closeBtns = container.querySelectorAll('button[aria-label="Dismiss notification"]'); expect(closeBtns.length).toBe(2); act(() => { closeBtns[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); closeBtns[1].dispatchEvent(new MouseEvent('click', { bubbles: true })); }); // Both are in the exiting state — both still in the DOM. const toasts = container.querySelectorAll('[role="alert"]'); expect(toasts.length).toBe(2); toasts.forEach((t) => expect(t.className).toMatch(/exiting/)); // Both removed from the store after the timeout. act(() => { vi.advanceTimersByTime(TOAST_EXIT_MS); }); expect(useNotificationStore.getState().notifications).toHaveLength(0); vi.useRealTimers(); }); test('a durable toast does not auto-dismiss, and its action runs then dismisses it', () => { vi.useFakeTimers(); const onClick = vi.fn(); let id: string; act(() => { id = useNotificationStore.getState().notify({ kind: 'info', title: 'Update available', message: 'A new version is ready.', durable: true, action: { label: 'Reload', onClick }, }); root.render(); }); // Well past the auto-dismiss window — still present (durable). act(() => { vi.advanceTimersByTime(60000); }); expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true); // The action button runs the handler, then the toast exits. const actionBtn = Array.from(container.querySelectorAll('button')).find( (b) => b.textContent === 'Reload', ); expect(actionBtn).toBeTruthy(); act(() => { actionBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onClick).toHaveBeenCalledTimes(1); act(() => { vi.advanceTimersByTime(TOAST_EXIT_MS); }); expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(false); vi.useRealTimers(); }); test('clicking close twice does not reset the exit timer', () => { vi.useFakeTimers(); let id: string; act(() => { id = useNotificationStore.getState().notify({ kind: 'error', title: 'T', message: 'M' }); root.render(); }); const closeBtn = container.querySelector('button[aria-label="Dismiss notification"]'); act(() => { closeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); // A second click while already exiting — should be a no-op for the timer. act(() => { closeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); act(() => { vi.advanceTimersByTime(TOAST_EXIT_MS); }); // Notification is gone — second click didn't schedule a duplicate removal. expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(false); vi.useRealTimers(); }); });