Compare commits

...

14 Commits

Author SHA1 Message Date
Marcel
140b321868 refactor(notification): provide notification store via context + fixture
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m50s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
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>
2026-06-02 20:27:19 +02:00
Marcel
13c6b7d27f test: inject real ConfirmService via context (batch 2/2)
Completes Phase 2a: geschichten/[id], persons/[id]/edit and admin/tags/[id]
page specs now provide a real createConfirmService() via render context
instead of mocking confirm.svelte. Zero confirm.svelte vi.mocks remain
across the client suite (AC#4). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
a4d4ff35d9 test: inject real ConfirmService via context (batch 1/2)
Replaces the vi.mock('$lib/shared/services/confirm.svelte') stub with a
real createConfirmService() provided through render's context map, mirroring
the existing admin/tags/[id]/page.svelte.spec.ts pattern. The generic
confirm.test-fixture.svelte renders only ConfirmDialog and cannot wrap an
arbitrary page; none of these specs trigger confirm(), so the children's
getConfirmService() simply reads the provided context instead of a module
mock. No vi.mock of confirm.svelte remains in these 5 specs. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
e5cf98e1d4 test(hooks): migrate useUnsavedWarning spec to shared $app/navigation mock
Replaces the local beforeNavigate-capture plumbing and simulateNavigate
helper with the shared $mocks/$app/navigation module via a sync factory.
The per-test reset now comes from the shared module's embedded beforeEach.
Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
cc76d4de91 test(mocks): add shared $app/navigation mock with simulateNavigate
Exports the standard nav functions as vi.fn() and a beforeNavigate that
captures the registered callback. The exported simulateNavigate(href)
helper fires that callback and returns the cancel spy — the whole
capture-and-fire pattern lives in the shared module, not the raw callback.
An embedded beforeEach clears the captured callback and the mock call
histories before every test. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
8b1eeba91c test: migrate remaining 3 $app/forms consumers to shared mock
Completes Phase 1a after the load-bearing ChronikFuerDichBox spec proved
the pattern. ChronikFuerDichBox.test and NotificationDropdown.test (rich
result-firing interceptors) keep their submit-fired assertions
(optimisticMarkRead/MarkAllRead) and use formsMock.setFormResult for the
failure branch. NotificationBell.spec used the simpler intercept-only
factory and renders no form of its own, so it adopts the shared superset
purely as a render-time stub. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
d2fb023876 test(activity): migrate ChronikFuerDichBox spec to shared $app/forms mock
Load-bearing first migration (ADR-012): this is the hardest case — its
enhance submit callback actually fires and reads the form result. Replaces
the duplicated 23-line interceptor factory with vi.mock('$app/forms',
() => ({ ...formsMock })) via $mocks, and the per-test mockFormResult
mutation with formsMock.setFormResult({ type: 'failure' }). The reset now
comes from the shared module's embedded beforeEach. The existing
optimisticMarkRead/optimisticMarkAllRead-on-submit assertions remain as the
positive proof the callback fired. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
01c28ca13c test(mocks): add shared $app/forms interceptor mock body
Single home for the non-trivial form-interceptor enhance() shared by the
four complex consumers: it intercepts submit, invokes the SubmitFunction,
and fires the returned callback with a configurable result. setFormResult()
drives the success/failure branch; an embedded beforeEach resets it before
every test so isolation is structural. Consumed via vi.mock('$app/forms',
() => ({ ...formsMock })) through the $mocks alias. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
f6778b0eff build(frontend): register $mocks in kit.alias for tsconfig resolution
The vite resolve.alias (added for the client + coverage runs) does not
reach svelte-check, which resolves paths through the generated tsconfig.
Declaring $mocks in kit.alias feeds both the generated tsconfig paths and
the sveltekit() vite plugin, so editor/type-check resolve it too. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
a0bcdcd19a docs(adr): amend ADR-012 with no-factory ban + shared-mock dedup (#560)
Records the 2026-06-02 revision from #560: (1) no-factory vi.mock of a
SvelteKit virtual module is forbidden (the PR #657 partial-mock failure),
guarded by a seventh enforcement layer; (2) shared mock body + per-spec
sync factory via the $mocks alias is the sanctioned dedup; (3) Option C
config-level auto-resolve is rejected. Also corrects the stale 4.1.0
patch filename to 4.1.6 and links #657. Part of #560.
2026-06-02 20:27:19 +02:00
Marcel
fa95cc5e21 test(meta): ban no-factory vi.mock of virtual modules
A vi.mock('$app/navigation') with no factory does not auto-resolve to a
__mocks__ file for SvelteKit virtual modules — it substitutes some
exports and leaves others (replaceState) bound to the live router, which
is exactly the PR #657 failure. This Node-mode source scan, mirroring
no-async-mock-factories and no-duplicate-mock-ids, fails at every vitest
invocation if any *.svelte.{spec,test}.ts reintroduces the pattern, and
forecloses ADR-012's rejected Option C. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
7c2d1807df build(frontend): add $mocks alias for shared browser-test mock bodies
Declares $mocks -> src/__mocks__ in both vite.config.ts and
vitest.client-coverage.config.ts so shared mock modules resolve in the
client test run and the coverage job alike. Enables the sync-factory
dedup pattern from ADR-012 (vi.mock('$app/forms', () => ({ ...formsMock }))).
Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
e1bd090acd build(frontend): exact-pin @vitest/browser-playwright to 4.1.6
Drop the caret so the version cannot float off the patched release.
patches/@vitest+browser-playwright+4.1.6.patch backports vitest PR #10267
(the duplicate-mock-id birpc race, ADR-012) and only applies to 4.1.6; a
caret range could resolve to a version the patch rejects. A top-level
"//" key records the removal condition since package.json forbids
comments. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
c56aa1c645 refactor(admin-tags): migrate tag-edit page from $app/stores to $app/state
The legacy $app/stores subscription API is replaced with the modern
$app/state reactive proxy (page.url.pathname), per ADR-012's
architectural follow-on. The two spec mocks of $app/stores are replaced
with sync-factory $app/state mocks, matching the existing convention in
aktivitaeten/documents specs. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
31 changed files with 870 additions and 424 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,37 @@ 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.
### Sanctioned dedup: shared mock body + per-spec sync factory
To remove duplicated factory bodies without removing the factory, keep one shared mock module per virtual module under `src/__mocks__/` and import it via the `$mocks` alias into a sync factory:
```ts
import * as formsMock from "$mocks/$app/forms";
vi.mock("$app/forms", () => ({ ...formsMock }));
```
The shared module owns any non-trivial mock logic and embeds its own `beforeEach` reset of mutable state, so isolation is structural. The `$mocks` alias is declared in **both** `vite.config.ts` and `vitest.client-coverage.config.ts` so it resolves in the coverage job too. Only genuinely-shared logic is consolidated; the ~80 trivial inline factories (`enhance: () => () => {}`, `{ goto: vi.fn() }`) are left untouched.
### 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,85 @@
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 dedup pattern keeps the factory and shares its body:
// import * as formsMock from '$mocks/$app/forms';
// vi.mock('$app/forms', () => ({ ...formsMock }));
//
// 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 shared-body spread factory', () => {
const fixture = `import * as formsMock from '$mocks/$app/forms';
vi.mock('$app/forms', () => ({ ...formsMock }));`;
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

@@ -0,0 +1,43 @@
// Shared browser-test mock body for the SvelteKit `$app/forms` virtual module.
//
// Imported into a sync vi.mock factory via the $mocks alias:
// import * as formsMock from '$mocks/$app/forms';
// vi.mock('$app/forms', () => ({ ...formsMock }));
//
// `enhance` intercepts the form's submit event, invokes the component's
// SubmitFunction, and — when that returns a post-submit callback — calls it
// with the configurable `_formResult`. Tests drive the success/failure branch
// with `setFormResult({ type: 'failure' })`. The embedded `beforeEach` resets
// the result before every test, so isolation is structural, not per-spec.
// See ADR-012.
import { beforeEach } from 'vitest';
export type FormEnhanceResult = { type: string; data?: Record<string, unknown> };
let _formResult: FormEnhanceResult = { type: 'success' };
export function setFormResult(result: FormEnhanceResult): void {
_formResult = result;
}
export function enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: { result: FormEnhanceResult; update: () => Promise<void> }) => Promise<void>
): { destroy: () => void } {
const handler = async (e: Event) => {
e.preventDefault();
const callback = submit?.({ formData: new FormData(node) } as never);
if (typeof callback === 'function') {
await callback({ result: _formResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
beforeEach(() => {
_formResult = { type: 'success' };
});

View File

@@ -0,0 +1,62 @@
// Shared browser-test mock body for the SvelteKit `$app/navigation` virtual module.
//
// Imported into a sync vi.mock factory via the $mocks alias:
// import * as navMock from '$mocks/$app/navigation';
// vi.mock('$app/navigation', () => ({ ...navMock }));
//
// All navigation functions are vi.fn() stubs. `beforeNavigate` additionally
// captures the registered callback so a test can drive it through the exported
// `simulateNavigate(href)` helper — the whole capture-and-fire pattern lives
// here, not the raw callback. The embedded `beforeEach` clears the captured
// callback and the mock call histories before every test, so isolation is
// structural. See ADR-012.
import { beforeEach, vi } from 'vitest';
type BeforeNavigateCallback = (nav: {
cancel: () => void;
to: { url: { href: string } } | null;
}) => void;
let _registeredBeforeNavigate: BeforeNavigateCallback | null = null;
export const goto = vi.fn();
export const invalidate = vi.fn();
export const invalidateAll = vi.fn();
export const beforeNavigate = vi.fn((fn: BeforeNavigateCallback) => {
_registeredBeforeNavigate = fn;
});
export const afterNavigate = vi.fn();
export const preloadCode = vi.fn();
export const preloadData = vi.fn();
export const pushState = vi.fn();
export const replaceState = vi.fn();
export const disableScrollHandling = vi.fn();
export const onNavigate = vi.fn();
const _navMocks = [
goto,
invalidate,
invalidateAll,
beforeNavigate,
afterNavigate,
preloadCode,
preloadData,
pushState,
replaceState,
disableScrollHandling,
onNavigate
];
// Fire the captured beforeNavigate callback as if navigating to `href`.
// Returns the cancel spy so the test can assert whether navigation was blocked.
export function simulateNavigate(href: string | null = '/somewhere'): ReturnType<typeof vi.fn> {
const cancel = vi.fn();
_registeredBeforeNavigate?.({ cancel, to: href ? { url: { href } } : null });
return cancel;
}
beforeEach(() => {
_registeredBeforeNavigate = null;
_navMocks.forEach((mock) => mock.mockClear());
});

View File

@@ -4,36 +4,12 @@ import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications.svelte';
import * as formsMock from '$mocks/$app/forms';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
vi.mock('$app/forms', () => ({ ...formsMock }));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
function notif(partial: Partial<NotificationItem>): NotificationItem {
@@ -176,7 +152,7 @@ describe('ChronikFuerDichBox', () => {
});
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
formsMock.setFormResult({ type: 'failure' });
render(ChronikFuerDichBox, {
unread: [notif({ id: 'err-1' })],
optimisticMarkRead: vi.fn(),

View File

@@ -3,36 +3,12 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications';
import * as formsMock from '$mocks/$app/forms';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
vi.mock('$app/forms', () => ({ ...formsMock }));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
@@ -160,7 +136,7 @@ describe('ChronikFuerDichBox', () => {
});
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
formsMock.setFormResult({ type: 'failure' });
render(ChronikFuerDichBox, {
props: {
unread: [mention({ id: 'err-1' })],

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,44 +1,43 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { m } from '$lib/paraglide/messages.js';
import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte';
import * as formsMock from '$mocks/$app/forms';
import NotificationFixture from './notification.test-fixture.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$app/forms', () => ({
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
const handler = (e: Event) => {
e.preventDefault();
submit?.({ formData: new FormData(node) } as never);
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
vi.mock('$app/forms', () => ({ ...formsMock }));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
// NotificationBell.onMount calls store.init(), which opens an EventSource and
// fetches the unread count. Stub both so no real network or 401 → /login
// navigation fires; the real store + provideNotificationStore() run otherwise.
class NoopEventSource {
static CLOSED = 2;
readyState = 0;
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
addEventListener() {}
close() {}
}
vi.mock('$lib/notification/notifications.svelte', () => ({
notificationStore: {
get notifications() {
return mockNotificationList.value;
},
get unreadCount() {
return mockNotificationList.value.length;
},
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn(),
fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(),
destroy: vi.fn()
}
}));
beforeEach(() => {
vi.stubGlobal('EventSource', NoopEventSource);
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 }))
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockNotificationList.value = [];
vi.unstubAllGlobals();
});
const tick = () => new Promise((r) => setTimeout(r, 0));
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n1',
type: 'REPLY',
@@ -52,30 +51,85 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...overrides
});
describe('NotificationBell — cursor and tooltip', () => {
type Api = { setNotifications: (items: NotificationItem[]) => void };
function renderBell(): Api {
let api: Api = { setNotifications: () => {} };
render(NotificationFixture, { onReady: (a: Api) => (api = a) });
return api;
}
function bellButton(): HTMLButtonElement {
return document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
}
function unreadBadge(): HTMLElement {
return bellButton().querySelector<HTMLElement>('[aria-live="polite"]')!;
}
describe('NotificationBell — rendering', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.classList.contains('cursor-pointer')).toBe(true);
});
it('bell button title equals aria-label when unreadCount is 0', async () => {
mockNotificationList.value = [];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
it('bell button title equals aria-label when unreadCount is 3', async () => {
mockNotificationList.value = [
makeNotification({ id: 'n1' }),
makeNotification({ id: 'n2' }),
makeNotification({ id: 'n3' })
];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
renderBell();
await tick();
expect(bellButton().classList.contains('cursor-pointer')).toBe(true);
});
});
// AC#5: the bell's announced unread count must hold across the four a11y states.
// The count is announced via the aria-live badge text and the button title /
// aria-label; both must stay consistent as the store's notifications change.
describe('NotificationBell — announced unread count across a11y states', () => {
it('empty: announces no unread count and hides the live badge', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(true);
});
it('single: announces one unread notification', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([makeNotification({ id: 'n1', read: false })]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 1 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(false);
expect(unreadBadge().textContent?.trim()).toBe('1');
});
it('many: announces the exact unread count', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: false }),
makeNotification({ id: 'n3', read: false })
]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 3 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().textContent?.trim()).toBe('3');
});
it('error: degrades to a zero announced count when the unread-count load fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
renderBell();
await tick();
// A failed load leaves the count at 0; the bell still announces a valid,
// non-urgent state rather than a broken count.
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(true);
});
});

View File

@@ -3,41 +3,15 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import NotificationDropdown from './NotificationDropdown.svelte';
import * as formsMock from '$mocks/$app/forms';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Configurable result for the enhance mock — tests that need failure set
// mockFormResult.type = 'failure' before clicking.
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
// Invoke the SubmitFunction and always call the returned result callback with
// mockFormResult so tests can exercise both success and failure branches.
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await cb({ result: mockFormResult, update: async () => {} } as never);
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
vi.mock('$app/forms', () => ({ ...formsMock }));
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockFormResult.type = 'success'; // reset to default after each test
});
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
@@ -235,7 +209,7 @@ describe('NotificationDropdown', () => {
});
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
mockFormResult.type = 'failure';
formsMock.setFormResult({ type: 'failure' });
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
@@ -372,7 +346,7 @@ describe('NotificationDropdown', () => {
});
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
formsMock.setFormResult({ type: 'failure' });
const onClose = vi.fn();
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
render(NotificationDropdown, {

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

@@ -1,35 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import * as navMock from '$mocks/$app/navigation';
// Capture the beforeNavigate callback so tests can simulate navigation events
let registeredBeforeNavigate:
| ((nav: { cancel: () => void; to: { url: { href: string } } | null }) => void)
| null = null;
vi.mock('$app/navigation', () => ({ ...navMock }));
const mockGoto = vi.fn();
vi.mock('$app/navigation', () => ({
beforeNavigate: vi.fn((fn: typeof registeredBeforeNavigate) => {
registeredBeforeNavigate = fn;
}),
goto: mockGoto
}));
const { simulateNavigate, goto: mockGoto } = navMock;
const { createUnsavedWarning } = await import('./useUnsavedWarning.svelte');
function simulateNavigate(href: string | null = '/somewhere') {
const cancel = vi.fn();
registeredBeforeNavigate?.({
cancel,
to: href ? { url: { href } } : null
});
return cancel;
}
beforeEach(() => {
registeredBeforeNavigate = null;
mockGoto.mockClear();
});
describe('createUnsavedWarning', () => {
it('isDirty starts false', () => {
const w = createUnsavedWarning();

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

View File

@@ -8,6 +8,11 @@ const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
// $mocks resolves shared browser-test mock bodies (src/__mocks__). Declared here
// so svelte-check/tsconfig and both vite configs resolve it. See ADR-012.
alias: {
$mocks: 'src/__mocks__'
},
prerender: {
entries: ['/hilfe/transkription'],
// Disable crawl: by default SvelteKit follows nav links from

View File

@@ -6,8 +6,15 @@ import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { fileURLToPath } from 'node:url';
export default defineConfig({
resolve: {
alias: {
// Shared browser-test mock bodies, imported into sync vi.mock factories. See ADR-012.
$mocks: fileURLToPath(new URL('./src/__mocks__', import.meta.url))
}
},
optimizeDeps: {
include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention']
},

View File

@@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
import { fileURLToPath } from 'node:url';
// Standalone config for browser-project Istanbul coverage.
// Uses a dedicated root-level coverage block because Vitest 4 ignores
@@ -11,6 +12,12 @@ import { sveltekit } from '@sveltejs/kit/vite';
// Plugins mirrored from vite.config.ts: tailwindcss, sveltekit, devtoolsJson, paraglideVitePlugin
// Update here whenever vite.config.ts plugins change.
export default defineConfig({
resolve: {
alias: {
// Shared browser-test mock bodies, imported into sync vi.mock factories. See ADR-012.
$mocks: fileURLToPath(new URL('./src/__mocks__', import.meta.url))
}
},
optimizeDeps: {
include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention']
},