mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
239 lines
8.5 KiB
TypeScript
239 lines
8.5 KiB
TypeScript
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(<Toaster />);
|
|
});
|
|
const toast = container.querySelector('[role="alert"], [role="status"]');
|
|
expect(toast).not.toBeNull();
|
|
// The status glyph is a direct-child <svg> 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(<Toaster />);
|
|
});
|
|
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(<Toaster />);
|
|
});
|
|
|
|
// 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(<Toaster />);
|
|
});
|
|
|
|
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(<Toaster />);
|
|
});
|
|
|
|
// 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(<Toaster />);
|
|
});
|
|
|
|
// 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(<Toaster />);
|
|
});
|
|
|
|
// 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(<Toaster />);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|