Files
familienarchiv/frontend/src/lib/timeline/timelineGrouping.spec.ts
Marcel 4b11d66ca5 feat(timeline): add the client-side letter regroup transform
Pure module powering the #827 Datum·Ereignis·Thema toggle: buildEventLookup
(curated events that survived the #780 layer filter), hasLooseLetters (the
control's enabled state), and bucketLetters (cluster loose letters by
linkedEventId or primary root tag, with a "Weitere Briefe"/"Ohne Thema"
fallback). Filter-then-group, no refetch.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:31:03 +02:00

158 lines
5.6 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { buildEventLookup, bucketLetters, hasLooseLetters } from './timelineGrouping';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
// Entry factories pinned to the shapes the grouping transform discriminates (#827).
const letter = (overrides = {}) => makeEntry({ kind: 'LETTER', ...overrides });
const curatedEvent = (id: string, title: string, overrides = {}) =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
documentId: undefined,
eventId: id,
title,
senderName: '',
receiverName: '',
...overrides
});
describe('buildEventLookup (REQ-019)', () => {
it('collects curated events (eventId set) from year bands and the undated bucket', () => {
const dto = makeTimelineDTO({
years: [makeYear(1915, [curatedEvent('e1', 'Briefe von der Front'), letter()])],
undated: [curatedEvent('e2', 'Unbekanntes Ereignis')]
});
const lookup = buildEventLookup(dto);
expect(lookup.get('e1')).toBe('Briefe von der Front');
expect(lookup.get('e2')).toBe('Unbekanntes Ereignis');
expect(lookup.size).toBe(2);
});
it('ignores letters and derived life-events (no eventId)', () => {
const dto = makeTimelineDTO({
years: [
makeYear(1915, [
letter({ linkedEventId: 'e1' }),
makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: true, eventId: undefined })
])
]
});
expect(buildEventLookup(dto).size).toBe(0);
});
});
describe('hasLooseLetters (REQ-018)', () => {
it('is true when a year band or the undated bucket holds a letter', () => {
expect(hasLooseLetters(makeTimelineDTO({ years: [makeYear(1915, [letter()])] }))).toBe(true);
expect(hasLooseLetters(makeTimelineDTO({ undated: [letter({ documentId: 'u1' })] }))).toBe(
true
);
});
it('is false when only events remain', () => {
const dto = makeTimelineDTO({ years: [makeYear(1915, [curatedEvent('e1', 'Ereignis')])] });
expect(hasLooseLetters(dto)).toBe(false);
});
});
describe('bucketLetters — Ereignis mode (REQ-003/006/019)', () => {
const lookup = new Map([
['e1', 'Briefe von der Front'],
['e2', 'Weihnachten 1915']
]);
it('clusters letters under the curated event named by linkedEventId, with matching counts', () => {
const letters = [
letter({ documentId: 'a', linkedEventId: 'e1' }),
letter({ documentId: 'b', linkedEventId: 'e1' }),
letter({ documentId: 'c', linkedEventId: 'e2' })
];
const buckets = bucketLetters(letters, 'event', lookup);
const front = buckets.find((b) => b.title === 'Briefe von der Front');
expect(front?.kind).toBe('event');
expect(front?.letters).toHaveLength(2);
expect(buckets.find((b) => b.title === 'Weihnachten 1915')?.letters).toHaveLength(1);
});
it('drops a letter with no linkedEventId into the fallback bucket (REQ-006)', () => {
const letters = [letter({ documentId: 'a', linkedEventId: undefined })];
const buckets = bucketLetters(letters, 'event', lookup);
expect(buckets).toHaveLength(1);
expect(buckets[0].kind).toBe('fallback');
expect(buckets[0].letters).toHaveLength(1);
});
it('drops a letter whose linked event is absent from the lookup into fallback (REQ-019)', () => {
// e9 is not in the filtered view (its layer was toggled off) → no cluster.
const letters = [letter({ documentId: 'a', linkedEventId: 'e9' })];
const buckets = bucketLetters(letters, 'event', lookup);
expect(buckets).toHaveLength(1);
expect(buckets[0].kind).toBe('fallback');
});
it('keeps the fallback bucket last', () => {
const letters = [
letter({ documentId: 'a', linkedEventId: undefined }),
letter({ documentId: 'b', linkedEventId: 'e1' })
];
const buckets = bucketLetters(letters, 'event', lookup);
expect(buckets[buckets.length - 1].kind).toBe('fallback');
});
});
describe('bucketLetters — Thema mode (REQ-004/007/008)', () => {
const noEvents = new Map<string, string>();
it('buckets letters under their primary root tag with name and colour', () => {
const letters = [
letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }),
letter({ documentId: 'b', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }),
letter({
documentId: 'c',
rootTagId: 't2',
rootTagName: 'Weihnachten',
rootTagColor: 'amber'
})
];
const buckets = bucketLetters(letters, 'thema', noEvents);
const krieg = buckets.find((b) => b.title === 'Krieg');
expect(krieg?.kind).toBe('tag');
expect(krieg?.color).toBe('sienna');
expect(krieg?.letters).toHaveLength(2);
expect(buckets.find((b) => b.title === 'Weihnachten')?.color).toBe('amber');
});
it('drops an untagged letter into the "Ohne Thema" fallback bucket (REQ-007)', () => {
const letters = [letter({ documentId: 'a', rootTagId: undefined })];
const buckets = bucketLetters(letters, 'thema', noEvents);
expect(buckets).toHaveLength(1);
expect(buckets[0].kind).toBe('fallback');
expect(buckets[0].color).toBeNull();
});
it('places a letter in exactly one bucket (REQ-008)', () => {
const letters = [
letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' })
];
const buckets = bucketLetters(letters, 'thema', noEvents);
const occurrences = buckets.flatMap((b) => b.letters).filter((l) => l.documentId === 'a');
expect(occurrences).toHaveLength(1);
});
it('carries a null colour through for a colourless root tag', () => {
const letters = [
letter({
documentId: 'a',
rootTagId: 't3',
rootTagName: 'Allgemein',
rootTagColor: undefined
})
];
const buckets = bucketLetters(letters, 'thema', noEvents);
expect(buckets[0].kind).toBe('tag');
expect(buckets[0].color).toBeNull();
});
});