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(); 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(); }); });