From 975223c9728ad1b64885d9200d45f883214dbb93 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 2 Jun 2026 20:33:38 +0200 Subject: [PATCH] feat(briefwechsel): remove the standalone Briefwechsel view and its tests Delete the /briefwechsel route in full (page, server load, eight components and all co-located unit tests) and its end-to-end coverage (briefwechsel-rows.visual, briefwechsel-a11y, the bilateral-correspondence fixture, and the stale korrespondenz spec which targeted the route's former /korrespondenz path). The card link now deep-links into document search, so this view has no remaining inbound references. Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/briefwechsel-a11y.spec.ts | 65 ---- frontend/e2e/briefwechsel-rows.visual.spec.ts | 79 ----- .../e2e/fixtures/bilateral-correspondence.ts | 62 ---- frontend/e2e/korrespondenz.spec.ts | 127 ------- .../src/routes/briefwechsel/+page.server.ts | 79 ----- frontend/src/routes/briefwechsel/+page.svelte | 160 --------- .../briefwechsel/ConversationFilterBar.svelte | 142 -------- .../ConversationFilterBar.svelte.test.ts | 119 ------- .../briefwechsel/ConversationTimeline.svelte | 117 ------- .../ConversationTimeline.svelte.test.ts | 101 ------ .../CorrespondentSuggestionsDropdown.svelte | 103 ------ ...spondentSuggestionsDropdown.svelte.test.ts | 155 -------- .../CorrespondenzFilterControls.svelte | 48 --- ...CorrespondenzFilterControls.svelte.test.ts | 54 --- .../briefwechsel/CorrespondenzHero.svelte | 92 ----- .../CorrespondenzHero.svelte.spec.ts | 51 --- .../CorrespondenzHero.svelte.test.ts | 67 ---- .../CorrespondenzPersonBar.svelte | 187 ---------- .../CorrespondenzPersonBar.svelte.test.ts | 142 -------- .../briefwechsel/SinglePersonHintBar.svelte | 50 --- .../SinglePersonHintBar.svelte.test.ts | 53 --- .../routes/briefwechsel/page.server.spec.ts | 205 ----------- .../routes/briefwechsel/page.svelte.spec.ts | 330 ------------------ .../routes/briefwechsel/page.svelte.test.ts | 163 --------- 24 files changed, 2751 deletions(-) delete mode 100644 frontend/e2e/briefwechsel-a11y.spec.ts delete mode 100644 frontend/e2e/briefwechsel-rows.visual.spec.ts delete mode 100644 frontend/e2e/fixtures/bilateral-correspondence.ts delete mode 100644 frontend/e2e/korrespondenz.spec.ts delete mode 100644 frontend/src/routes/briefwechsel/+page.server.ts delete mode 100644 frontend/src/routes/briefwechsel/+page.svelte delete mode 100644 frontend/src/routes/briefwechsel/ConversationFilterBar.svelte delete mode 100644 frontend/src/routes/briefwechsel/ConversationFilterBar.svelte.test.ts delete mode 100644 frontend/src/routes/briefwechsel/ConversationTimeline.svelte delete mode 100644 frontend/src/routes/briefwechsel/ConversationTimeline.svelte.test.ts delete mode 100644 frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte delete mode 100644 frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts delete mode 100644 frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte delete mode 100644 frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte.test.ts delete mode 100644 frontend/src/routes/briefwechsel/CorrespondenzHero.svelte delete mode 100644 frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts delete mode 100644 frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.test.ts delete mode 100644 frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte delete mode 100644 frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte.test.ts delete mode 100644 frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte delete mode 100644 frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte.test.ts delete mode 100644 frontend/src/routes/briefwechsel/page.server.spec.ts delete mode 100644 frontend/src/routes/briefwechsel/page.svelte.spec.ts delete mode 100644 frontend/src/routes/briefwechsel/page.svelte.test.ts diff --git a/frontend/e2e/briefwechsel-a11y.spec.ts b/frontend/e2e/briefwechsel-a11y.spec.ts deleted file mode 100644 index 74d659b9..00000000 --- a/frontend/e2e/briefwechsel-a11y.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import AxeBuilder from '@axe-core/playwright'; -import { test, expect } from '@playwright/test'; -import { - seedBilateralPair, - cleanupBilateralPair, - type BilateralPair -} from './fixtures/bilateral-correspondence'; - -// Accessibility coverage for the briefwechsel thumbnail-row layout. Seeds -// two persons + a bilateral document via the shared fixture so the page -// reaches the results state (not the hero), then runs axe-core -// (wcag2a + wcag2aa) across three viewports and two color schemes. - -const VIEWPORTS = [ - { name: 'mobile', width: 375, height: 812 }, - { name: 'tablet', width: 768, height: 1024 }, - { name: 'desktop', width: 1280, height: 800 } -] as const; - -const THEMES = ['light', 'dark'] as const; - -let pair: BilateralPair; - -test.describe('Accessibility — /briefwechsel row layout', () => { - test.beforeAll(async ({ request }) => { - pair = await seedBilateralPair(request, 'A11y'); - }); - - test.afterAll(async ({ request }) => { - await cleanupBilateralPair(request, pair); - }); - - for (const vp of VIEWPORTS) { - for (const theme of THEMES) { - test(`${vp.name} / ${theme} has no wcag2a/wcag2aa violations`, async ({ page }) => { - await page.setViewportSize({ width: vp.width, height: vp.height }); - await page.emulateMedia({ colorScheme: theme }); - await page.goto( - `/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}` - ); - await page.waitForSelector('[data-hydrated]'); - - // Assert we actually reached the row layout, not the hero — otherwise - // the axe sweep silently scans the wrong DOM. - await expect(page.getByTestId('conv-person-bar')).toBeVisible(); - - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa']) - .include('main') - .analyze(); - - if (results.violations.length > 0) { - const summary = results.violations - .map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`) - .join('\n'); - console.log( - `\nAccessibility violations on briefwechsel (${vp.name}/${theme}):\n${summary}` - ); - } - - expect(results.violations).toEqual([]); - }); - } - } -}); diff --git a/frontend/e2e/briefwechsel-rows.visual.spec.ts b/frontend/e2e/briefwechsel-rows.visual.spec.ts deleted file mode 100644 index 3b0991bb..00000000 --- a/frontend/e2e/briefwechsel-rows.visual.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { - seedBilateralPair, - cleanupBilateralPair, - type BilateralPair -} from './fixtures/bilateral-correspondence'; - -// Visual + structural coverage for the new briefwechsel row layout. -// -// Seeds a bilateral correspondence pair via the shared fixture so the page -// reaches the row state. The structural test asserts that a -// ConversationThumbnail tile AND the DistributionBar render — regressions -// that silently drop to the hero or break the {#each} wiring fail here. -// -// Snapshot assertions are gated on the VISUAL env flag because they need -// pre-captured baselines (see `playwright test --update-snapshots` to -// regenerate after intentional UI changes). CI can opt in via VISUAL=1. -const VISUAL = process.env.VISUAL === '1'; - -let pair: BilateralPair; - -test.describe('Briefwechsel — thumbnail-row layout', () => { - test.beforeAll(async ({ request }) => { - pair = await seedBilateralPair(request, 'Visual'); - }); - - test.afterAll(async ({ request }) => { - await cleanupBilateralPair(request, pair); - }); - - async function openBilateral(page: import('@playwright/test').Page) { - await page.goto( - `/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}` - ); - await page.waitForSelector('[data-hydrated]'); - // Parity with the a11y spec: fail loudly if we ever end up on the hero - // instead of the row layout. - await expect(page.getByTestId('conv-person-bar')).toBeVisible(); - } - - test('renders a ConversationThumbnail tile and the DistributionBar', async ({ page }) => { - await openBilateral(page); - - // Tile appears for the seeded document - await expect(page.locator('[data-testid="conv-thumb-tile"]').first()).toBeVisible(); - - // DistributionBar is present (role=img with a descriptive aria-label) - const bar = page.locator('[role="img"]'); - await expect(bar).toBeVisible(); - const label = (await bar.getAttribute('aria-label')) ?? ''; - expect(label.length).toBeGreaterThan(0); - }); - - // Visual regression — one snapshot per (viewport × theme). Tolerance stays - // generous (maxDiffPixels: 100) so antialiasing jitter doesn't flip them on - // unrelated runs; genuine layout changes are still caught because the - // thumbnail tile and distribution bar dominate the frame. - test.describe('snapshots', () => { - test.skip(!VISUAL, 'VISUAL=1 required to compare baselines'); - - for (const viewport of [ - { name: 'mobile', width: 375, height: 812 }, - { name: 'tablet', width: 768, height: 1024 }, - { name: 'desktop', width: 1280, height: 800 } - ] as const) { - for (const theme of ['light', 'dark'] as const) { - test(`${viewport.name} / ${theme}`, async ({ page }) => { - await page.setViewportSize({ width: viewport.width, height: viewport.height }); - await page.emulateMedia({ colorScheme: theme }); - await openBilateral(page); - await expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, { - maxDiffPixels: 100, - fullPage: true - }); - }); - } - } - }); -}); diff --git a/frontend/e2e/fixtures/bilateral-correspondence.ts b/frontend/e2e/fixtures/bilateral-correspondence.ts deleted file mode 100644 index ee32ef96..00000000 --- a/frontend/e2e/fixtures/bilateral-correspondence.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { APIRequestContext } from '@playwright/test'; - -/** - * Test fixture for the briefwechsel row layout. - * - * Creates two persons and one document with sender/receiver between them so - * that `/briefwechsel?senderId=X&receiverId=Y` navigates straight to the row - * state (not the hero). Each seed uses a `Date.now()`-suffixed last name so - * parallel runs and reruns never collide. - * - * The backend does not expose a person-delete endpoint, so only the document - * is cleaned up in {@link cleanupBilateralPair}. The two timestamped persons - * remain in the DB — acceptable for the test environment, and the unique - * suffix means they cannot conflict with later runs. - */ - -export interface BilateralPair { - senderId: string; - receiverId: string; - documentId: string; -} - -export async function seedBilateralPair( - request: APIRequestContext, - prefix: string -): Promise { - const timestamp = Date.now(); - - const senderRes = await request.post('/api/persons', { - data: { firstName: prefix, lastName: `Sender-${timestamp}` } - }); - if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`); - const senderId = (await senderRes.json()).id as string; - - const receiverRes = await request.post('/api/persons', { - data: { firstName: prefix, lastName: `Receiver-${timestamp}` } - }); - if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`); - const receiverId = (await receiverRes.json()).id as string; - - const docRes = await request.post('/api/documents', { - multipart: { - title: `${prefix} Brief`, - documentDate: '1950-06-15', - senderId, - receiverIds: receiverId - } - }); - if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`); - const documentId = (await docRes.json()).id as string; - - return { senderId, receiverId, documentId }; -} - -export async function cleanupBilateralPair( - request: APIRequestContext, - pair: BilateralPair -): Promise { - // Only the document is purged — the backend has no person-delete endpoint - // and the timestamped last names make orphaned person rows safe to leave. - await request.delete(`/api/documents/${pair.documentId}`); -} diff --git a/frontend/e2e/korrespondenz.spec.ts b/frontend/e2e/korrespondenz.spec.ts deleted file mode 100644 index 6fcf0680..00000000 --- a/frontend/e2e/korrespondenz.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -function buildAxe(page: Parameters[0]['page']) { - return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']); -} - -test.describe('Korrespondenz – empty state', () => { - test('shows the search heading when no person is selected', async ({ page }) => { - await page.goto('/korrespondenz'); - await expect(page.getByText(/Korrespondenz durchsuchen/i)).toBeVisible(); - const a11y = await buildAxe(page).analyze(); - expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' }); - }); - - test('nav link goes to /korrespondenz', async ({ page }) => { - await page.goto('/'); - // Click the nav link (desktop text or mobile icon) - const navLink = page.getByRole('link', { name: /Korrespondenz/i }).first(); - await navLink.click(); - await expect(page).toHaveURL(/\/korrespondenz/); - }); -}); - -test.describe('Korrespondenz – single-person mode', () => { - test('shows hint bar and documents when navigated with senderId', async ({ page }) => { - // Get a real person ID from the persons list - await page.goto('/persons'); - const firstPersonLink = page.locator('a[href^="/persons/"]').first(); - await firstPersonLink.click(); - await page.waitForURL(/\/persons\/.+/); - - // Extract the person ID from the URL - const personId = page.url().split('/persons/')[1].split('?')[0]; - - // Navigate to korrespondenz in single-person mode - await page.goto(`/korrespondenz?senderId=${personId}`); - - // Hint bar should be visible - await expect(page.getByText(/Alle Briefe von/i)).toBeVisible(); - - // Filter controls should be active (not dimmed) - const filterStrip = page.locator('[aria-disabled="false"]').first(); - await expect(filterStrip).toBeAttached(); - - const a11y = await buildAxe(page).analyze(); - expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-single-person.png' }); - }); - - test('sort toggle changes URL direction param', async ({ page }) => { - await page.goto('/persons'); - const firstPersonLink = page.locator('a[href^="/persons/"]').first(); - await firstPersonLink.click(); - await page.waitForURL(/\/persons\/.+/); - const personId = page.url().split('/persons/')[1].split('?')[0]; - - await page.goto(`/korrespondenz?senderId=${personId}&dir=DESC`); - await page.getByTestId('conv-sort-btn').click(); - - await expect(page).toHaveURL(/dir=ASC/); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-sort-asc.png' }); - }); -}); - -test.describe('Korrespondenz – bilateral mode', () => { - test('shows asymmetry bar when both persons have shared documents', async ({ page }) => { - // Navigate to a person then follow a co-correspondent suggestion if available - await page.goto('/persons'); - const firstPersonLink = page.locator('a[href^="/persons/"]').first(); - await firstPersonLink.click(); - await page.waitForURL(/\/persons\/.+/); - const senderId = page.url().split('/persons/')[1].split('?')[0]; - - // Try to find a co-correspondent link from the person detail page - const corrLink = page - .locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]') - .first(); - if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) { - await corrLink.click(); - await page.waitForURL(/\/korrespondenz\?.*receiverId=/); - - // Hint bar should NOT be shown in bilateral mode - await expect(page.getByText(/Alle Briefe von/i)).not.toBeVisible(); - - const a11y = await buildAxe(page).analyze(); - expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-bilateral.png' }); - } else { - // E2E seed must include bilateral correspondents — a missing link is a test failure. - throw new Error( - `No bilateral correspondent links found for person ${senderId}. Ensure the E2E seed contains at least one bilateral correspondence pair.` - ); - } - }); - - test('swap button swaps sender and receiver in URL', async ({ page }) => { - await page.goto('/persons'); - const firstPersonLink = page.locator('a[href^="/persons/"]').first(); - await firstPersonLink.click(); - await page.waitForURL(/\/persons\/.+/); - const senderId = page.url().split('/persons/')[1].split('?')[0]; - - const corrLink = page - .locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]') - .first(); - if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) { - const href = await corrLink.getAttribute('href'); - await corrLink.click(); - await page.waitForURL(/\/korrespondenz\?.*receiverId=/); - - // Extract original receiverId from the href - const url = new URL(href!, 'http://x'); - const originalReceiverId = url.searchParams.get('receiverId')!; - - // Click swap - await page.getByTestId('conv-swap-btn').click(); - - // After swap the former receiver is now senderId - await expect(page).toHaveURL(new RegExp(`senderId=${originalReceiverId}`)); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-swapped.png' }); - } else { - test.skip(true, `No bilateral correspondent links found for person ${senderId}`); - } - }); -}); diff --git a/frontend/src/routes/briefwechsel/+page.server.ts b/frontend/src/routes/briefwechsel/+page.server.ts deleted file mode 100644 index 675c15b2..00000000 --- a/frontend/src/routes/briefwechsel/+page.server.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { components } from '$lib/generated/api'; -import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; -import { getErrorMessage } from '$lib/shared/errors'; - -export async function load({ url, fetch, locals }) { - const senderId = url.searchParams.get('senderId') || ''; - const receiverId = url.searchParams.get('receiverId') || ''; - const from = url.searchParams.get('from') || ''; - const to = url.searchParams.get('to') || ''; - const dir = url.searchParams.get('dir') || 'DESC'; - - const canWrite = - (locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) => - g.permissions.includes('WRITE_ALL') - ) ?? false; - - const api = createApiClient(fetch); - - let documents: components['schemas']['Document'][] = []; - let senderName = ''; - let receiverName = ''; - - const requests: Promise[] = []; - - if (senderId) { - requests.push( - api - .GET('/api/documents/conversation', { - params: { - query: { - senderId, - receiverId: receiverId || undefined, - dir, - from: from || undefined, - to: to || undefined - } - } - }) - .then((result) => { - if (!result.response.ok) { - throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); - } - documents = result.data ?? []; - }) - ); - - requests.push( - api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => { - if (!result.response.ok) { - throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); - } - const p = result.data as { displayName: string } | undefined; - if (p) senderName = p.displayName; - }) - ); - } - - if (receiverId) { - requests.push( - api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => { - if (!result.response.ok) { - throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); - } - const p = result.data as { displayName: string } | undefined; - if (p) receiverName = p.displayName; - }) - ); - } - - await Promise.all(requests); - - return { - documents, - canWrite, - initialValues: { senderName, receiverName }, - filters: { senderId, receiverId, from, to, dir } - }; -} diff --git a/frontend/src/routes/briefwechsel/+page.svelte b/frontend/src/routes/briefwechsel/+page.svelte deleted file mode 100644 index b5091b0c..00000000 --- a/frontend/src/routes/briefwechsel/+page.svelte +++ /dev/null @@ -1,160 +0,0 @@ - - -{#if showHero} - -
- -
-{:else} - -
-
- (showAdvanced = !showAdvanced)} - /> - - {#if showAdvanced} - - {/if} - - {#if isSinglePerson} - - {/if} -
- -
- {#if data.documents.length === 0} -
-

{m.conv_no_results_heading()}

-

{m.conv_no_results_text()}

-
- {:else} - - {/if} -
-
-{/if} diff --git a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte b/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte deleted file mode 100644 index 1ac6d236..00000000 --- a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte +++ /dev/null @@ -1,142 +0,0 @@ - - -
-
- -
- onapplyFilters()} - /> -
- - -
- -
- - -
- onapplyFilters()} - /> -
-
- -
- -
- - onapplyFilters()} - class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" - /> -
- - -
- - onapplyFilters()} - class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" - /> -
- - -
- -
-
-
diff --git a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte.test.ts b/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte.test.ts deleted file mode 100644 index 8b4578a9..00000000 --- a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import ConversationFilterBar from './ConversationFilterBar.svelte'; - -afterEach(cleanup); - -const baseProps = (overrides: Record = {}) => ({ - senderId: '', - receiverId: '', - fromDate: '', - toDate: '', - sortDir: 'DESC', - initialSenderName: '', - initialReceiverName: '', - onapplyFilters: () => {}, - ontoggleSort: () => {}, - onswapPersons: () => {}, - ...overrides -}); - -describe('ConversationFilterBar', () => { - it('renders the two PersonTypeahead inputs and the date inputs', async () => { - render(ConversationFilterBar, { props: baseProps() }); - - const dateInputs = document.querySelectorAll('input[type="date"]'); - expect(dateInputs.length).toBe(2); - }); - - it('marks the swap button invisible when only one person is set', async () => { - render(ConversationFilterBar, { props: baseProps({ senderId: 'p1' }) }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - expect(swap.className).toContain('invisible'); - }); - - it('marks the swap button visible when both persons are set', async () => { - render(ConversationFilterBar, { - props: baseProps({ senderId: 'p1', receiverId: 'p2' }) - }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - expect(swap.className).not.toContain('invisible'); - }); - - it('renders "Neueste zuerst" when sortDir is DESC', async () => { - render(ConversationFilterBar, { props: baseProps({ sortDir: 'DESC' }) }); - - await expect.element(page.getByText('Neueste zuerst')).toBeVisible(); - }); - - it('renders "Älteste zuerst" when sortDir is ASC', async () => { - render(ConversationFilterBar, { props: baseProps({ sortDir: 'ASC' }) }); - - await expect.element(page.getByText('Älteste zuerst')).toBeVisible(); - }); - - it('rotates the chevron 180° when sortDir is ASC', async () => { - render(ConversationFilterBar, { props: baseProps({ sortDir: 'ASC' }) }); - - const sortBtn = Array.from(document.querySelectorAll('button')).find((b) => - b.textContent?.toLowerCase().includes('älteste') - ); - const chevron = sortBtn?.querySelector('svg'); - expect(chevron?.getAttribute('class')).toContain('rotate-180'); - }); - - it('does not rotate the chevron when sortDir is DESC', async () => { - render(ConversationFilterBar, { props: baseProps({ sortDir: 'DESC' }) }); - - const sortBtn = Array.from(document.querySelectorAll('button')).find((b) => - b.textContent?.toLowerCase().includes('neueste') - ); - const chevron = sortBtn?.querySelector('svg'); - expect(chevron?.getAttribute('class')).not.toContain('rotate-180'); - }); - - it('calls ontoggleSort when the sort button is clicked', async () => { - const ontoggleSort = vi.fn(); - render(ConversationFilterBar, { props: baseProps({ ontoggleSort }) }); - - const sortBtn = Array.from(document.querySelectorAll('button')).find((b) => - b.textContent?.toLowerCase().includes('neueste') - ); - sortBtn?.click(); - expect(ontoggleSort).toHaveBeenCalledOnce(); - }); - - it('calls onswapPersons when the swap button is clicked', async () => { - const onswapPersons = vi.fn(); - render(ConversationFilterBar, { - props: baseProps({ senderId: 'p1', receiverId: 'p2', onswapPersons }) - }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - swap.click(); - expect(onswapPersons).toHaveBeenCalledOnce(); - }); - - it('calls onapplyFilters when fromDate input changes', async () => { - const onapplyFilters = vi.fn(); - render(ConversationFilterBar, { props: baseProps({ onapplyFilters }) }); - - const fromInput = document.querySelector('#dateFrom') as HTMLInputElement; - fromInput.value = '1899-04-14'; - fromInput.dispatchEvent(new Event('change', { bubbles: true })); - expect(onapplyFilters).toHaveBeenCalled(); - }); - - it('calls onapplyFilters when toDate input changes', async () => { - const onapplyFilters = vi.fn(); - render(ConversationFilterBar, { props: baseProps({ onapplyFilters }) }); - - const toInput = document.querySelector('#dateTo') as HTMLInputElement; - toInput.value = '1950-12-31'; - toInput.dispatchEvent(new Event('change', { bubbles: true })); - expect(onapplyFilters).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte deleted file mode 100644 index 3cb2dba9..00000000 --- a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - -{#if isBilateral && documents.length > 0} - -{/if} - -
- {#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)} - {#if showYearDivider && year !== null} -
- {year} - {countsByYear.get(year) ?? 0} Briefe -
- {/if} - - - {/each} - - {#if canWrite} - - {/if} -
diff --git a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte.test.ts b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte.test.ts deleted file mode 100644 index 98840368..00000000 --- a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import ConversationTimeline from './ConversationTimeline.svelte'; - -afterEach(cleanup); - -const sender = { id: 'p1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }; -const receiver = { id: 'p2', firstName: 'Bert', lastName: 'Meier', displayName: 'Bert Meier' }; - -const makeDoc = (overrides: Record = {}) => ({ - id: 'd1', - originalFilename: 'brief.pdf', - documentDate: '1923-04-15', - sender, - receivers: [receiver], - tags: [], - ...overrides -}); - -const baseProps = (overrides: Record = {}) => ({ - documents: [makeDoc()], - senderId: 'p1', - receiverId: '', - canWrite: false, - senderName: 'Anna Schmidt', - receiverName: 'Bert Meier', - ...overrides -}); - -describe('ConversationTimeline', () => { - it('renders the year divider for the document year', async () => { - render(ConversationTimeline, { props: baseProps() }); - - await expect.element(page.getByTestId('year-divider')).toBeVisible(); - }); - - it('renders one year divider per distinct year', async () => { - render(ConversationTimeline, { - props: baseProps({ - documents: [ - makeDoc({ id: 'd1', documentDate: '1923-04-15' }), - makeDoc({ id: 'd2', documentDate: '1924-06-20' }), - makeDoc({ id: 'd3', documentDate: '1925-12-31' }) - ] - }) - }); - - const dividers = document.querySelectorAll('[data-testid="year-divider"]'); - expect(dividers.length).toBe(3); - }); - - it('does not duplicate the year divider when consecutive documents share a year', async () => { - render(ConversationTimeline, { - props: baseProps({ - documents: [ - makeDoc({ id: 'd1', documentDate: '1923-04-15' }), - makeDoc({ id: 'd2', documentDate: '1923-06-20' }) - ] - }) - }); - - const dividers = document.querySelectorAll('[data-testid="year-divider"]'); - expect(dividers.length).toBe(1); - }); - - it('does not render a year divider for documents with no documentDate', async () => { - render(ConversationTimeline, { - props: baseProps({ - documents: [makeDoc({ documentDate: undefined })] - }) - }); - - const dividers = document.querySelectorAll('[data-testid="year-divider"]'); - expect(dividers.length).toBe(0); - }); - - it('renders the new-document link when canWrite is true', async () => { - render(ConversationTimeline, { props: baseProps({ canWrite: true }) }); - - await expect - .element(page.getByTestId('conv-new-doc-link')) - .toHaveAttribute('href', '/documents/new?senderId=p1'); - }); - - it('appends receiverId to the new-document URL when set', async () => { - render(ConversationTimeline, { - props: baseProps({ canWrite: true, receiverId: 'p2' }) - }); - - await expect - .element(page.getByTestId('conv-new-doc-link')) - .toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2'); - }); - - it('hides the new-document link when canWrite is false', async () => { - render(ConversationTimeline, { props: baseProps({ canWrite: false }) }); - - await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte deleted file mode 100644 index 41f16b5e..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte +++ /dev/null @@ -1,103 +0,0 @@ - - -
handleKeydown(e, e.currentTarget as HTMLElement)} -> - -
- {m.conv_suggestions_heading()} -
- - - {#if !loading} - {#each correspondents as person (person.id)} -
onselect(person.id)} - onkeydown={(e) => e.key === 'Enter' && onselect(person.id)} - > - - - - {person.displayName} -
- {/each} - {/if} - - -
- - -
onselect('')} - onkeydown={(e) => e.key === 'Enter' && onselect('')} - > - {m.conv_suggestions_all_label({ name: senderName })} -
-
diff --git a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts deleted file mode 100644 index 227edbea..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -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/briefwechsel/CorrespondenzFilterControls.svelte b/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte deleted file mode 100644 index a3053ee6..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - -
- -
- - onapplyFilters()} - class="block w-full rounded-md border border-line bg-surface px-3 py-2.5 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" - /> -
- - -
- - onapplyFilters()} - class="block w-full rounded-md border border-line bg-surface px-3 py-2.5 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" - /> -
-
diff --git a/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte.test.ts deleted file mode 100644 index 80710f78..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte'; - -afterEach(cleanup); - -describe('CorrespondenzFilterControls', () => { - it('renders both date input labels', async () => { - render(CorrespondenzFilterControls, { props: { onapplyFilters: () => {} } }); - - await expect.element(page.getByText('Zeitraum von')).toBeVisible(); - await expect.element(page.getByText('Zeitraum bis')).toBeVisible(); - }); - - it('renders two DateInputs with stable ids', async () => { - render(CorrespondenzFilterControls, { props: { onapplyFilters: () => {} } }); - - expect(document.getElementById('conv-from')).not.toBeNull(); - expect(document.getElementById('conv-to')).not.toBeNull(); - }); - - it('hydrates the from input from fromDate', async () => { - render(CorrespondenzFilterControls, { - props: { fromDate: '1923-04-15', onapplyFilters: () => {} } - }); - - const fromInput = document.getElementById('conv-from') as HTMLInputElement; - expect(fromInput.value).toContain('1923'); - }); - - it('hydrates the to input from toDate', async () => { - render(CorrespondenzFilterControls, { - props: { toDate: '1925-12-31', onapplyFilters: () => {} } - }); - - const toInput = document.getElementById('conv-to') as HTMLInputElement; - expect(toInput.value).toContain('1925'); - }); - - it('calls onapplyFilters when the from date changes', async () => { - const onapplyFilters = vi.fn(); - render(CorrespondenzFilterControls, { props: { onapplyFilters } }); - - const fromInput = document.getElementById('conv-from') as HTMLInputElement; - fromInput.value = '15.04.1923'; - fromInput.dispatchEvent(new Event('change', { bubbles: true })); - - // onchange wires through DateInput; direct DOM dispatch should bubble. - // At minimum, no crash + the spy may or may not have been called - // depending on DateInput's internals — just smoke-check it didn't throw. - expect(typeof onapplyFilters).toBe('function'); - }); -}); diff --git a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte b/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte deleted file mode 100644 index 4543c02e..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - -
- -

- {m.conv_empty_heading()} -

- - - - {m.conv_hero_crosslink()} - - - -
- -
- - - {#if recentPersons.length > 0} -
-
- {m.conv_hero_divider()} -
-
- -
- - {m.conv_empty_recent_label()} - -
- {#each recentPersons as person (person.id)} - - {/each} -
-
- {/if} -
diff --git a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts b/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts deleted file mode 100644 index cad5bc96..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import CorrespondenzHero from './CorrespondenzHero.svelte'; - -vi.mock('$app/navigation', () => ({ goto: vi.fn() })); - -afterEach(cleanup); - -const noop = () => {}; - -describe('CorrespondenzHero — headline and cross-link', () => { - it('renders the discovery headline', async () => { - render(CorrespondenzHero, { onSelectPerson: noop }); - await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument(); - }); - - it('renders a cross-link to the document search page', async () => { - render(CorrespondenzHero, { onSelectPerson: noop }); - const link = page.getByRole('link', { name: /Zur Dokumentensuche/i }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute('href', '/'); - }); - - it('renders a person typeahead input', async () => { - render(CorrespondenzHero, { onSelectPerson: noop }); - // PersonTypeahead renders , not role="textbox" - await expect.element(page.getByTestId('conv-hero').getByRole('combobox')).toBeInTheDocument(); - }); -}); - -describe('CorrespondenzHero — recent persons', () => { - it('shows recent person chips when provided', async () => { - render(CorrespondenzHero, { - onSelectPerson: noop, - recentPersons: [{ id: 'r1', name: 'Clara Braun' }] - }); - await expect.element(page.getByText('Clara Braun')).toBeInTheDocument(); - }); - - it('calls onSelectPerson when a recent person chip is clicked', async () => { - const spy = vi.fn(); - render(CorrespondenzHero, { - onSelectPerson: spy, - recentPersons: [{ id: 'r1', name: 'Clara Braun' }] - }); - await expect.element(page.getByText('Clara Braun')).toBeInTheDocument(); - document.querySelector('[data-testid="recent-person-r1"]')!.click(); - expect(spy).toHaveBeenCalledWith('r1'); - }); -}); diff --git a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.test.ts deleted file mode 100644 index ac516958..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import CorrespondenzHero from './CorrespondenzHero.svelte'; - -afterEach(cleanup); - -describe('CorrespondenzHero', () => { - it('renders the headline and cross-link', async () => { - render(CorrespondenzHero, { props: { onSelectPerson: () => {} } }); - - await expect.element(page.getByRole('heading', { name: /wessen briefe/i })).toBeVisible(); - await expect.element(page.getByRole('link', { name: /dokumentensuche/i })).toBeVisible(); - }); - - it('omits the recent-persons section when recentPersons is empty', async () => { - render(CorrespondenzHero, { props: { onSelectPerson: () => {} } }); - - await expect.element(page.getByText('Zuletzt geöffnet')).not.toBeInTheDocument(); - }); - - it('renders the recent-persons divider and chips when persons are provided', async () => { - render(CorrespondenzHero, { - props: { - onSelectPerson: () => {}, - recentPersons: [ - { id: 'p1', name: 'Anna Schmidt' }, - { id: 'p2', name: 'Bert Meier' } - ] - } - }); - - await expect.element(page.getByText('Zuletzt geöffnet')).toBeVisible(); - await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); - await expect.element(page.getByText('Bert Meier')).toBeVisible(); - }); - - it('calls onSelectPerson with the recent-person id when clicked', async () => { - const onSelectPerson = vi.fn(); - render(CorrespondenzHero, { - props: { - onSelectPerson, - recentPersons: [{ id: 'p-42', name: 'Anna Schmidt' }] - } - }); - - const btn = document.querySelector('[data-testid="recent-person-p-42"]') as HTMLButtonElement; - btn.click(); - - expect(onSelectPerson).toHaveBeenCalledWith('p-42'); - }); - - it('renders the avatar initial in the recent-person chip', async () => { - render(CorrespondenzHero, { - props: { - onSelectPerson: () => {}, - recentPersons: [{ id: 'p1', name: 'anna schmidt' }] - } - }); - - // Avatar shows the uppercase first letter - const avatars = document.querySelectorAll( - '[data-testid^="recent-person-"] span[aria-hidden="true"]' - ); - expect(avatars[0].textContent?.trim()).toBe('A'); - }); -}); diff --git a/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte b/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte deleted file mode 100644 index 5dc6bc34..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - - -
- -
- { if (id) onapplyFilters(); }} - /> -
- - - - - -
- { - showSuggestions = false; - onapplyFilters(); - }} - onfocused={handleCorrespondentFocused} - /> - {#if showSuggestions && senderId && !receiverId} - (showSuggestions = false)} - /> - {/if} -
-
- - -
- - - - - - - - - {m.conv_letters_count({ count: documentCount })} - -
diff --git a/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte.test.ts deleted file mode 100644 index 02734709..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte'; - -afterEach(cleanup); - -const baseProps = (overrides: Record = {}) => ({ - senderId: '', - receiverId: '', - initialSenderName: '', - initialReceiverName: '', - sortDir: 'DESC', - showAdvanced: false, - documentCount: 0, - onapplyFilters: () => {}, - onswapPersons: () => {}, - ontoggleSort: () => {}, - ontoggleAdvanced: () => {}, - ...overrides -}); - -describe('CorrespondenzPersonBar', () => { - it('renders the two PersonTypeahead inputs', async () => { - render(CorrespondenzPersonBar, { props: baseProps() }); - - const inputs = document.querySelectorAll('input'); - expect(inputs.length).toBeGreaterThanOrEqual(2); - }); - - it('hides the swap button when only one person is set', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ senderId: 'p1' }) }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - expect(swap.classList.contains('opacity-0')).toBe(true); - expect(swap.tabIndex).toBe(-1); - }); - - it('shows the swap button when both senderId and receiverId are set', async () => { - render(CorrespondenzPersonBar, { - props: baseProps({ senderId: 'p1', receiverId: 'p2' }) - }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - expect(swap.classList.contains('opacity-0')).toBe(false); - expect(swap.tabIndex).toBe(0); - }); - - it('renders the "Neueste" label when sortDir is DESC', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ sortDir: 'DESC' }) }); - - await expect.element(page.getByText('Neueste')).toBeVisible(); - }); - - it('renders the "Älteste" label when sortDir is ASC', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ sortDir: 'ASC' }) }); - - await expect.element(page.getByText('Älteste')).toBeVisible(); - }); - - it('marks the sort button as aria-pressed when sortDir is ASC', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ sortDir: 'ASC' }) }); - - const sort = document.querySelector('[data-testid="conv-sort-btn"]') as HTMLElement; - expect(sort.getAttribute('aria-pressed')).toBe('true'); - }); - - it('renders the document count', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ documentCount: 42 }) }); - - await expect.element(page.getByText('42 Briefe')).toBeVisible(); - }); - - it('calls ontoggleSort when the sort button is clicked', async () => { - const ontoggleSort = vi.fn(); - render(CorrespondenzPersonBar, { props: baseProps({ ontoggleSort }) }); - - const sort = document.querySelector('[data-testid="conv-sort-btn"]') as HTMLElement; - sort.click(); - - expect(ontoggleSort).toHaveBeenCalledOnce(); - }); - - it('calls onswapPersons when the swap button is clicked', async () => { - const onswapPersons = vi.fn(); - render(CorrespondenzPersonBar, { - props: baseProps({ senderId: 'p1', receiverId: 'p2', onswapPersons }) - }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - swap.click(); - - expect(onswapPersons).toHaveBeenCalledOnce(); - }); - - it('opens the suggestions dropdown on receiver focus when a senderId is set', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify([{ id: 'p3', firstName: 'Carl', lastName: 'Brandt' }]), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - ); - try { - render(CorrespondenzPersonBar, { - props: baseProps({ senderId: 'p1', receiverId: '' }) - }); - - // Find the second PersonTypeahead input (Korrespondent) and trigger focus event - const inputs = document.querySelectorAll('input[type="text"]'); - const corrInput = inputs[inputs.length - 1] as HTMLInputElement; - corrInput.dispatchEvent(new Event('focus', { bubbles: true })); - - // Confirm the typeahead fired the suggestions fetch. - await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled()); - } finally { - fetchSpy.mockRestore(); - } - }); - - it('does not show advanced filter chevron rotation when showAdvanced is false', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ showAdvanced: false }) }); - - // The filter toggle button has a chevron — should NOT be rotated - const buttons = document.querySelectorAll('button'); - const filterBtn = Array.from(buttons).find((b) => - b.textContent?.toLowerCase().includes('filter') - ); - const chevron = filterBtn?.querySelector('img'); - expect(chevron?.getAttribute('class')).not.toContain('rotate-180'); - }); - - it('rotates the filter chevron when showAdvanced is true', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ showAdvanced: true }) }); - - const buttons = document.querySelectorAll('button'); - const filterBtn = Array.from(buttons).find((b) => - b.textContent?.toLowerCase().includes('filter') - ); - const chevron = filterBtn?.querySelector('img'); - expect(chevron?.getAttribute('class')).toContain('rotate-180'); - }); -}); diff --git a/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte b/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte deleted file mode 100644 index ab867c4f..00000000 --- a/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - -
- - - {#if hasDateFilter} - {senderName} - · - {fromYear}–{toYear} - · - {sortLabel} - {:else} - Alle Briefe von {senderName} — wähle einen Korrespondenten oben um einzugrenzen - {/if} -
diff --git a/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte.test.ts b/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte.test.ts deleted file mode 100644 index 20cb9ea9..00000000 --- a/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import SinglePersonHintBar from './SinglePersonHintBar.svelte'; - -afterEach(cleanup); - -describe('SinglePersonHintBar', () => { - it('renders the no-filter prompt when neither fromDate nor toDate is supplied', async () => { - render(SinglePersonHintBar, { props: { senderName: 'Anna Schmidt' } }); - - await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); - await expect.element(page.getByText(/wähle einen korrespondenten/i)).toBeVisible(); - }); - - it('renders the year range and sort label when fromDate is supplied', async () => { - render(SinglePersonHintBar, { - props: { - senderName: 'Anna Schmidt', - fromDate: '1923-01-01', - toDate: '1925-12-31', - sortDir: 'DESC' - } - }); - - await expect.element(page.getByText('1923–1925')).toBeVisible(); - await expect.element(page.getByText('Neueste')).toBeVisible(); - }); - - it('uses the "Älteste" label when sortDir is ASC', async () => { - render(SinglePersonHintBar, { - props: { senderName: 'Anna Schmidt', fromDate: '1923-01-01', sortDir: 'ASC' } - }); - - await expect.element(page.getByText('Älteste')).toBeVisible(); - }); - - it('hides the no-filter prompt when fromDate alone is set', async () => { - render(SinglePersonHintBar, { - props: { senderName: 'Anna Schmidt', fromDate: '1923-01-01' } - }); - - await expect.element(page.getByText(/wähle einen korrespondenten/i)).not.toBeInTheDocument(); - }); - - it('shows year range using only fromYear when toDate is empty', async () => { - render(SinglePersonHintBar, { - props: { senderName: 'Anna Schmidt', fromDate: '1923-01-01' } - }); - - await expect.element(page.getByText('1923–')).toBeVisible(); - }); -}); diff --git a/frontend/src/routes/briefwechsel/page.server.spec.ts b/frontend/src/routes/briefwechsel/page.server.spec.ts deleted file mode 100644 index 5329e896..00000000 --- a/frontend/src/routes/briefwechsel/page.server.spec.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { load } from './+page.server'; - -vi.mock('$lib/shared/api.server', () => ({ - createApiClient: vi.fn(), - extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code -})); -vi.mock('$lib/shared/errors', () => ({ - getErrorMessage: (code: string) => code ?? 'Unknown error' -})); - -import { createApiClient } from '$lib/shared/api.server'; - -const writeUser = { groups: [{ permissions: ['WRITE_ALL'] }] }; -const readUser = { groups: [{ permissions: ['READ_ALL'] }] }; - -function makeUrl(params: Record = {}): URL { - const url = new URL('http://x/korrespondenz'); - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); - return url; -} - -function mockApi(calls: { ok: boolean; data?: unknown; status?: number }[]) { - const GET = vi.fn(); - for (const call of calls) { - GET.mockResolvedValueOnce({ - response: { ok: call.ok, status: call.status ?? (call.ok ? 200 : 500) }, - data: call.data, - error: call.ok ? undefined : { code: 'INTERNAL_ERROR' } - }); - } - vi.mocked(createApiClient).mockReturnValue({ GET } as ReturnType); - return GET; -} - -beforeEach(() => vi.clearAllMocks()); - -// ─── No senderId ────────────────────────────────────────────────────────────── - -describe('korrespondenz load — no senderId', () => { - it('returns empty documents without calling the conversation endpoint', async () => { - const GET = mockApi([]); - - const result = await load({ - url: makeUrl(), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }); - - expect(result.documents).toEqual([]); - expect(GET).not.toHaveBeenCalled(); - }); -}); - -// ─── With senderId, no receiverId ──────────────────────────────────────────── - -describe('korrespondenz load — senderId set, no receiverId', () => { - it('calls the conversation endpoint and the sender person endpoint', async () => { - const docs = [{ id: 'd1', title: 'Testbrief' }]; - const GET = mockApi([ - { ok: true, data: docs }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - } - ]); - - const result = await load({ - url: makeUrl({ senderId: 'p1' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }); - - expect(result.documents).toEqual(docs); - expect(result.initialValues.senderName).toBe('Hans Müller'); - expect(result.initialValues.receiverName).toBe(''); - expect(GET).toHaveBeenCalledTimes(2); - }); -}); - -// ─── With senderId and receiverId ──────────────────────────────────────────── - -describe('korrespondenz load — senderId and receiverId set', () => { - it('calls conversation, sender person, and receiver person endpoints', async () => { - const GET = mockApi([ - { ok: true, data: [] }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - }, - { - ok: true, - data: { - firstName: 'Anna', - lastName: 'Schmidt', - displayName: 'Anna Schmidt', - personType: 'PERSON' - } - } - ]); - - const result = await load({ - url: makeUrl({ senderId: 'p1', receiverId: 'p2' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }); - - expect(result.initialValues.senderName).toBe('Hans Müller'); - expect(result.initialValues.receiverName).toBe('Anna Schmidt'); - expect(GET).toHaveBeenCalledTimes(3); - }); -}); - -// ─── canWrite derivation ───────────────────────────────────────────────────── - -describe('korrespondenz load — canWrite', () => { - it('derives canWrite true from WRITE_ALL permission', async () => { - mockApi([ - { ok: true, data: [] }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - } - ]); - - const result = await load({ - url: makeUrl({ senderId: 'p1' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: writeUser } - }); - - expect(result.canWrite).toBe(true); - }); - - it('derives canWrite false when user lacks WRITE_ALL', async () => { - mockApi([ - { ok: true, data: [] }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - } - ]); - - const result = await load({ - url: makeUrl({ senderId: 'p1' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }); - - expect(result.canWrite).toBe(false); - }); -}); - -// ─── Backend error propagation ──────────────────────────────────────────────── - -describe('korrespondenz load — backend error', () => { - it('throws when the conversation endpoint returns non-ok', async () => { - mockApi([ - { ok: false, status: 500 }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - } - ]); - - await expect( - load({ - url: makeUrl({ senderId: 'p1' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }) - ).rejects.toMatchObject({ status: 500 }); - }); -}); diff --git a/frontend/src/routes/briefwechsel/page.svelte.spec.ts b/frontend/src/routes/briefwechsel/page.svelte.spec.ts deleted file mode 100644 index f23171d8..00000000 --- a/frontend/src/routes/briefwechsel/page.svelte.spec.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import Page from './+page.svelte'; - -vi.mock('$app/navigation', () => ({ goto: vi.fn() })); - -afterEach(cleanup); - -// ─── Test data ──────────────────────────────────────────────────────────────── - -const baseData = { - user: undefined, - canWrite: true, - canAnnotate: false, - canBlogWrite: false, - documents: [], - initialValues: { senderName: '', receiverName: '' }, - filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const } -}; - -const withSender = { - ...baseData, - initialValues: { senderName: 'Hans Müller', receiverName: '' }, - filters: { ...baseData.filters, senderId: 'p1' } -}; - -const withPersons = { - ...baseData, - initialValues: { senderName: 'Hans Müller', receiverName: 'Anna Schmidt' }, - filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' } -}; - -const makePerson = (overrides: Record = {}) => ({ - id: 'p1', - firstName: 'Hans', - lastName: 'Müller', - personType: 'PERSON' as const, - familyMember: false, - displayName: 'Hans Müller', - ...overrides -}); - -const hansPerson = makePerson(); -const annaPerson = makePerson({ - id: 'p2', - firstName: 'Anna', - lastName: 'Schmidt', - displayName: 'Anna Schmidt' -}); - -const makeDoc = (overrides: Record = {}) => ({ - id: 'd1', - title: 'Testbrief', - originalFilename: 'testbrief.pdf', - status: 'UPLOADED' as const, - documentDate: '1923-04-12', - location: 'Berlin', - metadataComplete: false, - scriptType: 'UNKNOWN' as const, - sender: makePerson(), - receivers: [ - makePerson({ - id: 'p2', - firstName: 'Anna', - lastName: 'Schmidt', - displayName: 'Anna Schmidt' - }) - ], - tags: [], - transcription: undefined, - filePath: undefined, - createdAt: '1923-04-12T00:00:00Z', - updatedAt: '1923-04-12T00:00:00Z', - ...overrides -}); - -const withDocs = { - ...withPersons, - documents: [makeDoc()] -}; - -// ─── Hero state (no senderId) ──────────────────────────────────────────────── - -describe('Briefwechsel page – hero state', () => { - it('shows the hero when no person is selected', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument(); - }); - - it('shows the discovery headline', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument(); - }); - - it('does not show the person bar in hero state', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument(); - await expect.element(page.getByTestId('conv-person-bar')).not.toBeInTheDocument(); - }); - - it('does not show filter controls in hero state', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument(); - await expect.element(page.getByTestId('conv-filter-controls')).not.toBeInTheDocument(); - }); - - it('does not show the new document link when no person is selected', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); - }); - - it('does not show a year divider when no person is selected', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('year-divider')).not.toBeInTheDocument(); - }); -}); - -// ─── Results state (senderId set) ──────────────────────────────────────────── - -describe('Briefwechsel page – results state', () => { - it('does not show the hero when senderId is set', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument(); - await expect.element(page.getByTestId('conv-hero')).not.toBeInTheDocument(); - }); - - it('shows the person bar when senderId is set', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument(); - }); - - it('hides filter controls by default (collapsible)', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument(); - await expect.element(page.getByTestId('conv-filter-controls')).not.toBeInTheDocument(); - }); -}); - -// ─── Recent persons chips ───────────────────────────────────────────────────── - -describe('Briefwechsel page – recent persons', () => { - it('shows recent person chips from localStorage', async () => { - localStorage.setItem( - 'korrespondenz_recent_persons', - JSON.stringify([{ id: 'r1', name: 'Clara Braun' }]) - ); - render(Page, { data: baseData }); - await expect.element(page.getByText('Clara Braun')).toBeInTheDocument(); - localStorage.removeItem('korrespondenz_recent_persons'); - }); - - it('does not crash when localStorage contains corrupt JSON', async () => { - localStorage.setItem('korrespondenz_recent_persons', '}{not valid json'); - render(Page, { data: baseData }); - await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument(); - localStorage.removeItem('korrespondenz_recent_persons'); - }); -}); - -// ─── Single-person hint bar ─────────────────────────────────────────────────── - -describe('Briefwechsel page – single-person hint bar', () => { - it('shows hint bar when only senderId is set', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).toBeInTheDocument(); - }); - - it('does not show hint bar when both persons are set', async () => { - render(Page, { data: { ...withPersons, documents: [makeDoc()] } }); - await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).not.toBeInTheDocument(); - }); - - it('does not show hint bar when no person is set', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByText(/Alle Briefe von/i)).not.toBeInTheDocument(); - }); -}); - -// ─── Strip letter count ─────────────────────────────────────────────────────── - -describe('Briefwechsel page – strip letter count', () => { - it('shows 0 Briefe when senderId is set but no documents', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('0 Briefe'); - }); - - it('shows correct count when documents are loaded', async () => { - render(Page, { data: { ...withPersons, documents: [makeDoc()] } }); - await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('1 Briefe'); - }); -}); - -// ─── No results ─────────────────────────────────────────────────────────────── - -describe('Briefwechsel page – no results', () => { - it('shows "no documents found" when a person is selected but there are no documents', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument(); - }); -}); - -// ─── Swap button ────────────────────────────────────────────────────────────── - -describe('Briefwechsel page – swap button', () => { - it('swap button is invisible when only one person is set', async () => { - render(Page, { data: withSender }); - const btn = document.querySelector('[data-testid="conv-swap-btn"]'); - expect(btn).not.toBeNull(); - expect(btn!.className).toMatch(/opacity-0/); - }); - - it('swap button is visible when both persons are set', async () => { - render(Page, { data: withPersons }); - const btn = document.querySelector('[data-testid="conv-swap-btn"]'); - expect(btn).not.toBeNull(); - expect(btn!.className).not.toMatch(/opacity-0/); - }); - - it('calls goto with swapped sender and receiver when clicked', async () => { - const { goto } = await import('$app/navigation'); - vi.mocked(goto).mockClear(); - render(Page, { data: withPersons }); - document.querySelector('[data-testid="conv-swap-btn"]')!.click(); - expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything()); - expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything()); - }); -}); - -// ─── Distribution bar (bilateral only) ──────────────────────────────────────── - -describe('Briefwechsel page – distribution bar', () => { - it('renders the DistributionBar when both persons are set and there are documents', async () => { - const data = { - ...withPersons, - documents: [ - makeDoc({ id: 'out1', sender: hansPerson, receivers: [annaPerson] }), - makeDoc({ id: 'in1', sender: annaPerson, receivers: [hansPerson] }), - makeDoc({ id: 'in2', sender: annaPerson, receivers: [hansPerson] }) - ] - }; - render(Page, { data }); - const bar = document.querySelector('[role="img"]'); - expect(bar).not.toBeNull(); - const label = bar!.getAttribute('aria-label') ?? ''; - expect(label).toContain('Hans Müller'); - expect(label).toContain('Anna Schmidt'); - expect(label).toMatch(/\b1\b/); - expect(label).toMatch(/\b2\b/); - }); - - it('does not render the DistributionBar in single-person mode', async () => { - render(Page, { data: { ...withSender, documents: [makeDoc()] } }); - const bar = document.querySelector('[role="img"]'); - expect(bar).toBeNull(); - }); - - it('renders a ConversationThumbnail tile for each document in the list', async () => { - // A broken `{#each}` wiring in ConversationTimeline would silently stop - // rendering rows while the DistributionBar above it kept working. Assert - // the per-row tile so that class of regression is caught. - const data = { - ...withPersons, - documents: [makeDoc({ id: 'd-a' }), makeDoc({ id: 'd-b' }), makeDoc({ id: 'd-c' })] - }; - render(Page, { data }); - const tiles = document.querySelectorAll('[data-testid="conv-thumb-tile"]'); - expect(tiles).toHaveLength(3); - }); -}); - -// ─── Year dividers ──────────────────────────────────────────────────────────── - -describe('Briefwechsel page – year dividers', () => { - it('renders a year divider for the first document', async () => { - render(Page, { data: withDocs }); - await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); - }); - - it('renders a divider for each new year in the document list', async () => { - const data = { - ...withPersons, - documents: [ - makeDoc({ documentDate: '1923-04-12' }), - makeDoc({ id: 'd2', documentDate: '1965-08-03' }) - ] - }; - render(Page, { data }); - await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); - await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965'); - }); - - it('does not render a second divider for documents from the same year', async () => { - const data = { - ...withPersons, - documents: [ - makeDoc({ documentDate: '1923-04-12' }), - makeDoc({ id: 'd2', documentDate: '1923-09-01' }) - ] - }; - render(Page, { data }); - await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); - await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument(); - }); -}); - -// ─── New document link ──────────────────────────────────────────────────────── - -describe('Briefwechsel page – new document link', () => { - it('shows the link with correct href for a write user (bilateral)', async () => { - render(Page, { data: { ...withDocs, canWrite: true } }); - const link = page.getByTestId('conv-new-doc-link'); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1')); - await expect.element(link).toHaveAttribute('href', expect.stringContaining('receiverId=p2')); - }); - - it('shows the link with correct href for single-person mode', async () => { - render(Page, { data: { ...withSender, documents: [makeDoc()], canWrite: true } }); - const link = page.getByTestId('conv-new-doc-link'); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1')); - await expect.element(link).not.toHaveAttribute('href', expect.stringContaining('receiverId')); - }); - - it('hides the link for a read-only user', async () => { - render(Page, { data: { ...withDocs, canWrite: false } }); - await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/src/routes/briefwechsel/page.svelte.test.ts b/frontend/src/routes/briefwechsel/page.svelte.test.ts deleted file mode 100644 index 8bbcd354..00000000 --- a/frontend/src/routes/briefwechsel/page.svelte.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; - -vi.mock('$app/navigation', () => ({ - beforeNavigate: () => {}, - afterNavigate: () => {}, - goto: vi.fn(), - invalidate: vi.fn(), - invalidateAll: vi.fn(), - preloadCode: vi.fn(), - preloadData: vi.fn(), - pushState: vi.fn(), - replaceState: vi.fn(), - disableScrollHandling: vi.fn(), - onNavigate: () => () => {} -})); - -const { default: BriefwechselPage } = await import('./+page.svelte'); - -afterEach(cleanup); - -const baseData = (overrides: Record = {}) => ({ - filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: '', receiverName: '' }, - documents: [], - canWrite: false, - ...overrides -}); - -describe('briefwechsel/+ page', () => { - it('renders the hero when no senderId is set', async () => { - render(BriefwechselPage, { props: { data: baseData() } }); - - // CorrespondenzHero should render - const inputs = document.querySelectorAll('input'); - expect(inputs.length).toBeGreaterThan(0); - }); - - it('renders the results card when a senderId is set', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna Schmidt', receiverName: '' } - }) - } - }); - - // CorrespondenzPersonBar should render with results context - await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible(); - }); - - it('renders the SinglePersonHintBar when there is a sender but no receiver', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna', receiverName: '' } - }) - } - }); - - // "Alle Briefe von Anna" message from SinglePersonHintBar - await expect.element(page.getByText(/wähle einen korrespondenten/i)).toBeVisible(); - }); - - it('renders the empty results message when documents is empty and a sender is set', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna', receiverName: '' } - }) - } - }); - - await expect.element(page.getByText('Keine Dokumente gefunden.')).toBeVisible(); - }); - - it('hides the SinglePersonHintBar when both sender and receiver are set', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: 'p2', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna', receiverName: 'Bert' } - }) - } - }); - - await expect.element(page.getByText(/wähle einen korrespondenten/i)).not.toBeInTheDocument(); - }); - - it('renders the timeline when documents is non-empty', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: 'p2', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna', receiverName: 'Bert' }, - documents: [ - { - id: 'd1', - title: 'Brief 1', - documentDate: '1899-04-14', - sender: { id: 'p1', displayName: 'Anna' }, - receivers: [{ id: 'p2', displayName: 'Bert' }], - status: 'UPLOADED' - } - ] - }) - } - }); - - expect(document.body.textContent).toContain('Brief 1'); - }); - - it('writes the senderName to localStorage when sender filter is set on mount', async () => { - localStorage.removeItem('korrespondenz_recent_persons'); - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna Schmidt', receiverName: '' } - }) - } - }); - - // persistRecentPerson runs in onMount — the persisted entry must include the name. - await vi.waitFor(() => { - const stored = localStorage.getItem('korrespondenz_recent_persons'); - expect(stored).toContain('Anna Schmidt'); - }); - }); - - it('falls back to an empty recent-persons list when localStorage is malformed', async () => { - localStorage.setItem('korrespondenz_recent_persons', 'not-json'); - render(BriefwechselPage, { - props: { data: baseData() } - }); - - // Page still mounts; the malformed entry must not break rendering. The page renders - // a max-w-7xl container at the root. - expect(document.querySelector('.max-w-7xl')).not.toBeNull(); - localStorage.removeItem('korrespondenz_recent_persons'); - }); - - it('appends the senderName when only sender is set on mount (persistRecentPerson path)', async () => { - localStorage.removeItem('korrespondenz_recent_persons'); - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p-test', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Test Person', receiverName: '' } - }) - } - }); - - await vi.waitFor(() => { - const stored = localStorage.getItem('korrespondenz_recent_persons'); - expect(stored).toContain('Test Person'); - }); - }); -});