317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { expandEventOccurrences, type RecurrenceRule } from './eventRecurrence'
|
|
|
|
// Local-time Date factory (matches scheduleOccurrences.test.ts). Months
|
|
// are 0-indexed in the Date constructor, 1-indexed here.
|
|
const dt = (
|
|
year: number,
|
|
month1to12: number,
|
|
day: number,
|
|
hours = 0,
|
|
minutes = 0,
|
|
): Date => new Date(year, month1to12 - 1, day, hours, minutes, 0, 0)
|
|
|
|
// Reference calendar:
|
|
// April 2026 — Wed 1; Thu 2, 9, 16, 23, 30; Tue 7, 14, 21, 28; Mon 6, 13, 20, 27; Sun 5, 12, 19, 26
|
|
// May 2026 — Tue 5, 12, 19, 26; Thu 7, 14, 21, 28; Mon 4, 11, 18, 25
|
|
// June 2026 — Tue 2, 9, 16, 23, 30; Thu 4, 11, 18, 25; Mon 1, 8, 15, 22, 29
|
|
// March 2026 — Mon 2, 9, 16, 23, 30 (five Mondays)
|
|
|
|
describe('expandEventOccurrences', () => {
|
|
it('returns [] for recurrenceType = none', () => {
|
|
const result = expandEventOccurrences(
|
|
{ date: dt(2026, 5, 1, 18, 30).toISOString(), recurrenceType: 'none' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 7, 1) },
|
|
)
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('returns [] when event.date is missing or invalid', () => {
|
|
expect(
|
|
expandEventOccurrences(
|
|
{ recurrenceType: 'weekly' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 5, 1) },
|
|
),
|
|
).toEqual([])
|
|
expect(
|
|
expandEventOccurrences(
|
|
{ date: 'not-a-date', recurrenceType: 'weekly' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 5, 1) },
|
|
),
|
|
).toEqual([])
|
|
})
|
|
|
|
describe('daily', () => {
|
|
it('yields every day from event.date to horizon at the event HH:mm', () => {
|
|
const eventDate = dt(2026, 4, 10, 18, 30) // Fri
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'daily' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 4, 14, 23, 59) },
|
|
)
|
|
expect(result.map((d) => d.getDate())).toEqual([10, 11, 12, 13, 14])
|
|
for (const d of result) {
|
|
expect(d.getHours()).toBe(18)
|
|
expect(d.getMinutes()).toBe(30)
|
|
}
|
|
})
|
|
|
|
it('starts from now when event.date is before now', () => {
|
|
// Past event.date — enumeration should start at `now`, not backfill.
|
|
const eventDate = dt(2026, 4, 1, 9, 0)
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'daily' },
|
|
{ now: dt(2026, 4, 10, 8, 0), horizon: dt(2026, 4, 12, 23, 59) },
|
|
)
|
|
// `after` is now=04-10 08:00; daily occurrences at 09:00 fall on
|
|
// the 10th, 11th, 12th.
|
|
expect(result.map((d) => `${d.getDate()} ${d.getHours()}:${d.getMinutes()}`))
|
|
.toEqual(['10 9:0', '11 9:0', '12 9:0'])
|
|
})
|
|
|
|
it('returns [] when horizon < after', () => {
|
|
const eventDate = dt(2026, 4, 10, 18, 30)
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'daily' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 4, 9) },
|
|
)
|
|
expect(result).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('weekly', () => {
|
|
it('derives the weekday from event.date', () => {
|
|
// 2026-04-07 is a Tuesday
|
|
const eventDate = dt(2026, 4, 7, 19, 0)
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'weekly' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 4, 30) },
|
|
)
|
|
expect(result.map((d) => d.getDate())).toEqual([7, 14, 21, 28])
|
|
for (const d of result) {
|
|
expect(d.getDay()).toBe(2) // Tuesday
|
|
expect(d.getHours()).toBe(19)
|
|
expect(d.getMinutes()).toBe(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('biweekly', () => {
|
|
it('anchors parity on event.date', () => {
|
|
const eventDate = dt(2026, 4, 7, 19, 0) // Tuesday
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'biweekly' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30, 23, 59) },
|
|
)
|
|
// From 04-07: 04-07, 04-21, 05-05, 05-19, 06-02, 06-16, 06-30
|
|
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
|
|
'4-7',
|
|
'4-21',
|
|
'5-5',
|
|
'5-19',
|
|
'6-2',
|
|
'6-16',
|
|
'6-30',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('monthlyByDate', () => {
|
|
it('uses event.date.getDate() as the day-of-month', () => {
|
|
const eventDate = dt(2026, 4, 13, 18, 30)
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 7, 31) },
|
|
)
|
|
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
|
|
'4-13',
|
|
'5-13',
|
|
'6-13',
|
|
'7-13',
|
|
])
|
|
for (const d of result) {
|
|
expect(d.getHours()).toBe(18)
|
|
expect(d.getMinutes()).toBe(30)
|
|
}
|
|
})
|
|
|
|
it('skips months without the required day (the 31st)', () => {
|
|
const eventDate = dt(2026, 1, 31, 20, 0)
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' },
|
|
{ now: dt(2026, 1, 1), horizon: dt(2026, 5, 31, 23, 59) },
|
|
)
|
|
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
|
|
'1-31',
|
|
'3-31',
|
|
'5-31',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('monthlyByWeekday', () => {
|
|
it('derives weekday + weekOfMonth from event.date', () => {
|
|
// 2026-04-13 is the 2nd Monday of April
|
|
const eventDate = dt(2026, 4, 13, 20, 0)
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByWeekday' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 7, 31) },
|
|
)
|
|
// 2nd Monday of each month: Apr 13, May 11, Jun 8, Jul 13
|
|
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
|
|
'4-13',
|
|
'5-11',
|
|
'6-8',
|
|
'7-13',
|
|
])
|
|
for (const d of result) {
|
|
expect(d.getDay()).toBe(1) // Monday
|
|
}
|
|
})
|
|
|
|
it('clamps a 5th-occurrence event.date to "fourth"', () => {
|
|
// 2026-03-30 is the 5th Monday of March.
|
|
// Expected derivation: 'fourth' Monday (not 'last') — see weekOfMonthOf rationale.
|
|
const eventDate = dt(2026, 3, 30, 20, 0)
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByWeekday' },
|
|
{ now: dt(2026, 3, 1), horizon: dt(2026, 6, 30) },
|
|
)
|
|
// 4th Monday of each month: Mar 23 (before now? no — now is Mar 1),
|
|
// Apr 27, May 25, Jun 22. March 23 is before event.date though.
|
|
// Since `after = max(now, eventDate) = 2026-03-30`, March's 23rd is
|
|
// excluded. So: Apr 27, May 25, Jun 22.
|
|
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
|
|
'4-27',
|
|
'5-25',
|
|
'6-22',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('custom', () => {
|
|
it('merges two monthly rules, sorted and deduplicated', () => {
|
|
// 3rd Tuesday AND 4th Thursday of each month Apr-Jun 2026.
|
|
// Apr: Tue 21, Thu 23 → 4-21, 4-23
|
|
// May: Tue 19, Thu 28 → 5-19, 5-28
|
|
// Jun: Tue 16, Thu 25 → 6-16, 6-25
|
|
const rules: RecurrenceRule[] = [
|
|
{ frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
|
|
{ frequency: 'monthlyByWeekday', weekday: 'thursday', weekOfMonth: 'fourth' },
|
|
]
|
|
const eventDate = dt(2026, 4, 21, 18, 30) // seed
|
|
const result = expandEventOccurrences(
|
|
{
|
|
date: eventDate.toISOString(),
|
|
recurrenceType: 'custom',
|
|
recurrenceRules: rules,
|
|
},
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
|
|
)
|
|
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
|
|
'4-21',
|
|
'4-23',
|
|
'5-19',
|
|
'5-28',
|
|
'6-16',
|
|
'6-25',
|
|
])
|
|
for (const d of result) {
|
|
expect(d.getHours()).toBe(18)
|
|
expect(d.getMinutes()).toBe(30)
|
|
}
|
|
})
|
|
|
|
it('deduplicates when rules overlap', () => {
|
|
// Every Tuesday AND 3rd Tuesday: the 3rd-Tuesday dates are subsumed
|
|
// by weekly, so the output equals the weekly sequence alone.
|
|
const rules: RecurrenceRule[] = [
|
|
{ frequency: 'weekly', weekday: 'tuesday' },
|
|
{ frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
|
|
]
|
|
const eventDate = dt(2026, 4, 7, 18, 30)
|
|
const result = expandEventOccurrences(
|
|
{
|
|
date: eventDate.toISOString(),
|
|
recurrenceType: 'custom',
|
|
recurrenceRules: rules,
|
|
},
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 4, 30) },
|
|
)
|
|
expect(result.map((d) => d.getDate())).toEqual([7, 14, 21, 28])
|
|
})
|
|
|
|
it('drops rule rows with missing dependent fields rather than throwing', () => {
|
|
const rules: RecurrenceRule[] = [
|
|
{ frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' },
|
|
// Incomplete: weekOfMonth missing — should be dropped.
|
|
{ frequency: 'monthlyByWeekday', weekday: 'friday' },
|
|
// Incomplete: dayOfMonth missing — should be dropped.
|
|
{ frequency: 'monthlyByDate' },
|
|
]
|
|
const eventDate = dt(2026, 4, 21, 18, 30)
|
|
const result = expandEventOccurrences(
|
|
{
|
|
date: eventDate.toISOString(),
|
|
recurrenceType: 'custom',
|
|
recurrenceRules: rules,
|
|
},
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
|
|
)
|
|
// Only the first (valid) rule contributes.
|
|
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
|
|
'4-21',
|
|
'5-19',
|
|
'6-16',
|
|
])
|
|
})
|
|
|
|
it('returns [] when recurrenceType=custom but recurrenceRules is empty or missing', () => {
|
|
const eventDate = dt(2026, 4, 13, 18, 30)
|
|
expect(
|
|
expandEventOccurrences(
|
|
{
|
|
date: eventDate.toISOString(),
|
|
recurrenceType: 'custom',
|
|
recurrenceRules: [],
|
|
},
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
|
|
),
|
|
).toEqual([])
|
|
expect(
|
|
expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'custom' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) },
|
|
),
|
|
).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('horizon truncation (caller-supplied endDate clipping)', () => {
|
|
it('stops at horizon', () => {
|
|
const eventDate = dt(2026, 4, 13, 18, 30)
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' },
|
|
{ now: dt(2026, 4, 1), horizon: dt(2026, 5, 31, 23, 59) },
|
|
)
|
|
expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([
|
|
'4-13',
|
|
'5-13',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('time-of-day invariant', () => {
|
|
it('every occurrence has the same local HH:mm as event.date', () => {
|
|
const eventDate = dt(2026, 4, 7, 15, 45)
|
|
const result = expandEventOccurrences(
|
|
{ date: eventDate.toISOString(), recurrenceType: 'weekly' },
|
|
{ now: dt(2026, 3, 1), horizon: dt(2026, 11, 30) },
|
|
)
|
|
expect(result.length).toBeGreaterThan(20) // spans the DST transitions
|
|
for (const d of result) {
|
|
expect(d.getHours()).toBe(15)
|
|
expect(d.getMinutes()).toBe(45)
|
|
}
|
|
})
|
|
})
|
|
})
|