import { describe, expect, it } from 'vitest' import type { EntityObject } from '../../../../chrono_manager/src/models/entity' import { buildOccurrenceIndex, occurrencesForDay, occurrencesForRange, } from '../../../src/utils/occurrenceIndex' import { daysVisibleRange } from '../../../src/utils/dateRanges' function localIso(year: number, month: number, day: number, hour = 0): string { return new Date(year, month, day, hour).toISOString() } function eventEntity( identifier: string, startsOn: string | null, endsOn: string | null, timeless = false, ): EntityObject { return { identifier, properties: { startsOn, endsOn, timeless, }, } as unknown as EntityObject } describe('occurrence index', () => { it('normalizes and sorts event timestamps once', () => { const later = eventEntity('later', localIso(2026, 5, 15, 13), localIso(2026, 5, 15, 14)) const earlier = eventEntity('earlier', localIso(2026, 5, 15, 9), localIso(2026, 5, 15, 10)) const invalid = eventEntity('invalid', 'not-a-date', null) const index = buildOccurrenceIndex([later, invalid, earlier]) expect(index.sorted.map(occurrence => occurrence.entity.identifier)).toEqual(['earlier', 'later']) expect(index.byEntity.get('earlier')?.[0]?.durationMs).toBe(60 * 60 * 1000) expect(index.byEntity.has('invalid')).toBe(false) }) it('places an overlapping event in each occupied day bucket', () => { const overnight = eventEntity( 'overnight', localIso(2026, 5, 15, 23), localIso(2026, 5, 16, 1), ) const index = buildOccurrenceIndex([overnight]) expect(occurrencesForDay(index, new Date(2026, 5, 15))).toHaveLength(1) expect(occurrencesForDay(index, new Date(2026, 5, 16))).toHaveLength(1) expect(index.sorted[0].multiDay).toBe(true) expect(index.sorted[0].timeless).toBe(true) }) it('honors an exclusive midnight end', () => { const allDay = eventEntity( 'all-day', localIso(2026, 5, 20), localIso(2026, 5, 21), true, ) const index = buildOccurrenceIndex([allDay]) expect(occurrencesForDay(index, new Date(2026, 5, 20))).toHaveLength(1) expect(occurrencesForDay(index, new Date(2026, 5, 21))).toHaveLength(0) }) it('treats UTC timestamps on timeless events as floating calendar dates', () => { const allDay = eventEntity( 'utc-all-day', '2025-03-27T00:00:00+00:00', '2025-03-28T00:00:00+00:00', true, ) const index = buildOccurrenceIndex([allDay]) expect(occurrencesForDay(index, new Date(2025, 2, 26))).toHaveLength(0) expect(occurrencesForDay(index, new Date(2025, 2, 27))).toHaveLength(1) expect(occurrencesForDay(index, new Date(2025, 2, 28))).toHaveLength(0) expect(new Date(index.sorted[0].startMs).getHours()).toBe(0) }) it('retrieves overlapping occurrences without duplicates', () => { const spanning = eventEntity( 'spanning', localIso(2026, 5, 14), localIso(2026, 5, 18), true, ) const outside = eventEntity('outside', localIso(2026, 6, 1), localIso(2026, 6, 1, 1)) const index = buildOccurrenceIndex([outside, spanning]) const occurrences = occurrencesForRange(index, daysVisibleRange(new Date(2026, 5, 15), 2)) expect(occurrences.map(occurrence => occurrence.entity.identifier)).toEqual(['spanning']) }) it('gives a zero-duration event a queryable minimum duration', () => { const instant = eventEntity('instant', localIso(2026, 5, 15, 9), null) const index = buildOccurrenceIndex([instant]) expect(index.sorted[0].durationMs).toBe(1) expect(occurrencesForDay(index, new Date(2026, 5, 15))).toHaveLength(1) }) })