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:
Marcel
2026-06-02 20:24:46 +02:00
committed by marcel
parent 29015ee864
commit ad067d2e0e
8 changed files with 356 additions and 206 deletions

View File

@@ -10,6 +10,7 @@ import AppNav from './AppNav.svelte';
import UserMenu from './UserMenu.svelte';
import ConfirmDialog from '$lib/shared/primitives/ConfirmDialog.svelte';
import { provideConfirmService } from '$lib/shared/services/confirm.svelte';
import { provideNotificationStore } from '$lib/notification/notifications.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
let { children, data } = $props();
@@ -18,6 +19,11 @@ let { children, data } = $props();
// ConfirmDialog below reads it via getConfirmService() and renders the <dialog>.
provideConfirmService();
// Provide the notification store to the tree. NotificationBell (header) and the
// Chronik page read it via getNotificationStore(); the bell drives the SSE
// lifecycle through init()/destroy() on mount.
provideNotificationStore();
// Auto-clear the bulk-selection store when the user leaves the routes that
// surface the BulkSelectionBar. Without this the selection silently follows
// the user to /persons / /admin etc. and reappears as a stale 3-doc count

View File

@@ -3,7 +3,10 @@ import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page, navigating } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
import { notificationStore, type NotificationItem } from '$lib/notification/notifications.svelte';
import {
getNotificationStore,
type NotificationItem
} from '$lib/notification/notifications.svelte';
import ChronikFuerDichBox from '$lib/activity/ChronikFuerDichBox.svelte';
import ChronikFilterPills from '$lib/activity/ChronikFilterPills.svelte';
import ChronikTimeline from '$lib/activity/ChronikTimeline.svelte';
@@ -26,9 +29,11 @@ interface Props {
const { data }: Props = $props();
// Prefer the live SSE singleton for unread items so newly arriving mentions
const notificationStore = getNotificationStore();
// Prefer the live SSE store for unread items so newly arriving mentions
// prepend without a reload. On first mount, seed from the server-loaded unread
// set if the singleton hasn't populated yet.
// set if the store hasn't populated yet.
onMount(() => {
notificationStore.init();
});
@@ -59,7 +64,7 @@ const seedUnread = $derived<NotificationItem[]>(
}))
);
// If the singleton has any data (including zero after mark-all), trust it;
// If the store has any data (including zero after mark-all), trust it;
// otherwise fall back to the SSR-seeded unread set.
const unread = $derived<NotificationItem[]>(
notificationStore.notifications.length > 0 ? liveUnread : seedUnread

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { createNotificationStore, NOTIFICATION_KEY } from '$lib/notification/notifications.svelte';
const mockNavigating = { type: null };
const mockPage = { url: new URL('http://localhost/aktivitaeten') };
@@ -28,19 +29,35 @@ vi.mock('$app/navigation', () => ({
onNavigate: () => () => {}
}));
vi.mock('$lib/notification/notifications.svelte', () => ({
notificationStore: {
notifications: [],
init: vi.fn(),
destroy: vi.fn(),
markRead: vi.fn(),
markAllRead: vi.fn()
}
}));
// The Chronik page's onMount calls store.init(), opening an EventSource and
// fetching the unread count. Stub both so no real network / 401 → /login fires.
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 }))
);
});
const { default: AktivitaetenPage } = await import('./+page.svelte');
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const notificationContext = () => new Map([[NOTIFICATION_KEY, createNotificationStore()]]);
const baseData = (overrides: Record<string, unknown> = {}) => ({
filter: 'alle' as const,
@@ -52,13 +69,16 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
describe('aktivitaeten page', () => {
it('renders the page heading', async () => {
render(AktivitaetenPage, { props: { data: baseData() } });
render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
await expect.element(page.getByRole('heading', { name: /aktivitäten/i })).toBeVisible();
});
it('renders the error card when loadError is "activity"', async () => {
render(AktivitaetenPage, { props: { data: baseData({ loadError: 'activity' }) } });
render(AktivitaetenPage, {
context: notificationContext(),
props: { data: baseData({ loadError: 'activity' }) }
});
// ChronikErrorCard renders some retry mechanism
const main = document.querySelector('main');
@@ -69,7 +89,7 @@ describe('aktivitaeten page', () => {
});
it('renders the FuerDichBox and FilterPills when loadError is null', async () => {
render(AktivitaetenPage, { props: { data: baseData() } });
render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
// FuerDichBox shows the inbox-zero state when no unread
const fuerDich = document.querySelector('[data-testid="chronik-inbox-zero"]');
@@ -81,7 +101,7 @@ describe('aktivitaeten page', () => {
});
it('renders the first-run empty state when activityFeed is empty', async () => {
render(AktivitaetenPage, { props: { data: baseData() } });
render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
const empty = document.querySelector('[data-testid="chronik-empty-state"]');
expect(empty?.getAttribute('data-variant')).toBe('first-run');
@@ -89,6 +109,7 @@ describe('aktivitaeten page', () => {
it('renders the filter-empty empty state when feed has items but filter rules out all', async () => {
render(AktivitaetenPage, {
context: notificationContext(),
props: {
data: baseData({
filter: 'fuer-dich' as const,
@@ -114,6 +135,7 @@ describe('aktivitaeten page', () => {
it('renders the timeline when displayFeed is non-empty', async () => {
render(AktivitaetenPage, {
context: notificationContext(),
props: {
data: baseData({
filter: 'alle' as const,
@@ -142,6 +164,7 @@ describe('aktivitaeten page', () => {
it('renders without crashing when filter is set to a non-default value', async () => {
render(AktivitaetenPage, {
context: notificationContext(),
props: { data: baseData({ filter: 'transkription' as const }) }
});