The spine now runs mint → navy → slate, matching the spec's life-thread, using --palette-mint / --palette-navy / --c-tag-slate (no --palette-slate token exists). Semantic tokens only — no raw hex. Refs #833 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
9.6 KiB
TypeScript
277 lines
9.6 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
||
import { cleanup, render } from 'vitest-browser-svelte';
|
||
import * as m from '$lib/paraglide/messages.js';
|
||
import TimelineView from './TimelineView.svelte';
|
||
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
|
||
|
||
afterEach(() => cleanup());
|
||
|
||
describe('TimelineView', () => {
|
||
it('shows the empty state and no ol when there are no years and no undated (REQ-017)', () => {
|
||
render(TimelineView, { timeline: makeTimelineDTO() });
|
||
expect(document.body.textContent).toContain('Noch keine Ereignisse.');
|
||
expect(document.querySelector('ol')).toBeNull();
|
||
expect(document.querySelector('section')).toBeNull();
|
||
});
|
||
|
||
it('renders the timeline as a single <ol> with each band a <section>, ascending (REQ-006)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({
|
||
years: [
|
||
makeYear(1914, [makeEntry({ documentId: 'a' })]),
|
||
makeYear(1916, [makeEntry({ documentId: 'b' })])
|
||
]
|
||
})
|
||
});
|
||
expect(document.querySelectorAll('ol')).toHaveLength(1);
|
||
const headings = Array.from(document.querySelectorAll('section h2')).map((h) => h.textContent);
|
||
expect(headings.some((t) => t?.includes('1914'))).toBe(true);
|
||
const order = headings.map((t) => t?.trim());
|
||
expect(order.indexOf('1914')).toBeLessThan(order.indexOf('1916'));
|
||
});
|
||
|
||
it('folds an interior run of empty years into one GapSpan (REQ-015)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({
|
||
years: [
|
||
makeYear(1909, [makeEntry({ documentId: 'a' })]),
|
||
makeYear(1915, [makeEntry({ documentId: 'b' })])
|
||
]
|
||
})
|
||
});
|
||
expect(document.body.textContent).toContain('1910–1914');
|
||
expect(document.body.textContent).toContain('keine Einträge');
|
||
});
|
||
|
||
it('folds a single empty interior year as a single year (REQ-015)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({
|
||
years: [
|
||
makeYear(1911, [makeEntry({ documentId: 'a' })]),
|
||
makeYear(1913, [makeEntry({ documentId: 'b' })])
|
||
]
|
||
})
|
||
});
|
||
expect(document.body.textContent).toContain('1912');
|
||
expect(document.body.textContent).not.toContain('1912–1912');
|
||
});
|
||
|
||
it('renders an "Ohne Datum" section when undated is non-empty (REQ-016)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({
|
||
years: [makeYear(1914, [makeEntry({ documentId: 'a' })])],
|
||
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
|
||
})
|
||
});
|
||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||
expect(document.body.textContent).toContain('Ohne Datum');
|
||
});
|
||
|
||
it('omits the "Ohne Datum" section from the DOM when undated is empty (REQ-016)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])] })
|
||
});
|
||
expect(document.querySelector('[data-testid="undated-section"]')).toBeNull();
|
||
});
|
||
|
||
it('frames the undated section with a dashed border and shows the count (REQ-012)', () => {
|
||
const undated = Array.from({ length: 11 }, (_, i) =>
|
||
makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: `u-${i}` })
|
||
);
|
||
render(TimelineView, { timeline: makeTimelineDTO({ undated }) });
|
||
const section = document.querySelector('[data-testid="undated-section"]') as HTMLElement;
|
||
expect(section).not.toBeNull();
|
||
expect(section.classList.contains('border-dashed')).toBe(true);
|
||
const h2 = section.querySelector('h2');
|
||
expect(h2?.textContent).toContain(m.timeline_undated_section());
|
||
expect(h2?.textContent).toContain('11');
|
||
});
|
||
|
||
it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({
|
||
years: [
|
||
makeYear(1914, [makeEntry({ documentId: 'a' })]),
|
||
makeYear(1915, [makeEntry({ documentId: 'b' })])
|
||
],
|
||
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
|
||
}),
|
||
personId: undefined
|
||
});
|
||
// Two year bands inside the <ol>, plus the separate undated section.
|
||
expect(document.querySelectorAll('ol section h2')).toHaveLength(2);
|
||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||
});
|
||
|
||
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({
|
||
undated: [
|
||
makeEntry({
|
||
kind: 'EVENT',
|
||
type: 'PERSONAL',
|
||
derived: false,
|
||
eventId: 'e1',
|
||
precision: 'UNKNOWN',
|
||
eventDate: undefined,
|
||
title: 'Auswanderung',
|
||
senderName: '',
|
||
receiverName: '',
|
||
documentId: undefined
|
||
})
|
||
]
|
||
})
|
||
});
|
||
// The event renders inside the undated section…
|
||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||
expect(document.body.textContent).toContain('Auswanderung');
|
||
// …as an EventPill (its edit affordance), never as a letter card linking
|
||
// to /documents/undefined with "Unbekannt → Unbekannt".
|
||
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
|
||
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
|
||
expect(document.body.textContent).not.toContain('Unbekannt');
|
||
});
|
||
|
||
it('renders an undated HISTORICAL EVENT as a world band, not a letter card (REQ-009/016)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({
|
||
undated: [
|
||
makeEntry({
|
||
kind: 'EVENT',
|
||
type: 'HISTORICAL',
|
||
derived: false,
|
||
precision: 'UNKNOWN',
|
||
eventDate: undefined,
|
||
title: 'Weltwirtschaftskrise',
|
||
senderName: '',
|
||
receiverName: '',
|
||
documentId: undefined
|
||
})
|
||
]
|
||
})
|
||
});
|
||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||
expect(document.body.textContent).toContain('Weltwirtschaftskrise');
|
||
// HISTORICAL → WorldBand carries the sr-only "Weltgeschehen" cue (REQ-018),
|
||
// not a broken document link.
|
||
expect(document.body.textContent).toContain('Weltgeschehen');
|
||
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
|
||
});
|
||
|
||
it('still renders an undated LETTER as a letter card (REQ-016)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({
|
||
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
|
||
})
|
||
});
|
||
expect(document.querySelector('a[href="/documents/u1"]')).not.toBeNull();
|
||
});
|
||
|
||
it('renders two derived events in one band without key collision (no-double-null-key)', () => {
|
||
const a = makeEntry({
|
||
kind: 'EVENT',
|
||
derived: true,
|
||
derivedType: 'BIRTH',
|
||
title: 'Geburt: Anna',
|
||
senderName: '',
|
||
receiverName: '',
|
||
documentId: undefined,
|
||
eventId: undefined,
|
||
linkedPersonIds: ['p1']
|
||
});
|
||
const b = makeEntry({
|
||
kind: 'EVENT',
|
||
derived: true,
|
||
derivedType: 'BIRTH',
|
||
title: 'Geburt: Bertha',
|
||
senderName: '',
|
||
receiverName: '',
|
||
documentId: undefined,
|
||
eventId: undefined,
|
||
linkedPersonIds: ['p2']
|
||
});
|
||
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1915, [a, b])] }) });
|
||
expect(document.body.textContent).toContain('Geburt: Anna');
|
||
expect(document.body.textContent).toContain('Geburt: Bertha');
|
||
});
|
||
|
||
it('shows the redundant non-color cue label for each layer (REQ-018)', () => {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({
|
||
years: [
|
||
makeYear(1914, [
|
||
makeEntry({
|
||
kind: 'EVENT',
|
||
derived: true,
|
||
derivedType: 'BIRTH',
|
||
title: 'Geburt: Hans',
|
||
senderName: '',
|
||
receiverName: '',
|
||
documentId: undefined
|
||
}),
|
||
makeEntry({
|
||
kind: 'EVENT',
|
||
derived: false,
|
||
type: 'PERSONAL',
|
||
eventId: 'e1',
|
||
title: 'Auswanderung',
|
||
senderName: '',
|
||
receiverName: '',
|
||
documentId: undefined
|
||
}),
|
||
makeEntry({
|
||
kind: 'EVENT',
|
||
derived: false,
|
||
type: 'HISTORICAL',
|
||
precision: 'RANGE',
|
||
eventDate: '1914-01-01',
|
||
eventDateEnd: '1918-12-31',
|
||
title: 'Erster Weltkrieg',
|
||
senderName: '',
|
||
receiverName: '',
|
||
documentId: undefined
|
||
})
|
||
])
|
||
]
|
||
})
|
||
});
|
||
expect(document.body.textContent).toContain('Weltgeschehen');
|
||
expect(document.body.textContent).toContain('Familie');
|
||
expect(document.body.textContent).toContain('Geburt');
|
||
});
|
||
|
||
it('paints the axis with a three-stop mint→navy→slate gradient (REQ-006)', () => {
|
||
// The palette tokens live in layout.css, which component tests don't load,
|
||
// so define exactly the three the gradient must reference; an undefined
|
||
// fourth token would invalidate the declaration and yield "none".
|
||
const root = document.documentElement;
|
||
root.style.setProperty('--palette-mint', '#a1dcd8');
|
||
root.style.setProperty('--palette-navy', '#012851');
|
||
root.style.setProperty('--c-tag-slate', '#607080');
|
||
try {
|
||
render(TimelineView, {
|
||
timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry()])] })
|
||
});
|
||
const axis = document.querySelector('.timeline-axis') as HTMLElement;
|
||
expect(axis).not.toBeNull();
|
||
const bg = getComputedStyle(axis, '::before').backgroundImage;
|
||
expect(bg).toContain('gradient');
|
||
// three colour stops: mint, navy, slate
|
||
expect((bg.match(/rgb/g) ?? []).length).toBe(3);
|
||
} finally {
|
||
root.style.removeProperty('--palette-mint');
|
||
root.style.removeProperty('--palette-navy');
|
||
root.style.removeProperty('--c-tag-slate');
|
||
}
|
||
});
|
||
|
||
it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => {
|
||
const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` }));
|
||
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) });
|
||
const sides = Array.from(document.querySelectorAll('.letter-row')).map((el) =>
|
||
el.getAttribute('data-side')
|
||
);
|
||
expect(sides).toEqual(['left', 'right', 'left', 'right']);
|
||
});
|
||
});
|