Compare commits

...

17 Commits

Author SHA1 Message Date
Marcel
27b6d58632 test(notification): make setNotifications authoritative in bell a11y tests
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m13s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 45s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
nightly / deploy-staging (push) Successful in 2m13s
CI showed the single/many a11y tests failing with count 0: init()'s async
fetchUnreadCount resolved to {count:0} AFTER setNotifications() ran,
clobbering the seeded count (the flake Sara predicted in review). Stub
fetch to never settle so the announced count is driven solely by
setNotifications — deterministic, no race. Also rewrites the 'error' test
to seed a count then fail the load and assert the count SURVIVES, so it is
a meaningful state distinct from 'empty' (was byte-identical, flagged by
Felix/Sara/Leonie). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
4db2e97490 revert(test): abandon shared-mock dedup — infeasible in vitest browser mode
CI proved cross-file sharing of a virtual-module mock body cannot work in
@vitest/browser-playwright 4.1.6: the static-import spread fails the hoist
("no top level variables"), and the await-vi.hoisted-import form fails to
parse ("Unexpected identifier 'vi'"). vi.hoisted has the same hoist
constraint as vi.mock, so there is no way to thread an external module's
body into the factory here.

Reverts Phase 1: restores the 4 $app/forms/$app/navigation specs to their
inline factories, inlines NotificationBell.spec's forms stub, deletes the
src/__mocks__/$app/* modules and the $mocks alias (vite, vitest-coverage,
kit). The no-factory-ban meta-test stays (no-factory vi.mock is still
banned). ADR-012 amended to record the infeasibility. Everything else
($app/state migration, confirm context-inject, notification refactor, the
pin, the meta-test) is unaffected. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
25b23843c9 fix(test): load shared mocks via vi.hoisted, not a static import
CI caught that vi.mock('$app/forms', () => ({ ...formsMock })) with a
static `import * as formsMock` fails: vitest hoists vi.mock above the
import, so the factory references an uninitialised binding
("no top level variables inside"). Load the shared mock module via
`const formsMock = await vi.hoisted(() => import('$mocks/...'))` instead —
the factory may reference a vi.hoisted binding, and the dynamic import runs
at collection time (not in the lazily-invoked factory), so it stays clear
of ADR-012's birpc race and the no-async-mock-factories guard. Applies to
all 5 shared-mock consumers ($app/forms x4, $app/navigation x1). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
ad067d2e0e refactor(notification): provide notification store via context + fixture
Converts the module-singleton notificationStore into a context-provided
store so its specs can drive it without mocking the module. notifications.svelte
now exports createNotificationStore() (the former singleton body), plus
provideNotificationStore()/getNotificationStore()/NOTIFICATION_KEY mirroring
the confirm service. Root +layout provides it; NotificationBell and the
Chronik page read it via getNotificationStore().

Tests:
- notifications.svelte.spec drives a fresh createNotificationStore() per test
  (replacing __resetForTest/__setNavigateForTest with setNavigate()).
- notification.test-fixture.svelte wraps the bell, provides the store, and
  exposes setNotifications(items) via onReady (option b).
- NotificationBell.svelte.spec asserts the announced unread count across the
  empty / single / many / error a11y states (AC#5), stubbing EventSource+fetch.
- aktivitaeten page spec injects a real store via render context.

Per the recorded Phase-2b decision (full context refactor). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
29015ee864 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-03 11:38:22 +02:00
Marcel
b1b8505b93 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-03 11:38:22 +02:00
Marcel
abe860bec7 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-03 11:38:22 +02:00
Marcel
ec9d46da7a 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-03 11:38:22 +02:00
Marcel
e562b3bbea 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-03 11:38:22 +02:00
Marcel
e725910402 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-03 11:38:22 +02:00
Marcel
782a34e34b 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-03 11:38:22 +02:00
Marcel
30f450b0d1 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-03 11:38:22 +02:00
Marcel
d4c0287e92 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-03 11:38:22 +02:00
Marcel
301cfc5c9e 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-03 11:38:22 +02:00
Marcel
724c3881e4 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-03 11:38:22 +02:00
Marcel
fab2930ca8 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-03 11:38:22 +02:00
Marcel
d83707ec3b 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-03 11:38:22 +02:00
22 changed files with 746 additions and 303 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { m } from '$lib/paraglide/messages.js';
import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte';
import NotificationFixture from './notification.test-fixture.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$app/forms', () => ({
@@ -15,30 +16,37 @@ vi.mock('$app/forms', () => ({
}
}));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
// NotificationBell.onMount calls store.init(), which opens an EventSource and
// fetches the unread count. Stub both so no real network or 401 → /login
// navigation fires; the real store + provideNotificationStore() run otherwise.
class NoopEventSource {
static CLOSED = 2;
readyState = 0;
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
addEventListener() {}
close() {}
}
vi.mock('$lib/notification/notifications.svelte', () => ({
notificationStore: {
get notifications() {
return mockNotificationList.value;
},
get unreadCount() {
return mockNotificationList.value.length;
},
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn(),
fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(),
destroy: vi.fn()
}
}));
beforeEach(() => {
vi.stubGlobal('EventSource', NoopEventSource);
// init()'s fetchUnreadCount() never settles, so the bell's announced count is
// driven solely by setNotifications() — removes the init-fetch-vs-setNotifications
// race (the real fetch would resolve to {count:0} and clobber the seeded count).
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(() => {}))
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockNotificationList.value = [];
vi.unstubAllGlobals();
});
const tick = () => new Promise((r) => setTimeout(r, 0));
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n1',
type: 'REPLY',
@@ -52,30 +60,87 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...overrides
});
describe('NotificationBell — cursor and tooltip', () => {
type Api = { setNotifications: (items: NotificationItem[]) => void };
function renderBell(): Api {
let api: Api = { setNotifications: () => {} };
render(NotificationFixture, { onReady: (a: Api) => (api = a) });
return api;
}
function bellButton(): HTMLButtonElement {
return document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
}
function unreadBadge(): HTMLElement {
return bellButton().querySelector<HTMLElement>('[aria-live="polite"]')!;
}
describe('NotificationBell — rendering', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.classList.contains('cursor-pointer')).toBe(true);
});
it('bell button title equals aria-label when unreadCount is 0', async () => {
mockNotificationList.value = [];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
it('bell button title equals aria-label when unreadCount is 3', async () => {
mockNotificationList.value = [
makeNotification({ id: 'n1' }),
makeNotification({ id: 'n2' }),
makeNotification({ id: 'n3' })
];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
renderBell();
await tick();
expect(bellButton().classList.contains('cursor-pointer')).toBe(true);
});
});
// AC#5: the bell's announced unread count must hold across the four a11y states.
// The count is announced via the aria-live badge text and the button title /
// aria-label; both must stay consistent as the store's notifications change.
describe('NotificationBell — announced unread count across a11y states', () => {
it('empty: announces no unread count and hides the live badge', async () => {
const { setNotifications } = renderBell();
setNotifications([]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(true);
});
it('single: announces one unread notification', async () => {
const { setNotifications } = renderBell();
setNotifications([makeNotification({ id: 'n1', read: false })]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 1 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(false);
expect(unreadBadge().textContent?.trim()).toBe('1');
});
it('many: announces the exact unread count', async () => {
const { setNotifications } = renderBell();
setNotifications([
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: false }),
makeNotification({ id: 'n3', read: false })
]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 3 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().textContent?.trim()).toBe('3');
});
it('error: a failed unread-count load does not wipe the announced count', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
const { setNotifications } = renderBell();
setNotifications([
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: false })
]);
await tick();
// init()'s fetchUnreadCount rejects; its catch must leave the already-set
// count intact rather than silently zeroing the live region. This is a
// distinct state from "empty" — the count survived a transient load failure.
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 2 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().textContent?.trim()).toBe('2');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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