diff --git a/.specify/rtm.md b/.specify/rtm.md index 08c560d3..3e6d369a 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -209,3 +209,11 @@ | REQ-013 | `GET /api/timeline` failure → existing localized error state via `getErrorMessage(code)` (unchanged #779) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` (unchanged error path) | covered by #779 `zeitstrahl` error-state tests (regression — no change) | Done | | REQ-014 | HISTORICAL curated event with ≥1 linked letter keeps its full-width WorldBand — never clusters into a card (preserves #779 REQ-009); its letters stay loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`buildEventLookup` excludes HISTORICAL) | `eventClustering.spec.ts#excludes a HISTORICAL event so its letters stay loose, keeping its WorldBand (REQ-014)`; `TimelineView.svelte.spec.ts#keeps a HISTORICAL event a WorldBand with a same-year linked letter — never clusters (REQ-014)` | Done | | REQ-015 | a cross-year ✉ card is placed at its earliest linked letter's chronological position in the band — never appended after later-dated loose letters | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#interleaves a cross-year card before a later-dated loose letter in the same band (REQ-015)` | Done | +| REQ-001 | `TimelineEvent.description` flows through `TimelineEntryDTO` to the frontend; null for letters and derived events | #844 | event-note-display | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#mapEvent`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#mapEvent_populates_description_from_event`, `#mapEvent_leaves_description_null_when_event_has_none`, `#mapDocument_leaves_description_null_for_letter`; `TimelineControllerTest#timelineIncludesEventDescription` | Done | +| REQ-002 | description text is HTML-escaped; no `{@html}` — Svelte `{...}` interpolation ensures XSS safety (CWE-79) | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#escapesHtml — renders XSS payload as inert text, no injected element` | Done | +| REQ-003 | newlines in the description are preserved visually via `white-space: pre-line` | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#preservesLineBreaks — note element carries whitespace-pre-line class` | Done | +| REQ-004 | description renders below the title/subtitle line in EventPill (PERSONAL) and WorldBand (HISTORICAL) | #844 | event-note-display | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `e2e/zeitstrahl-note.spec.ts#PERSONAL curated event note appears below its title`, `#HISTORICAL curated event note appears below its title` | Done | +| REQ-005 | description longer than 3 lines is clamped and shows a disclosure toggle with aria-expanded=false (show more) | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#clampsAndShowsToggle — long note shows "mehr anzeigen" with aria-expanded=false` | Done | +| REQ-006 | short description (≤ 3 lines) renders fully with no toggle | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#shortNoteNoToggle — a one-line note renders fully with no disclosure control` | Done | +| REQ-007 | clicking the toggle expands the note (aria-expanded=true, "show less"); clicking again collapses it | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#toggleExpandsCollapses — click expands, re-click collapses` | Done | +| REQ-008 | null, empty, or blank-only description renders nothing (no note element in DOM) | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#blankNoteRendersNothing — null/empty string/blank-only string produces no note element` (3 cases) | Done | diff --git a/frontend/e2e/zeitstrahl-note.spec.ts b/frontend/e2e/zeitstrahl-note.spec.ts new file mode 100644 index 00000000..0279fa8f --- /dev/null +++ b/frontend/e2e/zeitstrahl-note.spec.ts @@ -0,0 +1,77 @@ +import { test, expect, type APIRequestContext } from '@playwright/test'; + +/** + * Curator-note display on /zeitstrahl (#844) — validates that a description saved + * against a curated timeline event surfaces in the DOM under that event's title. + * Covers REQ-001 (description flows from backend) and REQ-004 (rendered below title). + */ + +const stamp = () => new Date().toISOString().replace(/[^0-9]/g, ''); + +async function createEvent( + request: APIRequestContext, + type: 'PERSONAL' | 'HISTORICAL', + title: string, + description: string +): Promise { + const res = await request.post('/api/timeline/events', { + data: { + title, + type, + eventDate: '1918-11-11', + precision: 'DAY', + description + } + }); + if (!res.ok()) throw new Error(`create event failed: ${res.status()} ${await res.text()}`); + return (await res.json()).id as string; +} + +async function deleteEvent(request: APIRequestContext, id: string): Promise { + await request.delete(`/api/timeline/events/${id}`); +} + +test.describe('Zeitstrahl event note (#844)', () => { + let personalEventId: string; + let historicalEventId: string; + const personalTitle = `E2E Persönlich ${stamp()}`; + const historicalTitle = `E2E Historisch ${stamp()}`; + const personalNote = 'Persönliche Notiz für diesen Moment.'; + const historicalNote = 'Historischer Kontext für dieses Ereignis.'; + + test.beforeAll(async ({ request }) => { + personalEventId = await createEvent(request, 'PERSONAL', personalTitle, personalNote); + historicalEventId = await createEvent(request, 'HISTORICAL', historicalTitle, historicalNote); + }); + + test.afterAll(async ({ request }) => { + if (personalEventId) await deleteEvent(request, personalEventId); + if (historicalEventId) await deleteEvent(request, historicalEventId); + }); + + test('PERSONAL curated event note appears below its title on /zeitstrahl (REQ-001, REQ-004)', async ({ + page + }) => { + await page.goto('/zeitstrahl'); + + const personalEntry = page.locator('li').filter({ hasText: personalTitle }).first(); + await expect(personalEntry).toBeVisible({ timeout: 10000 }); + + const note = personalEntry.locator('[data-testid="event-note"]'); + await expect(note).toBeVisible(); + await expect(note).toContainText(personalNote); + }); + + test('HISTORICAL curated event note appears below its title on /zeitstrahl (REQ-001, REQ-004)', async ({ + page + }) => { + await page.goto('/zeitstrahl'); + + const historicalEntry = page.locator('li').filter({ hasText: historicalTitle }).first(); + await expect(historicalEntry).toBeVisible({ timeout: 10000 }); + + const note = historicalEntry.locator('[data-testid="event-note"]'); + await expect(note).toBeVisible(); + await expect(note).toContainText(historicalNote); + }); +});