Thread groupingMode through TimelineView → YearBand. TimelineView resolves the event lookup once over the filtered view (so Ereignis clusters never reference a filtered-out event). In non-Datum modes YearBand keeps its event pills/world-bands identical (REQ-001) and replaces the loose letters with per-year LetterBuckets (REQ-002/003/004); Datum keeps the original card/strip path. The undated bucket is unchanged in every mode. Refs #827 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
225 lines
8.7 KiB
TypeScript
225 lines
8.7 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import { cleanup, render } from 'vitest-browser-svelte';
|
|
import { page } from 'vitest/browser';
|
|
import YearBand from './YearBand.svelte';
|
|
import { makeEntry, makeYear } from './test-factories';
|
|
|
|
afterEach(async () => {
|
|
cleanup();
|
|
await page.viewport(1280, 720);
|
|
});
|
|
|
|
function manyLetters(year: number, count: number) {
|
|
return Array.from({ length: count }, (_, i) =>
|
|
makeEntry({ eventDate: `${year}-01-10`, documentId: `doc-${i}` })
|
|
);
|
|
}
|
|
|
|
describe('YearBand', () => {
|
|
it('renders a section with a sticky h2 at top:4rem showing the year (REQ-006)', () => {
|
|
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
|
const section = document.querySelector('section');
|
|
expect(section).not.toBeNull();
|
|
const h2 = section?.querySelector('h2');
|
|
expect(h2?.textContent).toContain('1914');
|
|
const cs = getComputedStyle(h2 as HTMLElement);
|
|
expect(cs.position).toBe('sticky');
|
|
expect(cs.top).toBe('64px');
|
|
});
|
|
|
|
it('renders each letter as a card when the band holds <= 12 letters (REQ-011)', () => {
|
|
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
|
|
expect(document.querySelectorAll('a')).toHaveLength(3);
|
|
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
|
|
});
|
|
|
|
it('renders a single strip when the band holds > 12 letters (REQ-012)', () => {
|
|
render(YearBand, { year: makeYear(1915, manyLetters(1915, 30)) });
|
|
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
|
|
// collapsed: no individual letter links yet
|
|
expect(document.querySelectorAll('a')).toHaveLength(0);
|
|
});
|
|
|
|
it('renders entries in DTO order — DAY-precision letter above a YEAR-precision letter (REQ-003)', () => {
|
|
const dayLetter = makeEntry({
|
|
precision: 'DAY',
|
|
eventDate: '1923-04-12',
|
|
title: 'Tagesgenau',
|
|
documentId: 'day'
|
|
});
|
|
const yearLetter = makeEntry({
|
|
precision: 'YEAR',
|
|
eventDate: '1923-01-01',
|
|
title: 'Nur Jahr',
|
|
documentId: 'year'
|
|
});
|
|
render(YearBand, { year: makeYear(1923, [dayLetter, yearLetter]) });
|
|
const links = Array.from(document.querySelectorAll('a'));
|
|
expect(links[0].getAttribute('href')).toBe('/documents/day');
|
|
expect(links[1].getAttribute('href')).toBe('/documents/year');
|
|
});
|
|
|
|
it('renders an EVENT as a pill and a HISTORICAL event as a band', () => {
|
|
const pill = makeEntry({
|
|
kind: 'EVENT',
|
|
derived: true,
|
|
derivedType: 'MARRIAGE',
|
|
title: 'Heirat',
|
|
senderName: '',
|
|
receiverName: '',
|
|
documentId: undefined
|
|
});
|
|
const band = makeEntry({
|
|
kind: 'EVENT',
|
|
derived: false,
|
|
type: 'HISTORICAL',
|
|
precision: 'RANGE',
|
|
eventDate: '1914-01-01',
|
|
eventDateEnd: '1918-12-31',
|
|
title: 'Erster Weltkrieg',
|
|
senderName: '',
|
|
receiverName: '',
|
|
documentId: undefined
|
|
});
|
|
render(YearBand, { year: makeYear(1914, [pill, band]) });
|
|
expect(document.body.textContent).toContain('Heirat');
|
|
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
|
|
});
|
|
|
|
it('centers the year badge on the axis at desktop (REQ-003)', async () => {
|
|
await page.viewport(1440, 900);
|
|
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
|
const section = document.querySelector('section') as HTMLElement;
|
|
const badge = document.querySelector('[data-testid="year-label"]') as HTMLElement;
|
|
const s = section.getBoundingClientRect();
|
|
const b = badge.getBoundingClientRect();
|
|
const sectionCenter = s.left + s.width / 2;
|
|
const badgeCenter = b.left + b.width / 2;
|
|
expect(Math.abs(badgeCenter - sectionCenter)).toBeLessThan(8);
|
|
});
|
|
|
|
it('left-aligns the year badge at phone width (REQ-003)', async () => {
|
|
await page.viewport(375, 800);
|
|
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
|
const section = document.querySelector('section') as HTMLElement;
|
|
const badge = document.querySelector('[data-testid="year-label"]') as HTMLElement;
|
|
const s = section.getBoundingClientRect();
|
|
const b = badge.getBoundingClientRect();
|
|
// hugs the left spine — clearly not centered
|
|
expect(b.left - s.left).toBeLessThan(s.width / 3);
|
|
});
|
|
|
|
it('keeps the sticky year heading at top:4rem (REQ-003)', () => {
|
|
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
|
const h2 = document.querySelector('h2') as HTMLElement;
|
|
const cs = getComputedStyle(h2);
|
|
expect(cs.position).toBe('sticky');
|
|
expect(cs.top).toBe('64px');
|
|
});
|
|
|
|
it('renders a year-badge node marker that clears the badge text on phone (REQ-004)', async () => {
|
|
await page.viewport(375, 800);
|
|
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
|
const node = document.querySelector('[data-testid="year-node"]') as HTMLElement;
|
|
const label = document.querySelector('[data-testid="year-label"]') as HTMLElement;
|
|
expect(node).not.toBeNull();
|
|
const n = node.getBoundingClientRect();
|
|
const l = label.getBoundingClientRect();
|
|
expect(n.right).toBeLessThanOrEqual(l.left + 0.5);
|
|
// The badge must paint above the node so the centered desktop pill never
|
|
// occludes the white year text (regression guard).
|
|
expect(Number(getComputedStyle(label).zIndex)).toBeGreaterThan(
|
|
Number(getComputedStyle(node).zIndex)
|
|
);
|
|
});
|
|
|
|
it('renders one connector dot per letter row, each clearing its card on phone (REQ-005)', async () => {
|
|
await page.viewport(375, 800);
|
|
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
|
|
const dots = document.querySelectorAll('[data-testid="letter-dot"]');
|
|
expect(dots).toHaveLength(3);
|
|
const row = document.querySelector('.letter-row') as HTMLElement;
|
|
const dot = row.querySelector('[data-testid="letter-dot"]') as HTMLElement;
|
|
const card = row.querySelector('a') as HTMLElement;
|
|
expect(dot.getBoundingClientRect().right).toBeLessThanOrEqual(
|
|
card.getBoundingClientRect().left + 0.5
|
|
);
|
|
});
|
|
|
|
it('positions the year-node from the inherited --spine-x token (REQ-003/004)', async () => {
|
|
await page.viewport(375, 800);
|
|
// The spine X lives once on .timeline-axis as --spine-x; the markers must
|
|
// track that token, not a hard-coded offset, so they never desync.
|
|
document.documentElement.style.setProperty('--spine-x', '3rem');
|
|
try {
|
|
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
|
const node = document.querySelector('[data-testid="year-node"]') as HTMLElement;
|
|
const section = document.querySelector('section') as HTMLElement;
|
|
const n = node.getBoundingClientRect();
|
|
const s = section.getBoundingClientRect();
|
|
const nodeCenter = n.left + n.width / 2;
|
|
// --spine-x:3rem = 48px from the band's left edge
|
|
expect(Math.abs(nodeCenter - s.left - 48)).toBeLessThan(2);
|
|
} finally {
|
|
document.documentElement.style.removeProperty('--spine-x');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('YearBand — grouping modes (#827)', () => {
|
|
it('keeps individual letter cards and no buckets in Datum mode (default)', () => {
|
|
render(YearBand, { year: makeYear(1915, manyLetters(1915, 3)) });
|
|
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
|
|
expect(document.querySelectorAll('a')).toHaveLength(3);
|
|
});
|
|
|
|
it('clusters loose letters under their linked event in Ereignis mode (REQ-002/003)', () => {
|
|
const a = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1915-03-01' });
|
|
const b = makeEntry({ documentId: 'b', linkedEventId: 'e1', eventDate: '1915-04-01' });
|
|
render(YearBand, {
|
|
year: makeYear(1915, [a, b]),
|
|
groupingMode: 'event',
|
|
eventLookup: new Map([['e1', 'Briefe von der Front']])
|
|
});
|
|
expect(document.querySelectorAll('[data-testid="letter-bucket"]')).toHaveLength(1);
|
|
expect(document.body.textContent).toContain('Briefe von der Front');
|
|
// no alternating individual letter rows in grouped mode
|
|
expect(document.querySelector('.letter-row')).toBeNull();
|
|
});
|
|
|
|
it('still renders the event world-band in Ereignis mode (REQ-001)', () => {
|
|
const band = makeEntry({
|
|
kind: 'EVENT',
|
|
type: 'HISTORICAL',
|
|
precision: 'RANGE',
|
|
eventDate: '1914-01-01',
|
|
eventDateEnd: '1918-12-31',
|
|
title: 'Erster Weltkrieg',
|
|
senderName: '',
|
|
receiverName: '',
|
|
documentId: undefined
|
|
});
|
|
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1914-05-01' });
|
|
render(YearBand, {
|
|
year: makeYear(1914, [band, letter]),
|
|
groupingMode: 'event',
|
|
eventLookup: new Map([['e1', 'Front']])
|
|
});
|
|
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
|
|
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
|
|
});
|
|
|
|
it('buckets loose letters under their root tag in Thema mode (REQ-004)', () => {
|
|
const a = makeEntry({
|
|
documentId: 'a',
|
|
rootTagId: 't1',
|
|
rootTagName: 'Krieg',
|
|
rootTagColor: 'sienna',
|
|
eventDate: '1915-03-01'
|
|
});
|
|
render(YearBand, { year: makeYear(1915, [a]), groupingMode: 'thema', eventLookup: new Map() });
|
|
const chip = document.querySelector('[data-testid="bucket-header-chip"]');
|
|
expect(chip?.textContent).toContain('Krieg');
|
|
});
|
|
});
|