Files
familienarchiv/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts
Marcel cc118ffb16 feat(briefwechsel): add ThumbnailRow for the new correspondence row layout
Combines ConversationThumbnail with a quote-styled summary, truncated
meta line, and up to three tag chips (the rest collapsed into "+N").
The colored left border tells a reader at a glance whether this
letter left or entered the perspective person's mailbox — replacing
the previous status dot + script-type icons that were too busy for
the list view. Relative-year label ("vor 76 Jahren") is derived from
documentDate so the list carries temporal context without a full
date column.

Rendering rules:
- title falls back to originalFilename when empty
- summary uses a text expression, never {@html}, so inline markup
  in the summary field is escaped (XSS regression test locks this)
- focus-visible outline + focus-within hover keep keyboard-only
  users in sync with mouse hover feedback
- aria-label always pairs title with the formatted date so screen
  readers hear both identifiers

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00

178 lines
5.0 KiB
TypeScript

import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import ThumbnailRow from './ThumbnailRow.svelte';
afterEach(() => {
cleanup();
});
const baseDoc = {
id: 'd1',
title: 'Liebe Anna',
originalFilename: 'liebe_anna.pdf',
documentDate: '1950-06-01',
location: 'Berlin',
summary: 'Heute schreibe ich Dir, weil die Kinder gesund sind.',
contentType: 'application/pdf',
thumbnailKey: 'thumbnails/d1.jpg',
thumbnailGeneratedAt: '2026-04-01T12:00:00Z',
thumbnailAspect: 'PORTRAIT' as const,
pageCount: 2,
sender: { id: 'hans', firstName: 'Hans', lastName: 'Müller', displayName: 'Hans Müller' },
receivers: [{ id: 'anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }],
tags: [
{ id: 't1', name: 'Familie' },
{ id: 't2', name: 'Krieg' },
{ id: 't3', name: 'Reise' },
{ id: 't4', name: 'Arbeit' },
{ id: 't5', name: 'Zuhause' }
]
};
describe('ThumbnailRow', () => {
it('renders the title, date, location, and summary quote', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
expect(document.body.textContent).toContain('Liebe Anna');
expect(document.body.textContent).toContain('Berlin');
expect(document.body.textContent).toContain('Heute schreibe ich Dir');
});
it('falls back to originalFilename when title is empty', () => {
render(ThumbnailRow, {
doc: { ...baseDoc, title: '' },
isOut: true,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
expect(document.body.textContent).toContain('liebe_anna.pdf');
});
it('shows the other-party name when showOtherParty=true (non-bilateral list)', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: true,
now: new Date('2026-06-01T00:00:00Z')
});
// Out-going from Hans, other party is first receiver (Anna Schmidt)
expect(document.body.textContent).toContain('Anna Schmidt');
});
it('hides the other-party name when showOtherParty=false (bilateral list)', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
// Anna is the receiver; in a bilateral list we suppress party names.
expect(document.body.textContent).not.toContain('Anna Schmidt');
});
it('renders at most 3 tag chips and signals any remainder with "+N"', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips.length).toBeLessThanOrEqual(3);
expect(document.body.textContent).toMatch(/\+2/);
});
it('renders relative-year label derived from documentDate', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
// 1950-06-01 → 2026-06-01 = 76 years
expect(document.body.textContent).toContain('vor 76 Jahren');
});
it('sets border-l class based on isOut', () => {
const { unmount } = render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
let link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
expect(link.className).toContain('border-l-primary');
unmount();
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
expect(link.className).toContain('border-l-accent');
});
it('exposes a descriptive aria-label combining title and date', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
const label = link.getAttribute('aria-label') ?? '';
expect(label).toContain('Liebe Anna');
expect(label).toMatch(/1950/);
});
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {
render(ThumbnailRow, {
doc: {
...baseDoc,
summary: 'safe <img src=x onerror="alert(1)"> text'
},
isOut: true,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
// No real img tag from the summary, the ConversationThumbnail img is fine.
const imgs = document.querySelectorAll('img[onerror]');
expect(imgs.length).toBe(0);
expect(document.body.textContent).toContain('<img src=x onerror="alert(1)">');
});
it('handles missing optional fields without crashing', () => {
render(ThumbnailRow, {
doc: {
id: 'n1',
title: 'Ohne Datum',
originalFilename: 'x.pdf',
contentType: 'application/pdf',
thumbnailAspect: 'PORTRAIT'
},
isOut: true,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
expect(document.body.textContent).toContain('Ohne Datum');
});
});