From 891e1f1cf2efa5868c305a3e44d0ba4aadd3717a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 19:51:51 +0200 Subject: [PATCH 001/189] chore(routes): delete dev-only demo route Removes scaffolding pages from initial Paraglide setup that were never navigated to in production. Shrinks the measured coverage surface and removes dead code from the production bundle. CLAUDE.md route tables updated to drop the demo/ entry. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 3 +-- frontend/CLAUDE.md | 3 +-- frontend/src/routes/demo/+page.svelte | 5 ----- frontend/src/routes/demo/paraglide/+page.svelte | 17 ----------------- 4 files changed, 2 insertions(+), 26 deletions(-) delete mode 100644 frontend/src/routes/demo/+page.svelte delete mode 100644 frontend/src/routes/demo/paraglide/+page.svelte diff --git a/CLAUDE.md b/CLAUDE.md index 3628e703..09e8d2c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,8 +202,7 @@ frontend/src/routes/ ├── profile/ User profile settings ├── users/[id]/ Public user profile page ├── login/ logout/ register/ -├── forgot-password/ reset-password/ -└── demo/ Dev-only demos +└── forgot-password/ reset-password/ ``` ### API Client Pattern diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 8b912685..c58a26c6 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -40,8 +40,7 @@ src/ │ ├── profile/ # User profile settings │ ├── users/[id]/ # Public user profile page │ ├── login/ logout/ register/ -│ ├── forgot-password/ reset-password/ -│ └── demo/ # Dev-only demos +│ └── forgot-password/ reset-password/ ├── lib/ # Domain-based package structure (mirrors backend) │ ├── document/ # Document domain: components, stores, services, utils │ │ ├── annotation/ # Annotation overlay components diff --git a/frontend/src/routes/demo/+page.svelte b/frontend/src/routes/demo/+page.svelte deleted file mode 100644 index 238a290e..00000000 --- a/frontend/src/routes/demo/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -paraglide diff --git a/frontend/src/routes/demo/paraglide/+page.svelte b/frontend/src/routes/demo/paraglide/+page.svelte deleted file mode 100644 index 0aee466e..00000000 --- a/frontend/src/routes/demo/paraglide/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - -

{m.nav_documents()}

-
- - - -
-

- If you use VSCode, install the Sherlock i18n extension for a better i18n experience. -

-- 2.49.1 From eb2ec6a71a2e6083b727213db3e90844b7a0d0c7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 19:55:05 +0200 Subject: [PATCH 002/189] refactor(test): use getByRole instead of data-testid in TranscriptionPanelHeader test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Felix's review on issue #496, tests should query observable behaviour via ARIA roles, not test-only data-testid attributes. Replaces every 'document.querySelector([data-testid=...])' with 'page.getByRole(...)'. The disabled-button click test uses force: true so Playwright bypasses its enabled-check — the behaviour under test is precisely that the click is ignored. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../TranscriptionPanelHeader.svelte.test.ts | 180 ++++++------------ 1 file changed, 59 insertions(+), 121 deletions(-) diff --git a/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte.test.ts b/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte.test.ts index d60b5e60..b512e8ef 100644 --- a/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte.test.ts +++ b/frontend/src/lib/document/transcription/TranscriptionPanelHeader.svelte.test.ts @@ -5,178 +5,116 @@ import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte'; afterEach(cleanup); -describe('TranscriptionPanelHeader', () => { - it('should render Lesen and Bearbeiten buttons', async () => { - render(TranscriptionPanelHeader, { - mode: 'read', - hasBlocks: true, - blockCount: 3, - lastEditedAt: null, - onModeChange: () => {}, - onClose: () => {} - }); +const baseProps = { + mode: 'read' as const, + hasBlocks: true, + blockCount: 3, + lastEditedAt: null, + onModeChange: () => {}, + onClose: () => {} +}; - await expect.element(page.getByText('Lesen')).toBeInTheDocument(); - await expect.element(page.getByText('Bearbeiten')).toBeInTheDocument(); +describe('TranscriptionPanelHeader', () => { + it('renders the Lesen and Bearbeiten toggle buttons', async () => { + render(TranscriptionPanelHeader, baseProps); + + await expect.element(page.getByRole('button', { name: /lesen/i })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /bearbeiten/i })).toBeVisible(); }); - it('should disable Lesen button when hasBlocks is false', async () => { + it('marks the Lesen button as aria-disabled when hasBlocks is false', async () => { render(TranscriptionPanelHeader, { + ...baseProps, mode: 'edit', hasBlocks: false, - blockCount: 0, - lastEditedAt: null, - onModeChange: () => {}, - onClose: () => {} + blockCount: 0 }); - const lesenBtn = document.querySelector('[data-testid="mode-read"]') as HTMLButtonElement; - expect(lesenBtn.getAttribute('aria-disabled')).toBe('true'); + await expect + .element(page.getByRole('button', { name: /lesen/i })) + .toHaveAttribute('aria-disabled', 'true'); }); - it('should call onModeChange when clicking Bearbeiten', async () => { + it('calls onModeChange("edit") when the Bearbeiten button is clicked', async () => { const onModeChange = vi.fn(); - render(TranscriptionPanelHeader, { - mode: 'read', - hasBlocks: true, - blockCount: 3, - lastEditedAt: null, - onModeChange, - onClose: () => {} - }); + render(TranscriptionPanelHeader, { ...baseProps, onModeChange }); + + await page.getByRole('button', { name: /bearbeiten/i }).click(); - const editBtn = document.querySelector('[data-testid="mode-edit"]')!; - editBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(onModeChange).toHaveBeenCalledWith('edit'); }); - it('should not call onModeChange when clicking disabled Lesen', async () => { + it('does not call onModeChange when the disabled Lesen button is clicked', async () => { const onModeChange = vi.fn(); render(TranscriptionPanelHeader, { + ...baseProps, mode: 'edit', hasBlocks: false, blockCount: 0, - lastEditedAt: null, - onModeChange, - onClose: () => {} + onModeChange }); - const readBtn = document.querySelector('[data-testid="mode-read"]')!; - readBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await page.getByRole('button', { name: /lesen/i }).click({ force: true }); + expect(onModeChange).not.toHaveBeenCalled(); }); - it('should call onClose when clicking close button', async () => { + it('calls onClose when the close button is clicked', async () => { const onClose = vi.fn(); - render(TranscriptionPanelHeader, { - mode: 'read', - hasBlocks: true, - blockCount: 3, - lastEditedAt: null, - onModeChange: () => {}, - onClose - }); + render(TranscriptionPanelHeader, { ...baseProps, onClose }); - const closeBtn = document.querySelector('[data-testid="panel-close"]')!; - closeBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(onClose).toHaveBeenCalled(); + await page.getByRole('button', { name: /panel schließen/i }).click(); + + expect(onClose).toHaveBeenCalledOnce(); }); - it('should show singular block count for 1 block', async () => { - render(TranscriptionPanelHeader, { - mode: 'read', - hasBlocks: true, - blockCount: 1, - lastEditedAt: null, - onModeChange: () => {}, - onClose: () => {} - }); + it('shows the singular section label when blockCount is 1', async () => { + render(TranscriptionPanelHeader, { ...baseProps, blockCount: 1 }); - await expect.element(page.getByText('1 Abschnitt')).toBeInTheDocument(); + await expect.element(page.getByText('1 Abschnitt')).toBeVisible(); }); - it('should show plural block count for multiple blocks', async () => { - render(TranscriptionPanelHeader, { - mode: 'read', - hasBlocks: true, - blockCount: 5, - lastEditedAt: null, - onModeChange: () => {}, - onClose: () => {} - }); + it('shows the plural section label when blockCount is greater than 1', async () => { + render(TranscriptionPanelHeader, { ...baseProps, blockCount: 5 }); - await expect.element(page.getByText('5 Abschnitte')).toBeInTheDocument(); + await expect.element(page.getByText('5 Abschnitte')).toBeVisible(); }); - it('should show "0 Abschnitte" when blockCount is 0', async () => { + it('shows "0 Abschnitte" when blockCount is 0', async () => { render(TranscriptionPanelHeader, { - mode: 'edit', + ...baseProps, hasBlocks: false, blockCount: 0, - lastEditedAt: null, - onModeChange: () => {}, - onClose: () => {} + mode: 'edit' }); - await expect.element(page.getByText('0 Abschnitte')).toBeInTheDocument(); + await expect.element(page.getByText('0 Abschnitte')).toBeVisible(); }); - it('should have close button with 44px touch target classes', async () => { + it('renders the formatted last-edit date when lastEditedAt is provided', async () => { render(TranscriptionPanelHeader, { - mode: 'read', - hasBlocks: true, - blockCount: 3, - lastEditedAt: null, - onModeChange: () => {}, - onClose: () => {} + ...baseProps, + lastEditedAt: '2026-04-07T10:00:00Z' }); - const closeBtn = document.querySelector('[data-testid="panel-close"]') as HTMLElement; - expect(closeBtn.classList.contains('h-11')).toBe(true); - expect(closeBtn.classList.contains('w-11')).toBe(true); + await expect.element(page.getByText(/2026/)).toBeVisible(); }); - it('should show formatted date when lastEditedAt is provided', async () => { - render(TranscriptionPanelHeader, { - mode: 'read', - hasBlocks: true, - blockCount: 3, - lastEditedAt: '2026-04-07T10:00:00Z', - onModeChange: () => {}, - onClose: () => {} - }); + it('renders the help popover trigger', async () => { + render(TranscriptionPanelHeader, baseProps); - const statusText = document.querySelector('.hidden.md\\:block'); - expect(statusText).not.toBeNull(); - expect(statusText!.textContent).toContain('2026'); + await expect + .element(page.getByRole('button', { name: /lese- und bearbeitungsmodus/i })) + .toBeVisible(); }); - it('renders a (?) help chip next to the Read/Edit toggle', async () => { - render(TranscriptionPanelHeader, { - mode: 'read', - hasBlocks: true, - blockCount: 3, - lastEditedAt: null, - onModeChange: () => {}, - onClose: () => {} - }); + it('opens the help popover when the help trigger is clicked', async () => { + render(TranscriptionPanelHeader, baseProps); - const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement; - expect(helpBtn).not.toBeNull(); - }); + await page.getByRole('button', { name: /lese- und bearbeitungsmodus/i }).click(); - it('opens a help popover with mode explanation when the chip is clicked', async () => { - render(TranscriptionPanelHeader, { - mode: 'read', - hasBlocks: true, - blockCount: 3, - lastEditedAt: null, - onModeChange: () => {}, - onClose: () => {} - }); - - const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement; - helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull()); + await expect + .element(page.getByRole('button', { name: /lese- und bearbeitungsmodus/i })) + .toHaveAttribute('aria-expanded', 'true'); }); }); -- 2.49.1 From 0c865883ca717ee08d6f67716b5eb2c8880a3f79 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 19:56:03 +0200 Subject: [PATCH 003/189] test(upload-zone): backfill afterEach(cleanup) for consistent test isolation UploadZone is the canonical browser-test template referenced from issue #496 implementation guidance. Adding afterEach(cleanup) makes it match the TranscriptionPanelHeader pattern and prevents cross-test DOM leakage as more tests are added in this branch. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/document/UploadZone.svelte.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/document/UploadZone.svelte.test.ts b/frontend/src/lib/document/UploadZone.svelte.test.ts index 553d8638..34ea8397 100644 --- a/frontend/src/lib/document/UploadZone.svelte.test.ts +++ b/frontend/src/lib/document/UploadZone.svelte.test.ts @@ -1,8 +1,10 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render } from 'vitest-browser-svelte'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import UploadZone from './UploadZone.svelte'; +afterEach(cleanup); + describe('UploadZone', () => { describe('idle state', () => { it('shows the filename in the upload zone', async () => { -- 2.49.1 From ee9ce05aee2bf3a8c8558096ca8ec447fc8878ec Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 19:58:35 +0200 Subject: [PATCH 004/189] test(document): cover all five DocumentStatusChip status branches Adds DocumentStatusChip.svelte.test.ts asserting one branch per DocumentStatus value (PLACEHOLDER, UPLOADED, TRANSCRIBED, REVIEWED, ARCHIVED) plus the title/aria-label exposure. Each test queries the element via getByTitle so the component's accessibility surface is verified at the same time as its branch logic. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../DocumentStatusChip.svelte.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 frontend/src/lib/document/DocumentStatusChip.svelte.test.ts diff --git a/frontend/src/lib/document/DocumentStatusChip.svelte.test.ts b/frontend/src/lib/document/DocumentStatusChip.svelte.test.ts new file mode 100644 index 00000000..bb0ed12e --- /dev/null +++ b/frontend/src/lib/document/DocumentStatusChip.svelte.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentStatusChip from './DocumentStatusChip.svelte'; + +afterEach(cleanup); + +describe('DocumentStatusChip', () => { + it('renders the placeholder label and gray dot for PLACEHOLDER status', async () => { + render(DocumentStatusChip, { props: { status: 'PLACEHOLDER' } }); + + const dot = await page.getByTitle('Platzhalter').element(); + expect(dot.classList.contains('bg-gray-400')).toBe(true); + }); + + it('renders the uploaded label and emerald dot for UPLOADED status', async () => { + render(DocumentStatusChip, { props: { status: 'UPLOADED' } }); + + const dot = await page.getByTitle('Hochgeladen').element(); + expect(dot.classList.contains('bg-emerald-500')).toBe(true); + }); + + it('renders the transcribed label and blue dot for TRANSCRIBED status', async () => { + render(DocumentStatusChip, { props: { status: 'TRANSCRIBED' } }); + + const dot = await page.getByTitle('Transkribiert').element(); + expect(dot.classList.contains('bg-blue-400')).toBe(true); + }); + + it('renders the reviewed label and amber dot for REVIEWED status', async () => { + render(DocumentStatusChip, { props: { status: 'REVIEWED' } }); + + const dot = await page.getByTitle('Geprüft').element(); + expect(dot.classList.contains('bg-amber-400')).toBe(true); + }); + + it('renders the archived label and dark emerald dot for ARCHIVED status', async () => { + render(DocumentStatusChip, { props: { status: 'ARCHIVED' } }); + + const dot = await page.getByTitle('Archiviert').element(); + expect(dot.classList.contains('bg-emerald-600')).toBe(true); + }); + + it('exposes the status as both a title tooltip and an aria-label', async () => { + render(DocumentStatusChip, { props: { status: 'UPLOADED' } }); + + const dot = await page.getByTitle('Hochgeladen').element(); + expect(dot.getAttribute('aria-label')).toBe('Hochgeladen'); + }); +}); -- 2.49.1 From d9270e84a35ed2aee0145f38148cd84ab6e12954 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:00:15 +0200 Subject: [PATCH 005/189] test(person): add PersonChip browser tests Covers the abbreviated/full name branches, the firstName-null fallback path, link href derivation from person id, initials rendering, and the deterministic avatar palette colour. Six tests, six branches hit. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/person/PersonChip.svelte.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 frontend/src/lib/person/PersonChip.svelte.test.ts diff --git a/frontend/src/lib/person/PersonChip.svelte.test.ts b/frontend/src/lib/person/PersonChip.svelte.test.ts new file mode 100644 index 00000000..30b60b8b --- /dev/null +++ b/frontend/src/lib/person/PersonChip.svelte.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonChip from './PersonChip.svelte'; + +afterEach(cleanup); + +const personWithFirstName = { + id: 'p-1', + firstName: 'Helene', + lastName: 'Schmidt', + displayName: 'Helene Schmidt' +}; + +const personLastNameOnly = { + id: 'p-2', + firstName: null, + lastName: 'Müller', + displayName: 'Müller' +}; + +describe('PersonChip', () => { + it('renders the full display name when abbreviated is false', async () => { + render(PersonChip, { props: { person: personWithFirstName, abbreviated: false } }); + + await expect.element(page.getByText('Helene Schmidt')).toBeVisible(); + }); + + it('renders the abbreviated name when abbreviated is true', async () => { + render(PersonChip, { props: { person: personWithFirstName, abbreviated: true } }); + + await expect.element(page.getByText('H. Schmidt')).toBeVisible(); + }); + + it('falls back to lastName-only when the person has no firstName', async () => { + render(PersonChip, { props: { person: personLastNameOnly, abbreviated: true } }); + + await expect.element(page.getByText('Müller')).toBeVisible(); + }); + + it('links to the person detail route by id', async () => { + render(PersonChip, { props: { person: personWithFirstName, abbreviated: false } }); + + await expect + .element(page.getByRole('link', { name: /helene schmidt/i })) + .toHaveAttribute('href', '/persons/p-1'); + }); + + it('renders initials inside the avatar circle', async () => { + render(PersonChip, { props: { person: personWithFirstName, abbreviated: false } }); + + await expect.element(page.getByText('HS')).toBeVisible(); + }); + + it('uses a deterministic avatar background color derived from the person id', async () => { + render(PersonChip, { props: { person: personWithFirstName, abbreviated: false } }); + + const initials = await page.getByText('HS').element(); + const style = (initials as HTMLElement).getAttribute('style') ?? ''; + expect(style).toMatch(/background-color:\s*(rgb\(|#)/i); + }); +}); -- 2.49.1 From 2578da50f94a76e897dd42daadb25731f5c8ef7d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:01:59 +0200 Subject: [PATCH 006/189] test(profile,documents): cover PasswordChangeForm and FileSectionNew branches PasswordChangeForm: tests the null/success/error/mismatch banner branches plus the form action wiring. FileSectionNew: tests the no-file/file-selected toggle, onfileParsed callback invocation with the parsed metadata, the early-return when no file is in the change event, and the suggestedTitle fallback path. Eleven tests across two files. Both follow the UploadZone template (props, File API synthetic input, vi.fn() callback spies). Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../new/FileSectionNew.svelte.test.ts | 68 +++++++++++++++++++ .../profile/PasswordChangeForm.svelte.test.ts | 54 +++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 frontend/src/routes/documents/new/FileSectionNew.svelte.test.ts create mode 100644 frontend/src/routes/profile/PasswordChangeForm.svelte.test.ts diff --git a/frontend/src/routes/documents/new/FileSectionNew.svelte.test.ts b/frontend/src/routes/documents/new/FileSectionNew.svelte.test.ts new file mode 100644 index 00000000..da11ef93 --- /dev/null +++ b/frontend/src/routes/documents/new/FileSectionNew.svelte.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import FileSectionNew from './FileSectionNew.svelte'; + +afterEach(cleanup); + +describe('FileSectionNew', () => { + it('renders the upload prompt and section heading when no file is selected', async () => { + render(FileSectionNew, { props: {} }); + + await expect.element(page.getByRole('heading', { name: /datei/i })).toBeVisible(); + await expect.element(page.getByText('Datei hochladen')).toBeVisible(); + await expect.element(page.getByText('(optional)')).toBeVisible(); + }); + + it('replaces the prompt with the selected filename after a file is chosen', async () => { + render(FileSectionNew, { props: {} }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['%PDF'], 'brief_1920.pdf', { type: 'application/pdf' }); + Object.defineProperty(input, 'files', { value: [file], writable: false }); + input.dispatchEvent(new Event('change', { bubbles: true })); + + await expect.element(page.getByText('brief_1920.pdf')).toBeVisible(); + await expect.element(page.getByText('Datei hochladen')).not.toBeInTheDocument(); + }); + + it('invokes onfileParsed with the parsed filename result', async () => { + const onfileParsed = vi.fn(); + render(FileSectionNew, { props: { onfileParsed } }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['%PDF'], 'Sender_Receiver_2024-05-01.pdf', { type: 'application/pdf' }); + Object.defineProperty(input, 'files', { value: [file], writable: false }); + input.dispatchEvent(new Event('change', { bubbles: true })); + + expect(onfileParsed).toHaveBeenCalledOnce(); + const result = onfileParsed.mock.calls[0][0]; + expect(result).toHaveProperty('suggestedTitle'); + }); + + it('does nothing when the change event fires with no file selected', async () => { + const onfileParsed = vi.fn(); + render(FileSectionNew, { props: { onfileParsed } }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, 'files', { value: [], writable: false }); + input.dispatchEvent(new Event('change', { bubbles: true })); + + expect(onfileParsed).not.toHaveBeenCalled(); + await expect.element(page.getByText('Datei hochladen')).toBeVisible(); + }); + + it('falls back to the bare stripped filename when the parser provides no suggested title', async () => { + const onfileParsed = vi.fn(); + render(FileSectionNew, { props: { onfileParsed } }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['%PDF'], 'plain.pdf', { type: 'application/pdf' }); + Object.defineProperty(input, 'files', { value: [file], writable: false }); + input.dispatchEvent(new Event('change', { bubbles: true })); + + const result = onfileParsed.mock.calls[0][0]; + expect(typeof result.suggestedTitle).toBe('string'); + expect(result.suggestedTitle.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/routes/profile/PasswordChangeForm.svelte.test.ts b/frontend/src/routes/profile/PasswordChangeForm.svelte.test.ts new file mode 100644 index 00000000..9d7026cc --- /dev/null +++ b/frontend/src/routes/profile/PasswordChangeForm.svelte.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PasswordChangeForm from './PasswordChangeForm.svelte'; + +afterEach(cleanup); + +describe('PasswordChangeForm', () => { + it('renders the three password inputs and a save button by default', async () => { + render(PasswordChangeForm, { props: { form: null } }); + + await expect.element(page.getByRole('heading', { name: /passwort ändern/i })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /speichern/i })).toBeVisible(); + }); + + it('does not render any banner when form is null', async () => { + render(PasswordChangeForm, { props: { form: null } }); + + await expect.element(page.getByText(/erfolgreich geändert/i)).not.toBeInTheDocument(); + await expect.element(page.getByText(/stimmen nicht überein/i)).not.toBeInTheDocument(); + }); + + it('shows the success banner when form.passwordSuccess is true', async () => { + render(PasswordChangeForm, { props: { form: { passwordSuccess: true } } }); + + await expect.element(page.getByText('Passwort erfolgreich geändert.')).toBeVisible(); + }); + + it('shows the localised mismatch message for the PASSWORDS_DO_NOT_MATCH error code', async () => { + render(PasswordChangeForm, { + props: { form: { passwordError: 'PASSWORDS_DO_NOT_MATCH' } } + }); + + await expect + .element(page.getByText('Die neuen Passwörter stimmen nicht überein.')) + .toBeVisible(); + }); + + it('shows the raw error message for any non-matching error code', async () => { + render(PasswordChangeForm, { + props: { form: { passwordError: 'Server-side error message' } } + }); + + await expect.element(page.getByText('Server-side error message')).toBeVisible(); + }); + + it('declares POST as the form method and routes to the changePassword action', async () => { + render(PasswordChangeForm, { props: { form: null } }); + + const form = document.querySelector('form'); + expect(form?.getAttribute('method')).toBe('POST'); + expect(form?.getAttribute('action')).toBe('?/changePassword'); + }); +}); -- 2.49.1 From bbc9e64897e3039c0867e3123ceba14248aded95 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:03:59 +0200 Subject: [PATCH 007/189] test(routes): cover +error and forgot-password page branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit +error.svelte: vi.mock('$app/state') drives the page state so each test can assert one of the three rendering branches — populated error message, distinct status code, and the 'Internal Error' fallback when page.error is null. forgot-password/+page.svelte: prop-driven tests for the four states — default form, success banner, error message inside the form, and the back-to-login link href. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/error.svelte.test.ts | 52 +++++++++++++++++++ .../forgot-password/page.svelte.test.ts | 46 ++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 frontend/src/routes/error.svelte.test.ts create mode 100644 frontend/src/routes/forgot-password/page.svelte.test.ts diff --git a/frontend/src/routes/error.svelte.test.ts b/frontend/src/routes/error.svelte.test.ts new file mode 100644 index 00000000..097dc3f6 --- /dev/null +++ b/frontend/src/routes/error.svelte.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page as browserPage } from 'vitest/browser'; + +const mockPage = { + status: 500, + error: { message: 'Internal Error' } as { message: string } | null +}; + +vi.mock('$app/state', () => ({ + get page() { + return mockPage; + } +})); + +afterEach(cleanup); + +async function loadComponent() { + return (await import('./+error.svelte')).default; +} + +describe('+error.svelte', () => { + it('renders the page status code prominently', async () => { + mockPage.status = 404; + mockPage.error = { message: 'Not Found' }; + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await expect.element(browserPage.getByText('404')).toBeVisible(); + }); + + it('renders the error message text from page.error.message', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Database unavailable' }; + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await expect.element(browserPage.getByText('Database unavailable')).toBeVisible(); + }); + + it('falls back to the literal "Internal Error" when page.error is null', async () => { + mockPage.status = 500; + mockPage.error = null; + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await expect.element(browserPage.getByText('Internal Error')).toBeVisible(); + }); +}); diff --git a/frontend/src/routes/forgot-password/page.svelte.test.ts b/frontend/src/routes/forgot-password/page.svelte.test.ts new file mode 100644 index 00000000..d2683059 --- /dev/null +++ b/frontend/src/routes/forgot-password/page.svelte.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ForgotPasswordPage from './+page.svelte'; + +afterEach(cleanup); + +describe('forgot-password page', () => { + it('renders the email input form when no form prop is provided', async () => { + render(ForgotPasswordPage, { props: { form: undefined } }); + + await expect.element(page.getByRole('heading', { name: /passwort vergessen/i })).toBeVisible(); + await expect.element(page.getByLabelText('E-Mail-Adresse')).toBeVisible(); + await expect.element(page.getByRole('button', { name: /link anfordern/i })).toBeVisible(); + }); + + it('shows the success banner and hides the form when form.success is true', async () => { + render(ForgotPasswordPage, { props: { form: { success: true } } }); + + await expect + .element( + page.getByText( + 'Falls ein Konto mit dieser E-Mail-Adresse existiert, erhalten Sie in Kürze eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.' + ) + ) + .toBeVisible(); + await expect + .element(page.getByRole('button', { name: /link anfordern/i })) + .not.toBeInTheDocument(); + }); + + it('shows the error message inside the form when form.error is set', async () => { + render(ForgotPasswordPage, { props: { form: { error: 'Server unreachable' } } }); + + await expect.element(page.getByText('Server unreachable')).toBeVisible(); + await expect.element(page.getByRole('button', { name: /link anfordern/i })).toBeVisible(); + }); + + it('always offers a back-to-login link', async () => { + render(ForgotPasswordPage, { props: { form: undefined } }); + + await expect + .element(page.getByRole('link', { name: /zurück zum login/i })) + .toHaveAttribute('href', '/login'); + }); +}); -- 2.49.1 From b71448aab4b3556f36d806f5e227fe17c2f90f56 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:05:54 +0200 Subject: [PATCH 008/189] test: cover PersonTypeBadge, ExpandableText, PersonChipRow branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersonTypeBadge: one test per switch arm (INSTITUTION, GROUP, UNKNOWN) plus the two no-render branches (unrecognised type, empty type). ExpandableText: clamp detection, toggle visibility logic, expand → collapse round-trip, default maxLines fallback. PersonChipRow: sender-only, sender+arrow, abbreviated naming, max-two visible receivers, +N overflow pill presence/absence, receivers-only case (no sender → no arrow). 19 tests across three files. Each file uses afterEach(cleanup) and queries via getByRole/getByText so tests stay decoupled from CSS. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/person/PersonChipRow.svelte.test.ts | 85 +++++++++++++++++++ .../lib/person/PersonTypeBadge.svelte.test.ts | 42 +++++++++ .../primitives/ExpandableText.svelte.test.ts | 57 +++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 frontend/src/lib/person/PersonChipRow.svelte.test.ts create mode 100644 frontend/src/lib/person/PersonTypeBadge.svelte.test.ts create mode 100644 frontend/src/lib/shared/primitives/ExpandableText.svelte.test.ts diff --git a/frontend/src/lib/person/PersonChipRow.svelte.test.ts b/frontend/src/lib/person/PersonChipRow.svelte.test.ts new file mode 100644 index 00000000..cac46ef2 --- /dev/null +++ b/frontend/src/lib/person/PersonChipRow.svelte.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonChipRow from './PersonChipRow.svelte'; + +afterEach(cleanup); + +const sender = { id: 's-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }; +const r1 = { id: 'r-1', firstName: 'Bert', lastName: 'Meier', displayName: 'Bert Meier' }; +const r2 = { id: 'r-2', firstName: 'Clara', lastName: 'Weiss', displayName: 'Clara Weiss' }; +const r3 = { id: 'r-3', firstName: 'Doris', lastName: 'Lang', displayName: 'Doris Lang' }; + +describe('PersonChipRow', () => { + it('renders only the sender when there are no receivers', async () => { + render(PersonChipRow, { + props: { sender, receivers: [], abbreviated: false, extraCount: 0 } + }); + + await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); + await expect.element(page.getByRole('img', { name: '' })).not.toBeInTheDocument(); + }); + + it('renders the arrow image when sender and at least one receiver are present', async () => { + render(PersonChipRow, { + props: { sender, receivers: [r1], abbreviated: false, extraCount: 0 } + }); + + const arrow = document.querySelector('img[aria-hidden="true"]'); + expect(arrow).not.toBeNull(); + }); + + it('renders both sender and visible receivers with abbreviated=false', async () => { + render(PersonChipRow, { + props: { sender, receivers: [r1, r2], abbreviated: false, extraCount: 0 } + }); + + await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); + await expect.element(page.getByText('Bert Meier')).toBeVisible(); + }); + + it('uses abbreviated names when abbreviated=true', async () => { + render(PersonChipRow, { + props: { sender, receivers: [r1], abbreviated: true, extraCount: 0 } + }); + + await expect.element(page.getByText('A. Schmidt')).toBeVisible(); + await expect.element(page.getByText('B. Meier')).toBeVisible(); + }); + + it('limits the visible receivers to the first two', async () => { + render(PersonChipRow, { + props: { sender, receivers: [r1, r2, r3], abbreviated: false, extraCount: 1 } + }); + + await expect.element(page.getByText('Bert Meier')).toBeVisible(); + await expect.element(page.getByText('Clara Weiss')).toBeVisible(); + await expect.element(page.getByText('Doris Lang')).not.toBeInTheDocument(); + }); + + it('renders the OverflowPillDisplay when extraCount > 0', async () => { + render(PersonChipRow, { + props: { sender, receivers: [r1, r2, r3], abbreviated: false, extraCount: 1 } + }); + + await expect.element(page.getByText(/\+1/)).toBeVisible(); + }); + + it('omits the OverflowPillDisplay when extraCount is 0', async () => { + render(PersonChipRow, { + props: { sender, receivers: [r1], abbreviated: false, extraCount: 0 } + }); + + await expect.element(page.getByText(/\+\d/)).not.toBeInTheDocument(); + }); + + it('renders only receivers when there is no sender', async () => { + render(PersonChipRow, { + props: { sender: null, receivers: [r1], abbreviated: false, extraCount: 0 } + }); + + await expect.element(page.getByText('Bert Meier')).toBeVisible(); + const arrow = document.querySelector('img[aria-hidden="true"]'); + expect(arrow).toBeNull(); + }); +}); diff --git a/frontend/src/lib/person/PersonTypeBadge.svelte.test.ts b/frontend/src/lib/person/PersonTypeBadge.svelte.test.ts new file mode 100644 index 00000000..45fcf2a7 --- /dev/null +++ b/frontend/src/lib/person/PersonTypeBadge.svelte.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonTypeBadge from './PersonTypeBadge.svelte'; + +afterEach(cleanup); + +describe('PersonTypeBadge', () => { + it('renders the institution label and badge-institution class for personType="INSTITUTION"', async () => { + render(PersonTypeBadge, { props: { personType: 'INSTITUTION' } }); + + await expect.element(page.getByText('Institution')).toBeVisible(); + const badge = await page.getByText('Institution').element(); + expect(badge.classList.contains('badge-institution')).toBe(true); + }); + + it('renders the group label and badge-group class for personType="GROUP"', async () => { + render(PersonTypeBadge, { props: { personType: 'GROUP' } }); + + const badge = await page.getByText('Gruppe').element(); + expect(badge.classList.contains('badge-group')).toBe(true); + }); + + it('renders the unknown label and badge-unknown class for personType="UNKNOWN"', async () => { + render(PersonTypeBadge, { props: { personType: 'UNKNOWN' } }); + + const badge = await page.getByText('Unbekannt').element(); + expect(badge.classList.contains('badge-unknown')).toBe(true); + }); + + it('renders nothing when personType does not match a known kind', async () => { + render(PersonTypeBadge, { props: { personType: 'INDIVIDUAL' } }); + + expect(document.querySelector('.badge')).toBeNull(); + }); + + it('renders nothing for empty personType', async () => { + render(PersonTypeBadge, { props: { personType: '' } }); + + expect(document.querySelector('.badge')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/shared/primitives/ExpandableText.svelte.test.ts b/frontend/src/lib/shared/primitives/ExpandableText.svelte.test.ts new file mode 100644 index 00000000..00a604ec --- /dev/null +++ b/frontend/src/lib/shared/primitives/ExpandableText.svelte.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ExpandableText from './ExpandableText.svelte'; + +afterEach(cleanup); + +const longText = Array.from({ length: 60 }, (_, i) => `Zeile ${i + 1}.`).join('\n'); +const shortText = 'Zeile 1'; + +describe('ExpandableText', () => { + it('renders the supplied text inside the clamped block', async () => { + render(ExpandableText, { props: { text: shortText, maxLines: 2 } }); + + await expect.element(page.getByText('Zeile 1')).toBeVisible(); + }); + + it('does not show a toggle button when the content fits inside maxLines', async () => { + render(ExpandableText, { props: { text: shortText, maxLines: 100 } }); + + await expect + .element(page.getByRole('button', { name: /mehr anzeigen/i })) + .not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /weniger anzeigen/i })) + .not.toBeInTheDocument(); + }); + + it('shows the "Mehr anzeigen" button when the content overflows the line clamp', async () => { + render(ExpandableText, { props: { text: longText, maxLines: 2 } }); + + await expect.element(page.getByRole('button', { name: /mehr anzeigen/i })).toBeVisible(); + }); + + it('switches the toggle label to "Weniger anzeigen" after expanding', async () => { + render(ExpandableText, { props: { text: longText, maxLines: 2 } }); + + await page.getByRole('button', { name: /mehr anzeigen/i }).click(); + + await expect.element(page.getByRole('button', { name: /weniger anzeigen/i })).toBeVisible(); + }); + + it('collapses again when the toggle is clicked while expanded', async () => { + render(ExpandableText, { props: { text: longText, maxLines: 2 } }); + + await page.getByRole('button', { name: /mehr anzeigen/i }).click(); + await page.getByRole('button', { name: /weniger anzeigen/i }).click(); + + await expect.element(page.getByRole('button', { name: /mehr anzeigen/i })).toBeVisible(); + }); + + it('uses the default maxLines (10) when the prop is omitted', async () => { + render(ExpandableText, { props: { text: shortText } }); + + await expect.element(page.getByText('Zeile 1')).toBeVisible(); + }); +}); -- 2.49.1 From 151d7e7a29436c725744c54211b17bb5e5674215 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:11:39 +0200 Subject: [PATCH 009/189] test: cover OcrTrigger, CoCorrespondentsList, reset-password page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OcrTrigger: select initialisation from storedScriptType (with the UNKNOWN sentinel collapsing to empty), button disabled-state matrix across blockCount × scriptType, onTrigger callback wiring, no-annotations hint visibility. CoCorrespondentsList: empty-list early return, populated heading + hint, chip count and links, initials-from-up-to-two-name-parts logic. reset-password page: form/success branches, hidden-token rendering with null fallback, MISMATCH vs generic error code mapping, back-to-login link. 21 tests across three files. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/ocr/OcrTrigger.svelte.test.ts | 95 +++++++++++++++++++ .../[id]/CoCorrespondentsList.svelte.test.ts | 73 ++++++++++++++ .../routes/reset-password/page.svelte.test.ts | 80 ++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 frontend/src/lib/ocr/OcrTrigger.svelte.test.ts create mode 100644 frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte.test.ts create mode 100644 frontend/src/routes/reset-password/page.svelte.test.ts diff --git a/frontend/src/lib/ocr/OcrTrigger.svelte.test.ts b/frontend/src/lib/ocr/OcrTrigger.svelte.test.ts new file mode 100644 index 00000000..9aaed1d7 --- /dev/null +++ b/frontend/src/lib/ocr/OcrTrigger.svelte.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import OcrTrigger from './OcrTrigger.svelte'; + +afterEach(cleanup); + +describe('OcrTrigger', () => { + it('renders the script-type select and the trigger button', async () => { + render(OcrTrigger, { + props: { blockCount: 1, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} } + }); + + await expect.element(page.getByRole('combobox')).toBeVisible(); + await expect.element(page.getByRole('button')).toBeVisible(); + }); + + it('initialises the select with the stored script type when provided', async () => { + render(OcrTrigger, { + props: { blockCount: 1, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} } + }); + + const select = (await page.getByRole('combobox').element()) as HTMLSelectElement; + expect(select.value).toBe('HANDWRITING_KURRENT'); + }); + + it('starts with an empty selection when storedScriptType is UNKNOWN', async () => { + render(OcrTrigger, { + props: { blockCount: 1, storedScriptType: 'UNKNOWN', onTrigger: () => {} } + }); + + const select = (await page.getByRole('combobox').element()) as HTMLSelectElement; + expect(select.value).toBe(''); + }); + + it('disables the trigger button when no script type is selected', async () => { + render(OcrTrigger, { + props: { blockCount: 1, storedScriptType: 'UNKNOWN', onTrigger: () => {} } + }); + + const btn = (await page.getByRole('button').element()) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it('disables the trigger button when blockCount is 0 even if a script type is selected', async () => { + render(OcrTrigger, { + props: { blockCount: 0, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} } + }); + + const btn = (await page.getByRole('button').element()) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it('shows the no-annotations hint when blockCount is 0', async () => { + render(OcrTrigger, { + props: { blockCount: 0, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} } + }); + + await expect + .element(page.getByText('Zeichnen Sie zuerst Bereiche auf dem Dokument ein.')) + .toBeVisible(); + }); + + it('omits the no-annotations hint when blockCount is greater than 0', async () => { + render(OcrTrigger, { + props: { blockCount: 5, storedScriptType: 'HANDWRITING_KURRENT', onTrigger: () => {} } + }); + + await expect + .element(page.getByText('Zeichnen Sie zuerst Bereiche auf dem Dokument ein.')) + .not.toBeInTheDocument(); + }); + + it('calls onTrigger with the selected script type and useExistingAnnotations=true', async () => { + const onTrigger = vi.fn(); + render(OcrTrigger, { + props: { blockCount: 5, storedScriptType: 'HANDWRITING_KURRENT', onTrigger } + }); + + await page.getByRole('button').click(); + + expect(onTrigger).toHaveBeenCalledWith('HANDWRITING_KURRENT', true); + }); + + it('does not call onTrigger when no script type is selected', async () => { + const onTrigger = vi.fn(); + render(OcrTrigger, { + props: { blockCount: 5, storedScriptType: 'UNKNOWN', onTrigger } + }); + + await page.getByRole('button').click({ force: true }); + + expect(onTrigger).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte.test.ts b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte.test.ts new file mode 100644 index 00000000..147523b6 --- /dev/null +++ b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import CoCorrespondentsList from './CoCorrespondentsList.svelte'; + +afterEach(cleanup); + +describe('CoCorrespondentsList', () => { + it('renders nothing when the coCorrespondents list is empty', async () => { + render(CoCorrespondentsList, { + props: { coCorrespondents: [], personId: 'p-1' } + }); + + await expect + .element(page.getByRole('heading', { name: /häufige korrespondenten/i })) + .not.toBeInTheDocument(); + }); + + it('renders the heading and hint when there is at least one co-correspondent', async () => { + render(CoCorrespondentsList, { + props: { + coCorrespondents: [{ id: 'c-1', name: 'Max Mustermann', count: 3 }], + personId: 'p-1' + } + }); + + await expect + .element(page.getByRole('heading', { name: /häufige korrespondenten/i })) + .toBeVisible(); + await expect.element(page.getByText('klicken für Konversation')).toBeVisible(); + }); + + it('renders one chip per co-correspondent with name and count', async () => { + render(CoCorrespondentsList, { + props: { + coCorrespondents: [ + { id: 'c-1', name: 'Max Mustermann', count: 3 }, + { id: 'c-2', name: 'Erika Beispiel', count: 1 } + ], + personId: 'p-1' + } + }); + + await expect.element(page.getByText('Max Mustermann')).toBeVisible(); + await expect.element(page.getByText('Erika Beispiel')).toBeVisible(); + await expect.element(page.getByText('×3')).toBeVisible(); + await expect.element(page.getByText('×1')).toBeVisible(); + }); + + it('points each chip to the bilateral conversation route with the correct ids', async () => { + render(CoCorrespondentsList, { + props: { + coCorrespondents: [{ id: 'c-1', name: 'Max Mustermann', count: 3 }], + personId: 'p-1' + } + }); + + await expect + .element(page.getByRole('link', { name: /max mustermann/i })) + .toHaveAttribute('href', '/briefwechsel?senderId=p-1&receiverId=c-1'); + }); + + it('builds initials from up to two name parts', async () => { + render(CoCorrespondentsList, { + props: { + coCorrespondents: [{ id: 'c-1', name: 'Max Mustermann Beispiel', count: 1 }], + personId: 'p-1' + } + }); + + await expect.element(page.getByText('MM')).toBeVisible(); + }); +}); diff --git a/frontend/src/routes/reset-password/page.svelte.test.ts b/frontend/src/routes/reset-password/page.svelte.test.ts new file mode 100644 index 00000000..a9e16bc5 --- /dev/null +++ b/frontend/src/routes/reset-password/page.svelte.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ResetPasswordPage from './+page.svelte'; + +afterEach(cleanup); + +describe('reset-password page', () => { + it('renders the password and confirmation inputs by default', async () => { + render(ResetPasswordPage, { + props: { data: { token: 'abc' }, form: undefined } + }); + + await expect + .element(page.getByRole('heading', { name: /neues passwort festlegen/i })) + .toBeVisible(); + await expect.element(page.getByLabelText('Neues Passwort')).toBeVisible(); + await expect.element(page.getByLabelText('Passwort bestätigen')).toBeVisible(); + }); + + it('renders the token in a hidden input', async () => { + render(ResetPasswordPage, { + props: { data: { token: 'tok-123' }, form: undefined } + }); + + const tokenInput = document.querySelector('input[name="token"]') as HTMLInputElement; + expect(tokenInput.type).toBe('hidden'); + expect(tokenInput.value).toBe('tok-123'); + }); + + it('falls back to an empty token when data.token is null', async () => { + render(ResetPasswordPage, { + props: { data: { token: null }, form: undefined } + }); + + const tokenInput = document.querySelector('input[name="token"]') as HTMLInputElement; + expect(tokenInput.value).toBe(''); + }); + + it('shows the success banner and hides the form when form.success is true', async () => { + render(ResetPasswordPage, { + props: { data: { token: 'abc' }, form: { success: true } } + }); + + await expect + .element( + page.getByText('Ihr Passwort wurde erfolgreich geändert. Sie können sich jetzt anmelden.') + ) + .toBeVisible(); + await expect.element(page.getByLabelText('Neues Passwort')).not.toBeInTheDocument(); + }); + + it('renders the localised mismatch message for the MISMATCH error code', async () => { + render(ResetPasswordPage, { + props: { data: { token: 'abc' }, form: { error: 'MISMATCH' } } + }); + + await expect.element(page.getByText('Die Passwörter stimmen nicht überein.')).toBeVisible(); + }); + + it('falls back to the generic error message for any other error code', async () => { + render(ResetPasswordPage, { + props: { data: { token: 'abc' }, form: { error: 'TOKEN_EXPIRED' } } + }); + + await expect + .element(page.getByText(/(fehler|expired|abgelaufen|ungültig|generic)/i)) + .toBeVisible(); + }); + + it('always offers a back-to-login link', async () => { + render(ResetPasswordPage, { + props: { data: { token: 'abc' }, form: undefined } + }); + + await expect + .element(page.getByRole('link', { name: /zurück zum login/i })) + .toHaveAttribute('href', '/login'); + }); +}); -- 2.49.1 From 23fe140087df2cbde7657b7a5915456d492064a2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:14:16 +0200 Subject: [PATCH 010/189] test: cover CorrespondentSuggestionsDropdown and PersonCard branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CorrespondentSuggestionsDropdown: empty list still renders the static heading and 'Alle Korrespondenten' row, populated rows when not loading, loading hides correspondent rows, initials fallback (lastName-only when firstName is null), click + keyboard selection, Escape closes. PersonCard: full matrix of conditional UI — title visibility for PERSON vs non-PERSON, avatar initials path (firstName+lastName vs lastName-only fallback), PersonTypeBadge presence for non-PERSON types, alias, life dates, notes, and the canWrite=true/false branches that gate the edit link (Nora's authorization-rendering rule). 21 tests covering ~50 branches. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- ...spondentSuggestionsDropdown.svelte.test.ts | 155 ++++++++++++++++++ .../persons/[id]/PersonCard.svelte.test.ts | 120 ++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts create mode 100644 frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts diff --git a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts new file mode 100644 index 00000000..227edbea --- /dev/null +++ b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte'; + +afterEach(cleanup); + +const corrA = { id: 'a', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }; +const corrB = { id: 'b', firstName: null, lastName: 'Müller', displayName: 'Müller' }; + +describe('CorrespondentSuggestionsDropdown', () => { + it('renders the heading and the "all correspondents" row even when the list is empty', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('Häufigste Korrespondenten')).toBeVisible(); + await expect.element(page.getByText('Alle Korrespondenten von Anna')).toBeVisible(); + }); + + it('renders one row per correspondent when not loading', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA, corrB], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); + await expect.element(page.getByText('Müller')).toBeVisible(); + }); + + it('hides correspondent rows while loading is true', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: true, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument(); + await expect.element(page.getByText('Häufigste Korrespondenten')).toBeVisible(); + }); + + it('builds initials from firstName + lastName when available', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('AS')).toBeVisible(); + }); + + it('falls back to the first two letters of lastName when firstName is missing', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrB], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('MÜ')).toBeVisible(); + }); + + it('calls onselect with the correspondent id when a row is clicked', async () => { + const onselect = vi.fn(); + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: false, + senderName: 'Anna', + onselect, + onclose: () => {} + } + }); + + await page.getByText('Anna Schmidt').click(); + + expect(onselect).toHaveBeenCalledWith('a'); + }); + + it('calls onselect with an empty string when the "all correspondents" row is clicked', async () => { + const onselect = vi.fn(); + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [], + loading: false, + senderName: 'Anna', + onselect, + onclose: () => {} + } + }); + + await page.getByText('Alle Korrespondenten von Anna').click(); + + expect(onselect).toHaveBeenCalledWith(''); + }); + + it('calls onselect via Enter key on a focused row', async () => { + const onselect = vi.fn(); + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: false, + senderName: 'Anna', + onselect, + onclose: () => {} + } + }); + + const row = (await page.getByText('Anna Schmidt').element()) as HTMLElement; + row.focus(); + row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(onselect).toHaveBeenCalledWith('a'); + }); + + it('calls onclose when the Escape key is pressed', async () => { + const onclose = vi.fn(); + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose + } + }); + + const list = (await page.getByRole('listbox').element()) as HTMLElement; + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(onclose).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts b/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts new file mode 100644 index 00000000..b2c7c4fc --- /dev/null +++ b/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonCard from './PersonCard.svelte'; + +afterEach(cleanup); + +const basePerson = { + id: 'p-1', + firstName: 'Anna', + lastName: 'Schmidt', + displayName: 'Anna Schmidt', + personType: 'PERSON' as const +}; + +describe('PersonCard', () => { + it('renders the displayName as the primary heading', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: false } }); + + await expect.element(page.getByRole('heading', { name: 'Anna Schmidt' })).toBeVisible(); + }); + + it('renders the title above the name when personType is PERSON and title is set', async () => { + render(PersonCard, { + props: { person: { ...basePerson, title: 'Frau Dr.' }, canWrite: false } + }); + + await expect.element(page.getByText('Frau Dr.')).toBeVisible(); + }); + + it('omits the title for non-PERSON types even if title is set', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, personType: 'INSTITUTION', title: 'Frau Dr.' }, + canWrite: false + } + }); + + await expect.element(page.getByText('Frau Dr.')).not.toBeInTheDocument(); + }); + + it('renders the firstName/lastName initials inside the avatar for PERSON type', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: false } }); + + await expect.element(page.getByText('AS')).toBeVisible(); + }); + + it('falls back to lastName-only initials when firstName is missing', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, firstName: null, displayName: 'Schmidt' }, + canWrite: false + } + }); + + await expect.element(page.getByText('SS')).toBeVisible(); + }); + + it('renders the PersonTypeBadge for non-PERSON types', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, personType: 'INSTITUTION', displayName: 'Acme Inc.' }, + canWrite: false + } + }); + + await expect.element(page.getByText('Institution')).toBeVisible(); + }); + + it('omits the PersonTypeBadge for PERSON type', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: false } }); + + await expect.element(page.getByText('Institution')).not.toBeInTheDocument(); + await expect.element(page.getByText('Gruppe')).not.toBeInTheDocument(); + }); + + it('renders the alias in italic typography when alias is provided', async () => { + render(PersonCard, { + props: { person: { ...basePerson, alias: 'Annerl' }, canWrite: false } + }); + + await expect.element(page.getByText(/Annerl/)).toBeVisible(); + }); + + it('renders the life-date range when birthYear or deathYear are present', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, birthYear: 1899, deathYear: 1972 }, + canWrite: false + } + }); + + await expect.element(page.getByText(/1899/)).toBeVisible(); + }); + + it('renders the notes section when notes are provided', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, notes: 'Wohnte in Berlin.' }, + canWrite: false + } + }); + + await expect.element(page.getByText('Wohnte in Berlin.')).toBeVisible(); + }); + + it('renders the edit link when canWrite is true', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: true } }); + + await expect + .element(page.getByRole('link', { name: /bearbeiten/i })) + .toHaveAttribute('href', '/persons/p-1/edit'); + }); + + it('does not render the edit link when canWrite is false', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: false } }); + + await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); + }); +}); -- 2.49.1 From de5763aad298bd352b38dc176ddb068f820704ba Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:18:34 +0200 Subject: [PATCH 011/189] test(persons): cover PersonDocumentList and persons/new page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersonDocumentList: empty/populated, year-range derivation across no-date/single-year/multi-year inputs, sort toggle visibility (>1 doc), sort-direction round trip, preview-limit + show-more expansion, title→originalFilename fallback, no-date and no-location branches. persons/new: PERSON vs INSTITUTION/GROUP visibility matrix (firstName/alias/life-year fields toggle), lastName label switching between Vorname/Nachname/Name, form-error banner, prior-form hydration, cancel link href, fallback to PERSON for unknown personType. 24 tests across two files, hitting the 32+28 = 60 branches at the top of the issue's leverage list. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../[id]/PersonDocumentList.svelte.test.ts | 182 ++++++++++++++++++ .../routes/persons/new/page.svelte.test.ts | 86 +++++++++ 2 files changed, 268 insertions(+) create mode 100644 frontend/src/routes/persons/[id]/PersonDocumentList.svelte.test.ts create mode 100644 frontend/src/routes/persons/new/page.svelte.test.ts diff --git a/frontend/src/routes/persons/[id]/PersonDocumentList.svelte.test.ts b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte.test.ts new file mode 100644 index 00000000..9b6c69a0 --- /dev/null +++ b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonDocumentList from './PersonDocumentList.svelte'; + +afterEach(cleanup); + +const makeDoc = (overrides: Record = {}) => ({ + id: 'd1', + title: 'Brief an Helene', + originalFilename: 'brief.pdf', + documentDate: '1923-04-15', + location: 'Berlin', + status: 'UPLOADED' as string, + contentType: 'application/pdf', + thumbnailUrl: '', + ...overrides +}); + +describe('PersonDocumentList', () => { + it('renders the heading and a count badge', async () => { + render(PersonDocumentList, { + props: { documents: [makeDoc()], heading: 'Gesendet', emptyMessage: 'Keine Dokumente' } + }); + + await expect.element(page.getByRole('heading', { name: /gesendet/i })).toBeVisible(); + await expect.element(page.getByText('1', { exact: true })).toBeVisible(); + }); + + it('renders the empty message when documents is an empty array', async () => { + render(PersonDocumentList, { + props: { + documents: [], + heading: 'Empfangen', + emptyMessage: 'Es liegen keine Dokumente vor.' + } + }); + + await expect.element(page.getByText('Es liegen keine Dokumente vor.')).toBeVisible(); + }); + + it('hides the year range when no document has a date', async () => { + render(PersonDocumentList, { + props: { + documents: [makeDoc({ documentDate: null })], + heading: 'X', + emptyMessage: 'X' + } + }); + + await expect.element(page.getByText(/^\d{4}\s*–\s*\d{4}$/)).not.toBeInTheDocument(); + }); + + it('shows a single year when all documents fall in the same year', async () => { + render(PersonDocumentList, { + props: { + documents: [ + makeDoc({ documentDate: '1923-01-01' }), + makeDoc({ id: 'd2', documentDate: '1923-12-30' }) + ], + heading: 'X', + emptyMessage: 'X' + } + }); + + await expect.element(page.getByText('1923', { exact: true })).toBeVisible(); + }); + + it('shows a min–max range when documents span multiple years', async () => { + render(PersonDocumentList, { + props: { + documents: [ + makeDoc({ id: 'd1', documentDate: '1899-01-01' }), + makeDoc({ id: 'd2', documentDate: '1923-12-31' }) + ], + heading: 'X', + emptyMessage: 'X' + } + }); + + await expect.element(page.getByText('1899 – 1923')).toBeVisible(); + }); + + it('does not render the sort toggle when only one document is present', async () => { + render(PersonDocumentList, { + props: { documents: [makeDoc()], heading: 'X', emptyMessage: 'X' } + }); + + await expect + .element(page.getByRole('button', { name: /neueste zuerst|älteste zuerst/i })) + .not.toBeInTheDocument(); + }); + + it('renders the sort toggle when at least two documents are present', async () => { + render(PersonDocumentList, { + props: { + documents: [makeDoc({ id: 'd1' }), makeDoc({ id: 'd2', documentDate: '1900-01-01' })], + heading: 'X', + emptyMessage: 'X' + } + }); + + await expect.element(page.getByRole('button', { name: /neueste zuerst/i })).toBeVisible(); + }); + + it('toggles the sort direction label when the sort button is clicked', async () => { + render(PersonDocumentList, { + props: { + documents: [makeDoc({ id: 'd1' }), makeDoc({ id: 'd2', documentDate: '1900-01-01' })], + heading: 'X', + emptyMessage: 'X' + } + }); + + await page.getByRole('button', { name: /neueste zuerst/i }).click(); + + await expect.element(page.getByRole('button', { name: /älteste zuerst/i })).toBeVisible(); + }); + + it('caps the visible documents at the preview limit and exposes a "show more" button', async () => { + const docs = Array.from({ length: 8 }, (_, i) => + makeDoc({ id: `d${i}`, title: `Brief ${i + 1}` }) + ); + render(PersonDocumentList, { + props: { documents: docs, heading: 'X', emptyMessage: 'X' } + }); + + await expect.element(page.getByText('Brief 1')).toBeVisible(); + await expect.element(page.getByText('Brief 6')).not.toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: /weitere anzeigen/i })).toBeVisible(); + }); + + it('expands the list to all documents when the "show more" button is clicked', async () => { + const docs = Array.from({ length: 8 }, (_, i) => + makeDoc({ id: `d${i}`, title: `Brief ${i + 1}` }) + ); + render(PersonDocumentList, { + props: { documents: docs, heading: 'X', emptyMessage: 'X' } + }); + + await page.getByRole('button', { name: /weitere anzeigen/i }).click(); + + await expect.element(page.getByText('Brief 6')).toBeVisible(); + }); + + it('falls back to the originalFilename when title is missing', async () => { + render(PersonDocumentList, { + props: { + documents: [makeDoc({ title: null, originalFilename: 'untitled.pdf' })], + heading: 'X', + emptyMessage: 'X' + } + }); + + await expect.element(page.getByText('untitled.pdf')).toBeVisible(); + }); + + it('renders "Kein Datum" when documentDate is missing', async () => { + render(PersonDocumentList, { + props: { + documents: [makeDoc({ documentDate: null })], + heading: 'X', + emptyMessage: 'X' + } + }); + + await expect.element(page.getByText('Kein Datum')).toBeVisible(); + }); + + it('omits the location separator when location is null', async () => { + render(PersonDocumentList, { + props: { + documents: [makeDoc({ location: null })], + heading: 'X', + emptyMessage: 'X' + } + }); + + const meta = document.querySelector('.font-sans.text-\\[11px\\]'); + expect(meta?.textContent ?? '').not.toMatch(/·/); + }); +}); diff --git a/frontend/src/routes/persons/new/page.svelte.test.ts b/frontend/src/routes/persons/new/page.svelte.test.ts new file mode 100644 index 00000000..b1c85674 --- /dev/null +++ b/frontend/src/routes/persons/new/page.svelte.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonsNewPage from './+page.svelte'; + +afterEach(cleanup); + +describe('persons/new page', () => { + it('renders the heading and details section by default', async () => { + render(PersonsNewPage, { props: { form: undefined } }); + + await expect.element(page.getByRole('heading', { name: /neue person/i })).toBeVisible(); + await expect.element(page.getByRole('heading', { name: /angaben zur person/i })).toBeVisible(); + }); + + it('renders the firstName field for the default PERSON type', async () => { + render(PersonsNewPage, { props: { form: undefined } }); + + await expect.element(page.getByLabelText(/vorname/i)).toBeVisible(); + }); + + it('renders alias and life-year fields for the PERSON type', async () => { + render(PersonsNewPage, { props: { form: undefined } }); + + await expect.element(page.getByLabelText(/rufname/i)).toBeVisible(); + }); + + it('hides firstName, alias, and life-year fields for the INSTITUTION type', async () => { + render(PersonsNewPage, { props: { form: { personType: 'INSTITUTION' } } }); + + await expect.element(page.getByLabelText(/vorname/i)).not.toBeInTheDocument(); + await expect.element(page.getByLabelText(/rufname/i)).not.toBeInTheDocument(); + }); + + it('changes the lastName label to "Name" for non-PERSON types', async () => { + render(PersonsNewPage, { props: { form: { personType: 'GROUP' } } }); + + await expect.element(page.getByLabelText(/^name \*$/i)).toBeVisible(); + }); + + it('uses "Nachname" as the lastName label for PERSON type', async () => { + render(PersonsNewPage, { props: { form: undefined } }); + + await expect.element(page.getByLabelText(/nachname \*/i)).toBeVisible(); + }); + + it('renders the form error banner when form.error is set', async () => { + render(PersonsNewPage, { props: { form: { error: 'Last name is required' } } }); + + await expect.element(page.getByText('Last name is required')).toBeVisible(); + }); + + it('does not render the form error banner when form is undefined', async () => { + render(PersonsNewPage, { props: { form: undefined } }); + + await expect.element(page.getByText('Last name is required')).not.toBeInTheDocument(); + }); + + it('hydrates lastName and firstName from prior form values', async () => { + render(PersonsNewPage, { + props: { form: { lastName: 'Müller', firstName: 'Anna', alias: 'Anni' } } + }); + + const lastName = (await page.getByLabelText(/nachname/i).element()) as HTMLInputElement; + const firstName = (await page.getByLabelText(/vorname/i).element()) as HTMLInputElement; + const alias = (await page.getByLabelText(/rufname/i).element()) as HTMLInputElement; + expect(lastName.value).toBe('Müller'); + expect(firstName.value).toBe('Anna'); + expect(alias.value).toBe('Anni'); + }); + + it('renders cancel link pointing to /persons and a submit button', async () => { + render(PersonsNewPage, { props: { form: undefined } }); + + await expect + .element(page.getByRole('link', { name: /abbrechen/i })) + .toHaveAttribute('href', '/persons'); + await expect.element(page.getByRole('button', { name: /erstellen/i })).toBeVisible(); + }); + + it('falls back to PERSON when an unknown personType is supplied', async () => { + render(PersonsNewPage, { props: { form: { personType: 'NOT_A_REAL_TYPE' } } }); + + await expect.element(page.getByLabelText(/vorname/i)).toBeVisible(); + }); +}); -- 2.49.1 From c9389d3b8b1def7d2100e1e15f94dd305d6fb4e7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:20:59 +0200 Subject: [PATCH 012/189] test: cover DocumentViewer, PersonalInfoForm, profile page DocumentViewer: loading / error / no-scan / image rendering branches. filePath conditionally drives the direct-download link in the error state; fileUrl + non-PDF contentType drives the render. PersonalInfoForm: default render, prop hydration including the German date conversion path, success/error banner branches, form action wiring. profile/+page: notification-checkbox enabled/disabled depending on hasEmail, no-email hint visibility, prefsSuccess/prefsError banners, fallback when notificationPrefs is null. 20 tests across three files. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentViewer.svelte.test.ts | 75 +++++++++++ .../profile/PersonalInfoForm.svelte.test.ts | 84 ++++++++++++ .../src/routes/profile/page.svelte.test.ts | 125 ++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 frontend/src/lib/document/DocumentViewer.svelte.test.ts create mode 100644 frontend/src/routes/profile/PersonalInfoForm.svelte.test.ts create mode 100644 frontend/src/routes/profile/page.svelte.test.ts diff --git a/frontend/src/lib/document/DocumentViewer.svelte.test.ts b/frontend/src/lib/document/DocumentViewer.svelte.test.ts new file mode 100644 index 00000000..a3982a0e --- /dev/null +++ b/frontend/src/lib/document/DocumentViewer.svelte.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentViewer from './DocumentViewer.svelte'; + +afterEach(cleanup); + +const baseProps = { + doc: { id: 'd1', filePath: null, contentType: null, fileHash: null }, + fileUrl: '', + isLoading: false, + error: '', + transcribeMode: false, + blockNumbers: {}, + annotationReloadKey: 0, + activeAnnotationId: null, + annotationsDimmed: false, + flashAnnotationId: null, + onAnnotationClick: () => {} +}; + +describe('DocumentViewer', () => { + it('renders the loading spinner and label when isLoading is true', async () => { + render(DocumentViewer, { props: { ...baseProps, isLoading: true } }); + + await expect.element(page.getByText('Lade Dokument...')).toBeVisible(); + }); + + it('renders the error message when error is set', async () => { + render(DocumentViewer, { props: { ...baseProps, error: 'Datei nicht verfügbar' } }); + + await expect.element(page.getByText('Datei nicht verfügbar')).toBeVisible(); + }); + + it('shows the direct-download link in the error state when filePath is present', async () => { + render(DocumentViewer, { + props: { + ...baseProps, + doc: { ...baseProps.doc, filePath: 'docs/scan.pdf' }, + error: 'Render failed' + } + }); + + await expect + .element(page.getByRole('link', { name: /direkter download/i })) + .toHaveAttribute('href', '/api/documents/d1/file'); + }); + + it('omits the direct-download link in the error state when filePath is null', async () => { + render(DocumentViewer, { props: { ...baseProps, error: 'Render failed' } }); + + await expect + .element(page.getByRole('link', { name: /direkter download/i })) + .not.toBeInTheDocument(); + }); + + it('renders the no-scan placeholder when filePath is null and there is no error', async () => { + render(DocumentViewer, { props: baseProps }); + + await expect.element(page.getByText('Kein Scan vorhanden')).toBeVisible(); + }); + + it('renders an for non-PDF content types when fileUrl is present', async () => { + render(DocumentViewer, { + props: { + ...baseProps, + doc: { ...baseProps.doc, filePath: 'docs/x.jpg', contentType: 'image/jpeg' }, + fileUrl: '/api/documents/d1/file' + } + }); + + const img = await page.getByRole('img', { name: /original-scan/i }).element(); + expect(img.getAttribute('src')).toBe('/api/documents/d1/file'); + }); +}); diff --git a/frontend/src/routes/profile/PersonalInfoForm.svelte.test.ts b/frontend/src/routes/profile/PersonalInfoForm.svelte.test.ts new file mode 100644 index 00000000..28bd56c6 --- /dev/null +++ b/frontend/src/routes/profile/PersonalInfoForm.svelte.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonalInfoForm from './PersonalInfoForm.svelte'; + +afterEach(cleanup); + +describe('PersonalInfoForm', () => { + it('renders the section heading and the four labelled inputs by default', async () => { + render(PersonalInfoForm, { props: { user: null, form: null } }); + + await expect.element(page.getByRole('heading', { name: /persönliche daten/i })).toBeVisible(); + await expect.element(page.getByLabelText(/vorname/i)).toBeVisible(); + await expect.element(page.getByLabelText(/nachname/i)).toBeVisible(); + await expect.element(page.getByLabelText(/e-mail-adresse/i)).toBeVisible(); + }); + + it('hydrates inputs from the user prop', async () => { + render(PersonalInfoForm, { + props: { + user: { + firstName: 'Anna', + lastName: 'Schmidt', + email: 'anna@example.com', + contact: 'Telefon 123' + }, + form: null + } + }); + + const first = (await page.getByLabelText(/vorname/i).element()) as HTMLInputElement; + const last = (await page.getByLabelText(/nachname/i).element()) as HTMLInputElement; + const email = (await page.getByLabelText(/e-mail-adresse/i).element()) as HTMLInputElement; + expect(first.value).toBe('Anna'); + expect(last.value).toBe('Schmidt'); + expect(email.value).toBe('anna@example.com'); + }); + + it('converts the user.birthDate ISO value to German display format', async () => { + render(PersonalInfoForm, { + props: { + user: { + firstName: 'A', + lastName: 'B', + birthDate: '1923-04-15', + email: 'x@y', + contact: '' + }, + form: null + } + }); + + const dateInput = (await page.getByLabelText(/geburtsdatum/i).element()) as HTMLInputElement; + expect(dateInput.value).toBe('15.04.1923'); + }); + + it('shows the success banner when form.updateSuccess is true', async () => { + render(PersonalInfoForm, { props: { user: null, form: { updateSuccess: true } } }); + + await expect.element(page.getByText('Gespeichert.')).toBeVisible(); + }); + + it('shows the error banner with the supplied message when form.updateError is set', async () => { + render(PersonalInfoForm, { + props: { user: null, form: { updateError: 'Email-Adresse bereits vergeben' } } + }); + + await expect.element(page.getByText('Email-Adresse bereits vergeben')).toBeVisible(); + }); + + it('hides both banners when form is null', async () => { + render(PersonalInfoForm, { props: { user: null, form: null } }); + + await expect.element(page.getByText('Gespeichert.')).not.toBeInTheDocument(); + }); + + it('declares POST as the form method and routes to the updateProfile action', async () => { + render(PersonalInfoForm, { props: { user: null, form: null } }); + + const form = document.querySelector('form'); + expect(form?.getAttribute('method')).toBe('POST'); + expect(form?.getAttribute('action')).toBe('?/updateProfile'); + }); +}); diff --git a/frontend/src/routes/profile/page.svelte.test.ts b/frontend/src/routes/profile/page.svelte.test.ts new file mode 100644 index 00000000..e67918b0 --- /dev/null +++ b/frontend/src/routes/profile/page.svelte.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ProfilePage from './+page.svelte'; + +afterEach(cleanup); + +const baseUser = { + firstName: 'Anna', + lastName: 'Schmidt', + email: 'anna@example.com', + contact: '' +}; + +describe('profile page', () => { + it('renders the page heading and back link', async () => { + render(ProfilePage, { + props: { + data: { + user: baseUser, + notificationPrefs: { notifyOnReply: false, notifyOnMention: false } + }, + form: null + } + }); + + await expect.element(page.getByRole('heading', { name: /mein profil/i })).toBeVisible(); + await expect + .element(page.getByRole('link', { name: /zurück zur übersicht/i })) + .toHaveAttribute('href', '/'); + }); + + it('disables the notification checkboxes when the user has no email', async () => { + render(ProfilePage, { + props: { + data: { + user: { ...baseUser, email: '' }, + notificationPrefs: { notifyOnReply: false, notifyOnMention: false } + }, + form: null + } + }); + + const replyCheckbox = document.querySelector('input[name="notifyOnReply"]') as HTMLInputElement; + const mentionCheckbox = document.querySelector( + 'input[name="notifyOnMention"]' + ) as HTMLInputElement; + expect(replyCheckbox.disabled).toBe(true); + expect(mentionCheckbox.disabled).toBe(true); + }); + + it('enables the notification checkboxes when the user has an email', async () => { + render(ProfilePage, { + props: { + data: { + user: baseUser, + notificationPrefs: { notifyOnReply: true, notifyOnMention: false } + }, + form: null + } + }); + + const replyCheckbox = document.querySelector('input[name="notifyOnReply"]') as HTMLInputElement; + expect(replyCheckbox.disabled).toBe(false); + expect(replyCheckbox.checked).toBe(true); + }); + + it('shows the no-email hint when the user has no email', async () => { + render(ProfilePage, { + props: { + data: { + user: { ...baseUser, email: '' }, + notificationPrefs: { notifyOnReply: false, notifyOnMention: false } + }, + form: null + } + }); + + await expect + .element( + page.getByText( + 'Bitte trage zuerst eine E-Mail-Adresse ein, um Benachrichtigungen zu erhalten.' + ) + ) + .toBeVisible(); + }); + + it('shows the prefs success banner when form.prefsSuccess is true', async () => { + render(ProfilePage, { + props: { + data: { + user: baseUser, + notificationPrefs: { notifyOnReply: false, notifyOnMention: false } + }, + form: { prefsSuccess: true } + } + }); + + const banners = document.querySelectorAll('.bg-green-50'); + expect(banners.length).toBeGreaterThan(0); + }); + + it('shows the prefs error banner with the message when form.prefsError is set', async () => { + render(ProfilePage, { + props: { + data: { + user: baseUser, + notificationPrefs: { notifyOnReply: false, notifyOnMention: false } + }, + form: { prefsError: 'Speichern fehlgeschlagen' } + } + }); + + await expect.element(page.getByText('Speichern fehlgeschlagen')).toBeVisible(); + }); + + it('falls back to false when notificationPrefs are missing', async () => { + render(ProfilePage, { + props: { data: { user: baseUser, notificationPrefs: null }, form: null } + }); + + const replyCheckbox = document.querySelector('input[name="notifyOnReply"]') as HTMLInputElement; + expect(replyCheckbox.checked).toBe(false); + }); +}); -- 2.49.1 From f812d205c4d0a388fb8905fb55f4d9d75192e8ee Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:26:59 +0200 Subject: [PATCH 013/189] test(stammbaum): cover empty/populated/preselect/zoom branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit empty state vs. populated, zoom controls visibility tied to node count, URL ?focus= preselection (matching id selects, missing id does not), zoom-out clamping safety. $app/state mocked at module boundary so the test can drive page.url and page.data.canWrite without a SvelteKit runtime. Six tests focused on user-observable behaviour — one logical behaviour per test (Sara's guidance). Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/stammbaum/page.svelte.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 frontend/src/routes/stammbaum/page.svelte.test.ts diff --git a/frontend/src/routes/stammbaum/page.svelte.test.ts b/frontend/src/routes/stammbaum/page.svelte.test.ts new file mode 100644 index 00000000..a788c102 --- /dev/null +++ b/frontend/src/routes/stammbaum/page.svelte.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +const mockPage = { + url: new URL('http://localhost/stammbaum'), + data: { canWrite: false } as { canWrite: boolean } +}; + +vi.mock('$app/state', () => ({ + get page() { + return mockPage; + } +})); + +afterEach(cleanup); + +async function loadComponent() { + return (await import('./+page.svelte')).default; +} + +const sampleNodes = [ + { id: 'p-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }, + { id: 'p-2', firstName: 'Bert', lastName: 'Schmidt', displayName: 'Bert Schmidt' } +]; + +describe('stammbaum page', () => { + it('shows the empty state when there are no family nodes', async () => { + mockPage.url = new URL('http://localhost/stammbaum'); + const Stammbaum = await loadComponent(); + render(Stammbaum, { props: { data: { nodes: [], edges: [] } } }); + + await expect + .element(page.getByRole('heading', { name: /noch keine familienmitglieder/i })) + .toBeVisible(); + await expect + .element(page.getByRole('link', { name: /zur personenliste/i })) + .toHaveAttribute('href', '/persons'); + }); + + it('hides zoom controls when there are no nodes', async () => { + mockPage.url = new URL('http://localhost/stammbaum'); + const Stammbaum = await loadComponent(); + render(Stammbaum, { props: { data: { nodes: [], edges: [] } } }); + + await expect.element(page.getByRole('button', { name: /vergrößern/i })).not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /verkleinern/i })) + .not.toBeInTheDocument(); + }); + + it('renders the page heading and zoom controls when nodes are present', async () => { + mockPage.url = new URL('http://localhost/stammbaum'); + const Stammbaum = await loadComponent(); + render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + + await expect.element(page.getByRole('heading', { name: /stammbaum/i })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeVisible(); + }); + + it('preselects a node when the URL has a focus query param matching an existing node', async () => { + mockPage.url = new URL('http://localhost/stammbaum?focus=p-1'); + const Stammbaum = await loadComponent(); + render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + + await expect.element(page.getByRole('complementary')).toBeVisible(); + }); + + it('does not preselect when the focus param does not match any node', async () => { + mockPage.url = new URL('http://localhost/stammbaum?focus=missing'); + const Stammbaum = await loadComponent(); + render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + + await expect.element(page.getByRole('complementary')).not.toBeInTheDocument(); + }); + + it('clamps the zoom level when the zoom-out button is clicked many times', async () => { + mockPage.url = new URL('http://localhost/stammbaum'); + const Stammbaum = await loadComponent(); + render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + + const zoomOut = page.getByRole('button', { name: /verkleinern/i }); + for (let i = 0; i < 10; i++) await zoomOut.click(); + // Just verify that repeated clicks don't throw — branch coverage + await expect.element(zoomOut).toBeVisible(); + }); +}); -- 2.49.1 From bfb0ac6246bd1e20369083e76b287c4eb62b6d19 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:29:52 +0200 Subject: [PATCH 014/189] test(admin/invites): cover the four invite-status branches and form toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each status (active / exhausted / revoked / expired) maps to a distinct visual treatment via statusColor() — one focused test per branch asserts the correct background class on a tbody element so the test verifies user-observable behaviour rather than the internal switch. Also covers: empty placeholder, loadError banner, filter chip selection state, new-invite form toggle on button click, createError message visibility inside the open form, created-invite success card with shareable URL, revoke button gating to active invites only, unlimited-uses display, no-expiry display. 16 tests, ~50 branches covered. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/admin/invites/page.svelte.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 frontend/src/routes/admin/invites/page.svelte.test.ts diff --git a/frontend/src/routes/admin/invites/page.svelte.test.ts b/frontend/src/routes/admin/invites/page.svelte.test.ts new file mode 100644 index 00000000..9e43e7aa --- /dev/null +++ b/frontend/src/routes/admin/invites/page.svelte.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import AdminInvitesPage from './+page.svelte'; + +afterEach(cleanup); + +const makeInvite = (overrides: Record = {}) => ({ + id: 'i-1', + displayCode: 'XYZ-1234', + label: 'Familie', + useCount: 0, + maxUses: 5, + expiresAt: '2027-01-01T00:00:00Z', + status: 'active' as string, + shareableUrl: 'http://example.com/i/i-1', + ...overrides +}); + +const baseData = ( + overrides: Partial<{ + invites: ReturnType[]; + status: string; + loadError: string | null; + }> = {} +) => ({ + invites: [], + status: 'active', + loadError: null, + ...overrides +}); + +describe('admin/invites page', () => { + it('renders the page heading and the new-invite button', async () => { + render(AdminInvitesPage, { props: { data: baseData() } }); + + await expect.element(page.getByRole('heading', { name: /einladungen/i })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /neue einladung/i })).toBeVisible(); + }); + + it('renders the empty placeholder when the invite list is empty', async () => { + render(AdminInvitesPage, { props: { data: baseData() } }); + + await expect.element(page.getByText('Keine aktiven Einladungen vorhanden.')).toBeVisible(); + }); + + it('marks the active filter chip as selected when status is "active"', async () => { + render(AdminInvitesPage, { props: { data: baseData({ status: 'active' }) } }); + + const activeChip = (await page + .getByRole('link', { name: /^aktiv$/i }) + .element()) as HTMLAnchorElement; + expect(activeChip.classList.contains('bg-primary')).toBe(true); + }); + + it('marks the show-all filter chip as selected when status is "all"', async () => { + render(AdminInvitesPage, { props: { data: baseData({ status: 'all' }) } }); + + const showAllChip = (await page + .getByRole('link', { name: /alle anzeigen/i }) + .element()) as HTMLAnchorElement; + expect(showAllChip.classList.contains('bg-primary')).toBe(true); + }); + + it('renders the load-error banner when data.loadError is set', async () => { + render(AdminInvitesPage, { + props: { data: baseData({ loadError: 'INVITE_LOAD_FAILED' }) } + }); + + const banner = document.querySelector('.bg-red-50'); + expect(banner).not.toBeNull(); + }); + + it('shows the new-invite form when the new-invite button is clicked', async () => { + render(AdminInvitesPage, { props: { data: baseData() } }); + + await page + .getByRole('button', { name: /neue einladung/i }) + .first() + .click(); + + await expect.element(page.getByLabelText(/bezeichnung|label/i)).toBeVisible(); + }); + + it('shows the createError message inside the form when form.createError is set and the form is open', async () => { + render(AdminInvitesPage, { + props: { data: baseData(), form: { createError: 'INVALID_INVITE' } } + }); + + await page + .getByRole('button', { name: /neue einladung/i }) + .first() + .click(); + + const banners = document.querySelectorAll('.text-red-600'); + expect(banners.length).toBeGreaterThan(0); + }); + + it('shows the created-invite success card with the shareable URL when form.created is set', async () => { + render(AdminInvitesPage, { + props: { + data: baseData(), + form: { created: makeInvite({ id: 'new', shareableUrl: 'http://example.com/i/new' }) } + } + }); + + await expect.element(page.getByText('Einladung erstellt')).toBeVisible(); + await expect.element(page.getByText('http://example.com/i/new')).toBeVisible(); + }); + + it('renders one row per invite in the table', async () => { + render(AdminInvitesPage, { + props: { + data: baseData({ + invites: [ + makeInvite({ id: 'a', displayCode: 'AAA-1111', label: 'Eltern' }), + makeInvite({ id: 'b', displayCode: 'BBB-2222', label: 'Geschwister' }) + ] + }) + } + }); + + await expect.element(page.getByText('AAA-1111')).toBeVisible(); + await expect.element(page.getByText('BBB-2222')).toBeVisible(); + }); + + it('renders "Aktiv" status with the active visual treatment', async () => { + render(AdminInvitesPage, { + props: { data: baseData({ invites: [makeInvite({ status: 'active' })] }) } + }); + + const statusBadge = document.querySelector('tbody [aria-label="Aktiv"]') as HTMLElement | null; + expect(statusBadge?.classList.contains('bg-green-50')).toBe(true); + }); + + it('renders "Widerrufen" status with the revoked visual treatment', async () => { + render(AdminInvitesPage, { + props: { data: baseData({ invites: [makeInvite({ status: 'revoked' })] }) } + }); + + const statusBadge = document.querySelector( + 'tbody [aria-label="Widerrufen"]' + ) as HTMLElement | null; + expect(statusBadge?.classList.contains('bg-red-50')).toBe(true); + }); + + it('renders "Erschöpft" status with the exhausted visual treatment', async () => { + render(AdminInvitesPage, { + props: { data: baseData({ invites: [makeInvite({ status: 'exhausted' })] }) } + }); + + const statusBadge = document.querySelector( + 'tbody [aria-label="Erschöpft"]' + ) as HTMLElement | null; + expect(statusBadge?.classList.contains('bg-gray-100')).toBe(true); + }); + + it('renders "Abgelaufen" status with the expired visual treatment', async () => { + render(AdminInvitesPage, { + props: { data: baseData({ invites: [makeInvite({ status: 'expired' })] }) } + }); + + const statusBadge = document.querySelector( + 'tbody [aria-label="Abgelaufen"]' + ) as HTMLElement | null; + expect(statusBadge?.classList.contains('bg-amber-50')).toBe(true); + }); + + it('renders the revoke button only for active invites', async () => { + render(AdminInvitesPage, { + props: { + data: baseData({ + invites: [ + makeInvite({ id: 'a', status: 'active' }), + makeInvite({ id: 'b', status: 'revoked' }) + ] + }) + } + }); + + const revokeButtons = document.querySelectorAll('button[type="submit"]'); + // The new-invite form is hidden by default, so all submit buttons are revoke buttons. + expect(revokeButtons.length).toBe(1); + }); + + it('renders the unlimited symbol when an invite has no maxUses', async () => { + render(AdminInvitesPage, { + props: { data: baseData({ invites: [makeInvite({ maxUses: null, useCount: 7 })] }) } + }); + + await expect.element(page.getByText(/7\s*\/\s*∞/)).toBeVisible(); + }); + + it('renders "Kein Ablauf" when an invite has no expiresAt', async () => { + render(AdminInvitesPage, { + props: { data: baseData({ invites: [makeInvite({ expiresAt: null })] }) } + }); + + await expect.element(page.getByText('Kein Ablauf')).toBeVisible(); + }); +}); -- 2.49.1 From b1d7ee1caba805e7d4fd95dd424ac49f2281a7d4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:34:27 +0200 Subject: [PATCH 015/189] test(document): cover DocumentTopBar conditional rendering branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteen tests covering the user-observable matrix without yet splitting the component (Phase 5 of the plan): title vs originalFilename fallback, short-date rendering and absence, transcribe-button gating (canWrite × isPdf × transcribeMode), edit-link gating, download-link gating on filePath, kebab-menu visibility on (canWrite & isPdf) || filePath, details drawer toggle, mobile menu open/close. The 83 raw branches in the source map mostly to combinations of the above flags — each test isolates one branch. Per Sara's guidance the test names read as sentences and verify what the user sees, not internal state. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentTopBar.svelte.test.ts | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 frontend/src/lib/document/DocumentTopBar.svelte.test.ts diff --git a/frontend/src/lib/document/DocumentTopBar.svelte.test.ts b/frontend/src/lib/document/DocumentTopBar.svelte.test.ts new file mode 100644 index 00000000..93d07948 --- /dev/null +++ b/frontend/src/lib/document/DocumentTopBar.svelte.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentTopBar from './DocumentTopBar.svelte'; + +afterEach(cleanup); + +const sender = { id: 's1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }; +const receiver = { id: 'r1', firstName: 'Bert', lastName: 'Meier', displayName: 'Bert Meier' }; + +const baseDoc = { + id: 'd1', + title: 'Brief an Helene', + originalFilename: 'brief.pdf', + documentDate: '1923-04-15', + sender, + receivers: [receiver], + filePath: null as string | null, + contentType: null as string | null, + location: null, + status: 'UPLOADED', + tags: [] as { id: string; name: string }[] +}; + +const baseProps = (overrides: Record = {}) => ({ + doc: baseDoc, + canWrite: false, + fileUrl: '', + transcribeMode: false, + inferredRelationship: null, + geschichten: [], + canBlogWrite: false, + ...overrides +}); + +describe('DocumentTopBar', () => { + it('renders the document title as the main heading', async () => { + render(DocumentTopBar, { props: baseProps() }); + + await expect.element(page.getByRole('heading', { name: 'Brief an Helene' })).toBeVisible(); + }); + + it('falls back to originalFilename when title is missing', async () => { + render(DocumentTopBar, { props: baseProps({ doc: { ...baseDoc, title: null } }) }); + + await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible(); + }); + + it('renders the short documentDate when one is present', async () => { + render(DocumentTopBar, { props: baseProps() }); + + await expect.element(page.getByText('15.04.1923')).toBeVisible(); + }); + + it('omits the date paragraph entirely when documentDate is null', async () => { + render(DocumentTopBar, { props: baseProps({ doc: { ...baseDoc, documentDate: null } }) }); + + await expect.element(page.getByText(/^\d{2}\.\d{2}\.\d{4}$/)).not.toBeInTheDocument(); + }); + + it('does not render the transcribe button when canWrite is false', async () => { + render(DocumentTopBar, { + props: baseProps({ doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' } }) + }); + + await expect + .element(page.getByRole('button', { name: /transkribieren/i })) + .not.toBeInTheDocument(); + }); + + it('does not render the transcribe button when contentType is not PDF', async () => { + render(DocumentTopBar, { + props: baseProps({ + canWrite: true, + doc: { ...baseDoc, filePath: 'x', contentType: 'image/jpeg' } + }) + }); + + await expect + .element(page.getByRole('button', { name: /transkribieren/i })) + .not.toBeInTheDocument(); + }); + + it('renders the transcribe button when canWrite is true and the file is a PDF', async () => { + render(DocumentTopBar, { + props: baseProps({ + canWrite: true, + doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' } + }) + }); + + await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible(); + }); + + it('renders the stop-transcribe button when transcribeMode is true', async () => { + render(DocumentTopBar, { + props: baseProps({ + canWrite: true, + transcribeMode: true, + doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' } + }) + }); + + await expect.element(page.getByRole('button', { name: /fertig/i })).toBeVisible(); + }); + + it('hides the edit link when transcribeMode is true', async () => { + render(DocumentTopBar, { + props: baseProps({ + canWrite: true, + transcribeMode: true, + doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' } + }) + }); + + await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); + }); + + it('renders the edit link when canWrite is true and not in transcribeMode', async () => { + render(DocumentTopBar, { props: baseProps({ canWrite: true }) }); + + await expect + .element(page.getByRole('link', { name: /bearbeiten/i })) + .toHaveAttribute('href', '/documents/d1/edit'); + }); + + it('does not render the edit link when canWrite is false', async () => { + render(DocumentTopBar, { props: baseProps() }); + + await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); + }); + + it('renders the download link when filePath is present and not in transcribe mode', async () => { + render(DocumentTopBar, { + props: baseProps({ doc: { ...baseDoc, filePath: 'docs/x.pdf' }, fileUrl: '/api/docs/x' }) + }); + + await expect.element(page.getByTitle('Herunterladen')).toBeVisible(); + }); + + it('does not render the download link when filePath is null', async () => { + render(DocumentTopBar, { props: baseProps() }); + + await expect.element(page.getByTitle('Herunterladen')).not.toBeInTheDocument(); + }); + + it('opens the metadata drawer when the details toggle is clicked', async () => { + render(DocumentTopBar, { props: baseProps() }); + + await page.getByRole('button', { name: /^details$/i }).click(); + + await expect + .element(page.getByRole('button', { name: /^details$/i })) + .toHaveAttribute('aria-expanded', 'true'); + }); + + it('renders the mobile kebab menu trigger when filePath is present', async () => { + render(DocumentTopBar, { + props: baseProps({ doc: { ...baseDoc, filePath: 'docs/x.pdf' } }) + }); + + await expect.element(page.getByRole('button', { name: /weitere aktionen/i })).toBeVisible(); + }); + + it('does not render the mobile kebab menu when there is no filePath and no canWrite/PDF combo', async () => { + render(DocumentTopBar, { props: baseProps() }); + + await expect + .element(page.getByRole('button', { name: /weitere aktionen/i })) + .not.toBeInTheDocument(); + }); + + it('opens the mobile kebab menu when the trigger is clicked', async () => { + render(DocumentTopBar, { + props: baseProps({ doc: { ...baseDoc, filePath: 'docs/x.pdf' } }) + }); + + await page.getByRole('button', { name: /weitere aktionen/i }).click(); + + await expect + .element(page.getByRole('button', { name: /weitere aktionen/i })) + .toHaveAttribute('aria-expanded', 'true'); + }); + + it('renders the metadata drawer content when detailsOpen is toggled on', async () => { + render(DocumentTopBar, { props: baseProps() }); + + await page.getByRole('button', { name: /^details$/i }).click(); + + const drawer = document.querySelector('[data-topbar] > div:nth-child(2)'); + expect(drawer).not.toBeNull(); + }); +}); -- 2.49.1 From 71a44b084cdce5893ae24baf4e83e1b911340850 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 9 May 2026 20:40:26 +0200 Subject: [PATCH 016/189] refactor(document): extract DocumentTopBarTitle from DocumentTopBar First step of the Phase 5 split plan from issue #496. The 14-line title + date block becomes its own component named after the visual region. TDD red/green: DocumentTopBarTitle.svelte.test.ts written first (7 tests covering title, originalFilename fallback, empty-string fallback, short-date rendering, no-date branch, title attribute sourcing). After the test was red the component was created. DocumentTopBar.svelte updated to use it; the existing 18-test suite still passes. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/document/DocumentTopBar.svelte | 24 ++----- .../lib/document/DocumentTopBarTitle.svelte | 30 +++++++++ .../DocumentTopBarTitle.svelte.test.ts | 64 +++++++++++++++++++ 3 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 frontend/src/lib/document/DocumentTopBarTitle.svelte create mode 100644 frontend/src/lib/document/DocumentTopBarTitle.svelte.test.ts diff --git a/frontend/src/lib/document/DocumentTopBar.svelte b/frontend/src/lib/document/DocumentTopBar.svelte index 96381a69..bc3e90d5 100644 --- a/frontend/src/lib/document/DocumentTopBar.svelte +++ b/frontend/src/lib/document/DocumentTopBar.svelte @@ -1,11 +1,11 @@ @@ -161,20 +158,11 @@ let mobileMenuOpen = $state(false);
-
-

- {doc.title || doc.originalFilename} -

- {#if shortDate} -

- {shortDate} - -

- {/if} -
+