import { describe, expect, it } from "vitest"; import { materializeOccurrence } from "./materialize-api.js"; import { compareWall } from "./nominal.js"; import { InvalidNominalLocalError } from "./errors.js"; import { wallOfInstant } from "./timezone.js"; const ISO = (ms: number) => new Date(ms).toISOString().replace(".000Z", "Z"); describe("materializeOccurrence — normal (unambiguous) times", () => { it("resolves a plain New York evening slot", () => { expect(ISO(materializeOccurrence("2024-03-09T21:00", "America/New_York"))) .toBe("2024-03-10T02:00:00Z"); }); it("passes UTC straight through", () => { expect(ISO(materializeOccurrence("2024-03-10T02:30", "UTC"))) .toBe("2024-03-10T02:30:00Z"); }); it("resolves Kolkata (no DST, half-hour offset)", () => { expect(ISO(materializeOccurrence("2024-06-01T09:00", "Asia/Kolkata"))) .toBe("2024-06-01T03:30:00Z"); }); }); describe("DST gap — nonexistent local time fires at the first valid instant after", () => { it("2:30 on New York spring-forward (2024-03-10) shifts to 3:00 local / 07:00Z", () => { const fire = materializeOccurrence("2024-03-10T02:30", "America/New_York"); expect(ISO(fire)).toBe("2024-03-10T07:00:00Z"); const wall = wallOfInstant(fire, "America/New_York"); expect(`${wall.hour}:${String(wall.minute).padStart(2, "0")}`).toBe("3:00"); }); it("2:45 shifts to 3:00 as well", () => { expect(ISO(materializeOccurrence("2024-03-10T02:45", "America/New_York"))) .toBe("2024-03-10T07:00:00Z"); }); it("2:00 (whole gap hour) shifts to 3:00", () => { expect(ISO(materializeOccurrence("2024-03-10T02:00", "America/New_York"))) .toBe("2024-03-10T07:00:00Z"); }); it("1:59 — last pre-gap minute — is untouched", () => { expect(ISO(materializeOccurrence("2024-03-10T01:59", "America/New_York"))) .toBe("2024-03-10T06:59:00Z"); }); it("Berlin 02:30 spring-forward shifts to 03:00 local", () => { // EU transitions at 01:00 UTC: 2024-03-31 02:00->03:00 local expect(ISO(materializeOccurrence("2024-03-31T02:30", "Europe/Berlin"))) .toBe("2024-03-31T01:00:00Z"); }); it("whole-day gap: Pacific/Apia skipped 2011-12-30; a slot on that day fires at the first valid instant after the gap", () => { // The zone jumped 2011-12-29T23:59:59-10:00 -> 2011-12-31T00:00:00+14:00, // so 2011-12-30 never existed. First valid instant after the gap is // 2011-12-31T00:00 local == 2011-12-30T10:00Z. const fire = materializeOccurrence("2011-12-30T09:00", "Pacific/Apia"); expect(ISO(fire)).toBe("2011-12-30T10:00:00Z"); const wall = wallOfInstant(fire, "Pacific/Apia"); expect(`${wall.year}-${wall.month}-${String(wall.day).padStart(2, "0")}T${wall.hour}:${wall.minute}`).toBe("2011-12-31T0:0"); }); it("a slot just before the Apia skip (23:00 on 12-29) is untouched", () => { expect(ISO(materializeOccurrence("2011-12-29T23:00", "Pacific/Apia"))) .toBe("2011-12-30T09:00:00Z"); }); }); describe("DST fold — ambiguous local time fires once, at the earlier instant", () => { it("1:30 on New York fall-back (2024-11-03) fires at EDT (earlier offset)", () => { // 01:30 EDT == 05:30Z; the later reading 01:30 EST == 06:30Z is NOT used. const fire = materializeOccurrence("2024-11-03T01:30", "America/New_York"); expect(ISO(fire)).toBe("2024-11-03T05:30:00Z"); expect(fire).not.toBe(Date.parse("2024-11-03T06:30:00Z")); }); it("1:30 fires exactly once at 05:30Z (not 06:30Z)", () => { expect(ISO(materializeOccurrence("2024-11-03T01:30", "America/New_York"))) .toBe("2024-11-03T05:30:00Z"); }); it("1:00 fold hour fires at 05:00Z (earlier)", () => { expect(ISO(materializeOccurrence("2024-11-03T01:00", "America/New_York"))) .toBe("2024-11-03T05:00:00Z"); }); it("2:00 after the fold is unambiguous again", () => { expect(ISO(materializeOccurrence("2024-11-03T02:00", "America/New_York"))) .toBe("2024-11-03T07:00:00Z"); }); }); describe("validation", () => { it("rejects malformed nominal strings", () => { for (const bad of ["2024-3-10T02:30", "2024-03-10", "2024-03-10T2:30", "2024-13-01T00:00", "2024-03-32T00:00", "2024-03-10T25:00", "nonsense"]) { expect(() => materializeOccurrence(bad, "UTC")).toThrow(InvalidNominalLocalError); } }); it("rejects invalid zones", () => { expect(() => materializeOccurrence("2024-03-10T02:30", "America/Nowhere")).toThrow(); expect(() => materializeOccurrence("2024-03-10T02:30", "local")).toThrow(); }); it("is pure — repeated calls agree", () => { const a = materializeOccurrence("2024-03-10T02:30", "America/New_York"); const b = materializeOccurrence("2024-03-10T02:30", "America/New_York"); const c = materializeOccurrence("2024-03-10T02:30", "America/New_York"); expect(a).toBe(b); expect(b).toBe(c); }); it("compareWall orders wall clocks chronologically", () => { const w = (s: string) => ({ year: +s.slice(0, 4), month: +s.slice(5, 7), day: +s.slice(8, 10), hour: +s.slice(11, 13), minute: +s.slice(14, 16), second: 0, }); expect(compareWall(w("2024-03-10T01:59"), w("2024-03-10T03:00"))).toBeLessThan(0); expect(compareWall(w("2024-11-03T01:30"), w("2024-11-03T01:30"))).toBe(0); }); }); export {};