Files
familienarchiv/frontend/src/routes/zeitstrahl/page.svelte.spec.ts
Marcel ec0e4dfa45
Some checks failed
CI / fail2ban Regex (push) Successful in 46s
nightly / npm-audit (push) Failing after 15s
CI / Unit & Component Tests (push) Successful in 5m21s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 5m48s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
nightly / deploy-staging (push) Successful in 2m14s
fix(timeline): track the meta-line counts to the filtered view
The /zeitstrahl header sub-line counted the unfiltered timeline, so a
hidden layer (e.g. Letters off) still showed its entries in the totals
("1 Brief" with no letters on screen) — the documented D1 limitation.
Derive the meta from filteredTimeline so the range and letter/event
counts always match what is actually rendered. hasContent stays on the
full timeline so the filter bar and meta line still appear whenever the
archive has content.

Refs #780

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00

216 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 { 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<typeof makeTimelineDTO>) => ({
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('19091924');
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 }));
});
});