import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import * as m from '$lib/paraglide/messages.js'; import Page from './+page.svelte'; import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories'; afterEach(() => cleanup()); const event = (title: string) => makeEntry({ kind: 'EVENT', derived: true, derivedType: 'BIRTH', title, senderName: '', receiverName: '', documentId: undefined }); // The route's PageData merges the layout's auth fields with the page load's // timeline; the component only reads `timeline`, but the full shape keeps the // test type-clean. const pageData = (timeline: ReturnType) => ({ user: undefined, canWrite: false, canAnnotate: false, canBlogWrite: false, timeline }); describe('/zeitstrahl page', () => { it('wraps the timeline in a padded canvas surface, without an outer border (REQ-001)', () => { render(Page, { data: pageData(makeTimelineDTO({ years: [makeYear(1909, [makeEntry()])] })) }); const canvas = document.querySelector('[data-testid="timeline-canvas"]'); expect(canvas).not.toBeNull(); expect(canvas?.classList.contains('bg-canvas')).toBe(true); // The outer border was dropped in review (the page is already bg-canvas). expect(canvas?.classList.contains('border')).toBe(false); // the timeline renders inside the surface expect(canvas?.querySelector('ol')).not.toBeNull(); }); it('renders the meta sub-line with range, counts, and grouping (REQ-002)', () => { const dto = makeTimelineDTO({ years: [ makeYear(1909, [ makeEntry({ documentId: 'a' }), makeEntry({ documentId: 'b' }), event('Geburt') ]), makeYear(1924, [makeEntry({ documentId: 'c' }), event('Tod')]) ] }); render(Page, { data: pageData(dto) }); const sub = document.querySelector('[data-testid="timeline-meta"]'); expect(sub?.textContent).toContain('1909–1924'); expect(sub?.textContent).toContain(m.timeline_letters_count({ count: 3 })); expect(sub?.textContent).toContain(m.timeline_events_count({ count: 2 })); expect(sub?.textContent).toContain(m.timeline_grouping_date()); }); it('omits the range segment when there are no year bands (REQ-002)', () => { render(Page, { data: pageData(makeTimelineDTO({ undated: [makeEntry({ documentId: 'u1' })] })) }); const sub = document.querySelector('[data-testid="timeline-meta"]'); expect(sub).not.toBeNull(); expect(sub?.textContent).not.toContain('–'); expect(sub?.textContent).toContain(m.timeline_letters_count_singular()); }); it('omits the entire sub-line for an empty timeline (REQ-002)', () => { render(Page, { data: pageData(makeTimelineDTO()) }); expect(document.querySelector('[data-testid="timeline-meta"]')).toBeNull(); }); it('drops the letters segment instead of showing "0 Briefe" (REQ-002)', () => { render(Page, { data: pageData(makeTimelineDTO({ years: [makeYear(1914, [event('Geburt')])] })) }); const sub = document.querySelector('[data-testid="timeline-meta"]'); expect(sub).not.toBeNull(); expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 0 })); expect(sub?.textContent).toContain(m.timeline_grouping_date()); }); it('drops the events segment instead of showing "0 Ereignisse" (REQ-002)', () => { render(Page, { data: pageData(makeTimelineDTO({ undated: [makeEntry({ documentId: 'u1' })] })) }); const sub = document.querySelector('[data-testid="timeline-meta"]'); expect(sub).not.toBeNull(); expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 0 })); }); it('uses singular count labels for exactly one letter and one event (REQ-002)', () => { render(Page, { data: pageData( makeTimelineDTO({ years: [makeYear(1914, [makeEntry({ documentId: 'a' }), event('Geburt')])] }) ) }); const sub = document.querySelector('[data-testid="timeline-meta"]'); expect(sub?.textContent).toContain(m.timeline_letters_count_singular()); expect(sub?.textContent).toContain(m.timeline_events_count_singular()); // never the "1 Briefe"/"1 Ereignisse" plural forms for a count of one expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 1 })); expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 1 })); }); }); describe('/zeitstrahl layer filter (#780)', () => { const letter = (title: string, documentId: string) => makeEntry({ documentId, title }); const historical = (title: string) => makeEntry({ kind: 'EVENT', type: 'HISTORICAL', derived: false, eventId: 'h1', documentId: undefined, title, senderName: '', receiverName: '' }); const personal = (title: string) => makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: false, eventId: 'p1', documentId: undefined, title, senderName: '', receiverName: '' }); const mixed = () => makeTimelineDTO({ years: [ makeYear(1915, [ letter('Brief Eins', 'd1'), historical('Erster Weltkrieg'), personal('Umzug nach Berlin') ]) ] }); async function openBar() { await page.getByTestId('timeline-filter-trigger').click(); } it('hides letter cards when the Letters layer is off and restores them, with no fetch (REQ-005/002)', async () => { render(Page, { data: pageData(mixed()) }); await expect.element(page.getByText('Brief Eins')).toBeVisible(); await openBar(); await page.getByTestId('timeline-filter-letters').click(); await expect.poll(() => page.getByText('Brief Eins').query()).toBeNull(); await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible(); await page.getByTestId('timeline-filter-letters').click(); await expect.element(page.getByText('Brief Eins')).toBeVisible(); }); it('hides historical event cards when the Historical layer is off (REQ-004)', async () => { render(Page, { data: pageData(mixed()) }); await openBar(); await page.getByTestId('timeline-filter-historical').click(); await expect.poll(() => page.getByText('Erster Weltkrieg').query()).toBeNull(); await expect.element(page.getByText('Brief Eins')).toBeVisible(); await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible(); }); it('hides personal event cards when the Personal layer is off (REQ-003)', async () => { render(Page, { data: pageData(mixed()) }); await openBar(); await page.getByTestId('timeline-filter-personal').click(); await expect.poll(() => page.getByText('Umzug nach Berlin').query()).toBeNull(); await expect.element(page.getByText('Brief Eins')).toBeVisible(); await expect.element(page.getByText('Erster Weltkrieg')).toBeVisible(); }); it('shows the filtered-empty message + reset below the open bar when all layers are off (REQ-006)', async () => { render(Page, { data: pageData(mixed()) }); await openBar(); await page.getByTestId('timeline-filter-personal').click(); await page.getByTestId('timeline-filter-historical').click(); await page.getByTestId('timeline-filter-letters').click(); await expect.element(page.getByText(m.timeline_filter_empty_state())).toBeVisible(); await expect.element(page.getByTestId('timeline-filter-empty-reset')).toBeVisible(); // the generic TimelineView empty state is never what shows for a filtered-empty view expect(page.getByText(m.timeline_empty_state()).query()).toBeNull(); // the one-click reset restores every layer await page.getByTestId('timeline-filter-empty-reset').click(); await expect.element(page.getByText('Brief Eins')).toBeVisible(); }); it('recomputes the meta-line counts from the filtered view, so a hidden layer drops out of the totals (#780, resolves D1)', async () => { render(Page, { data: pageData(mixed()) }); const meta = page.getByTestId('timeline-meta'); // all layers on → the one letter and the two events are counted await expect.element(meta).toHaveTextContent(m.timeline_letters_count_singular()); await expect.element(meta).toHaveTextContent(m.timeline_events_count({ count: 2 })); await openBar(); await page.getByTestId('timeline-filter-letters').click(); // the hidden letter leaves the count instead of lingering as "1 Brief"; // the event total is untouched await expect.element(meta).not.toHaveTextContent(m.timeline_letters_count_singular()); await expect.element(meta).toHaveTextContent(m.timeline_events_count({ count: 2 })); }); }); describe('/zeitstrahl curator affordances (#842)', () => { const curated = (eventId: string) => makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: false, eventId, title: 'Auswanderung', senderName: '', receiverName: '', documentId: undefined }); const withWrite = (timeline: ReturnType) => ({ ...pageData(timeline), canWrite: true }); it('renders the add-event CTA in a wrapping header when the viewer can write (REQ-001)', () => { render(Page, { data: withWrite(makeTimelineDTO({ years: [makeYear(1909, [makeEntry()])] })) }); const add = document.querySelector( '[data-testid="timeline-add-event"]' ) as HTMLAnchorElement | null; expect(add).not.toBeNull(); expect(add?.getAttribute('href')).toBe('/zeitstrahl/events/new'); // The header wraps so the CTA drops below the heading at narrow widths (≤360px) // rather than overflowing — REQ-001. expect(add?.closest('header')?.classList.contains('flex-wrap')).toBe(true); }); it('renders no add-event CTA when the viewer cannot write (REQ-002)', () => { render(Page, { data: pageData(makeTimelineDTO({ years: [makeYear(1909, [makeEntry()])] })) }); expect(document.querySelector('[data-testid="timeline-add-event"]')).toBeNull(); }); it('threads canWrite to the timeline so a curator sees an event edit link (REQ-001/009)', () => { render(Page, { data: withWrite(makeTimelineDTO({ years: [makeYear(1924, [curated('p9')])] })) }); expect(document.querySelector('a[href="/zeitstrahl/events/p9/edit"]')).not.toBeNull(); }); it('shows no event edit link to a reader (REQ-007)', () => { render(Page, { data: pageData(makeTimelineDTO({ years: [makeYear(1924, [curated('p9')])] })) }); expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); }); }); describe('/zeitstrahl grouping toggle (#827)', () => { const historical = () => makeEntry({ kind: 'EVENT', type: 'HISTORICAL', derived: false, eventId: 'h1', documentId: undefined, title: 'Erster Weltkrieg', senderName: '', receiverName: '' }); const mixed = () => makeTimelineDTO({ years: [ makeYear(1915, [ makeEntry({ documentId: 'd1', title: 'Brief Eins', linkedEventId: 'h1' }), historical() ]) ] }); const radio = (value: string) => document.querySelector(`[data-value="${value}"]`) as HTMLElement; it('updates the meta-line grouping label when a mode is chosen (REQ-016)', async () => { render(Page, { data: pageData(mixed()) }); const meta = page.getByTestId('timeline-meta'); await expect.element(meta).toHaveTextContent(m.timeline_grouping_date()); radio('event').click(); await expect.element(meta).toHaveTextContent(m.timeline_grouping_event()); radio('thema').click(); await expect.element(meta).toHaveTextContent(m.timeline_grouping_thema()); }); it('regroups loose letters under their event client-side, no buckets in Datum (REQ-002/003)', async () => { render(Page, { data: pageData(mixed()) }); expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull(); radio('event').click(); await expect.element(page.getByTestId('letter-bucket')).toBeVisible(); }); it('disables the grouping control when the Letters layer is off, keeping the mode (REQ-018)', async () => { render(Page, { data: pageData(mixed()) }); radio('thema').click(); const control = page.getByTestId('grouping-control'); await expect.element(control).toHaveAttribute('aria-disabled', 'false'); // turn the Letters layer off → nothing to regroup await page.getByTestId('timeline-filter-trigger').click(); await page.getByTestId('timeline-filter-letters').click(); await expect.element(control).toHaveAttribute('aria-disabled', 'true'); // the chosen mode is retained for when letters return expect(radio('thema').getAttribute('aria-checked')).toBe('true'); // re-enabling restores the enabled control with the same mode (no reset to Datum) await page.getByTestId('timeline-filter-letters').click(); await expect.element(control).toHaveAttribute('aria-disabled', 'false'); expect(radio('thema').getAttribute('aria-checked')).toBe('true'); }); });