feat: recurrence expansion.
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user