Files
familienarchiv/frontend/src/lib/timeline/LetterCard.svelte.spec.ts
marcel 49d8ab78b4
Some checks failed
CI / Unit & Component Tests (push) Successful in 7m35s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 12m42s
CI / fail2ban Regex (push) Successful in 1m50s
CI / Semgrep Security Scan (push) Successful in 37s
CI / Compose Bucket Idempotency (push) Successful in 1m18s
Cluster event letters inline in the chronological /zeitstrahl (no grouping toggle) (#851)
Closes #850

## Summary

On `/zeitstrahl`, a curated event that has letters linked to it now renders as a contained event card — the event is the card header (accent glyph, title, `{date} · {kuratiert|abgeleitet}` subtitle, count, and a curator edit link), with its linked letters listed inside (first 5, then a keyboard-operable show-more/less toggle). Letters in a year *other* than the event's band get a lighter cross-year `✉ title` card. Every other letter stays a plain, alternating, density-folding chronological letter. There is **no grouping control** — clustering is automatic and always on. The meta-line drops its `Gruppierung: Datum` segment.

This supersedes #827: it keeps that branch's event-card clustering and the computed `linkedEventId`, and drops the toggle, the Thema mode, and the "Weitere Briefe" drawer.

## What changed

**Backend**
- `TimelineEntryDTO` gains a nullable `linkedEventId` (UUID; not `@Schema(REQUIRED)`).
- `TimelineService.resolveLetterEventLinks` resolves each letter's curated event in one batched pass over the events it already loads — no per-letter query, no new column, no Flyway migration.
- Regenerated the single `linkedEventId?` field in `api.ts`.

**Frontend**
- New `eventClustering.ts` (`buildEventLookup`, `splitYearLetters`, `CLUSTER_PREVIEW=5`) — filter-then-cluster: a letter clusters only if its `linkedEventId` is set AND present in the lookup, otherwise it stays loose.
- New `EventCluster.svelte` — the contained event card (same-year event header + edit link, or cross-year ✉ text header; first-5 + show-more).
- `LetterCard.svelte` gains `compact` + `variant='event'` (the `.lcard.ev` in-card letter).
- `YearBand.svelte` rebuilt to render event clusters inline; loose letters keep the alternating layout and density strip, and the strip counts **only** loose letters (no duplication).
- `TimelineView.svelte` builds the event lookup once and threads it + `canWrite` to each band.
- `+page.svelte` drops the grouping meta segment; the unused `timeline_grouping_date` key removed from de/en/es.
- New `timeline_bucket_show_more`/`_less` keys in all locales.
- REQ-010 `{@html}` grep gate over `lib/timeline/`.

## Tests (real runs)

- Backend `TimelineServiceTest`: **30 passed** (incl. the 2 new `linkedEventId` tests); `DerivedEventsAssemblyTest`: 17 passed; backend main sources compile.
- Frontend client sweep (`LetterCard`, `EventCluster`, `YearBand`, `TimelineView`, `zeitstrahl/page`): **81 passed** (5 files).
- Frontend server sweep (`eventClustering`, `messages`, `timeline-no-raw-html`): **18 passed** (3 files).
- `svelte-check`: no new errors in the touched files (pre-existing baseline noise elsewhere unchanged).

RTM: thirteen `REQ-001..013` rows added for #850 (feature `inline-event-clustering`), Status Done.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #851
2026-06-16 14:38:09 +02:00

185 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = '<script>alert(1)</script>';
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 — event-cluster variants (#850, REQ-002)', () => {
it('carries the .lcard.ev class in the event variant (REQ-002)', () => {
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-006)', () => {
render(LetterCard, { entry: makeEntry() });
expect(document.querySelector('a.ev')).toBeNull();
});
it('suppresses the per-letter tag chip when asked, even with a root tag', () => {
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', () => {
render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) });
expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull();
});
it('drops the compact date chip only when the title actually embeds the formatted date (#850)', () => {
// An archive title like "H-0023 6. Juli 1916" already carries the date, so inside an
// event card (where the band frames the time) the redundant chip is dropped.
const entry = makeEntry({ eventDate: '1916-07-06', precision: 'DAY' });
const dateLabel = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
render(LetterCard, { entry: { ...entry, title: `H-0023 ${dateLabel}` }, compact: true });
expect(document.querySelector('[data-testid="letter-date"]')).toBeNull();
expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown
});
it('keeps the compact date chip when the title does NOT embed the date (#850, finding #4)', () => {
// Titles are free-form OCR text — a titled letter whose title carries no date must keep
// its month/day, since inside an event card the band frames only the year.
render(LetterCard, {
entry: makeEntry({ eventDate: '1916-07-06', precision: 'DAY', title: 'Brief an Mutter' }),
compact: true
});
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
});
it('keeps the date in the compact variant when the letter has no title (#850)', () => {
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 (#850)', () => {
render(LetterCard, { entry: makeEntry(), compact: true });
expect(document.querySelector('a.lcard.compact')).not.toBeNull();
});
});