diff --git a/src/engine/datetime.test.ts b/src/engine/datetime.test.ts new file mode 100644 index 0000000..c327c52 --- /dev/null +++ b/src/engine/datetime.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + detectDateOrder, + resolveZone, + startOfToday, + addToDate, + isCalendarUnit, +} from "./datetime"; + +describe("datetime", () => { + describe("detectDateOrder", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should detect dmy", () => { + const mockFormatToParts = vi.fn().mockReturnValue([ + { type: "day", value: "31" }, + { type: "literal", value: "/" }, + { type: "month", value: "12" }, + { type: "literal", value: "/" }, + { type: "year", value: "2000" }, + ]); + + const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({ + formatToParts: mockFormatToParts, + }) as any); + + expect(detectDateOrder()).toBe("dmy"); + mockDateTimeFormat.mockRestore(); + }); + + it("should detect mdy", () => { + const mockFormatToParts = vi.fn().mockReturnValue([ + { type: "month", value: "12" }, + { type: "literal", value: "/" }, + { type: "day", value: "31" }, + { type: "literal", value: "/" }, + { type: "year", value: "2000" }, + ]); + + const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({ + formatToParts: mockFormatToParts, + }) as any); + + expect(detectDateOrder()).toBe("mdy"); + mockDateTimeFormat.mockRestore(); + }); + + it("should default to dmy if neither is found", () => { + const mockFormatToParts = vi.fn().mockReturnValue([ + { type: "year", value: "2000" }, + ]); + + const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({ + formatToParts: mockFormatToParts, + }) as any); + + expect(detectDateOrder()).toBe("dmy"); + mockDateTimeFormat.mockRestore(); + }); + }); + + describe("resolveZone", () => { + it("should resolve 'local' to system timezone", () => { + const mockResolvedOptions = vi.fn().mockReturnValue({ timeZone: "America/Los_Angeles" }); + const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({ + resolvedOptions: mockResolvedOptions, + }) as any); + + expect(resolveZone(["local"])).toBe("America/Los_Angeles"); + mockDateTimeFormat.mockRestore(); + }); + + it("should resolve known abbreviations", () => { + expect(resolveZone(["utc"])).toBe("UTC"); + expect(resolveZone(["est"])).toBe("America/New_York"); + expect(resolveZone(["pst"])).toBe("America/Los_Angeles"); + expect(resolveZone(["jst"])).toBe("Asia/Tokyo"); + }); + + it("should resolve known cities", () => { + expect(resolveZone(["new", "york"])).toBe("America/New_York"); + expect(resolveZone(["london"])).toBe("Europe/London"); + expect(resolveZone(["tokyo"])).toBe("Asia/Tokyo"); + expect(resolveZone(["buenos", "aires"])).toBe("America/Argentina/Buenos_Aires"); + }); + + it("should resolve russian cities", () => { + expect(resolveZone(["нью", "йорк"])).toBe("America/New_York"); + expect(resolveZone(["москва"])).toBe("Europe/Moscow"); + }); + + it("should ignore punctuation and case", () => { + expect(resolveZone(["New-York"])).toBe("America/New_York"); + expect(resolveZone(["LONdoN."])).toBe("Europe/London"); + }); + + it("should resolve raw IANA timezone", () => { + expect(resolveZone(["Europe", "Berlin"])).toBe("Europe/Berlin"); + expect(resolveZone(["America", "Argentina", "Mendoza"])).toBe("America/Argentina/Mendoza"); + }); + + it("should return null for unknown timezones", () => { + expect(resolveZone(["Unknown", "City"])).toBeNull(); + expect(resolveZone(["Invalid/Timezone"])).toBeNull(); + }); + }); + + describe("startOfToday", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return midnight of the current day", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-05-15T14:30:00Z")); + + const expected = new Date("2024-05-15T14:30:00Z"); + expected.setHours(0, 0, 0, 0); + + expect(startOfToday()).toBe(expected.getTime()); + }); + }); + + describe("addToDate", () => { + it("should add days", () => { + const ms = new Date("2024-01-01T12:00:00Z").getTime(); + const res = addToDate(ms, 5, "day"); + expect(new Date(res).toISOString()).toBe("2024-01-06T12:00:00.000Z"); + }); + + it("should add weeks", () => { + const ms = new Date("2024-01-01T12:00:00Z").getTime(); + const res = addToDate(ms, 2, "week"); + expect(new Date(res).toISOString()).toBe("2024-01-15T12:00:00.000Z"); + }); + + it("should add months", () => { + const ms = new Date("2024-01-15T12:00:00Z").getTime(); + const res = addToDate(ms, 2, "month"); + expect(new Date(res).toISOString()).toBe("2024-03-15T12:00:00.000Z"); + }); + + it("should clamp end of month correctly", () => { + const ms = new Date("2024-01-31T12:00:00Z").getTime(); + const res = addToDate(ms, 1, "month"); + // 2024 is a leap year, so Feb 29 + expect(new Date(res).toISOString()).toBe("2024-02-29T12:00:00.000Z"); + + const ms2 = new Date("2023-01-31T12:00:00Z").getTime(); + const res2 = addToDate(ms2, 1, "month"); + // 2023 is not a leap year, so Feb 28 + expect(new Date(res2).toISOString()).toBe("2023-02-28T12:00:00.000Z"); + }); + + it("should add years", () => { + const ms = new Date("2024-01-15T12:00:00Z").getTime(); + const res = addToDate(ms, 3, "year"); + expect(new Date(res).toISOString()).toBe("2027-01-15T12:00:00.000Z"); + }); + + it("should clamp end of month when adding years to leap day", () => { + const ms = new Date("2024-02-29T12:00:00Z").getTime(); + const res = addToDate(ms, 1, "year"); + expect(new Date(res).toISOString()).toBe("2025-02-28T12:00:00.000Z"); + }); + + it("should handle unit ids with prefixes", () => { + const ms = new Date("2024-01-01T12:00:00Z").getTime(); + const res = addToDate(ms, 5, "unit:day"); + expect(new Date(res).toISOString()).toBe("2024-01-06T12:00:00.000Z"); + }); + + it("should return original ms if amount is not an integer", () => { + const ms = new Date("2024-01-01T12:00:00Z").getTime(); + const res = addToDate(ms, 1.5, "day"); + expect(res).toBe(ms); + }); + + it("should return original ms for unhandled units", () => { + const ms = new Date("2024-01-01T12:00:00Z").getTime(); + const res = addToDate(ms, 1, "hour"); + expect(res).toBe(ms); + }); + }); + + describe("isCalendarUnit", () => { + it("should return true for valid calendar units with integer amounts", () => { + expect(isCalendarUnit("day", 1)).toBe(true); + expect(isCalendarUnit("week", 5)).toBe(true); + expect(isCalendarUnit("month", -2)).toBe(true); + expect(isCalendarUnit("year", 10)).toBe(true); + expect(isCalendarUnit("unit:day", 1)).toBe(true); + }); + + it("should return false for floating point amounts", () => { + expect(isCalendarUnit("day", 1.5)).toBe(false); + expect(isCalendarUnit("month", 0.5)).toBe(false); + }); + + it("should return false for non-calendar units", () => { + expect(isCalendarUnit("hour", 1)).toBe(false); + expect(isCalendarUnit("minute", 1)).toBe(false); + expect(isCalendarUnit("unit:meter", 1)).toBe(false); + }); + }); +});