import { describe, expect, it } from "vitest"; import { canonicalTimezone, currentTzdataVersion, isValidTimezone, offsetMs, toInstantMs, wallOfInstant, } from "./timezone.js"; import { InvalidInstantError, InvalidTimezoneError } from "./errors.js"; describe("canonicalTimezone", () => { it("accepts and canonicalizes IANA ids", () => { expect(canonicalTimezone("America/New_York")).toBe("America/New_York"); expect(canonicalTimezone("US/Eastern")).toBe("America/New_York"); expect(canonicalTimezone(" Europe/Paris ")).toBe("Europe/Paris"); expect(canonicalTimezone("Etc/GMT+5")).toBe("Etc/GMT+5"); }); it("accepts UTC in several spellings", () => { expect(canonicalTimezone("UTC")).toBe("UTC"); expect(canonicalTimezone("gmt")).toBe("UTC"); expect(canonicalTimezone("Zulu")).toBe("UTC"); }); it("rejects unknown zones", () => { expect(() => canonicalTimezone("America/Nowhere")).toThrow(InvalidTimezoneError); expect(() => canonicalTimezone("Mars/Olympus")).toThrow(InvalidTimezoneError); }); it("rejects offset spellings and empty input", () => { for (const bad of ["UTC+3", "+05:30", "-08:00", "5", ""]) { expect(() => canonicalTimezone(bad)).toThrow(InvalidTimezoneError); } }); it("rejects server-local idioms — a schedule zone must be explicit", () => { for (const bad of ["local", "system", "server", "SERVER", "server-local", "machine"]) { expect(() => canonicalTimezone(bad)).toThrow(InvalidTimezoneError); } }); it("isValidTimezone mirrors canonicalTimezone", () => { expect(isValidTimezone("Europe/Berlin")).toBe(true); expect(isValidTimezone("local")).toBe(false); expect(isValidTimezone("America/Nowhere")).toBe(false); }); }); describe("wallOfInstant / offsetMs", () => { it("reads New York offsets across the 2024 transitions", () => { expect(offsetMs(Date.parse("2024-03-10T06:59:59Z"), "America/New_York")).toBe(-5 * 3600_000); expect(offsetMs(Date.parse("2024-03-10T07:00:00Z"), "America/New_York")).toBe(-4 * 3600_000); expect(offsetMs(Date.parse("2024-11-03T05:59:59Z"), "America/New_York")).toBe(-4 * 3600_000); expect(offsetMs(Date.parse("2024-11-03T06:00:00Z"), "America/New_York")).toBe(-5 * 3600_000); }); it("reads half-hour and 45-minute zones", () => { expect(offsetMs(Date.parse("2024-06-01T12:00:00Z"), "Asia/Kolkata")).toBe(5.5 * 3600_000); expect(offsetMs(Date.parse("2024-06-01T12:00:00Z"), "Asia/Kathmandu")).toBe(5.75 * 3600_000); }); it("sees Pacific/Apia's 2011 whole-day skip", () => { const before = wallOfInstant(Date.parse("2011-12-29T09:59:59Z"), "Pacific/Apia"); const after = wallOfInstant(Date.parse("2011-12-30T10:00:00Z"), "Pacific/Apia"); expect(`${before.year}-${before.month}-${before.day}`).toBe("2011-12-28"); expect(`${after.year}-${after.month}-${after.day}`).toBe("2011-12-31"); }); it("returns UTC fields without a formatter", () => { const wall = wallOfInstant(Date.parse("2024-03-10T02:30:00Z"), "UTC"); expect([wall.year, wall.month, wall.day, wall.hour, wall.minute]).toEqual([2024, 3, 10, 2, 30]); }); }); describe("toInstantMs", () => { it("accepts dates, numbers and offset-carrying ISO strings", () => { expect(toInstantMs(new Date("2024-03-10T02:30:00Z"))).toBe(Date.parse("2024-03-10T02:30:00Z")); expect(toInstantMs(1710038400000)).toBe(1710038400000); expect(toInstantMs("2024-03-10T02:30:00Z")).toBe(Date.parse("2024-03-10T02:30:00Z")); expect(toInstantMs("2024-03-10T04:30:00+02:00")).toBe(Date.parse("2024-03-10T02:30:00Z")); }); it("rejects offset-less datetimes that would parse server-local", () => { for (const bad of ["2024-03-10T02:30:00", "2024-03-10 02:30", "2024-03-10T02:30:00.500"]) { expect(() => toInstantMs(bad)).toThrow(InvalidInstantError); } }); it("rejects NaN/Infinity/garbage", () => { expect(() => toInstantMs(new Date(NaN))).toThrow(InvalidInstantError); expect(() => toInstantMs(Number.NaN)).toThrow(InvalidInstantError); expect(() => toInstantMs(Number.POSITIVE_INFINITY)).toThrow(InvalidInstantError); expect(() => toInstantMs("not a date")).toThrow(InvalidInstantError); }); }); describe("currentTzdataVersion", () => { it("returns a non-empty version string", () => { expect(currentTzdataVersion()).toMatch(/.+/); }); });