refactor(notification): provide notification store via context + fixture
Converts the module-singleton notificationStore into a context-provided store so its specs can drive it without mocking the module. notifications.svelte now exports createNotificationStore() (the former singleton body), plus provideNotificationStore()/getNotificationStore()/NOTIFICATION_KEY mirroring the confirm service. Root +layout provides it; NotificationBell and the Chronik page read it via getNotificationStore(). Tests: - notifications.svelte.spec drives a fresh createNotificationStore() per test (replacing __resetForTest/__setNavigateForTest with setNavigate()). - notification.test-fixture.svelte wraps the bell, provides the store, and exposes setNotifications(items) via onReady (option b). - NotificationBell.svelte.spec asserts the announced unread count across the empty / single / many / error a11y states (AC#5), stubbing EventSource+fetch. - aktivitaeten page spec injects a real store via render context. Per the recorded Phase-2b decision (full context refactor). Part of #560. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,36 +1,43 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
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 NotificationBell from './NotificationBell.svelte';
|
||||
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 }));
|
||||
|
||||
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
||||
// 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() {}
|
||||
}
|
||||
|
||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||
notificationStore: {
|
||||
get notifications() {
|
||||
return mockNotificationList.value;
|
||||
},
|
||||
get unreadCount() {
|
||||
return mockNotificationList.value.length;
|
||||
},
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn(),
|
||||
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
init: vi.fn(),
|
||||
destroy: vi.fn()
|
||||
}
|
||||
}));
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('EventSource', NoopEventSource);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 }))
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockNotificationList.value = [];
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||
id: 'n1',
|
||||
type: 'REPLY',
|
||||
@@ -44,30 +51,85 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('NotificationBell — cursor and tooltip', () => {
|
||||
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<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||
}
|
||||
|
||||
function unreadBadge(): HTMLElement {
|
||||
return bellButton().querySelector<HTMLElement>('[aria-live="polite"]')!;
|
||||
}
|
||||
|
||||
describe('NotificationBell — rendering', () => {
|
||||
it('bell button has cursor-pointer class', async () => {
|
||||
render(NotificationBell);
|
||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
||||
});
|
||||
|
||||
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
||||
mockNotificationList.value = [];
|
||||
render(NotificationBell);
|
||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||
});
|
||||
|
||||
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
||||
mockNotificationList.value = [
|
||||
makeNotification({ id: 'n1' }),
|
||||
makeNotification({ id: 'n2' }),
|
||||
makeNotification({ id: 'n3' })
|
||||
];
|
||||
render(NotificationBell);
|
||||
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
|
||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user