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
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
185 lines
8.3 KiB
TypeScript
185 lines
8.3 KiB
TypeScript
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();
|
||
});
|
||
});
|