Consolidate shared vi.mock bodies + migrate confirm/notification specs (#560) #719

Merged
marcel merged 17 commits from feat/issue-560-shared-vimock-mocks into main 2026-06-03 11:38:23 +02:00
22 changed files with 746 additions and 303 deletions

View File

@@ -1,8 +1,8 @@
# ADR 012 — Browser-Mode Test Mocking Strategy
**Status:** Accepted
**Date:** 2026-05-11 (revised 2026-05-12)
**Issues:** [#535 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/535) · [#553 — revision](https://git.raddatz.cloud/marcel/familienarchiv/issues/553)
**Date:** 2026-05-11 (revised 2026-05-12, 2026-06-02)
**Issues:** [#535 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/535) · [#553 — revision](https://git.raddatz.cloud/marcel/familienarchiv/issues/553) · [#560 — shared-mock-body dedup](https://git.raddatz.cloud/marcel/familienarchiv/issues/560)
---
@@ -71,19 +71,19 @@ The original revision of this ADR allowed `vi.mock(virtualModule, factory)` for
`EnrichmentBlock.svelte.spec.ts` (issue #553) was statically imported and still produced the race: its `vi.mock('$app/stores', async () => { const mod = await import(...); return mod; })` factory performed a dynamic import in its body, and that body was invoked asynchronously when Chromium fetched the manually-mocked module — sometimes after the worker's birpc channel had already closed.
**Therefore: under `**/*.svelte.{test,spec}.ts`, every `vi.mock` factory body must be synchronous. No `await`, no `import(...)`.**
**Therefore: under `**/\*.svelte.{test,spec}.ts`, every `vi.mock`factory body must be synchronous. No`await`, no `import(...)`.\*\*
If a factory needs to share state with the spec (a mutable ref, a `vi.fn`, a writable store), use `vi.hoisted()` to lift the reference above `vi.mock`'s implicit hoist:
```ts
const { mockNavigating } = vi.hoisted(() => ({
mockNavigating: { type: null as string | null }
mockNavigating: { type: null as string | null },
}));
vi.mock('$app/state', () => ({
get navigating() {
return mockNavigating;
}
vi.mock("$app/state", () => ({
get navigating() {
return mockNavigating;
},
}));
```
@@ -91,7 +91,7 @@ The getter defers the read until consumption time; `vi.hoisted` guarantees the r
### Architectural follow-on: prefer `$app/state` over `$app/stores`
`$app/stores` is the deprecated subscription-based store API; `$app/state` is the modern reactive proxy. New components should import from `$app/state`. As part of #553 we migrated `EnrichmentBlock.svelte` from `$app/stores.navigating` to `$app/state.navigating` with `!!navigating.type` — matching the pattern already established in `routes/aktivitaeten/+page.svelte:117` and `routes/documents/+page.svelte:261`. Migration eliminated the *need* to mock a store at all in that spec.
`$app/stores` is the deprecated subscription-based store API; `$app/state` is the modern reactive proxy. New components should import from `$app/state`. As part of #553 we migrated `EnrichmentBlock.svelte` from `$app/stores.navigating` to `$app/state.navigating` with `!!navigating.type` — matching the pattern already established in `routes/aktivitaeten/+page.svelte:117` and `routes/documents/+page.svelte:261`. Migration eliminated the _need_ to mock a store at all in that spec.
**Pattern note:** When an overlay or dropdown triggers a navigation action, use `<button type="button">` with an `onclick` handler that calls `goto(path)` — do **not** use `<a href="…">` with `e.preventDefault()`. SvelteKit registers its link interceptor as a capture-phase `document` listener, so it fires before the component's bubble-phase `onclick`. By the time `e.preventDefault()` runs the router has already initiated navigation, which tears down the vitest-browser Playwright orchestrator iframe. A `<button>` carries no `href`, so the capture-phase interceptor never fires. See `NotificationDropdown.svelte` for the canonical example.
@@ -112,9 +112,9 @@ This is fixed upstream in [vitest PR #10267](https://github.com/vitest-dev/vites
**Enforcement layers** (added in #553's second cycle, extending the four-layer chain above):
5. **In-suite meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` globs `src/**/*.svelte.{test,spec}.ts`, extracts every `vi.mock` first-arg string, canonicalises by stripping a trailing `.js`/`.ts` after `.svelte`, and fails if any canonical ID is referenced under two or more distinct spellings. Same shape as `no-async-mock-factories.test.ts`.
6. **`patch-package` backport** of PR #10267 at `frontend/patches/@vitest+browser-playwright+4.1.0.patch`. Applied automatically by the `postinstall` hook. Closes the race at the route-handler level — even if a contributor reintroduces a duplicate-ID, the patched `register` handler unroutes the existing predicate before installing the new one.
6. **`patch-package` backport** of PR #10267 at `frontend/patches/@vitest+browser-playwright+4.1.6.patch`. Applied automatically by the `postinstall` hook. Closes the race at the route-handler level — even if a contributor reintroduces a duplicate-ID, the patched `register` handler unroutes the existing predicate before installing the new one.
**When to remove the patch.** Once `@vitest/browser-playwright` ships a release containing PR #10267, delete `patches/@vitest+browser-playwright+4.1.0.patch`. Bump the dependency to the version containing the fix. The in-suite meta-test stays — it's a cheap permanent guard against the contributor-facing pattern, independent of upstream library version.
**When to remove the patch.** Once `@vitest/browser-playwright` ships a release containing PR #10267, delete `patches/@vitest+browser-playwright+4.1.6.patch`. Bump the dependency to the version containing the fix. The in-suite meta-test stays — it's a cheap permanent guard against the contributor-facing pattern, independent of upstream library version.
---
@@ -129,6 +129,48 @@ This is fixed upstream in [vitest PR #10267](https://github.com/vitest-dev/vites
3. **In-suite meta-test** at `frontend/src/__meta__/no-async-mock-factories.test.ts` globs `src/**/*.svelte.{test,spec}.ts` and asserts none match the banned pattern. Catches at every vitest invocation — the layer hardest to disable.
4. **CI birpc assert** runs after the coverage step and fails the build if `[birpc] rpc is closed` appears in any log line. Catches the symptom even if all the upstream layers were bypassed.
5. **In-suite duplicate-ID meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` enforces the one-canonical-ID-per-module rule from the duplicate-id-hazard section above.
6. **`patch-package` backport** at `frontend/patches/@vitest+browser-playwright+4.1.0.patch` closes the upstream race itself, applied via `postinstall`. To be removed when `@vitest/browser-playwright` releases [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267).
6. **`patch-package` backport** at `frontend/patches/@vitest+browser-playwright+4.1.6.patch` closes the upstream race itself, applied via `postinstall`. To be removed when `@vitest/browser-playwright` releases [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267).
- **Acceptance verification:** `coverage-flake-probe.yml` is a `workflow_dispatch`-triggered matrix workflow that runs the coverage suite 20× in parallel against a single SHA and asserts zero birpc lines. One fire, parallel cost, deterministic signal — replaces accumulating 20 sequential push events.
- **When to revisit the LibLoader home:** If three or more components adopt this pattern, consider extracting a shared `$lib/types/lib-loader.ts` or a generic `DynamicImportLoader<T>` type to avoid parallel type definitions across modules.
---
## Revision 2026-06-02 (#560 — shared mock bodies, no-factory ban)
### No-factory `vi.mock` of a virtual module is forbidden
PR #657 attempted to delete `vi.mock` factories entirely and rely on Vitest auto-resolving a bare `vi.mock('$app/navigation')` to an adjacent `src/__mocks__/$app/navigation.ts`, the way Jest's `__mocks__/` directory works. **This is empirically false for SvelteKit virtual modules in browser-mode Vitest.** A no-factory `vi.mock(virtualModule)` substitutes _some_ exports (plain function references like `goto`) but leaves others bound to the live implementation — notably `replaceState`, which SvelteKit re-exports through a getter delegating to the live router. CI #1857 failed on `admin/tags/[id]` with `Cannot call replaceState(...) before router is initialized`, raised from a `$effect`. A partial auto-mock is therefore unsafe.
**Rule:** under `**/*.svelte.{spec,test}.ts`, a `vi.mock` of a virtual module must always pass a factory. The factory body must still be synchronous (the original binding invariant above). Enforced by a seventh layer:
7. **In-suite no-factory-ban meta-test** at `frontend/src/__meta__/no-factory-ban.test.ts` — same source-scan mechanism as the other meta-tests; fails if any browser spec contains a `vi.mock('mod')` with no second argument.
### Cross-file sharing of a virtual-module mock body is infeasible (the third false premise)
The original #560 plan ("Option A") proposed deduplicating the non-trivial interceptor factories by importing a shared body from `src/__mocks__/` into a sync factory:
```ts
import * as formsMock from "$mocks/$app/forms";
vi.mock("$app/forms", () => ({ ...formsMock }));
```
**CI proved this does not work in `@vitest/browser-playwright` 4.1.6**, across two runs:
1. The static-import form above fails at runtime — vitest hoists `vi.mock` _above_ the import, so the factory references an uninitialised binding: `vi.mock factory: make sure there are no top level variables inside, since this call is hoisted`.
2. The documented escape, loading the body through an async hoisted import, fails to even parse in browser mode — vitest's hoist transform mangles it: `SyntaxError: Unexpected identifier 'vi'`.
```ts
const formsMock = await vi.hoisted(() => import("$mocks/$app/forms")); // parse error in browser mode
```
`vi.hoisted` has the _same_ constraint as `vi.mock` (its factory can't reference top-level imports either, since it too is hoisted above them), so there is no way to get an external module's body into the hoisted context here. **Therefore: do not share virtual-module mock bodies across spec files. Define each `vi.mock` factory inline, with a synchronous body.** Duplicating the handful of interceptor factories is the accepted cost — it is the only pattern that works. The `src/__mocks__/$app/*` modules and the `$mocks` alias added for Option A were removed. (Revisit on a newer `@vitest/browser-playwright` whose hoist transform handles async `vi.hoisted` imports.)
The no-factory-ban above still stands: every `vi.mock` of a virtual module must pass an _inline_ sync factory — never no factory, never a spread of an imported binding.
### Rejected: Option C (config-level auto-resolve)
Re-enabling implicit `__mocks__/` auto-resolution through a Vitest config flag or a `setupFiles` shim was rejected. It trades auditability for cosmetics: the mock binding becomes a hidden default invisible at the call site, and its failure mode (a partial mock) is the hardest to debug — exactly the PR #657 class. The no-factory-ban meta-test deliberately keeps the door closed.
### Patch pin
`@vitest/browser-playwright` is exact-pinned (no caret) to `4.1.6` in `package.json` so `patches/@vitest+browser-playwright+4.1.6.patch` keeps applying; a caret range could float onto a version the patch rejects. Pin and patch are both removed once the library ships a release containing [PR #10267](https://github.com/vitest-dev/vitest/pull/10267).

View File

@@ -32,7 +32,7 @@
"@tailwindcss/vite": "^4.1.17",
"@types/diff": "^7.0.2",
"@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10",
"@vitest/browser-playwright": "4.1.6",
"@vitest/coverage-istanbul": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.39.1",

View File

@@ -3,6 +3,7 @@
"private": true,
"version": "0.0.1",
"type": "module",
"//@vitest/browser-playwright": "Exact-pinned (no caret) to 4.1.6 so patches/@vitest+browser-playwright+4.1.6.patch (backport of vitest PR #10267, the duplicate-mock-id birpc race) keeps applying. TODO: remove this pin and the patch once @vitest/browser-playwright ships a release containing PR #10267. See docs/adr/012-browser-test-mocking-strategy.md.",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -47,7 +48,7 @@
"@tailwindcss/vite": "^4.1.17",
"@types/diff": "^7.0.2",
"@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10",
"@vitest/browser-playwright": "4.1.6",
"@vitest/coverage-istanbul": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.39.1",

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Belt-and-braces detector for the no-factory vi.mock anti-pattern named in
// ADR-012 (the PR #657 failure class). A `vi.mock('$app/navigation')` with no
// factory does NOT auto-resolve to an adjacent __mocks__ file the way Jest's
// __mocks__/ does: for SvelteKit virtual modules, vitest substitutes some
// exports (plain function refs like goto) but leaves others bound to the live
// implementation (replaceState, which delegates through a getter). The result
// is a partial mock that crashes when an unsubstituted export is hit.
//
// The sanctioned form keeps an INLINE sync factory:
// vi.mock('$app/forms', () => ({ enhance(node, submit) { ... } }));
// (Sharing the body via a module imported into the factory is infeasible in
// browser mode — vitest hoists vi.mock above the import; see ADR-012.)
//
// ESLint and the CI grep guard catch the pattern earlier; this in-suite test
// catches it at every vitest invocation — the layer hardest to disable. It
// also forecloses ADR-012's rejected Option C (config-level auto-resolve).
//
// We scan source text rather than parsing AST: fast, no parser dependency,
// good enough for the named anti-pattern. The pattern matches a `vi.mock`
// call whose only argument is a string literal (no factory after a comma).
const NO_FACTORY_VI_MOCK = /vi\.mock\(\s*['"][^'"]+['"]\s*\)/;
export function hasNoFactoryViMock(source: string): boolean {
return NO_FACTORY_VI_MOCK.test(source);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SRC_ROOT = path.resolve(__dirname, '..');
function findBrowserSpecs(): string[] {
const entries = readdirSync(SRC_ROOT, { recursive: true, withFileTypes: true });
return entries
.filter(
(e) =>
e.isFile() && (e.name.endsWith('.svelte.test.ts') || e.name.endsWith('.svelte.spec.ts'))
)
.map((e) => path.join(e.parentPath ?? (e as { path: string }).path, e.name));
}
describe('scan: hasNoFactoryViMock', () => {
it('flags a vi.mock with a string id and no factory', () => {
expect(hasNoFactoryViMock(`vi.mock('$app/navigation');`)).toBe(true);
});
it('flags a no-factory vi.mock written across multiple lines', () => {
const fixture = `vi.mock(
'$app/forms'
);`;
expect(hasNoFactoryViMock(fixture)).toBe(true);
});
it('does not flag a vi.mock with an inline factory', () => {
expect(hasNoFactoryViMock(`vi.mock('$app/forms', () => ({ enhance: () => () => {} }));`)).toBe(
false
);
});
it('does not flag a vi.mock with a multi-line inline factory', () => {
const fixture = `vi.mock('$app/forms', () => ({
enhance: (node, submit) => ({ destroy() {} })
}));`;
expect(hasNoFactoryViMock(fixture)).toBe(false);
});
it('does not flag a vi.mock with a named factory reference', () => {
expect(hasNoFactoryViMock(`vi.mock('$app/state', factory);`)).toBe(false);
});
it('does not flag source with no vi.mock at all', () => {
expect(hasNoFactoryViMock(`const x = vi.fn();`)).toBe(false);
});
});
describe('browser specs: no no-factory vi.mock of a virtual module', () => {
it('every src/**/*.svelte.{test,spec}.ts file keeps its factory', () => {
const specFiles = findBrowserSpecs();
expect(specFiles.length).toBeGreaterThan(0);
const offenders = specFiles.filter((file) => hasNoFactoryViMock(readFileSync(file, 'utf-8')));
expect(offenders).toEqual([]);
});
});

View File

@@ -2,9 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
const { default: TranscriptionEditView } = await import('./TranscriptionEditView.svelte');
import type { TranscriptionBlockData } from '$lib/shared/types';
@@ -37,7 +35,10 @@ const baseProps = (overrides: Record<string, unknown> = {}) => ({
describe('TranscriptionEditView', () => {
it('renders the empty-state coach when there are no blocks', async () => {
render(TranscriptionEditView, { props: baseProps() });
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps()
});
// TranscribeCoachEmptyState renders some German text
expect(document.body.textContent).toMatch(/markier|block|transkrip/i);
@@ -45,6 +46,7 @@ describe('TranscriptionEditView', () => {
it('renders the review progress counter when there are blocks', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock({ id: 'b1', reviewed: false }), baseBlock({ id: 'b2', reviewed: true })]
})
@@ -55,6 +57,7 @@ describe('TranscriptionEditView', () => {
it('shows the "alle als fertig markieren" button when onMarkAllReviewed is provided', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock()],
onMarkAllReviewed: async () => {}
@@ -66,6 +69,7 @@ describe('TranscriptionEditView', () => {
it('disables the mark-all-reviewed button when all blocks are reviewed', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock({ reviewed: true })],
onMarkAllReviewed: async () => {}
@@ -80,6 +84,7 @@ describe('TranscriptionEditView', () => {
it('enables the mark-all-reviewed button when not all blocks are reviewed', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock({ reviewed: false })],
onMarkAllReviewed: async () => {}
@@ -93,7 +98,10 @@ describe('TranscriptionEditView', () => {
});
it('hides the mark-all-reviewed button when onMarkAllReviewed is not provided', async () => {
render(TranscriptionEditView, { props: baseProps({ blocks: [baseBlock()] }) });
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({ blocks: [baseBlock()] })
});
await expect
.element(page.getByRole('button', { name: /alle als fertig/i }))
@@ -102,6 +110,7 @@ describe('TranscriptionEditView', () => {
it('renders the OcrTrigger only when canRunOcr is true and onTriggerOcr is provided', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock()],
canRunOcr: true,
@@ -116,6 +125,7 @@ describe('TranscriptionEditView', () => {
it('hides the OcrTrigger when canRunOcr is false', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock()],
canRunOcr: false,
@@ -129,6 +139,7 @@ describe('TranscriptionEditView', () => {
it('renders the training-label chips when canWrite=true and there are blocks', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock()],
canWrite: true,
@@ -143,6 +154,7 @@ describe('TranscriptionEditView', () => {
it('hides the training-label section when canWrite is false', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock()],
canWrite: false
@@ -155,6 +167,7 @@ describe('TranscriptionEditView', () => {
it('toggles the training label chip when clicked', async () => {
const onToggleTrainingLabel = vi.fn().mockResolvedValue(undefined);
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock()],
canWrite: true,
@@ -174,6 +187,7 @@ describe('TranscriptionEditView', () => {
it('renders blocks sorted by sortOrder', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [
baseBlock({ id: 'b3', sortOrder: 3, text: 'Third' }),
@@ -193,6 +207,7 @@ describe('TranscriptionEditView', () => {
it('renders both blocks with their text after rerender with a new activeAnnotationId', async () => {
const { rerender } = render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [
baseBlock({ id: 'b1', annotationId: 'ann-1', sortOrder: 1, text: 'First' }),
@@ -223,6 +238,7 @@ describe('TranscriptionEditView', () => {
it('handleMarkAllReviewed calls onMarkAllReviewed when clicked', async () => {
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock({ reviewed: false })],
onMarkAllReviewed
@@ -238,6 +254,7 @@ describe('TranscriptionEditView', () => {
it('renders all blocks with their text', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [
baseBlock({ id: 'b1', text: 'Erster Block' }),
@@ -252,6 +269,7 @@ describe('TranscriptionEditView', () => {
it('shows the next-block CTA when there are blocks', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock()]
})
@@ -263,6 +281,7 @@ describe('TranscriptionEditView', () => {
it('shows the active training label highlighted when included in trainingLabels', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock()],
canWrite: true,
@@ -281,6 +300,7 @@ describe('TranscriptionEditView', () => {
it('renders the inactive training-label chip class when not in trainingLabels', async () => {
render(TranscriptionEditView, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: baseProps({
blocks: [baseBlock()],
canWrite: true,

View File

@@ -2,13 +2,13 @@
import { onMount, onDestroy } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import { notificationStore } from '$lib/notification/notifications.svelte';
import { getNotificationStore } from '$lib/notification/notifications.svelte';
import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false);
let bellButtonEl: HTMLButtonElement | null = null;
const stream = notificationStore;
const stream = getNotificationStore();
async function toggleDropdown() {
open = !open;

View File

@@ -1,7 +1,8 @@
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 NotificationFixture from './notification.test-fixture.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$app/forms', () => ({
@@ -15,30 +16,37 @@ vi.mock('$app/forms', () => ({
}
}));
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);
// init()'s fetchUnreadCount() never settles, so the bell's announced count is
// driven solely by setNotifications() — removes the init-fetch-vs-setNotifications
// race (the real fetch would resolve to {count:0} and clobber the seeded count).
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(() => {}))
);
});
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',
@@ -52,30 +60,87 @@ 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();
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();
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();
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: a failed unread-count load does not wipe the announced count', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
const { setNotifications } = renderBell();
setNotifications([
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: false })
]);
await tick();
// init()'s fetchUnreadCount rejects; its catch must leave the already-set
// count intact rather than silently zeroing the live region. This is a
// distinct state from "empty" — the count survived a transient load failure.
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 2 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().textContent?.trim()).toBe('2');
});
});

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { provideNotificationStore, type NotificationItem } from './notifications.svelte';
import NotificationBell from './NotificationBell.svelte';
type Api = { setNotifications: (items: NotificationItem[]) => void };
let { onReady }: { onReady: (api: Api) => void } = $props();
const store = provideNotificationStore();
onReady({ setNotifications: store.setNotifications });
</script>
<NotificationBell />

View File

@@ -39,19 +39,20 @@ vi.stubGlobal('EventSource', MockEventSource);
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
const { notificationStore, __resetForTest, __setNavigateForTest } =
await import('./notifications.svelte');
const { createNotificationStore } = await import('./notifications.svelte');
let navigateSpy: ReturnType<typeof vi.fn>;
let store: ReturnType<typeof createNotificationStore>;
let navigateSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
lastEventSource = null;
eventSourceCount = 0;
navigateSpy = vi.fn();
__setNavigateForTest(navigateSpy);
__resetForTest();
navigateSpy = vi.fn<(url: string) => void>();
// A fresh instance per test replaces the old __resetForTest() singleton reset.
store = createNotificationStore();
store.setNavigate(navigateSpy);
});
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
@@ -69,70 +70,70 @@ function makeNotification(overrides: Partial<NotificationItem> = {}): Notificati
};
}
describe('notificationStore (singleton)', () => {
describe('notification store', () => {
it('opens a single EventSource across multiple init() calls', () => {
notificationStore.init();
notificationStore.init();
notificationStore.init();
store.init();
store.init();
store.init();
expect(eventSourceCount).toBe(1);
});
it('closes the EventSource only after every init() is matched with destroy()', () => {
notificationStore.init();
notificationStore.init();
store.init();
store.init();
const es = lastEventSource!;
notificationStore.destroy();
store.destroy();
expect(es.close).not.toHaveBeenCalled();
notificationStore.destroy();
store.destroy();
expect(es.close).toHaveBeenCalledTimes(1);
});
it('reopens a fresh EventSource after full teardown', () => {
notificationStore.init();
notificationStore.destroy();
notificationStore.init();
store.init();
store.destroy();
store.init();
expect(eventSourceCount).toBe(2);
});
it('SSE notification event prepends notification and increments unreadCount', () => {
notificationStore.init();
store.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(notificationStore.notifications[0].id).toBe('sse-1');
expect(notificationStore.unreadCount).toBe(1);
expect(store.notifications[0].id).toBe('sse-1');
expect(store.unreadCount).toBe(1);
});
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
notificationStore.init();
store.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
notificationStore.optimisticMarkRead('sse-1');
store.optimisticMarkRead('sse-1');
expect(notificationStore.notifications[0].read).toBe(true);
expect(notificationStore.unreadCount).toBe(0);
expect(store.notifications[0].read).toBe(true);
expect(store.unreadCount).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();
});
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
notificationStore.init();
store.init();
const notification = makeNotification({ id: 'sse-1', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
notificationStore.optimisticMarkRead('sse-1');
store.optimisticMarkRead('sse-1');
expect(notificationStore.unreadCount).toBe(0);
expect(store.unreadCount).toBe(0);
});
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
notificationStore.init();
store.init();
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n1', read: false }))
@@ -143,18 +144,18 @@ describe('notificationStore (singleton)', () => {
);
mockFetch.mockReset();
notificationStore.optimisticMarkAllRead();
store.optimisticMarkAllRead();
expect(notificationStore.unreadCount).toBe(0);
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
expect(store.unreadCount).toBe(0);
expect(store.notifications.every((n) => n.read)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('notificationStore onerror handler', () => {
describe('notification store onerror handler', () => {
it('redirects to /login when readyState is CLOSED and server returns 401', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
notificationStore.init();
store.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CLOSED;
@@ -164,7 +165,7 @@ describe('notificationStore onerror handler', () => {
});
it('does not redirect when readyState is CLOSED and session is still valid', async () => {
notificationStore.init();
store.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CLOSED;
@@ -174,7 +175,7 @@ describe('notificationStore onerror handler', () => {
});
it('does not close or redirect before the error threshold when readyState is CONNECTING', async () => {
notificationStore.init();
store.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING;
@@ -187,7 +188,7 @@ describe('notificationStore onerror handler', () => {
it('closes and redirects after 3 consecutive CONNECTING errors when session returns 401', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
notificationStore.init();
store.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING;
@@ -200,7 +201,7 @@ describe('notificationStore onerror handler', () => {
});
it('closes but does not redirect after threshold when session is still valid', async () => {
notificationStore.init();
store.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING;
@@ -213,7 +214,7 @@ describe('notificationStore onerror handler', () => {
});
it('resets error count after a successful reconnect (onopen)', async () => {
notificationStore.init();
store.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING;

View File

@@ -1,121 +1,161 @@
import { getContext, setContext } from 'svelte';
import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications';
export type { NotificationItem };
let notifications = $state<NotificationItem[]>([]);
let unreadCount = $state(0);
let eventSource: EventSource | null = null;
let refCount = 0;
let errorCount = 0;
let navigate: (url: string) => void = (url) => {
window.location.href = url;
};
export const NOTIFICATION_KEY = Symbol('notification');
async function fetchNotifications(): Promise<void> {
try {
const res = await fetch('/api/notifications?size=10');
if (res.ok) {
const data = await res.json();
notifications = data.content ?? [];
}
} catch (e) {
console.error('Failed to fetch notifications', e);
}
export interface NotificationStore {
readonly notifications: NotificationItem[];
readonly unreadCount: number;
fetchNotifications(): Promise<void>;
fetchUnreadCount(): Promise<void>;
optimisticMarkRead(id: string): void;
optimisticMarkAllRead(): void;
init(): void;
destroy(): void;
/** Test-only: seed the notification list and derive the unread count. */
setNotifications(items: NotificationItem[]): void;
/** Test-only: override the 401 → redirect side-effect. */
setNavigate(fn: (url: string) => void): void;
}
async function fetchUnreadCount(): Promise<void> {
try {
const res = await fetch('/api/notifications/unread-count');
if (res.ok) {
const data = await res.json();
unreadCount = data.count;
}
} catch (e) {
console.error('Failed to fetch unread count', e);
}
}
function optimisticMarkRead(id: string): void {
const notification = notifications.find((n) => n.id === id);
if (notification && !notification.read) {
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
}
}
function optimisticMarkAllRead(): void {
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
}
function init(): void {
refCount += 1;
if (refCount > 1) return;
fetchUnreadCount();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = parseNotificationEvent((e as MessageEvent).data);
if (!notification) return;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
eventSource.onopen = () => {
fetchUnreadCount();
errorCount = 0;
export function createNotificationStore(): NotificationStore {
let notifications = $state<NotificationItem[]>([]);
let unreadCount = $state(0);
let eventSource: EventSource | null = null;
let refCount = 0;
let errorCount = 0;
let navigate: (url: string) => void = (url) => {
window.location.href = url;
};
eventSource.onerror = async () => {
if (eventSource?.readyState === EventSource.CLOSED) {
const res = await fetch('/api/notifications/unread-count');
if (res.status === 401) navigate('/login');
return;
async function fetchNotifications(): Promise<void> {
try {
const res = await fetch('/api/notifications?size=10');
if (res.ok) {
const data = await res.json();
notifications = data.content ?? [];
}
} catch (e) {
console.error('Failed to fetch notifications', e);
}
errorCount += 1;
if (errorCount >= 3) {
}
async function fetchUnreadCount(): Promise<void> {
try {
const res = await fetch('/api/notifications/unread-count');
if (res.ok) {
const data = await res.json();
unreadCount = data.count;
}
} catch (e) {
console.error('Failed to fetch unread count', e);
}
}
function optimisticMarkRead(id: string): void {
const notification = notifications.find((n) => n.id === id);
if (notification && !notification.read) {
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
}
}
function optimisticMarkAllRead(): void {
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
}
function init(): void {
refCount += 1;
if (refCount > 1) return;
fetchUnreadCount();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = parseNotificationEvent((e as MessageEvent).data);
if (!notification) return;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
eventSource.onopen = () => {
fetchUnreadCount();
errorCount = 0;
};
eventSource.onerror = async () => {
if (eventSource?.readyState === EventSource.CLOSED) {
const res = await fetch('/api/notifications/unread-count');
if (res.status === 401) navigate('/login');
return;
}
errorCount += 1;
if (errorCount >= 3) {
eventSource?.close();
eventSource = null;
errorCount = 0;
const res = await fetch('/api/notifications/unread-count');
if (res.status === 401) navigate('/login');
}
};
}
function destroy(): void {
if (refCount === 0) return;
refCount -= 1;
if (refCount === 0) {
eventSource?.close();
eventSource = null;
errorCount = 0;
const res = await fetch('/api/notifications/unread-count');
if (res.status === 401) navigate('/login');
}
}
return {
get notifications() {
return notifications;
},
get unreadCount() {
return unreadCount;
},
fetchNotifications,
fetchUnreadCount,
optimisticMarkRead,
optimisticMarkAllRead,
init,
destroy,
setNotifications(items: NotificationItem[]): void {
notifications = items;
unreadCount = items.filter((n) => !n.read).length;
},
setNavigate(fn: (url: string) => void): void {
navigate = fn;
}
};
}
function destroy(): void {
if (refCount === 0) return;
refCount -= 1;
if (refCount === 0) {
eventSource?.close();
eventSource = null;
/**
* Create a notification store and put it on the context. Call once high in the
* tree (root +layout.svelte). Descendants read it via getNotificationStore().
*/
export function provideNotificationStore(): NotificationStore {
const store = createNotificationStore();
setContext(NOTIFICATION_KEY, store);
return store;
}
export function getNotificationStore(): NotificationStore {
let store: NotificationStore | undefined;
try {
store = getContext<NotificationStore>(NOTIFICATION_KEY);
} catch {
throw new Error(
'NotificationStore not found — call provideNotificationStore() in +layout.svelte'
);
}
if (!store)
throw new Error(
'NotificationStore not found — call provideNotificationStore() in +layout.svelte'
);
return store;
}
export function __resetForTest(): void {
eventSource?.close();
eventSource = null;
refCount = 0;
errorCount = 0;
notifications = [];
unreadCount = 0;
}
export function __setNavigateForTest(fn: (url: string) => void): void {
navigate = fn;
}
export const notificationStore = {
get notifications() {
return notifications;
},
get unreadCount() {
return unreadCount;
},
fetchNotifications,
fetchUnreadCount,
optimisticMarkRead,
optimisticMarkAllRead,
init,
destroy
};

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

@@ -1,10 +1,8 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
const { default: AdminGroupEditPage } = await import('./+page.svelte');
@@ -19,13 +17,17 @@ const baseGroup = (overrides: Record<string, unknown> = {}) => ({
describe('admin/groups/[id] page', () => {
it('renders the edit heading with the group name', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: { group: baseGroup() }, form: undefined }
});
await expect.element(page.getByRole('heading', { name: /familie/i })).toBeVisible();
});
it('hydrates the name input from data.group.name', async () => {
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: { group: baseGroup({ name: 'Admins' }) }, form: undefined }
});
@@ -35,6 +37,7 @@ describe('admin/groups/[id] page', () => {
it('checks the permission checkboxes that are in data.group.permissions', async () => {
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: { group: baseGroup({ permissions: ['READ_ALL', 'ADMIN_TAG'] }) },
form: undefined
@@ -57,6 +60,7 @@ describe('admin/groups/[id] page', () => {
it('shows the success banner when form.success is true', async () => {
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: { group: baseGroup() }, form: { success: true } }
});
@@ -65,6 +69,7 @@ describe('admin/groups/[id] page', () => {
it('shows the error banner when form.error is set', async () => {
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: { group: baseGroup() },
form: { error: 'Name darf nicht leer sein.' }
@@ -75,21 +80,30 @@ describe('admin/groups/[id] page', () => {
});
it('renders the cancel link to /admin/groups', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: { group: baseGroup() }, form: undefined }
});
const links = document.querySelectorAll('a[href="/admin/groups"]');
expect(links.length).toBeGreaterThan(0);
});
it('renders the delete and save buttons', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: { group: baseGroup() }, form: undefined }
});
await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible();
await expect.element(page.getByRole('button', { name: /speichern/i })).toBeVisible();
});
it('does not render success banner when form is undefined', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: { group: baseGroup() }, form: undefined }
});
const banner = document.querySelector('.bg-green-50');
expect(banner).toBeNull();
@@ -97,6 +111,7 @@ describe('admin/groups/[id] page', () => {
it('does not render error-banner div when form.success is true (success path only)', async () => {
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: { group: baseGroup() }, form: { success: true } }
});
@@ -106,7 +121,10 @@ describe('admin/groups/[id] page', () => {
});
it('renders all 8 permission checkboxes (4 standard + 4 admin)', async () => {
render(AdminGroupEditPage, { props: { data: { group: baseGroup() }, form: undefined } });
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: { group: baseGroup() }, form: undefined }
});
const checkboxes = document.querySelectorAll('input[name="permissions"]');
expect(checkboxes.length).toBe(8);
@@ -114,6 +132,7 @@ describe('admin/groups/[id] page', () => {
it('handles a group with empty permissions array', async () => {
render(AdminGroupEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: { group: baseGroup({ permissions: [] }) }, form: undefined }
});

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { replaceState } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
@@ -44,7 +44,7 @@ $effect(() => {
$effect(() => {
if (data.mergeSuccess) {
replaceState($page.url.pathname, {});
replaceState(page.url.pathname, {});
}
});

View File

@@ -10,12 +10,9 @@ vi.mock('$app/navigation', () => ({
goto: vi.fn(),
replaceState: vi.fn()
}));
vi.mock('$app/stores', () => ({
page: {
subscribe: (fn: (v: { url: URL }) => void) => {
fn({ url: new URL('http://localhost/admin/tags/t1') });
return () => {};
}
vi.mock('$app/state', () => ({
get page() {
return { url: new URL('http://localhost/admin/tags/t1') };
}
}));

View File

@@ -2,20 +2,15 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
const mockPage = { url: { pathname: '/admin/tags/t1' } };
const mockPage = { url: new URL('http://localhost/admin/tags/t1') };
vi.mock('$app/stores', () => ({
page: {
subscribe: (fn: (v: typeof mockPage) => void) => {
fn(mockPage);
return () => {};
}
vi.mock('$app/state', () => ({
get page() {
return mockPage;
}
}));
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
@@ -52,13 +47,17 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
describe('admin/tags/[id] page', () => {
it('renders the edit heading with the tag name', async () => {
render(AdminTagEditPage, { props: { data: baseData(), form: undefined } });
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
await expect.element(page.getByRole('heading', { name: /personen/i })).toBeVisible();
});
it('hydrates the name input from data.tag.name', async () => {
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ tag: baseTag({ name: 'Reisen' }) }), form: undefined }
});
@@ -67,14 +66,20 @@ describe('admin/tags/[id] page', () => {
});
it('renders the color picker for top-level tags (no parentId)', async () => {
render(AdminTagEditPage, { props: { data: baseData(), form: undefined } });
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const colorPicker = document.querySelector('[data-testid="color-picker"]');
expect(colorPicker).not.toBeNull();
});
it('renders one color swatch per palette entry', async () => {
render(AdminTagEditPage, { props: { data: baseData(), form: undefined } });
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const swatches = document.querySelectorAll('[data-testid^="color-swatch-"]');
expect(swatches.length).toBeGreaterThanOrEqual(10);
@@ -82,6 +87,7 @@ describe('admin/tags/[id] page', () => {
it('marks the active color swatch as aria-pressed', async () => {
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ tag: baseTag({ color: 'amber' }) }), form: undefined }
});
@@ -91,6 +97,7 @@ describe('admin/tags/[id] page', () => {
it('shows the form-success banner when form.success is true', async () => {
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: { success: true } }
});
@@ -100,6 +107,7 @@ describe('admin/tags/[id] page', () => {
it('shows the form-error banner when form.error is set', async () => {
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: { error: 'Tag name already in use' } }
});
@@ -109,6 +117,7 @@ describe('admin/tags/[id] page', () => {
it('shows the merge-success banner when data.mergeSuccess is set', async () => {
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ mergeSuccess: 'old-id' }), form: undefined }
});
@@ -118,6 +127,7 @@ describe('admin/tags/[id] page', () => {
it('hides the color picker for child tags (parentId set)', async () => {
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({ tag: baseTag({ parentId: 't-parent' }) }),
form: undefined
@@ -129,7 +139,10 @@ describe('admin/tags/[id] page', () => {
});
it('does not show form-success banner when form is undefined', async () => {
render(AdminTagEditPage, { props: { data: baseData(), form: undefined } });
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const banners = document.querySelectorAll('.bg-green-50');
// Some other green elements may exist, but the form-success specifically
@@ -142,6 +155,7 @@ describe('admin/tags/[id] page', () => {
it('hides the merge-success banner when mergeSuccess is null', async () => {
render(AdminTagEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ mergeSuccess: null }), form: undefined }
});

View File

@@ -1,10 +1,8 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
const { default: AdminUserEditPage } = await import('./+page.svelte');
@@ -32,13 +30,19 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
describe('admin/users/[id] page', () => {
it('renders the edit heading with the user email', async () => {
render(AdminUserEditPage, { props: { data: baseData(), form: undefined } });
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
await expect.element(page.getByRole('heading', { name: /anna@example/i })).toBeVisible();
});
it('renders all three card sections', async () => {
render(AdminUserEditPage, { props: { data: baseData(), form: undefined } });
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
await expect.element(page.getByRole('heading', { name: /persönliche daten/i })).toBeVisible();
await expect.element(page.getByRole('heading', { name: /^gruppen$/i })).toBeVisible();
@@ -46,13 +50,17 @@ describe('admin/users/[id] page', () => {
});
it('shows the update success banner when form.success is true', async () => {
render(AdminUserEditPage, { props: { data: baseData(), form: { success: true } } });
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: { success: true } }
});
await expect.element(page.getByText('Änderungen gespeichert.')).toBeVisible();
});
it('shows the update error banner when form.error is set', async () => {
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: { error: 'E-Mail bereits vergeben' } }
});
@@ -60,7 +68,10 @@ describe('admin/users/[id] page', () => {
});
it('preselects the user groups in UserGroupsSection', async () => {
render(AdminUserEditPage, { props: { data: baseData(), form: undefined } });
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const checkboxes = Array.from(
document.querySelectorAll('input[name="groupIds"]')
@@ -70,27 +81,39 @@ describe('admin/users/[id] page', () => {
});
it('renders cancel link to /admin/users', async () => {
render(AdminUserEditPage, { props: { data: baseData(), form: undefined } });
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const cancel = document.querySelector('a[href="/admin/users"]');
expect(cancel).not.toBeNull();
});
it('renders the delete button', async () => {
render(AdminUserEditPage, { props: { data: baseData(), form: undefined } });
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible();
});
it('does not show success banner when form is undefined', async () => {
render(AdminUserEditPage, { props: { data: baseData(), form: undefined } });
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const banner = document.querySelector('.bg-green-50');
expect(banner).toBeNull();
});
it('does not show error banner when form.error is undefined', async () => {
render(AdminUserEditPage, { props: { data: baseData(), form: { success: false } } });
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: { success: false } }
});
// The error banner has both border-red-200 AND text-red-700 — the delete button has red-50
// background but is a button, not a div. Look for the specific error <div>.
@@ -100,6 +123,7 @@ describe('admin/users/[id] page', () => {
it('handles a user with empty groups list (selectedGroupIds defaults to [])', async () => {
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({ editUser: { ...baseEditUser, groups: [] } }),
form: undefined
@@ -117,6 +141,7 @@ describe('admin/users/[id] page', () => {
const editUser = { ...baseEditUser } as typeof baseEditUser & { groups?: undefined };
delete (editUser as { groups?: unknown }).groups;
render(AdminUserEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ editUser }), form: undefined }
});

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 }) }
});

View File

@@ -1,9 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
const { default: DocumentEditPage } = await import('./+page.svelte');
@@ -31,7 +29,10 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
describe('documents/[id]/edit page', () => {
it('renders the page with the DocumentEditLayout', async () => {
render(DocumentEditPage, { props: { data: baseData(), form: undefined } });
render(DocumentEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
// At minimum, the body has content
const main = document.body.firstElementChild;
@@ -39,7 +40,10 @@ describe('documents/[id]/edit page', () => {
});
it('renders both hidden submit-target forms', async () => {
render(DocumentEditPage, { props: { data: baseData(), form: undefined } });
render(DocumentEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const reviewForm = document.querySelector('form#mark-for-review-form');
const deleteForm = document.querySelector('form#delete-form');
@@ -48,7 +52,10 @@ describe('documents/[id]/edit page', () => {
});
it('renders the action bar with delete, cancel, mark-for-review, and save buttons/links', async () => {
render(DocumentEditPage, { props: { data: baseData(), form: undefined } });
render(DocumentEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
// Find delete button
const deleteBtn = Array.from(document.querySelectorAll('button')).find((b) =>
@@ -58,13 +65,17 @@ describe('documents/[id]/edit page', () => {
});
it('uses doc.title in the document title when set', async () => {
render(DocumentEditPage, { props: { data: baseData(), form: undefined } });
render(DocumentEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
await vi.waitFor(() => expect(document.title).toContain('Brief an Helene'));
});
it('falls back to originalFilename when title is empty', async () => {
render(DocumentEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({ document: { ...baseDoc, title: '', originalFilename: 'fallback.pdf' } }),
form: undefined
@@ -75,7 +86,10 @@ describe('documents/[id]/edit page', () => {
});
it('renders the cancel link to the document detail page', async () => {
render(DocumentEditPage, { props: { data: baseData(), form: undefined } });
render(DocumentEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const link = document.querySelector('a[href="/documents/d1"]');
expect(link).not.toBeNull();
@@ -83,6 +97,7 @@ describe('documents/[id]/edit page', () => {
it('passes form.error to DocumentEditLayout when form is set', async () => {
render(DocumentEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: { error: 'Save failed' } }
});

View File

@@ -27,9 +27,7 @@ vi.mock('$app/navigation', () => ({
onNavigate: () => () => {}
}));
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
const { default: DocumentDetailPage } = await import('./+page.svelte');
@@ -63,7 +61,10 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
describe('documents/[id] page', () => {
it('renders the DocumentTopBar and resolves the document title in svelte:head', async () => {
mockPage.url = new URL('http://localhost/documents/d1');
render(DocumentDetailPage, { props: { data: baseData() } });
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
expect(document.querySelector('[data-topbar]')).not.toBeNull();
await vi.waitFor(() => expect(document.title).toContain('Brief an Helene'));
@@ -71,7 +72,10 @@ describe('documents/[id] page', () => {
it('mounts the page region with the [data-hydrated] container', async () => {
mockPage.url = new URL('http://localhost/documents/d1');
render(DocumentDetailPage, { props: { data: baseData() } });
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
expect(document.querySelector('[data-hydrated]')).not.toBeNull();
});
@@ -79,7 +83,10 @@ describe('documents/[id] page', () => {
it('persists last-visited document ID to localStorage on mount', async () => {
localStorage.removeItem('familienarchiv.lastVisited');
mockPage.url = new URL('http://localhost/documents/d1');
render(DocumentDetailPage, { props: { data: baseData() } });
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
await vi.waitFor(() => {
const stored = localStorage.getItem('familienarchiv.lastVisited');
@@ -89,7 +96,10 @@ describe('documents/[id] page', () => {
it('uses doc.title as the document title when set', async () => {
mockPage.url = new URL('http://localhost/documents/d1');
render(DocumentDetailPage, { props: { data: baseData() } });
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
await vi.waitFor(() => expect(document.title).toContain('Brief an Helene'));
});
@@ -97,6 +107,7 @@ describe('documents/[id] page', () => {
it('falls back to originalFilename when title is empty', async () => {
mockPage.url = new URL('http://localhost/documents/d2');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
document: { ...baseDoc, id: 'd2', title: '', originalFilename: 'fallback.pdf' }
@@ -110,6 +121,7 @@ describe('documents/[id] page', () => {
it('falls back to "Dokument" when title and originalFilename are empty', async () => {
mockPage.url = new URL('http://localhost/documents/d3');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
document: { ...baseDoc, id: 'd3', title: '', originalFilename: '' }
@@ -122,7 +134,10 @@ describe('documents/[id] page', () => {
it('renders the topbar Edit-link affordance when canWrite is true', async () => {
mockPage.url = new URL('http://localhost/documents/d4');
render(DocumentDetailPage, { props: { data: baseData({ canWrite: true }) } });
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ canWrite: true }) }
});
await expect.element(browserPage.getByRole('link', { name: 'Bearbeiten' })).toBeVisible();
});
@@ -130,6 +145,7 @@ describe('documents/[id] page', () => {
it('renders the topbar when geschichten and inferredRelationship are passed through', async () => {
mockPage.url = new URL('http://localhost/documents/d5');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichten: [{ id: 'g1', title: 'Story', publishedAt: null }],
@@ -146,6 +162,7 @@ describe('documents/[id] page', () => {
it('renders the topbar even when doc.id is empty (defensive)', async () => {
mockPage.url = new URL('http://localhost/documents/d-empty');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ document: { ...baseDoc, id: '', title: 'No ID' } }) }
});
@@ -156,6 +173,7 @@ describe('documents/[id] page', () => {
it('renders sender data in the metadata drawer when sender is populated', async () => {
mockPage.url = new URL('http://localhost/documents/d7');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
document: {
@@ -176,6 +194,7 @@ describe('documents/[id] page', () => {
it('renders the topbar when filePath is set on the document', async () => {
mockPage.url = new URL('http://localhost/documents/d8');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
document: {
@@ -195,6 +214,7 @@ describe('documents/[id] page', () => {
it('renders the topbar with a complete user object passed through', async () => {
mockPage.url = new URL('http://localhost/documents/d9');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
document: { ...baseDoc, id: 'd9' },
@@ -209,6 +229,7 @@ describe('documents/[id] page', () => {
it('Escape keydown leaves the transcribe panel hidden when not already in transcribe mode', async () => {
mockPage.url = new URL('http://localhost/documents/d10');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ document: { ...baseDoc, id: 'd10' } }) }
});
@@ -220,6 +241,7 @@ describe('documents/[id] page', () => {
it('non-Escape keydown does not affect the transcribe panel state', async () => {
mockPage.url = new URL('http://localhost/documents/d11');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ document: { ...baseDoc, id: 'd11' } }) }
});
@@ -232,6 +254,7 @@ describe('documents/[id] page', () => {
it('renders the topbar with a deep-link comment query param', async () => {
mockPage.url = new URL('http://localhost/documents/d12?comment=c-abc');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ document: { ...baseDoc, id: 'd12' } }) }
});
@@ -241,6 +264,7 @@ describe('documents/[id] page', () => {
it('renders sender name and Edit affordance with all metadata populated', async () => {
mockPage.url = new URL('http://localhost/documents/d-meta');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
document: {
@@ -278,6 +302,7 @@ describe('documents/[id] page', () => {
it('enters transcribe mode and shows the panel close button when ?task=transcribe is set', async () => {
mockPage.url = new URL('http://localhost/documents/d-task?task=transcribe');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
document: { ...baseDoc, id: 'd-task' },
@@ -296,6 +321,7 @@ describe('documents/[id] page', () => {
try {
mockPage.url = new URL('http://localhost/documents/d-fail?task=transcribe');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({ document: { ...baseDoc, id: 'd-fail' } })
}
@@ -328,6 +354,7 @@ describe('documents/[id] page', () => {
try {
mockPage.url = new URL('http://localhost/documents/d-blocks?task=transcribe');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ document: { ...baseDoc, id: 'd-blocks' } }) }
});
await expect.element(browserPage.getByText('Erster')).toBeVisible();
@@ -343,6 +370,7 @@ describe('documents/[id] page', () => {
);
mockPage.url = new URL('http://localhost/documents/d-new');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({ document: { ...baseDoc, id: 'd-new', title: 'New Doc' } })
}
@@ -360,6 +388,7 @@ describe('documents/[id] page', () => {
try {
mockPage.url = new URL('http://localhost/documents/d-ocr-fail?task=transcribe');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ canWrite: true, document: { ...baseDoc, id: 'd-ocr-fail' } }) }
});
await vi.waitFor(() => {
@@ -391,6 +420,7 @@ describe('documents/[id] page', () => {
try {
mockPage.url = new URL('http://localhost/documents/d-ocr-run?task=transcribe');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ canWrite: true, document: { ...baseDoc, id: 'd-ocr-run' } }) }
});
await expect.element(browserPage.getByText('OCR läuft')).toBeVisible();
@@ -402,6 +432,7 @@ describe('documents/[id] page', () => {
it('renders the topbar when the document has all OCR-relevant fields populated', async () => {
mockPage.url = new URL('http://localhost/documents/d-ocr-meta?task=transcribe');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
document: {
@@ -424,6 +455,7 @@ describe('documents/[id] page', () => {
it('treats undefined geschichten as the empty array (geschichten ?? [] branch)', async () => {
mockPage.url = new URL('http://localhost/documents/d-no-stories');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
document: { ...baseDoc, id: 'd-no-stories' },
@@ -449,6 +481,7 @@ describe('documents/[id] page', () => {
try {
mockPage.url = new URL('http://localhost/documents/d-ocr-done?task=transcribe');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-done' } }) }
});
await vi.waitFor(() => {
@@ -474,6 +507,7 @@ describe('documents/[id] page', () => {
try {
mockPage.url = new URL('http://localhost/documents/d-ocr-no-job?task=transcribe');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-no-job' } }) }
});
await vi.waitFor(() => {
@@ -496,6 +530,7 @@ describe('documents/[id] page', () => {
try {
mockPage.url = new URL('http://localhost/documents/d-ocr-throw?task=transcribe');
render(DocumentDetailPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-throw' } }) }
});
await vi.waitFor(() => {

View File

@@ -1,10 +1,8 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
const { default: GeschichtePage } = await import('./+page.svelte');
@@ -33,7 +31,10 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
describe('geschichten/[id] page', () => {
it('renders the geschichte title as the level-1 heading', async () => {
render(GeschichtePage, { props: { data: baseData() } });
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
await expect
.element(page.getByRole('heading', { level: 1, name: /reise nach berlin/i }))
@@ -41,13 +42,17 @@ describe('geschichten/[id] page', () => {
});
it('renders the author full name from firstName + lastName', async () => {
render(GeschichtePage, { props: { data: baseData() } });
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
});
it('falls back to author email when no name is set', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichte: baseGeschichte({
@@ -62,6 +67,7 @@ describe('geschichten/[id] page', () => {
it('renders an empty author when author is null', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) }
});
@@ -69,13 +75,17 @@ describe('geschichten/[id] page', () => {
});
it('renders the publishedAt date suffix when publishedAt is set', async () => {
render(GeschichtePage, { props: { data: baseData() } });
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
await expect.element(page.getByText(/veröffentlicht am/i)).toBeVisible();
});
it('omits the publishedAt suffix when publishedAt is null', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) }
});
@@ -83,13 +93,17 @@ describe('geschichten/[id] page', () => {
});
it('omits the persons section when there are no linked persons', async () => {
render(GeschichtePage, { props: { data: baseData() } });
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
});
it('renders the persons section when there are linked persons', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichte: baseGeschichte({
@@ -108,13 +122,17 @@ describe('geschichten/[id] page', () => {
});
it('omits the documents section when there are no linked documents', async () => {
render(GeschichtePage, { props: { data: baseData() } });
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
});
it('renders the documents section when there are linked documents', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichte: baseGeschichte({
@@ -129,7 +147,10 @@ describe('geschichten/[id] page', () => {
});
it('renders edit and delete actions when canBlogWrite is true', async () => {
render(GeschichtePage, { props: { data: baseData({ canBlogWrite: true }) } });
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ canBlogWrite: true }) }
});
await expect
.element(page.getByRole('link', { name: /bearbeiten/i }))
@@ -138,7 +159,10 @@ describe('geschichten/[id] page', () => {
});
it('hides edit and delete actions when canBlogWrite is false', async () => {
render(GeschichtePage, { props: { data: baseData({ canBlogWrite: false }) } });
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ canBlogWrite: false }) }
});
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();

View File

@@ -1,10 +1,8 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
const { default: PersonEditPage } = await import('./+page.svelte');
@@ -29,19 +27,26 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
describe('persons/[id]/edit page', () => {
it('renders the edit heading', async () => {
render(PersonEditPage, { props: { data: baseData(), form: undefined } });
render(PersonEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
await expect.element(page.getByRole('heading', { name: /person bearbeiten/i })).toBeVisible();
});
it('renders the persons-section heading', async () => {
render(PersonEditPage, { props: { data: baseData(), form: undefined } });
render(PersonEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
await expect.element(page.getByRole('heading', { name: /angaben zur person/i })).toBeVisible();
});
it('shows the form-error banner when form.updateError is set', async () => {
render(PersonEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: { updateError: 'Last name is required' } }
});
@@ -49,14 +54,20 @@ describe('persons/[id]/edit page', () => {
});
it('does not show the form-error banner when form is undefined', async () => {
render(PersonEditPage, { props: { data: baseData(), form: undefined } });
render(PersonEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const banner = document.querySelector('.bg-red-50.border-red-200');
expect(banner).toBeNull();
});
it('renders the save bar with the discard href pointing to the person detail', async () => {
render(PersonEditPage, { props: { data: baseData(), form: undefined } });
render(PersonEditPage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData(), form: undefined }
});
const link = document.querySelector('a[href="/persons/p-1"]');
expect(link).not.toBeNull();