Compare commits

..

4 Commits

Author SHA1 Message Date
Marcel
fba685e7a4 refactor(briefwechsel): TagChipList defaults max to 3
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m47s
CI / OCR Service Tests (pull_request) Successful in 29s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
CI / Unit & Component Tests (push) Failing after 2m45s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 3m0s
Makes `max` an optional prop with default 3 — the common row-layout
case doesn't need to name the cap explicitly. ThumbnailRow's callsite
drops to `<TagChipList tags={doc.tags ?? []} />`, consistent with how
other shared components in $lib/components expose sensible defaults.

Refs #305
Fixes @leonievoss round-2 follow-up from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:08:33 +02:00
Marcel
0da34d0669 i18n(briefwechsel): ThumbnailRow direction label via Paraglide
Adds row_direction_sent / row_direction_received keys across the
three locale files (de: Gesendet/Empfangen, en: Sent/Received, es:
Enviada/Recibida) and routes ThumbnailRow's directionLabel through
Paraglide. An English or Spanish screen-reader user now hears
"Sent:" / "Enviada:" in their language, matching the DistributionBar
i18n pass.

Refs #305
Fixes @leonievoss round-2 follow-up from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:06:16 +02:00
Marcel
b7083d426c refactor(e2e): visual spec shares seedBilateralPair + asserts person-bar
Rewires briefwechsel-rows.visual.spec.ts against the shared fixture
(seedBilateralPair + cleanupBilateralPair), adds afterAll cleanup,
and folds the conv-person-bar visibility gate into openBilateral()
so both the structural test and the snapshot block fail loudly on
a hero-state regression — matching the a11y spec's safety net.

Refs #305
Fixes @saraholt follow-ups 1 + 2 + 3 from PR round-2 review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:02:36 +02:00
Marcel
00fa767419 refactor(e2e): extract seedBilateralPair fixture + afterAll cleanup
Lifts the three-API-call seeding (create sender, create receiver,
create document) out of briefwechsel-a11y.spec.ts and into a
dedicated fixtures module. The spec now calls seedBilateralPair()
in beforeAll and cleanupBilateralPair() in afterAll so the test
DB doesn't accrue seeded rows across reruns.

Two caveats captured in the helper docstring: the backend has no
person-delete endpoint (only the document is purged), and the
timestamped last names make leftover persons collision-free.

Refs #305
Fixes @saraholt follow-up 1 + 2 from PR round-2 review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:01:17 +02:00
10 changed files with 114 additions and 57 deletions

View File

@@ -1,8 +1,13 @@
import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
import {
seedBilateralPair,
cleanupBilateralPair,
type BilateralPair
} from './fixtures/bilateral-correspondence';
// Accessibility coverage specifically for the briefwechsel thumbnail-row
// layout. Seeds two persons + a bilateral document via the API so the page
// 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.
@@ -14,33 +19,15 @@ const VIEWPORTS = [
const THEMES = ['light', 'dark'] as const;
let senderId: string;
let receiverId: string;
let pair: BilateralPair;
test.describe('Accessibility — /briefwechsel row layout', () => {
test.beforeAll(async ({ request }) => {
const timestamp = Date.now();
const senderRes = await request.post('/api/persons', {
data: { firstName: 'A11y', lastName: `Sender-${timestamp}` }
});
if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`);
senderId = (await senderRes.json()).id;
pair = await seedBilateralPair(request, 'A11y');
});
const receiverRes = await request.post('/api/persons', {
data: { firstName: 'A11y', lastName: `Receiver-${timestamp}` }
});
if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`);
receiverId = (await receiverRes.json()).id;
const docRes = await request.post('/api/documents', {
multipart: {
title: 'A11y Test Brief',
documentDate: '1950-06-15',
senderId,
receiverIds: receiverId
}
});
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
test.afterAll(async ({ request }) => {
await cleanupBilateralPair(request, pair);
});
for (const vp of VIEWPORTS) {
@@ -49,7 +36,7 @@ test.describe('Accessibility — /briefwechsel row layout', () => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.emulateMedia({ colorScheme: theme });
await page.goto(
`/briefwechsel?senderId=${encodeURIComponent(senderId)}&receiverId=${encodeURIComponent(receiverId)}`
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
);
await page.waitForSelector('[data-hydrated]');

View File

@@ -1,8 +1,13 @@
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 API (beforeAll) so the page
// 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.
@@ -12,40 +17,25 @@ import { test, expect } from '@playwright/test';
// regenerate after intentional UI changes). CI can opt in via VISUAL=1.
const VISUAL = process.env.VISUAL === '1';
let senderId: string;
let receiverId: string;
let pair: BilateralPair;
test.describe('Briefwechsel — thumbnail-row layout', () => {
test.beforeAll(async ({ request }) => {
const timestamp = Date.now();
const senderRes = await request.post('/api/persons', {
data: { firstName: 'Visual', lastName: `Sender-${timestamp}` }
});
if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`);
senderId = (await senderRes.json()).id;
pair = await seedBilateralPair(request, 'Visual');
});
const receiverRes = await request.post('/api/persons', {
data: { firstName: 'Visual', lastName: `Receiver-${timestamp}` }
});
if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`);
receiverId = (await receiverRes.json()).id;
const docRes = await request.post('/api/documents', {
multipart: {
title: 'Visual Test Brief',
documentDate: '1950-06-15',
senderId,
receiverIds: receiverId
}
});
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
test.afterAll(async ({ request }) => {
await cleanupBilateralPair(request, pair);
});
async function openBilateral(page: import('@playwright/test').Page) {
await page.goto(
`/briefwechsel?senderId=${encodeURIComponent(senderId)}&receiverId=${encodeURIComponent(receiverId)}`
`/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 }) => {

View File

@@ -0,0 +1,62 @@
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

@@ -167,6 +167,8 @@
"conv_no_party": "—",
"dist_bar_segment": "{count} von {name}",
"dist_bar_aria": "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}",
"row_direction_sent": "Gesendet",
"row_direction_received": "Empfangen",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer",
"admin_tab_groups": "Gruppen",

View File

@@ -167,6 +167,8 @@
"conv_no_party": "—",
"dist_bar_segment": "{count} from {name}",
"dist_bar_aria": "Letter distribution in this period: {outCount} from {senderName}, {inCount} from {receiverName}",
"row_direction_sent": "Sent",
"row_direction_received": "Received",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Users",
"admin_tab_groups": "Groups",

View File

@@ -167,6 +167,8 @@
"conv_no_party": "—",
"dist_bar_segment": "{count} de {name}",
"dist_bar_aria": "Distribución de cartas en este período: {outCount} de {senderName}, {inCount} de {receiverName}",
"row_direction_sent": "Enviada",
"row_direction_received": "Recibida",
"admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios",
"admin_tab_groups": "Grupos",

View File

@@ -1,7 +1,7 @@
<script lang="ts">
type Tag = { id: string; name: string };
let { tags, max }: { tags: Tag[]; max: number } = $props();
let { tags, max = 3 }: { tags: Tag[]; max?: number } = $props();
const displayedTags = $derived(tags.slice(0, max));
const hiddenTagCount = $derived(Math.max(0, tags.length - max));

View File

@@ -31,4 +31,11 @@ describe('TagChipList', () => {
expect(chips).toHaveLength(0);
expect(document.body.textContent).not.toMatch(/\+/);
});
it('defaults max to 3 when the prop is omitted', () => {
render(TagChipList, { tags: makeTags(5) });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(3);
expect(document.body.textContent).toMatch(/\+2/);
});
});

View File

@@ -3,6 +3,7 @@ import ConversationThumbnail from '$lib/components/ConversationThumbnail.svelte'
import TagChipList from '$lib/components/TagChipList.svelte';
import { formatDate } from '$lib/utils/date';
import { relativeYearsDe } from '$lib/relativeTime';
import * as m from '$lib/paraglide/messages.js';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
@@ -47,7 +48,7 @@ const otherPartyName = $derived(
const relativeYearLabel = $derived(
doc.documentDate ? relativeYearsDe(new Date(doc.documentDate + 'T12:00:00'), now) : ''
);
const directionLabel = $derived(isOut ? 'Gesendet' : 'Empfangen');
const directionLabel = $derived(isOut ? m.row_direction_sent() : m.row_direction_received());
const ariaLabel = $derived(
`${directionLabel}: ${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
);
@@ -90,6 +91,6 @@ const ariaLabel = $derived(
{/if}
</div>
<TagChipList tags={doc.tags ?? []} max={3} />
<TagChipList tags={doc.tags ?? []} />
</div>
</a>

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import ThumbnailRow from './ThumbnailRow.svelte';
@@ -151,12 +152,14 @@ describe('ThumbnailRow', () => {
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
const label = link.getAttribute('aria-label') ?? '';
expect(label).toMatch(/^Gesendet:/);
// Direction label routes through Paraglide so EN / ES users don't hear
// "Gesendet" in their screen reader.
expect(label.startsWith(`${m.row_direction_sent()}:`)).toBe(true);
expect(label).toContain('Liebe Anna');
expect(label).toMatch(/1950/);
});
it('aria-label begins with "Empfangen:" for incoming letters', () => {
it('aria-label leads with the received direction label for incoming letters', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
@@ -165,7 +168,8 @@ describe('ThumbnailRow', () => {
});
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
expect(link.getAttribute('aria-label') ?? '').toMatch(/^Empfangen:/);
const label = link.getAttribute('aria-label') ?? '';
expect(label.startsWith(`${m.row_direction_received()}:`)).toBe(true);
});
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {