import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { m } from '$lib/paraglide/messages.js'; import type { NotificationItem } from '$lib/notification/notifications'; import * as formsMock from '$mocks/$app/forms'; import NotificationFixture from './notification.test-fixture.svelte'; vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() })); vi.mock('$app/forms', () => ({ ...formsMock })); // NotificationBell.onMount calls store.init(), which opens an EventSource and // fetches the unread count. Stub both so no real network or 401 → /login // navigation fires; the real store + provideNotificationStore() run otherwise. class NoopEventSource { static CLOSED = 2; readyState = 0; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener() {} close() {} } beforeEach(() => { vi.stubGlobal('EventSource', NoopEventSource); vi.stubGlobal( 'fetch', vi .fn() .mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 })) ); }); afterEach(() => { cleanup(); vi.clearAllMocks(); vi.unstubAllGlobals(); }); const tick = () => new Promise((r) => setTimeout(r, 0)); const makeNotification = (overrides: Partial = {}): NotificationItem => ({ id: 'n1', type: 'REPLY', documentId: 'doc-1', referenceId: 'ref-1', annotationId: null, read: false, createdAt: '2026-04-21T10:00:00Z', actorName: 'Anna', documentTitle: 'Test Doc', ...overrides }); type Api = { setNotifications: (items: NotificationItem[]) => void }; function renderBell(): Api { let api: Api = { setNotifications: () => {} }; render(NotificationFixture, { onReady: (a: Api) => (api = a) }); return api; } function bellButton(): HTMLButtonElement { return document.querySelector('button[aria-haspopup="true"]')!; } function unreadBadge(): HTMLElement { return bellButton().querySelector('[aria-live="polite"]')!; } describe('NotificationBell — rendering', () => { it('bell button has cursor-pointer class', async () => { renderBell(); await tick(); expect(bellButton().classList.contains('cursor-pointer')).toBe(true); }); }); // AC#5: the bell's announced unread count must hold across the four a11y states. // The count is announced via the aria-live badge text and the button title / // aria-label; both must stay consistent as the store's notifications change. describe('NotificationBell — announced unread count across a11y states', () => { it('empty: announces no unread count and hides the live badge', async () => { const { setNotifications } = renderBell(); await tick(); setNotifications([]); await tick(); const btn = bellButton(); expect(btn.getAttribute('title')).toBe(m.notification_bell_label()); expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); expect(unreadBadge().classList.contains('hidden')).toBe(true); }); it('single: announces one unread notification', async () => { const { setNotifications } = renderBell(); await tick(); setNotifications([makeNotification({ id: 'n1', read: false })]); await tick(); const btn = bellButton(); expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 1 })); expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); expect(unreadBadge().classList.contains('hidden')).toBe(false); expect(unreadBadge().textContent?.trim()).toBe('1'); }); it('many: announces the exact unread count', async () => { const { setNotifications } = renderBell(); await tick(); setNotifications([ makeNotification({ id: 'n1', read: false }), makeNotification({ id: 'n2', read: false }), makeNotification({ id: 'n3', read: false }) ]); await tick(); const btn = bellButton(); expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 3 })); expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); expect(unreadBadge().textContent?.trim()).toBe('3'); }); it('error: degrades to a zero announced count when the unread-count load fails', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))); renderBell(); await tick(); // A failed load leaves the count at 0; the bell still announces a valid, // non-urgent state rather than a broken count. const btn = bellButton(); expect(btn.getAttribute('title')).toBe(m.notification_bell_label()); expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); expect(unreadBadge().classList.contains('hidden')).toBe(true); }); });