import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { tick } from 'svelte'; import * as m from '$lib/paraglide/messages.js'; import LetterCard from './LetterCard.svelte'; import YearLetterStrip from './YearLetterStrip.svelte'; import { timelineDateLabel } from './dateLabel'; import { makeEntry } from './test-factories'; afterEach(() => cleanup()); const DOC_ID = '22222222-2222-2222-2222-222222222222'; describe('LetterCard', () => { it('renders sender, receiver, and title', () => { render(LetterCard, { entry: makeEntry({ senderName: 'Karl', receiverName: 'Elfriede', title: 'Feldpost' }) }); expect(document.body.textContent).toContain('Karl'); expect(document.body.textContent).toContain('Elfriede'); expect(document.body.textContent).toContain('Feldpost'); }); it('renders the precision date exactly as timelineDateLabel returns (REQ-013)', () => { const entry = makeEntry({ eventDate: '1915-06-15', precision: 'MONTH' }); const expected = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd); expect(expected).toBeTruthy(); render(LetterCard, { entry }); expect(document.body.textContent).toContain(expected as string); }); it('renders no date chip when timelineDateLabel returns null (REQ-013)', () => { const entry = makeEntry({ precision: 'UNKNOWN', eventDate: undefined }); render(LetterCard, { entry }); const chip = document.querySelector('[data-testid="letter-date"]'); expect(chip).toBeNull(); }); it('shows "Unbekannt" for an empty sender, never a bare arrow (REQ-014)', () => { render(LetterCard, { entry: makeEntry({ senderName: '', receiverName: 'Elfriede' }) }); expect(document.body.textContent).toContain('Unbekannt'); }); it('shows "Unbekannt" for an empty receiver (REQ-014)', () => { render(LetterCard, { entry: makeEntry({ senderName: 'Karl', receiverName: '' }) }); expect(document.body.textContent).toContain('Unbekannt'); }); it('links to exactly /documents/{documentId} with no target (REQ-023)', () => { render(LetterCard, { entry: makeEntry({ documentId: DOC_ID }) }); const link = document.querySelector('a') as HTMLAnchorElement; expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`); expect(link.hasAttribute('target')).toBe(false); }); it('has a touch target of at least 44px (REQ-020)', () => { render(LetterCard, { entry: makeEntry() }); const link = document.querySelector('a') as HTMLAnchorElement; expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); }); it('prefixes a present title with an aria-hidden ✉ and an sr-only "Brief" label (REQ-008)', () => { render(LetterCard, { entry: makeEntry({ title: 'Brief aus Stettin', documentId: DOC_ID }) }); const hidden = document.querySelector('[aria-hidden="true"]'); expect(hidden?.textContent).toContain('✉'); const srOnly = document.querySelector('.sr-only'); expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label()); // The glyph is decorative chrome — the document link is unchanged. const link = document.querySelector('a') as HTMLAnchorElement; expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`); }); it('renders no ✉ glyph and no "Brief" label when the title is empty (REQ-016)', () => { render(LetterCard, { entry: makeEntry({ title: '', senderName: 'Karl', receiverName: 'Elfriede' }) }); expect(document.body.textContent).not.toContain('✉'); expect(document.querySelector('.sr-only')).toBeNull(); // The row still shows sender → receiver and the date. expect(document.body.textContent).toContain('Karl'); expect(document.body.textContent).toContain('Elfriede'); expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull(); }); it('renders an HTML-bearing title verbatim as text, never as markup (security, REQ-021)', () => { const evil = ''; render(LetterCard, { entry: makeEntry({ title: evil }) }); expect(document.body.textContent).toContain(evil); expect(document.querySelector('a script')).toBeNull(); }); it('renders one root-tag chip beneath the meta line when rootTagName is present (REQ-008)', () => { render(LetterCard, { entry: makeEntry({ rootTagName: 'Familie', rootTagColor: 'sage' }) }); const chips = document.querySelectorAll('[data-testid="tag-chip"]'); expect(chips).toHaveLength(1); expect(chips[0].textContent).toContain('Familie'); }); it('renders no chip when the letter has no root tag (REQ-005/006)', () => { render(LetterCard, { entry: makeEntry({ rootTagName: undefined, rootTagColor: undefined }) }); expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull(); }); it('keeps a long tag name from overflowing the card at 320px, full name in the title (REQ-008a)', () => { document.body.style.width = '320px'; render(LetterCard, { entry: makeEntry({ rootTagName: 'Briefe von der Front und aus der Heimat', rootTagColor: 'sienna' }) }); const link = document.querySelector('a') as HTMLAnchorElement; expect(link.scrollWidth).toBeLessThanOrEqual(link.clientWidth); const chip = document.querySelector('[data-testid="tag-chip"]') as HTMLElement; expect(chip.getAttribute('title')).toBe('Briefe von der Front und aus der Heimat'); document.body.style.width = ''; }); it('renders the chip inside an expanded YearLetterStrip too (REQ-012)', async () => { render(YearLetterStrip, { letters: [makeEntry({ rootTagName: 'Familie', rootTagColor: 'sage', documentId: 'doc-1' })], year: 1909 }); (document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement).click(); await tick(); const chip = document.querySelector('[data-testid="tag-chip"]'); expect(chip?.textContent).toContain('Familie'); }); }); describe('LetterCard — grouping variants (#827, REQ-014/017)', () => { it('carries the .lcard.ev class in the event variant (REQ-014)', () => { render(LetterCard, { entry: makeEntry(), variant: 'event' }); expect(document.querySelector('a.lcard.ev')).not.toBeNull(); }); it('is a plain card with no .ev marker by default (REQ-014)', () => { render(LetterCard, { entry: makeEntry() }); expect(document.querySelector('a.ev')).toBeNull(); }); it('suppresses the per-letter tag chip when asked, even with a root tag (REQ-017)', () => { render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }), suppressTagChip: true }); expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull(); }); it('still shows the per-letter tag chip when not suppressed — Datum/Ereignis (REQ-017)', () => { render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) }); expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull(); }); it('drops the redundant date line in the compact variant when a title is present (#827)', () => { // Inside a per-year bucket the year already frames the time, and these archive // titles embed the date — so the compact in-bucket card omits the date chip. render(LetterCard, { entry: makeEntry({ title: 'H-0023 – 6. Juli 1916' }), compact: true }); expect(document.querySelector('[data-testid="letter-date"]')).toBeNull(); expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown }); it('keeps the date in the compact variant when the letter has no title (#827)', () => { render(LetterCard, { entry: makeEntry({ title: undefined }), compact: true }); expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull(); }); it('renders the compact variant on a single tighter row (#827)', () => { render(LetterCard, { entry: makeEntry(), compact: true }); expect(document.querySelector('a.lcard.compact')).not.toBeNull(); }); });