Add the grouping $state beside the #780 layer-filter state, render the GroupingControl stacked above the filter trigger (disabled, but kept in place, when no loose letters remain), make the meta-line grouping label track the active mode, and thread groupingMode into TimelineView — filter-then-group, no refetch. Refs #827 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
326 lines
12 KiB
TypeScript
326 lines
12 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 }));
|
||
});
|
||
});
|
||
|
||
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<typeof makeTimelineDTO>) => ({
|
||
...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');
|
||
});
|
||
});
|