Consolidate shared vi.mock bodies + migrate confirm/notification specs (#560) #719
@@ -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).
|
||||
|
||||
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
87
frontend/src/__meta__/no-factory-ban.test.ts
Normal file
87
frontend/src/__meta__/no-factory-ban.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
|
||||
|
||||
@@ -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, {});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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') };
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page, navigating } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { notificationStore, type NotificationItem } from '$lib/notification/notifications.svelte';
|
||||
import {
|
||||
getNotificationStore,
|
||||
type NotificationItem
|
||||
} from '$lib/notification/notifications.svelte';
|
||||
import ChronikFuerDichBox from '$lib/activity/ChronikFuerDichBox.svelte';
|
||||
import ChronikFilterPills from '$lib/activity/ChronikFilterPills.svelte';
|
||||
import ChronikTimeline from '$lib/activity/ChronikTimeline.svelte';
|
||||
@@ -26,9 +29,11 @@ interface Props {
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
// Prefer the live SSE singleton for unread items so newly arriving mentions
|
||||
const notificationStore = getNotificationStore();
|
||||
|
||||
// Prefer the live SSE store for unread items so newly arriving mentions
|
||||
// prepend without a reload. On first mount, seed from the server-loaded unread
|
||||
// set if the singleton hasn't populated yet.
|
||||
// set if the store hasn't populated yet.
|
||||
onMount(() => {
|
||||
notificationStore.init();
|
||||
});
|
||||
@@ -59,7 +64,7 @@ const seedUnread = $derived<NotificationItem[]>(
|
||||
}))
|
||||
);
|
||||
|
||||
// If the singleton has any data (including zero after mark-all), trust it;
|
||||
// If the store has any data (including zero after mark-all), trust it;
|
||||
// otherwise fall back to the SSR-seeded unread set.
|
||||
const unread = $derived<NotificationItem[]>(
|
||||
notificationStore.notifications.length > 0 ? liveUnread : seedUnread
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { createNotificationStore, NOTIFICATION_KEY } from '$lib/notification/notifications.svelte';
|
||||
|
||||
const mockNavigating = { type: null };
|
||||
const mockPage = { url: new URL('http://localhost/aktivitaeten') };
|
||||
@@ -28,19 +29,35 @@ vi.mock('$app/navigation', () => ({
|
||||
onNavigate: () => () => {}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||
notificationStore: {
|
||||
notifications: [],
|
||||
init: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
markAllRead: vi.fn()
|
||||
}
|
||||
}));
|
||||
// The Chronik page's onMount calls store.init(), opening an EventSource and
|
||||
// fetching the unread count. Stub both so no real network / 401 → /login fires.
|
||||
class NoopEventSource {
|
||||
static CLOSED = 2;
|
||||
readyState = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('EventSource', NoopEventSource);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 }))
|
||||
);
|
||||
});
|
||||
|
||||
const { default: AktivitaetenPage } = await import('./+page.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const notificationContext = () => new Map([[NOTIFICATION_KEY, createNotificationStore()]]);
|
||||
|
||||
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||
filter: 'alle' as const,
|
||||
@@ -52,13 +69,16 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||
|
||||
describe('aktivitaeten page', () => {
|
||||
it('renders the page heading', async () => {
|
||||
render(AktivitaetenPage, { props: { data: baseData() } });
|
||||
render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
|
||||
|
||||
await expect.element(page.getByRole('heading', { name: /aktivitäten/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the error card when loadError is "activity"', async () => {
|
||||
render(AktivitaetenPage, { props: { data: baseData({ loadError: 'activity' }) } });
|
||||
render(AktivitaetenPage, {
|
||||
context: notificationContext(),
|
||||
props: { data: baseData({ loadError: 'activity' }) }
|
||||
});
|
||||
|
||||
// ChronikErrorCard renders some retry mechanism
|
||||
const main = document.querySelector('main');
|
||||
@@ -69,7 +89,7 @@ describe('aktivitaeten page', () => {
|
||||
});
|
||||
|
||||
it('renders the FuerDichBox and FilterPills when loadError is null', async () => {
|
||||
render(AktivitaetenPage, { props: { data: baseData() } });
|
||||
render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
|
||||
|
||||
// FuerDichBox shows the inbox-zero state when no unread
|
||||
const fuerDich = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
||||
@@ -81,7 +101,7 @@ describe('aktivitaeten page', () => {
|
||||
});
|
||||
|
||||
it('renders the first-run empty state when activityFeed is empty', async () => {
|
||||
render(AktivitaetenPage, { props: { data: baseData() } });
|
||||
render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
|
||||
|
||||
const empty = document.querySelector('[data-testid="chronik-empty-state"]');
|
||||
expect(empty?.getAttribute('data-variant')).toBe('first-run');
|
||||
@@ -89,6 +109,7 @@ describe('aktivitaeten page', () => {
|
||||
|
||||
it('renders the filter-empty empty state when feed has items but filter rules out all', async () => {
|
||||
render(AktivitaetenPage, {
|
||||
context: notificationContext(),
|
||||
props: {
|
||||
data: baseData({
|
||||
filter: 'fuer-dich' as const,
|
||||
@@ -114,6 +135,7 @@ describe('aktivitaeten page', () => {
|
||||
|
||||
it('renders the timeline when displayFeed is non-empty', async () => {
|
||||
render(AktivitaetenPage, {
|
||||
context: notificationContext(),
|
||||
props: {
|
||||
data: baseData({
|
||||
filter: 'alle' as const,
|
||||
@@ -142,6 +164,7 @@ describe('aktivitaeten page', () => {
|
||||
|
||||
it('renders without crashing when filter is set to a non-default value', async () => {
|
||||
render(AktivitaetenPage, {
|
||||
context: notificationContext(),
|
||||
props: { data: baseData({ filter: 'transkription' as const }) }
|
||||
});
|
||||
|
||||
|
||||
@@ -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' } }
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user