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/dateRanges' import { expandEntityOccurrences } 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 occurrences = expandEntityOccurrences( entity, daysVisibleRange(new Date(2026, 5, 4), 6), ) expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([5, 7, 9]) expect(occurrences.every(occurrence => occurrence.durationMs === 60 * 60 * 1000)).toBe(true) expect(new Set(occurrences.map(occurrence => occurrence.key)).size).toBe(3) }) it('honors iteration limits before the requested range', () => { const entity = recurringEntity({ pattern: 'absolute', precision: 'daily', interval: 1, iterations: 3, }) const occurrences = expandEntityOccurrences( entity, daysVisibleRange(new Date(2026, 5, 10), 3), ) expect(occurrences).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 occurrences = expandEntityOccurrences( entity, daysVisibleRange(new Date(2026, 5, 1), 5), ) expect(occurrences.map(occurrence => new Date(occurrence.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 occurrences = expandEntityOccurrences( entity, daysVisibleRange(new Date(2026, 2, 7), 3), ) expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([9, 9, 9]) }) }) describe('weekly recurrence', () => { it('expands selected ISO weekdays in chronological order', () => { const entity = recurringEntity({ pattern: 'absolute', precision: 'weekly', interval: 1, onDayOfWeek: [1, 3], }) const occurrences = expandEntityOccurrences( entity, daysVisibleRange(new Date(2026, 5, 1), 10), ) expect(occurrences.map(occurrence => new Date(occurrence.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 occurrences = expandEntityOccurrences( entity, daysVisibleRange(new Date(2026, 5, 1), 3), ) expect(occurrences.map(occurrence => new Date(occurrence.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 occurrences = expandEntityOccurrences( entity, daysVisibleRange(new Date(2026, 5, 1), 5), ) const moved = occurrences.find(occurrence => occurrence.occurrenceId === sourceId) expect(occurrences).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') }) })