Compare commits
10 Commits
f74fe16747
...
a92644d69b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a92644d69b | ||
|
|
d1a69e0b43 | ||
|
|
3b3b551d84 | ||
|
|
78caac8d1a | ||
|
|
514f0174e3 | ||
|
|
54ef1643a4 | ||
|
|
3b83141ddf | ||
|
|
0a3e5d6260 | ||
|
|
858c9f0564 | ||
|
|
2d7fac6bb3 |
@@ -99,8 +99,9 @@ public class ThumbnailService {
|
||||
String thumbnailKey = thumbnailKeyFor(doc.getId());
|
||||
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
|
||||
|
||||
ThumbnailAspect aspect = aspectOf(preview.image());
|
||||
return persistThumbnailMetadata(doc, thumbnailKey, aspect, preview.pageCount());
|
||||
ThumbnailResult result = new ThumbnailResult(
|
||||
thumbnailKey, aspectOf(preview.image()), preview.pageCount());
|
||||
return persistThumbnailMetadata(doc, result);
|
||||
}
|
||||
|
||||
private static ThumbnailAspect aspectOf(BufferedImage source) {
|
||||
@@ -112,6 +113,10 @@ public class ThumbnailService {
|
||||
// 1 for image uploads; for PDFs it comes straight from PDDocument.
|
||||
private record SourcePreview(BufferedImage image, int pageCount) {}
|
||||
|
||||
// Everything the generate pipeline has already committed to storage and
|
||||
// now wants stamped onto the Document entity in a single save call.
|
||||
private record ThumbnailResult(String key, ThumbnailAspect aspect, int pageCount) {}
|
||||
|
||||
private static String thumbnailKeyFor(UUID documentId) {
|
||||
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
|
||||
}
|
||||
@@ -156,13 +161,12 @@ public class ThumbnailService {
|
||||
}
|
||||
}
|
||||
|
||||
private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey,
|
||||
ThumbnailAspect aspect, int pageCount) {
|
||||
private Outcome persistThumbnailMetadata(Document doc, ThumbnailResult result) {
|
||||
try {
|
||||
doc.setThumbnailKey(thumbnailKey);
|
||||
doc.setThumbnailKey(result.key());
|
||||
doc.setThumbnailGeneratedAt(LocalDateTime.now());
|
||||
doc.setThumbnailAspect(aspect);
|
||||
doc.setPageCount(pageCount);
|
||||
doc.setThumbnailAspect(result.aspect());
|
||||
doc.setPageCount(result.pageCount());
|
||||
documentRepository.save(doc);
|
||||
return Outcome.SUCCESS;
|
||||
} catch (Exception e) {
|
||||
@@ -172,7 +176,7 @@ public class ThumbnailService {
|
||||
// overwrite it cleanly. Logging distinctly so an operator tracking
|
||||
// backfill totals can spot the database-side issue.
|
||||
log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
|
||||
doc.getId(), thumbnailKey, e.getMessage());
|
||||
doc.getId(), result.key(), e.getMessage());
|
||||
return Outcome.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import AxeBuilder from '@axe-core/playwright';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Accessibility coverage specifically for the briefwechsel thumbnail-row
|
||||
// layout. Runs axe-core (wcag2a + wcag2aa) across three viewports and two
|
||||
// color schemes so color-contrast regressions on the meta text or the
|
||||
// distribution bar are caught independently of the site-wide sweep.
|
||||
// layout. Seeds two persons + a bilateral document via the API 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 },
|
||||
@@ -14,15 +14,49 @@ const VIEWPORTS = [
|
||||
|
||||
const THEMES = ['light', 'dark'] as const;
|
||||
|
||||
let senderId: string;
|
||||
let receiverId: string;
|
||||
|
||||
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;
|
||||
|
||||
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()}`);
|
||||
});
|
||||
|
||||
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');
|
||||
await page.goto(
|
||||
`/briefwechsel?senderId=${encodeURIComponent(senderId)}&receiverId=${encodeURIComponent(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')
|
||||
|
||||
@@ -2,23 +2,69 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
// Visual + structural coverage for the new briefwechsel row layout.
|
||||
//
|
||||
// Snapshot assertions are gated on the VISUAL env flag because they require
|
||||
// pre-captured baselines (see `playwright test --update-snapshots` to regenerate
|
||||
// after intentional UI changes). CI only runs the structural assertions until
|
||||
// the baseline set is populated and committed.
|
||||
// Seeds a bilateral correspondence pair via the API (beforeAll) 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 senderId: string;
|
||||
let receiverId: string;
|
||||
|
||||
test.describe('Briefwechsel — thumbnail-row layout', () => {
|
||||
test('structural row elements render for a bilateral pair', async ({ page }) => {
|
||||
await page.goto('/briefwechsel');
|
||||
// Hero is visible until a person is selected.
|
||||
await expect(page.getByTestId('conv-hero')).toBeVisible();
|
||||
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;
|
||||
|
||||
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()}`);
|
||||
});
|
||||
|
||||
// Visual regression — one snapshot per (viewport × theme). Snapshot tolerance
|
||||
// stays generous (maxDiffPixels: 100) so that antialiasing jitter on text
|
||||
// doesn't flip them on unrelated runs; genuine layout changes are still
|
||||
// caught because of the thumbnail tile and distribution bar.
|
||||
async function openBilateral(page: import('@playwright/test').Page) {
|
||||
await page.goto(
|
||||
`/briefwechsel?senderId=${encodeURIComponent(senderId)}&receiverId=${encodeURIComponent(receiverId)}`
|
||||
);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -31,7 +77,7 @@ test.describe('Briefwechsel — thumbnail-row layout', () => {
|
||||
test(`${viewport.name} / ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.goto('/briefwechsel');
|
||||
await openBilateral(page);
|
||||
await expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, {
|
||||
maxDiffPixels: 100,
|
||||
fullPage: true
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
"conv_hero_divider": "oder",
|
||||
"conv_empty_recent_label": "Zuletzt geöffnet",
|
||||
"conv_no_party": "—",
|
||||
"dist_bar_segment": "{count} von {name}",
|
||||
"dist_bar_aria": "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Benutzer",
|
||||
"admin_tab_groups": "Gruppen",
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
"conv_hero_divider": "or",
|
||||
"conv_empty_recent_label": "Recently opened",
|
||||
"conv_no_party": "—",
|
||||
"dist_bar_segment": "{count} from {name}",
|
||||
"dist_bar_aria": "Letter distribution in this period: {outCount} from {senderName}, {inCount} from {receiverName}",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Users",
|
||||
"admin_tab_groups": "Groups",
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
"conv_hero_divider": "o",
|
||||
"conv_empty_recent_label": "Recientemente abiertos",
|
||||
"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}",
|
||||
"admin_heading": "Panel de administración",
|
||||
"admin_tab_users": "Usuarios",
|
||||
"admin_tab_groups": "Grupos",
|
||||
|
||||
@@ -41,7 +41,7 @@ const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[
|
||||
{#if pageCount > 1}
|
||||
<span
|
||||
data-testid="conv-thumb-page-badge"
|
||||
class="absolute top-1 right-1 rounded-full bg-primary/90 px-1.5 py-0.5 text-xs leading-none font-bold text-surface"
|
||||
class="absolute top-1 right-1 rounded-full bg-primary/90 px-2 py-1 text-sm leading-none font-bold text-surface"
|
||||
>{pageCount}</span
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -78,6 +78,9 @@ describe('ConversationThumbnail', () => {
|
||||
const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]') as HTMLElement;
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge.textContent).toContain('4');
|
||||
// Senior-readable size: text-sm (14px) rather than text-xs (12px) on a
|
||||
// small tile avoids marginal legibility on a 320px phone.
|
||||
expect(badge.className).toContain('text-sm');
|
||||
});
|
||||
|
||||
it('hides the page badge when pageCount is 1 or missing', () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
outCount: number;
|
||||
inCount: number;
|
||||
@@ -12,16 +14,20 @@ const total = $derived(outCount + inCount);
|
||||
const outPct = $derived(total > 0 ? (outCount / total) * 100 : 0);
|
||||
const shortSenderName = $derived(senderName.split(' ')[0] ?? senderName);
|
||||
const shortReceiverName = $derived(receiverName.split(' ')[0] ?? receiverName);
|
||||
|
||||
const ariaLabel = $derived(m.dist_bar_aria({ outCount, senderName, inCount, receiverName }));
|
||||
const outSegmentText = $derived(m.dist_bar_segment({ count: outCount, name: shortSenderName }));
|
||||
const inSegmentText = $derived(m.dist_bar_segment({ count: inCount, name: shortReceiverName }));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
|
||||
role="img"
|
||||
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div class="flex justify-between text-sm font-bold">
|
||||
<span class="inline-flex items-center gap-1 text-primary"
|
||||
>{outCount} von {shortSenderName}
|
||||
>{outSegmentText}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
@@ -30,7 +36,7 @@ const shortReceiverName = $derived(receiverName.split(' ')[0] ?? receiverName);
|
||||
/></span
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 text-accent"
|
||||
>{inCount} von {shortReceiverName}
|
||||
>{inSegmentText}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
|
||||
@@ -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 DistributionBar from './DistributionBar.svelte';
|
||||
|
||||
@@ -8,7 +9,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('DistributionBar', () => {
|
||||
it('renders both counts and short names, and the two-tone fill bar', async () => {
|
||||
it('renders the Paraglide aria-label and visible segments', async () => {
|
||||
render(DistributionBar, {
|
||||
outCount: 3,
|
||||
inCount: 7,
|
||||
@@ -18,11 +19,22 @@ describe('DistributionBar', () => {
|
||||
|
||||
const container = document.querySelector('[role="img"]') as HTMLElement;
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.getAttribute('aria-label')).toContain('3 von Hans Müller');
|
||||
expect(container.getAttribute('aria-label')).toContain('7 von Anna Schmidt');
|
||||
|
||||
expect(container.textContent).toContain('3 von Hans');
|
||||
expect(container.textContent).toContain('7 von Anna');
|
||||
// The aria-label must come from Paraglide, not a hardcoded German string,
|
||||
// so the EN / ES users aren't served "Briefverteilung in diesem Zeitraum".
|
||||
const expectedAria = m.dist_bar_aria({
|
||||
outCount: 3,
|
||||
senderName: 'Hans Müller',
|
||||
inCount: 7,
|
||||
receiverName: 'Anna Schmidt'
|
||||
});
|
||||
expect(container.getAttribute('aria-label')).toBe(expectedAria);
|
||||
|
||||
// The visible "{count} from/von {name}" spans must also come from Paraglide.
|
||||
const outText = m.dist_bar_segment({ count: 3, name: 'Hans' });
|
||||
const inText = m.dist_bar_segment({ count: 7, name: 'Anna' });
|
||||
expect(container.textContent).toContain(outText);
|
||||
expect(container.textContent).toContain(inText);
|
||||
|
||||
// 3/10 → 30% / 70% split on the two segments
|
||||
const segments = container.querySelectorAll('[data-testid="dist-bar-segment"]');
|
||||
@@ -40,7 +52,8 @@ describe('DistributionBar', () => {
|
||||
});
|
||||
|
||||
const container = document.querySelector('[role="img"]') as HTMLElement;
|
||||
expect(container.textContent).toContain('1 von SingleWord');
|
||||
const expected = m.dist_bar_segment({ count: 1, name: 'SingleWord' });
|
||||
expect(container.textContent).toContain(expected);
|
||||
});
|
||||
|
||||
it('renders a zero-percent left segment when outCount is zero', async () => {
|
||||
|
||||
23
frontend/src/lib/components/TagChipList.svelte
Normal file
23
frontend/src/lib/components/TagChipList.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
let { tags, max }: { tags: Tag[]; max: number } = $props();
|
||||
|
||||
const displayedTags = $derived(tags.slice(0, max));
|
||||
const hiddenTagCount = $derived(Math.max(0, tags.length - max));
|
||||
</script>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-1 pt-0.5">
|
||||
{#each displayedTags as tag (tag.id)}
|
||||
<span
|
||||
data-testid="thumb-row-tag"
|
||||
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-xs text-ink-2"
|
||||
>{tag.name}</span
|
||||
>
|
||||
{/each}
|
||||
{#if hiddenTagCount > 0}
|
||||
<span class="text-xs font-bold text-ink-3">+{hiddenTagCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
34
frontend/src/lib/components/TagChipList.svelte.spec.ts
Normal file
34
frontend/src/lib/components/TagChipList.svelte.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
|
||||
import TagChipList from './TagChipList.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeTags = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ id: `t${i}`, name: `Tag${i}` }));
|
||||
|
||||
describe('TagChipList', () => {
|
||||
it('renders all tags as chips when under the cap', () => {
|
||||
render(TagChipList, { tags: makeTags(2), max: 3 });
|
||||
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||
expect(chips).toHaveLength(2);
|
||||
expect(document.body.textContent).not.toMatch(/\+/);
|
||||
});
|
||||
|
||||
it('caps visible chips at max and renders +N for the remainder', () => {
|
||||
render(TagChipList, { tags: makeTags(5), max: 3 });
|
||||
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||
expect(chips).toHaveLength(3);
|
||||
expect(document.body.textContent).toMatch(/\+2/);
|
||||
});
|
||||
|
||||
it('renders nothing when tags is empty', () => {
|
||||
render(TagChipList, { tags: [], max: 3 });
|
||||
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||
expect(chips).toHaveLength(0);
|
||||
expect(document.body.textContent).not.toMatch(/\+/);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
@@ -27,7 +28,7 @@ let {
|
||||
doc,
|
||||
isOut,
|
||||
showOtherParty,
|
||||
now
|
||||
now = new Date()
|
||||
}: {
|
||||
doc: Doc;
|
||||
isOut: boolean;
|
||||
@@ -36,8 +37,6 @@ let {
|
||||
} = $props();
|
||||
|
||||
const title = $derived(doc.title || doc.originalFilename);
|
||||
const displayedTags = $derived((doc.tags ?? []).slice(0, 3));
|
||||
const hiddenTagCount = $derived(Math.max(0, (doc.tags ?? []).length - 3));
|
||||
const otherPartyName = $derived(
|
||||
showOtherParty
|
||||
? isOut
|
||||
@@ -46,12 +45,11 @@ const otherPartyName = $derived(
|
||||
: ''
|
||||
);
|
||||
const relativeYearLabel = $derived(
|
||||
doc.documentDate
|
||||
? relativeYearsDe(new Date(doc.documentDate + 'T12:00:00'), now ?? new Date())
|
||||
: ''
|
||||
doc.documentDate ? relativeYearsDe(new Date(doc.documentDate + 'T12:00:00'), now) : ''
|
||||
);
|
||||
const directionLabel = $derived(isOut ? 'Gesendet' : 'Empfangen');
|
||||
const ariaLabel = $derived(
|
||||
`${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
|
||||
`${directionLabel}: ${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -92,19 +90,6 @@ const ariaLabel = $derived(
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if displayedTags.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-1 pt-0.5">
|
||||
{#each displayedTags as tag (tag.id)}
|
||||
<span
|
||||
data-testid="thumb-row-tag"
|
||||
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-xs text-ink-2"
|
||||
>{tag.name}</span
|
||||
>
|
||||
{/each}
|
||||
{#if hiddenTagCount > 0}
|
||||
<span class="text-xs font-bold text-ink-3">+{hiddenTagCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<TagChipList tags={doc.tags ?? []} max={3} />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -104,6 +104,20 @@ describe('ThumbnailRow', () => {
|
||||
expect(document.body.textContent).toContain('vor 76 Jahren');
|
||||
});
|
||||
|
||||
it('hides the relative-year label when documentDate is in the future', () => {
|
||||
// relativeYearsDe returns "" for future/invalid dates; the row must not
|
||||
// then render an empty chip or print "vor 0 Jahren".
|
||||
render(ThumbnailRow, {
|
||||
doc: { ...baseDoc, documentDate: '2030-01-01' },
|
||||
isOut: true,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
expect(document.body.textContent).not.toMatch(/vor \d+ Jahr/);
|
||||
expect(document.body.textContent).not.toMatch(/vor weniger/);
|
||||
});
|
||||
|
||||
it('sets border-l class based on isOut', () => {
|
||||
const { unmount } = render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
@@ -127,7 +141,7 @@ describe('ThumbnailRow', () => {
|
||||
expect(link.className).toContain('border-l-accent');
|
||||
});
|
||||
|
||||
it('exposes a descriptive aria-label combining title and date', () => {
|
||||
it('exposes a descriptive aria-label combining direction, title, and date', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: true,
|
||||
@@ -137,10 +151,23 @@ describe('ThumbnailRow', () => {
|
||||
|
||||
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||
const label = link.getAttribute('aria-label') ?? '';
|
||||
expect(label).toMatch(/^Gesendet:/);
|
||||
expect(label).toContain('Liebe Anna');
|
||||
expect(label).toMatch(/1950/);
|
||||
});
|
||||
|
||||
it('aria-label begins with "Empfangen:" for incoming letters', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: false,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||
expect(link.getAttribute('aria-label') ?? '').toMatch(/^Empfangen:/);
|
||||
});
|
||||
|
||||
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: {
|
||||
|
||||
@@ -30,20 +30,22 @@ const withPersons = {
|
||||
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
|
||||
};
|
||||
|
||||
const hansPerson = {
|
||||
const makePerson = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'p1',
|
||||
firstName: 'Hans',
|
||||
lastName: 'Müller',
|
||||
personType: 'PERSON' as const,
|
||||
displayName: 'Hans Müller'
|
||||
};
|
||||
const annaPerson = {
|
||||
displayName: 'Hans Müller',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const hansPerson = makePerson();
|
||||
const annaPerson = makePerson({
|
||||
id: 'p2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt',
|
||||
personType: 'PERSON' as const,
|
||||
displayName: 'Anna Schmidt'
|
||||
};
|
||||
});
|
||||
|
||||
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'd1',
|
||||
@@ -54,8 +56,15 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
location: 'Berlin',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN' as const,
|
||||
sender: hansPerson,
|
||||
receivers: [annaPerson],
|
||||
sender: makePerson(),
|
||||
receivers: [
|
||||
makePerson({
|
||||
id: 'p2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt',
|
||||
displayName: 'Anna Schmidt'
|
||||
})
|
||||
],
|
||||
tags: [],
|
||||
transcription: undefined,
|
||||
filePath: undefined,
|
||||
@@ -229,17 +238,33 @@ describe('Briefwechsel page – distribution bar', () => {
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
const bar = document.querySelector('[role="img"][aria-label*="Briefverteilung"]');
|
||||
const bar = document.querySelector('[role="img"]');
|
||||
expect(bar).not.toBeNull();
|
||||
expect(bar!.getAttribute('aria-label')).toContain('1 von Hans Müller');
|
||||
expect(bar!.getAttribute('aria-label')).toContain('2 von Anna Schmidt');
|
||||
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"][aria-label*="Briefverteilung"]');
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user