import { describe, expect, it } from 'vitest' import type { EntityObject } from '../../../../chrono_manager/src/models/entity' import type { EventOccurrence, EventMutation } from '../../../../chrono_manager/src/types/event' import { daysVisibleRange } from '../../../src/utils/date' import { expandEntityInstances } from '../../../src/utils/recurrence' function localIso(year: number, month: number, day: number, hour = 9): string { return new Date(year, month, day, hour).toISOString() } function recurringEntity( pattern: EventOccurrence, mutations: Record> = {}, startsOn = localIso(2026, 5, 1), ): EntityObject { return { identifier: 'calendar:event-1', properties: { startsOn, endsOn: new Date(new Date(startsOn).getTime() + 60 * 60 * 1000).toISOString(), timeless: false, label: 'Standup', color: '#123456', pattern, mutations, }, } as unknown as EntityObject } describe('daily recurrence', () => { it('expands interval rules only inside the requested range', () => { const entity = recurringEntity({ pattern: 'absolute', precision: 'daily', interval: 2, }) const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 4), 6), ) expect(instances.map(instance => new Date(instance.startMs).getDate())).toEqual([5, 7, 9]) expect(instances.every(instance => instance.durationMs === 60 * 60 * 1000)).toBe(true) expect(new Set(instances.map(instance => instance.key)).size).toBe(3) }) it('honors iteration limits before the requested range', () => { const entity = recurringEntity({ pattern: 'absolute', precision: 'daily', interval: 1, iterations: 3, }) const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 10), 3), ) expect(instances).toEqual([]) }) it('treats a date-only conclusion as an inclusive local date', () => { const entity = recurringEntity({ pattern: 'absolute', precision: 'daily', interval: 1, concludes: '2026-06-03', }) const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 1), 5), ) expect(instances.map(instance => new Date(instance.startMs).getDate())).toEqual([1, 2, 3]) }) it('preserves local wall-clock time across daylight-saving changes', () => { const entity = recurringEntity({ pattern: 'absolute', precision: 'daily', interval: 1, }, {}, localIso(2026, 2, 7, 9)) const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 2, 7), 3), ) expect(instances.map(instance => new Date(instance.startMs).getHours())).toEqual([9, 9, 9]) }) it('preserves exclusive calendar-day duration for recurring timeless events', () => { const entity = recurringEntity({ pattern: 'absolute', precision: 'daily', interval: 1, }, {}, '2026-03-07T00:00:00+00:00') const event = entity.properties as unknown as { endsOn: string timeless: boolean } event.endsOn = '2026-03-08T00:00:00+00:00' event.timeless = true const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 2, 7), 3), ) expect(instances).toHaveLength(3) expect(instances.map(instance => new Date(instance.startMs).getHours())).toEqual([0, 0, 0]) expect(instances.map(instance => { const end = new Date(instance.endMs) return [end.getDate(), end.getHours()] })).toEqual([[8, 0], [9, 0], [10, 0]]) }) }) describe('weekly recurrence', () => { it('expands selected ISO weekdays in chronological order', () => { const entity = recurringEntity({ pattern: 'absolute', precision: 'weekly', interval: 1, onDayOfWeek: [1, 3], }) const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 1), 10), ) expect(instances.map(instance => new Date(instance.startMs).getDate())).toEqual([1, 3, 8, 10]) }) }) describe('recurrence mutations', () => { it('removes excluded occurrences', () => { const excludedId = localIso(2026, 5, 2) const entity = recurringEntity({ pattern: 'absolute', precision: 'daily', interval: 1, }, { [excludedId]: { mutationId: excludedId, mutationExclusion: true, }, }) const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 1), 3), ) expect(instances.map(instance => new Date(instance.startMs).getDate())).toEqual([1, 3]) }) it('moves and restyles a modified occurrence without duplicating it', () => { const sourceId = localIso(2026, 5, 2) const movedStart = localIso(2026, 5, 4, 14) const entity = recurringEntity({ pattern: 'absolute', precision: 'daily', interval: 1, iterations: 2, }, { [sourceId]: { mutationId: sourceId, mutationExclusion: false, startsOn: movedStart, endsOn: new Date(new Date(movedStart).getTime() + 2 * 60 * 60 * 1000).toISOString(), label: 'Moved standup', color: '#abcdef', }, }) const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 1), 5), ) const moved = instances.find(instance => instance.instanceId === sourceId) expect(instances).toHaveLength(2) expect(new Date(moved!.startMs).getHours()).toBe(14) expect(moved!.durationMs).toBe(2 * 60 * 60 * 1000) expect(moved!.label).toBe('Moved standup') expect(moved!.color).toBe('#abcdef') }) })