test(timeline): add Playwright e2e + RTM rows for event note (#844)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m58s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 6m21s
CI / fail2ban Regex (pull_request) Failing after 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 20s

zeitstrahl-note.spec.ts seeds PERSONAL and HISTORICAL events with
descriptions via API and asserts the note appears in DOM (REQ-001, REQ-004).
RTM rows REQ-001–REQ-008 for #844 all marked Done.

Closes #844
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-16 15:08:25 +02:00
parent a63b8115a1
commit 82979be705
2 changed files with 85 additions and 0 deletions

View File

@@ -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-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-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-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 |

View File

@@ -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<string> {
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<void> {
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);
});
});