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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-02 20:33:38 +02:00
committed by marcel
parent 403a043d51
commit 975223c972
24 changed files with 0 additions and 2751 deletions

View File

@@ -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([]);
});
}
}
});

View File

@@ -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
});
});
}
}
});
});

View File

@@ -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<BilateralPair> {
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<void> {
// 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}`);
}

View File

@@ -1,127 +0,0 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
function buildAxe(page: Parameters<typeof AxeBuilder>[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}`);
}
});
});

View File

@@ -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<void>[] = [];
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 }
};
}

View File

@@ -1,160 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte';
import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte';
import SinglePersonHintBar from './SinglePersonHintBar.svelte';
import ConversationTimeline from './ConversationTimeline.svelte';
import CorrespondenzHero from './CorrespondenzHero.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
let senderId = $state(untrack(() => data.filters.senderId));
let receiverId = $state(untrack(() => data.filters.receiverId));
let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
const senderName = $derived(data.initialValues.senderName);
const receiverName = $derived(data.initialValues.receiverName);
$effect(() => {
if (data.filters.senderId && data.initialValues.senderName) {
persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
}
});
const isSinglePerson = $derived(!!senderId && !receiverId);
const showHero = $derived(!senderId && !data.filters.senderId);
let showAdvanced = $state(false);
const RECENT_STORAGE_KEY = 'korrespondenz_recent_persons';
const MAX_RECENT = 5;
interface RecentPerson {
id: string;
name: string;
}
let recentPersons = $state<RecentPerson[]>([]);
onMount(() => {
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
if (raw) {
recentPersons = JSON.parse(raw) as RecentPerson[];
}
} catch {
recentPersons = [];
}
});
function persistRecentPerson(id: string, name: string) {
if (!id) return;
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
const existing: RecentPerson[] = raw ? JSON.parse(raw) : [];
const filtered = existing.filter((p) => p.id !== id);
const updated = [{ id, name }, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
} catch {
// localStorage unavailable — silently ignore
}
}
function applyFilters() {
const params = new SvelteURLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`/briefwechsel?${params.toString()}`, { keepFocus: true });
}
function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
function swapPersons() {
const tmp = senderId;
senderId = receiverId;
receiverId = tmp;
applyFilters();
}
function selectPerson(id: string) {
if (!id) return;
senderId = id;
receiverId = '';
applyFilters();
}
</script>
{#if showHero}
<!-- Hero state: only on fresh page load with no context -->
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<CorrespondenzHero onSelectPerson={selectPerson} recentPersons={recentPersons} />
</div>
{:else}
<!-- Results state: card + content -->
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
<CorrespondenzPersonBar
bind:senderId={senderId}
bind:receiverId={receiverId}
initialSenderName={data.initialValues.senderName}
initialReceiverName={data.initialValues.receiverName}
sortDir={sortDir}
showAdvanced={showAdvanced}
documentCount={data.documents.length}
onapplyFilters={applyFilters}
onswapPersons={swapPersons}
ontoggleSort={toggleSort}
ontoggleAdvanced={() => (showAdvanced = !showAdvanced)}
/>
{#if showAdvanced}
<CorrespondenzFilterControls
bind:fromDate={fromDate}
bind:toDate={toDate}
onapplyFilters={applyFilters}
/>
{/if}
{#if isSinglePerson}
<SinglePersonHintBar
senderName={senderName}
fromDate={fromDate || undefined}
toDate={toDate || undefined}
sortDir={sortDir}
/>
{/if}
</div>
<div>
{#if data.documents.length === 0}
<div
class="flex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-sm"
>
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
</div>
{:else}
<ConversationTimeline
documents={data.documents}
senderId={senderId}
receiverId={receiverId}
canWrite={data.canWrite}
senderName={senderName}
receiverName={receiverName}
/>
{/if}
</div>
</div>
{/if}

View File

@@ -1,142 +0,0 @@
<script lang="ts">
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
senderId = $bindable(''),
receiverId = $bindable(''),
fromDate = $bindable(''),
toDate = $bindable(''),
sortDir = $bindable('DESC'),
initialSenderName = '',
initialReceiverName = '',
onapplyFilters,
ontoggleSort,
onswapPersons
}: {
senderId?: string;
receiverId?: string;
fromDate?: string;
toDate?: string;
sortDir?: string;
initialSenderName?: string;
initialReceiverName?: string;
onapplyFilters: () => void;
ontoggleSort: () => void;
onswapPersons: () => void;
} = $props();
</script>
<div class="relative z-20 mb-10 border border-line bg-surface p-8 shadow-sm">
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label={m.conv_label_person_a()}
bind:value={senderId}
initialName={initialSenderName}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
<!-- Swap button -->
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
<button
data-testid="conv-swap-btn"
onclick={onswapPersons}
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg md:w-auto {senderId &&
receiverId
? ''
: 'invisible'}"
title={m.conv_swap_btn()}
>
<svg
class="h-4 w-4 flex-shrink-0 md:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
></path>
</svg>
<span class="md:hidden">{m.conv_swap_btn()}</span>
</button>
</div>
<!-- Receiver -->
<div
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.conv_label_person_b()}
bind:value={receiverId}
initialName={initialReceiverName}
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
</div>
<div class="grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_from()}</label
>
<input
id="dateFrom"
type="date"
bind:value={fromDate}
onchange={() => 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"
/>
</div>
<!-- Date To -->
<div>
<label for="dateTo" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_to()}</label
>
<input
id="dateTo"
type="date"
bind:value={toDate}
onchange={() => 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"
/>
</div>
<!-- Sort Toggle -->
<div>
<button
onclick={ontoggleSort}
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
>
<span class="mr-2">{m.conv_sort_label()}</span>
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
<svg
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
</div>
</div>
</div>

View File

@@ -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<string, unknown> = {}) => ({
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();
});
});

View File

@@ -1,117 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import DistributionBar from '$lib/shared/primitives/DistributionBar.svelte';
import ThumbnailRow from '$lib/document/ThumbnailRow.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
interface Props {
documents: {
id: string;
title?: string;
originalFilename: string;
documentDate?: string;
location?: string;
summary?: string;
contentType?: string;
thumbnailKey?: string;
thumbnailGeneratedAt?: string;
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
pageCount?: number;
sender?: Person | null;
receivers?: Person[];
tags?: Tag[];
}[];
senderId: string;
receiverId?: string;
canWrite: boolean;
senderName?: string;
receiverName?: string;
}
let { documents, senderId, receiverId, canWrite, senderName, receiverName }: Props = $props();
const enrichedDocuments = $derived(
documents.map((doc, i) => {
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
const prevYear =
i > 0 && documents[i - 1].documentDate
? new Date(documents[i - 1].documentDate!).getFullYear()
: null;
const isOut = doc.sender?.id === senderId;
return { doc, year, showYearDivider: year !== null && year !== prevYear, isOut };
})
);
const countsByYear = $derived(
documents.reduce((acc, d) => {
if (d.documentDate) {
const y = new Date(d.documentDate).getFullYear();
acc.set(y, (acc.get(y) ?? 0) + 1);
}
return acc;
}, new Map<number, number>())
);
const outCount = $derived(documents.filter((d) => d.sender?.id === senderId).length);
const inCount = $derived(documents.length - outCount);
const isBilateral = $derived(!!senderId && !!receiverId);
const showOtherParty = $derived(!receiverId);
const newDocUrl = $derived(
`/documents/new?senderId=${encodeURIComponent(senderId)}${receiverId ? `&receiverId=${encodeURIComponent(receiverId)}` : ''}`
);
</script>
{#if isBilateral && documents.length > 0}
<DistributionBar
outCount={outCount}
inCount={inCount}
senderName={senderName ?? ''}
receiverName={receiverName ?? ''}
/>
{/if}
<div class="overflow-hidden rounded-sm border border-line bg-surface">
{#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)}
{#if showYearDivider && year !== null}
<div
data-testid="year-divider"
class="flex items-baseline gap-3 border-t-2 border-b border-line bg-muted px-[14px] py-[8px]"
>
<span class="text-2xl font-black tracking-tight text-primary">{year}</span>
<span class="text-sm font-bold text-ink-3">{countsByYear.get(year) ?? 0} Briefe</span>
</div>
{/if}
<ThumbnailRow doc={doc} isOut={isOut} showOtherParty={showOtherParty} />
{/each}
{#if canWrite}
<div class="flex justify-end border-t border-line px-[14px] py-[6px]">
<a
href={newDocUrl}
data-testid="conv-new-doc-link"
class="inline-flex items-center gap-1 text-xs font-bold text-primary/50 transition-colors hover:text-primary"
>
<svg
class="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{m.conv_new_doc_link()}
</a>
</div>
{/if}
</div>

View File

@@ -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<string, unknown> = {}) => ({
id: 'd1',
originalFilename: 'brief.pdf',
documentDate: '1923-04-15',
sender,
receivers: [receiver],
tags: [],
...overrides
});
const baseProps = (overrides: Record<string, unknown> = {}) => ({
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();
});
});

View File

@@ -1,103 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
interface Correspondent {
id: string;
firstName?: string | null;
lastName: string;
displayName: string;
}
interface Props {
correspondents: Correspondent[];
loading: boolean;
senderName: string;
onselect: (id: string) => void;
onclose: () => void;
}
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
function getOptionElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
}
function handleKeydown(event: KeyboardEvent, container: HTMLElement) {
const options = getOptionElements(container);
const focused = document.activeElement as HTMLElement;
const idx = options.indexOf(focused);
if (event.key === 'ArrowDown') {
event.preventDefault();
const next = options[idx + 1] ?? options[0];
next?.focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
const prev = options[idx - 1] ?? options[options.length - 1];
prev?.focus();
} else if (event.key === 'Escape') {
onclose();
}
}
function getInitials(person: Correspondent): string {
if (person.firstName)
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
return person.lastName.substring(0, 2).toUpperCase();
}
</script>
<div
use:clickOutside
onclickoutside={onclose}
role="listbox"
tabindex="-1"
aria-label={m.conv_suggestions_heading()}
class="absolute top-full right-0 left-0 z-30 mt-1 rounded-sm border border-line bg-surface shadow-lg"
onkeydown={(e) => handleKeydown(e, e.currentTarget as HTMLElement)}
>
<!-- Heading -->
<div class="px-3 pt-2 pb-1 text-[10px] font-bold tracking-widest text-ink-3 uppercase">
{m.conv_suggestions_heading()}
</div>
<!-- Correspondent rows -->
{#if !loading}
{#each correspondents as person (person.id)}
<div
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
onclick={() => onselect(person.id)}
onkeydown={(e) => e.key === 'Enter' && onselect(person.id)}
>
<!-- Avatar with initials -->
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
aria-hidden="true"
>
{getInitials(person)}
</span>
<!-- Svelte auto-escapes — do not use {@html} here. -->
{person.displayName}
</div>
{/each}
{/if}
<!-- Separator -->
<div class="mt-1 border-t border-line"></div>
<!-- "Alle Korrespondenten" row -->
<div
role="option"
aria-selected="false"
tabindex="0"
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
onclick={() => onselect('')}
onkeydown={(e) => e.key === 'Enter' && onselect('')}
>
{m.conv_suggestions_all_label({ name: senderName })}
</div>
</div>

View File

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

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
interface Props {
fromDate?: string;
toDate?: string;
onapplyFilters: () => void;
}
let { fromDate = $bindable(''), toDate = $bindable(''), onapplyFilters }: Props = $props();
</script>
<div
data-testid="conv-filter-controls"
transition:slide
class="mt-6 grid grid-cols-1 gap-6 border-t border-line-2 pt-6 md:grid-cols-12"
>
<!-- From date -->
<div class="md:col-span-3">
<label
for="conv-from"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.conv_label_from()}
</label>
<DateInput
id="conv-from"
bind:value={fromDate}
onchange={() => 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"
/>
</div>
<!-- To date -->
<div class="md:col-span-3">
<label for="conv-to" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.conv_label_to()}
</label>
<DateInput
id="conv-to"
bind:value={toDate}
onchange={() => 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"
/>
</div>
</div>

View File

@@ -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');
});
});

View File

@@ -1,92 +0,0 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js';
interface RecentPerson {
id: string;
name: string;
}
interface Props {
onSelectPerson: (id: string) => void;
recentPersons?: RecentPerson[];
}
const { onSelectPerson, recentPersons = [] }: Props = $props();
let senderId = $state('');
let typeaheadEl: HTMLDivElement | undefined = $state();
onMount(async () => {
await tick();
typeaheadEl?.querySelector<HTMLInputElement>('input[type="text"]')?.focus();
});
function handlePersonChange(id: string) {
if (id) {
onSelectPerson(id);
}
}
</script>
<div
data-testid="conv-hero"
class="mx-auto flex max-w-lg flex-col items-center gap-6 py-12 text-center sm:py-20"
>
<!-- Discovery headline -->
<h1 class="font-serif text-2xl font-black text-ink">
{m.conv_empty_heading()}
</h1>
<!-- Cross-link to document search -->
<a href="/" class="text-sm text-ink-3 transition-colors hover:text-primary">
{m.conv_hero_crosslink()}
</a>
<!-- Person typeahead (large, hero-sized) -->
<div class="w-full max-w-sm" bind:this={typeaheadEl}>
<PersonTypeahead
name="senderId"
label="Person"
large={true}
bind:value={senderId}
onchange={handlePersonChange}
/>
</div>
<!-- Recent persons -->
{#if recentPersons.length > 0}
<div class="flex w-full max-w-sm items-center gap-2">
<div class="flex-1 border-t border-line"></div>
<span class="text-xs font-bold tracking-wider text-ink-3 uppercase"
>{m.conv_hero_divider()}</span
>
<div class="flex-1 border-t border-line"></div>
</div>
<div class="flex w-full max-w-sm flex-col items-center gap-3">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.conv_empty_recent_label()}
</span>
<div class="flex flex-wrap justify-center gap-2">
{#each recentPersons as person (person.id)}
<button
type="button"
data-testid="recent-person-{person.id}"
onclick={() => onSelectPerson(person.id)}
class="flex items-center gap-2 rounded-full border border-line bg-surface px-4 py-2 text-sm font-bold text-ink transition-colors hover:border-primary hover:text-primary"
>
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-xs text-primary-fg"
aria-hidden="true"
>
{person.name.charAt(0).toUpperCase()}
</span>
{person.name}
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -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 <input role="combobox">, 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<HTMLElement>('[data-testid="recent-person-r1"]')!.click();
expect(spy).toHaveBeenCalledWith('r1');
});
});

View File

@@ -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');
});
});

View File

@@ -1,187 +0,0 @@
<script lang="ts">
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte';
import { m } from '$lib/paraglide/messages.js';
interface Props {
senderId?: string;
receiverId?: string;
initialSenderName?: string;
initialReceiverName?: string;
sortDir?: string;
showAdvanced?: boolean;
documentCount?: number;
onapplyFilters: () => void;
onswapPersons: () => void;
ontoggleSort: () => void;
ontoggleAdvanced: () => void;
}
let {
senderId = $bindable(''),
receiverId = $bindable(''),
initialSenderName = '',
initialReceiverName = '',
sortDir = 'DESC',
showAdvanced = false,
documentCount = 0,
onapplyFilters,
onswapPersons,
ontoggleSort,
ontoggleAdvanced
}: Props = $props();
interface Correspondent {
id: string;
firstName: string;
lastName: string;
}
let swapVisible = $derived(!!(senderId && receiverId));
let showSuggestions = $state(false);
let correspondents = $state<Correspondent[]>([]);
let loadingCorrespondents = $state(false);
async function handleCorrespondentFocused() {
if (!senderId) return;
showSuggestions = true;
loadingCorrespondents = true;
try {
const res = await fetch(`/api/persons/${senderId}/correspondents`);
correspondents = res.ok ? await res.json() : [];
} catch {
correspondents = [];
} finally {
loadingCorrespondents = false;
}
}
function handleSuggestionSelect(id: string) {
receiverId = id;
showSuggestions = false;
onapplyFilters();
}
</script>
<!-- Row 1: Person inputs -->
<div data-testid="conv-person-bar" class="flex items-end gap-4">
<!-- Person A -->
<div
class="min-w-0 flex-1 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label="Person"
bind:value={senderId}
initialName={initialSenderName}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={(id) => { if (id) onapplyFilters(); }}
/>
</div>
<!-- Swap button -->
<button
data-testid="conv-swap-btn"
type="button"
aria-label="Personen tauschen"
onclick={onswapPersons}
class="mb-[3px] flex items-center justify-center rounded border border-line bg-muted px-3 py-2.5 text-ink-3 transition-colors hover:border-primary hover:text-primary"
class:opacity-0={!swapVisible}
class:pointer-events-none={!swapVisible}
tabindex={swapVisible ? 0 : -1}
>
<div class="-my-1 flex flex-col items-center">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="h-3.5 w-3.5 opacity-60"
/>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="h-3.5 w-3.5 opacity-60"
/>
</div>
</button>
<!-- Korrespondent field -->
<div
class="relative min-w-0 flex-1 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={receiverId ? 'Korrespondent' : 'Korrespondent — optional'}
bind:value={receiverId}
initialName={initialReceiverName}
placeholder="Alle Korrespondenten"
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => {
showSuggestions = false;
onapplyFilters();
}}
onfocused={handleCorrespondentFocused}
/>
{#if showSuggestions && senderId && !receiverId}
<CorrespondentSuggestionsDropdown
correspondents={correspondents}
loading={loadingCorrespondents}
senderName=""
onselect={handleSuggestionSelect}
onclose={() => (showSuggestions = false)}
/>
{/if}
</div>
</div>
<!-- Row 2: Sort + Filter toggle + Count (mirrors document search bar pattern) -->
<div class="mt-4 flex items-center gap-4">
<!-- Sort button -->
<button
data-testid="conv-sort-btn"
type="button"
aria-label="Sortierung umkehren"
aria-pressed={sortDir === 'ASC'}
onclick={ontoggleSort}
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
>
{#if sortDir === 'ASC'}
{m.conv_strip_sort_oldest()}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Up-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 opacity-60"
/>
{:else}
{m.conv_strip_sort_newest()}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Down-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 opacity-60"
/>
{/if}
</button>
<!-- Filter toggle button -->
<button
onclick={ontoggleAdvanced}
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
/>
{m.docs_btn_filter()}
</button>
<!-- Document count -->
<span data-testid="conv-strip-count" class="ml-auto text-sm font-bold text-ink-3">
{m.conv_letters_count({ count: documentCount })}
</span>
</div>

View File

@@ -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<string, unknown> = {}) => ({
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');
});
});

View File

@@ -1,50 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
senderName: string;
fromDate?: string;
toDate?: string;
sortDir?: string;
}
let { senderName, fromDate = '', toDate = '', sortDir = 'DESC' }: Props = $props();
let hasDateFilter = $derived(!!(fromDate || toDate));
let sortLabel = $derived(
sortDir === 'ASC' ? m.conv_strip_sort_oldest() : m.conv_strip_sort_newest()
);
let fromYear = $derived(fromDate ? fromDate.substring(0, 4) : '');
let toYear = $derived(toDate ? toDate.substring(0, 4) : '');
</script>
<div
class="mt-4 flex items-center gap-1.5 rounded-sm border border-accent bg-accent-bg px-4 py-2.5 text-sm text-ink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="shrink-0"
>
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
{#if hasDateFilter}
<strong>{senderName}</strong>
<span>·</span>
<span>{fromYear}{toYear}</span>
<span>·</span>
<span>{sortLabel}</span>
{:else}
Alle Briefe von <strong>{senderName}</strong> — wähle einen Korrespondenten oben um einzugrenzen
{/if}
</div>

View File

@@ -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('19231925')).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();
});
});

View File

@@ -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<string, string> = {}): 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<typeof createApiClient>);
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 });
});
});

View File

@@ -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<string, unknown> = {}) => ({
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<string, unknown> = {}) => ({
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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();
});
});

View File

@@ -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<string, unknown> = {}) => ({
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');
});
});
});