test(06-03): add failing tests for hasRrule + bounded expansion

- Add hasRrule===true assertion for recurring events (weekly-dst.ics)
- Add hasRrule===false assertion for non-recurring events (single-duration.ics)
- Add weekly-count3.ics fixture (FREQ=WEEKLY;COUNT=3, 1-hour events)
- Add bounded RRULE test: expects exactly 3 occurrences in wide window
- Add per-occurrence duration test: each occurrence is 1 hour (not recurrence span)
- Tests are RED: hasRrule field absent from CalendarOccurrence interface
This commit is contained in:
Lucas Berger
2026-06-10 11:11:08 -04:00
parent b869fe0a93
commit 593302ee41
2 changed files with 140 additions and 0 deletions
+128
View File
@@ -8,6 +8,9 @@
* 3. EXDATE exclusions reduce the returned array by exactly one occurrence. * 3. EXDATE exclusions reduce the returned array by exactly one occurrence.
* 4. Timed event start/end strings are IANA-annotated ('...±HH:MM[IANA/Zone]') so * 4. Timed event start/end strings are IANA-annotated ('...±HH:MM[IANA/Zone]') so
* Temporal.ZonedDateTime.from() can parse them without throwing (cross-contract test). * Temporal.ZonedDateTime.from() can parse them without throwing (cross-contract test).
* 5. hasRrule is true for recurring events, false for non-recurring (D-08).
* 6. Bounded RRULE (COUNT=3) expands to exactly 3 occurrences; each occurrence's
* duration derives from DTSTART→DTEND (not the recurrence span) (D-06 invariant).
*/ */
import 'temporal-polyfill/global' import 'temporal-polyfill/global'
@@ -182,6 +185,131 @@ describe('expandOccurrences', () => {
}) })
}) })
describe('hasRrule field — D-08 (recurring series detection)', () => {
it('recurring event occurrence has hasRrule === true', () => {
// weekly-dst.ics has RRULE:FREQ=WEEKLY — all occurrences must have hasRrule === true
const rawVevent = loadFixture('weekly-dst.ics')
const windowStart = new Date('2026-03-01T00:00:00Z')
const windowEnd = new Date('2026-04-01T00:00:00Z')
const occurrences = expandOccurrences(
rawVevent,
windowStart,
windowEnd,
1,
'My Calendar',
1,
null,
'#4A90D9',
false,
)
expect(occurrences.length).toBeGreaterThan(0)
for (const occ of occurrences) {
expect(occ.hasRrule).toBe(true)
}
})
it('non-recurring event occurrence has hasRrule === false', () => {
// single-duration.ics has no RRULE — the single occurrence must have hasRrule === false
const rawVevent = loadFixture('single-duration.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-07-01T00:00:00Z')
const occurrences = expandOccurrences(
rawVevent,
windowStart,
windowEnd,
1,
'My Calendar',
1,
null,
'#4A90D9',
false,
)
expect(occurrences.length).toBe(1)
expect(occurrences[0].hasRrule).toBe(false)
})
})
describe('Bounded RRULE (COUNT=3) — D-06 invariant', () => {
it('expands to exactly 3 occurrences within a wide window (COUNT terminates expansion)', () => {
// weekly-count3.ics has RRULE:FREQ=WEEKLY;COUNT=3 starting 2026-06-01.
// A 6-month window must return exactly 3 occurrences — not more.
const rawVevent = loadFixture('weekly-count3.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-12-01T00:00:00Z')
const occurrences = expandOccurrences(
rawVevent,
windowStart,
windowEnd,
1,
'My Calendar',
1,
null,
'#4A90D9',
false,
)
expect(occurrences.length).toBe(3)
})
it('each bounded occurrence duration derives from DTSTART→DTEND (1 hour), not recurrence span', () => {
// Each occurrence must have end exactly 1 hour after start.
// The recurrence span is many weeks; duration must be per-event, not recurrence-level.
const rawVevent = loadFixture('weekly-count3.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-12-01T00:00:00Z')
const occurrences = expandOccurrences(
rawVevent,
windowStart,
windowEnd,
1,
'My Calendar',
1,
null,
'#4A90D9',
false,
)
expect(occurrences.length).toBe(3)
for (const occ of occurrences) {
const startZdt = Temporal.ZonedDateTime.from(occ.start)
const endZdt = Temporal.ZonedDateTime.from(occ.end)
// Each occurrence must be exactly 1 hour (3 600 000 ms).
// Use epochMilliseconds which is a regular number in the polyfill.
const durationMs = endZdt.epochMilliseconds - startZdt.epochMilliseconds
expect(durationMs).toBe(3_600_000)
}
})
it('bounded occurrences have hasRrule === true', () => {
const rawVevent = loadFixture('weekly-count3.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-12-01T00:00:00Z')
const occurrences = expandOccurrences(
rawVevent,
windowStart,
windowEnd,
1,
'My Calendar',
1,
null,
'#4A90D9',
false,
)
expect(occurrences.length).toBe(3)
for (const occ of occurrences) {
expect(occ.hasRrule).toBe(true)
}
})
})
describe('Cross-contract: expand output → Temporal.ZonedDateTime.from (regression guard)', () => { describe('Cross-contract: expand output → Temporal.ZonedDateTime.from (regression guard)', () => {
it('timed event start/end strings from weekly-dst.ics parse via Temporal.ZonedDateTime.from without throwing', () => { it('timed event start/end strings from weekly-dst.ics parse via Temporal.ZonedDateTime.from without throwing', () => {
// This is the integration test that was missing. It takes the actual serializeTime output // This is the integration test that was missing. It takes the actual serializeTime output
+12
View File
@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FamilySync//Test//EN
BEGIN:VEVENT
UID:weekly-count3@familysync.test
DTSTART:20260601T090000Z
DTEND:20260601T100000Z
RRULE:FREQ=WEEKLY;COUNT=3
SUMMARY:Weekly Count 3
DESCRIPTION:Bounded weekly series — exactly 3 occurrences (COUNT=3); each occurrence duration is 1 hour from DTSTART→DTEND
END:VEVENT
END:VCALENDAR