Files
chrono/tests/js/unit/recurrence.test.ts
T
Sebastian acbe0ad5ca feat: recurrence expansion.
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-29 22:19:38 -04:00

161 lines
4.9 KiB
TypeScript

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<string, Partial<EventMutation>> = {},
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')
})
})