import { describe, it, expect } from "vitest"; import { DateTime } from "luxon"; import { splitDateTime, combineDateTime, validateScheduledAt, defaultFirstFireIso, } from "./date-picker"; const TZ = "Asia/Kuala_Lumpur"; describe("splitDateTime", () => { it("splits a zoned ISO into date + time strings in that zone", () => { // 09:00 KL is the same wall-clock no matter what offset is on the ISO. expect(splitDateTime("2026-05-13T09:00:00+08:00", TZ)).toEqual({ date: "2026-05-13", time: "09:00", }); }); it("converts a UTC ISO into the operator's local wall-clock", () => { // 2026-05-13 01:00Z = 09:00 KL. expect(splitDateTime("2026-05-13T01:00:00Z", TZ)).toEqual({ date: "2026-05-13", time: "09:00", }); }); it("returns empty strings on malformed input", () => { expect(splitDateTime("not-an-iso", TZ)).toEqual({ date: "", time: "" }); }); }); describe("combineDateTime", () => { it("returns null when either field is missing", () => { expect(combineDateTime("", "09:00", TZ)).toBe(null); expect(combineDateTime("2026-05-13", "", TZ)).toBe(null); }); it("parses a valid pair into a luxon DateTime in the right zone", () => { const dt = combineDateTime("2026-05-13", "09:00", TZ); expect(dt).not.toBeNull(); expect(dt!.zoneName).toBe(TZ); // Use the offset format (timezone display varies by ICU build). expect(dt!.toFormat("yyyy-MM-dd HH:mm ZZ")).toBe("2026-05-13 09:00 +08:00"); }); it("returns null for an unparseable pair", () => { expect(combineDateTime("2026-99-99", "09:00", TZ)).toBe(null); }); }); describe("validateScheduledAt", () => { // Pin "now" so these tests are deterministic. 2026-05-13 09:00 KL. const NOW = DateTime.fromISO("2026-05-13T09:00:00", { zone: TZ }).toMillis(); it("rejects when the date is missing", () => { expect(validateScheduledAt("", "09:30", TZ, NOW)).toEqual({ ok: false, reason: "missing" }); }); it("rejects when the time is missing", () => { expect(validateScheduledAt("2026-05-13", "", TZ, NOW)).toEqual({ ok: false, reason: "missing" }); }); it("rejects an invalid date+time pair", () => { expect(validateScheduledAt("2026-99-99", "09:30", TZ, NOW)).toEqual({ ok: false, reason: "invalid", }); }); it("rejects timestamps clearly in the past", () => { expect(validateScheduledAt("2026-05-13", "07:00", TZ, NOW)).toEqual({ ok: false, reason: "past", }); }); it("bumps a same-minute time forward by one minute (the user clicked too fast)", () => { const r = validateScheduledAt("2026-05-13", "09:00", TZ, NOW); expect(r.ok).toBe(true); if (r.ok) { expect(r.dt.toFormat("HH:mm")).toBe("09:01"); } }); it("accepts any future time as-is", () => { const r = validateScheduledAt("2026-05-13", "10:30", TZ, NOW); expect(r.ok).toBe(true); if (r.ok) { expect(r.dt.toFormat("HH:mm")).toBe("10:30"); } }); it("respects a custom grace window", () => { // 30 seconds in the past, grace = 0 → reject. expect(validateScheduledAt("2026-05-13", "08:59", TZ, NOW + 0, 0)).toEqual({ ok: false, reason: "past", }); }); }); describe("defaultFirstFireIso", () => { it("rounds 'now' down to the start of the minute in the operator zone", () => { const now = DateTime.fromISO("2026-05-13T09:42:37.500", { zone: "UTC" }); const iso = defaultFirstFireIso(TZ, now); const back = DateTime.fromISO(iso, { zone: TZ }); expect(back.zoneName).toBe(TZ); expect(back.second).toBe(0); expect(back.millisecond).toBe(0); // 09:42:37 UTC = 17:42:37 KL → start of minute = 17:42 KL. expect(back.toFormat("HH:mm")).toBe("17:42"); }); });