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'; 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 }); 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 }); 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 }); // 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 }); // 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 }); const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]'); expect(chips.length).toBeLessThanOrEqual(3); expect(document.body.textContent).toMatch(/\+2/); }); it('does not render a relative-year label', () => { // Document date is historical; we deliberately omit the "vor N Jahren" // chip so the row can give vertical space to the title + summary. render(ThumbnailRow, { doc: baseDoc, isOut: true, showOtherParty: false }); expect(document.body.textContent).not.toMatch(/vor \d+ Jahr/); expect(document.body.textContent).not.toMatch(/vor weniger/); }); it('renders the title at text-lg so the row uses its full vertical space', () => { render(ThumbnailRow, { doc: baseDoc, isOut: true, showOtherParty: false }); const titleEl = [...document.querySelectorAll('div')].find( (el) => el.textContent?.trim() === 'Liebe Anna' && el.className.includes('truncate') ) as HTMLElement | undefined; expect(titleEl, 'title element not found').toBeDefined(); expect(titleEl!.className).toContain('text-lg'); }); it('renders a right-arrow icon for outgoing letters', () => { render(ThumbnailRow, { doc: baseDoc, isOut: true, showOtherParty: false }); const arrow = document.querySelector( '[data-testid="thumb-row-direction-icon"]' ) as HTMLImageElement | null; expect(arrow).not.toBeNull(); expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Right/); // Decorative — direction is already announced via the aria-label prefix. expect(arrow!.getAttribute('aria-hidden')).toBe('true'); }); it('renders a left-arrow icon for incoming letters', () => { render(ThumbnailRow, { doc: baseDoc, isOut: false, showOtherParty: false }); const arrow = document.querySelector( '[data-testid="thumb-row-direction-icon"]' ) as HTMLImageElement | null; expect(arrow).not.toBeNull(); expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Left/); }); it('sets border-l class based on isOut', () => { const { unmount } = render(ThumbnailRow, { doc: baseDoc, isOut: true, showOtherParty: false }); 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 }); link = document.querySelector('a[href="/documents/d1"]') as HTMLElement; expect(link.className).toContain('border-l-accent'); }); it('exposes a descriptive aria-label combining direction, title, and date', () => { render(ThumbnailRow, { doc: baseDoc, isOut: true, showOtherParty: false }); const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement; const label = link.getAttribute('aria-label') ?? ''; // 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 leads with the received direction label for incoming letters', () => { render(ThumbnailRow, { doc: baseDoc, isOut: false, showOtherParty: false }); const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement; 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)', () => { render(ThumbnailRow, { doc: { ...baseDoc, summary: 'safe text' }, isOut: true, showOtherParty: false }); // 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(''); }); 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 }); expect(document.body.textContent).toContain('Ohne Datum'); }); });