fix(02): update expand tests to assert IANA-annotated format + add cross-contract test
- Assert timed start/end strings include '[America/New_York]' bracket (not offset-only) - Assert DST boundary offsets: -05:00[America/New_York] pre-transition, -04:00[America/New_York] post - Add cross-contract regression test: feeds expandOccurrences output directly into Temporal.ZonedDateTime.from() to prove the expand→hydrate contract holds end-to-end - Import 'temporal-polyfill/global' at top of test file for the Temporal global - Rename describe block from 'RED stubs (Wave 0)' to reflect GREEN state
This commit is contained in:
@@ -1,23 +1,21 @@
|
||||
/**
|
||||
* RED test stubs for expandOccurrences() — Wave 0 state.
|
||||
* Tests for expandOccurrences() — GREEN state.
|
||||
*
|
||||
* These tests encode the concrete behavioral contracts that Plan 02 (GREEN phase) must satisfy.
|
||||
* All tests reference the not-yet-built module apps/api/src/broker/expand.ts and are expected
|
||||
* to fail until that module is implemented.
|
||||
*
|
||||
* Contracts locked here:
|
||||
* Contracts verified here:
|
||||
* 1. DST wall-clock correctness: occurrences in America/New_York must show 10:00 local time
|
||||
* on BOTH sides of the March 2026 EST→EDT boundary (not shifted ±1h by UTC fallback).
|
||||
* 2. All-day events return allDay:true and start as 'YYYY-MM-DD' with no time component.
|
||||
* 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
|
||||
* Temporal.ZonedDateTime.from() can parse them without throwing (cross-contract test).
|
||||
*/
|
||||
|
||||
import 'temporal-polyfill/global'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
// Not yet built — import will fail (RED state) until Plan 02 implements expand.ts
|
||||
import { expandOccurrences } from '../../src/broker/expand.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -27,7 +25,7 @@ function loadFixture(name: string): string {
|
||||
return readFileSync(join(FIXTURES, name), 'utf8')
|
||||
}
|
||||
|
||||
describe('expandOccurrences — RED stubs (Wave 0)', () => {
|
||||
describe('expandOccurrences', () => {
|
||||
|
||||
describe('DST correctness — weekly-dst.ics', () => {
|
||||
it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => {
|
||||
@@ -56,13 +54,14 @@ describe('expandOccurrences — RED stubs (Wave 0)', () => {
|
||||
expect(occurrences.length).toBeGreaterThan(0)
|
||||
|
||||
// The key contract: every occurrence must have local hour === 10 in America/New_York.
|
||||
// We verify this by checking the ISO string offset — before DST: offset is -05:00
|
||||
// (so 10:00-05:00 = 15:00 UTC); after DST: offset is -04:00 (10:00-04:00 = 14:00 UTC).
|
||||
// Both are valid as long as the LOCAL wall-clock hour is 10.
|
||||
// We verify this by checking the ISO string — before DST: '...T10:00:00-05:00[America/New_York]'
|
||||
// after DST: '...T10:00:00-04:00[America/New_York]'. Both include the IANA bracket.
|
||||
for (const occ of occurrences) {
|
||||
expect(occ.allDay).toBe(false)
|
||||
// start must be an offset-aware ISO string: '2026-03-01T10:00:00-05:00' or similar
|
||||
// start must be IANA-annotated: '2026-03-01T10:00:00-05:00[America/New_York]'
|
||||
expect(occ.start).toMatch(/T10:00:00/)
|
||||
// Must include IANA bracket — offset-only strings fail Temporal.ZonedDateTime.from()
|
||||
expect(occ.start).toContain('[America/New_York]')
|
||||
}
|
||||
|
||||
// Explicitly check one pre-transition occurrence (EST) and one post-transition (EDT)
|
||||
@@ -72,10 +71,12 @@ describe('expandOccurrences — RED stubs (Wave 0)', () => {
|
||||
expect(preTransition).toBeDefined()
|
||||
expect(postTransition).toBeDefined()
|
||||
|
||||
// Pre-transition occurrence: EST offset -05:00
|
||||
// Pre-transition occurrence: EST — '2026-03-01T10:00:00-05:00[America/New_York]'
|
||||
expect(preTransition!.start).toContain('T10:00:00')
|
||||
// Post-transition occurrence: EDT offset -04:00
|
||||
expect(preTransition!.start).toContain('-05:00[America/New_York]')
|
||||
// Post-transition occurrence: EDT — '2026-03-15T10:00:00-04:00[America/New_York]'
|
||||
expect(postTransition!.start).toContain('T10:00:00')
|
||||
expect(postTransition!.start).toContain('-04:00[America/New_York]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,4 +139,43 @@ describe('expandOccurrences — RED stubs (Wave 0)', () => {
|
||||
expect(june15).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
// This is the integration test that was missing. It takes the actual serializeTime output
|
||||
// (from expandOccurrences) and feeds each start/end through Temporal.ZonedDateTime.from()
|
||||
// to prove the expand→hydrate contract holds end-to-end.
|
||||
//
|
||||
// Previously, serializeTime emitted offset-only strings like '2026-03-01T10:00:00-05:00'
|
||||
// which caused Temporal.ZonedDateTime.from() to throw RangeError: Cannot parse.
|
||||
// Now it emits IANA-annotated strings like '2026-03-01T10:00:00-05:00[America/New_York]'.
|
||||
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,
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
|
||||
expect(occurrences.length).toBeGreaterThan(0)
|
||||
|
||||
for (const occ of occurrences) {
|
||||
// These must not throw — this is the cross-service contract
|
||||
expect(() => Temporal.ZonedDateTime.from(occ.start)).not.toThrow()
|
||||
expect(() => Temporal.ZonedDateTime.from(occ.end)).not.toThrow()
|
||||
|
||||
// Parsed ZonedDateTime must round-trip the wall-clock hour
|
||||
const startZdt = Temporal.ZonedDateTime.from(occ.start)
|
||||
expect(startZdt.hour).toBe(10)
|
||||
expect(startZdt.timeZoneId).toBe('America/New_York')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user