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