Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m42s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI proved cross-file sharing of a virtual-module mock body cannot work in
@vitest/browser-playwright 4.1.6: the static-import spread fails the hoist
("no top level variables"), and the await-vi.hoisted-import form fails to
parse ("Unexpected identifier 'vi'"). vi.hoisted has the same hoist
constraint as vi.mock, so there is no way to thread an external module's
body into the factory here.
Reverts Phase 1: restores the 4 $app/forms/$app/navigation specs to their
inline factories, inlines NotificationBell.spec's forms stub, deletes the
src/__mocks__/$app/* modules and the $mocks alias (vite, vitest-coverage,
kit). The no-factory-ban meta-test stays (no-factory vi.mock is still
banned). ADR-012 amended to record the infeasibility. Everything else
($app/state migration, confirm context-inject, notification refactor, the
pin, the meta-test) is unaffected. Part of #560.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
4.8 KiB
TypeScript
144 lines
4.8 KiB
TypeScript
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 NotificationFixture from './notification.test-fixture.svelte';
|
|
|
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
|
|
vi.mock('$app/forms', () => ({
|
|
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
|
|
const handler = (e: Event) => {
|
|
e.preventDefault();
|
|
submit?.({ formData: new FormData(node) } as never);
|
|
};
|
|
node.addEventListener('submit', handler);
|
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
|
}
|
|
}));
|
|
|
|
// 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> = {}): 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<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 () => {
|
|
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);
|
|
});
|
|
});
|