Compare commits
4 Commits
a92644d69b
...
fba685e7a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba685e7a4 | ||
|
|
0da34d0669 | ||
|
|
b7083d426c | ||
|
|
00fa767419 |
@@ -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]');
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
62
frontend/e2e/fixtures/bilateral-correspondence.ts
Normal file
62
frontend/e2e/fixtures/bilateral-correspondence.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user