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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user