import { describe, it, expect } from "vitest"; import { DateTime } from "luxon"; import { buildRrule, describeRecurrence, isoWeekdayToCron, kindFromRrule, specFromRrule, type RecurrenceSpec, } from "./recurrence"; const FIRST = DateTime.fromISO("2026-05-13T09:00:00", { zone: "Asia/Kuala_Lumpur" }); const baseSpec = (over: Partial = {}): RecurrenceSpec => ({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" }, ...over, }); describe("buildRrule", () => { it("returns null for one-off", () => { expect(buildRrule(baseSpec({ kind: "none" }), FIRST)).toBe(null); }); it("daily simple", () => { expect(buildRrule(baseSpec({ kind: "daily" }), FIRST)).toBe("FREQ=DAILY"); }); it("daily with interval", () => { expect(buildRrule(baseSpec({ kind: "daily", interval: 3 }), FIRST)).toBe( "FREQ=DAILY;INTERVAL=3", ); }); it("weekly with explicit days sorts to canonical order", () => { expect( buildRrule(baseSpec({ kind: "weekly", weeklyDays: [3, 1, 5] }), FIRST), ).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR"); }); it("weekly with no days falls back to first-fire weekday (Wed)", () => { expect(buildRrule(baseSpec({ kind: "weekly" }), FIRST)).toBe("FREQ=WEEKLY;BYDAY=WE"); }); it("monthly defaults to first-fire day-of-month", () => { expect(buildRrule(baseSpec({ kind: "monthly" }), FIRST)).toBe( "FREQ=MONTHLY;BYMONTHDAY=13", ); }); it("monthly honours explicit monthDay", () => { expect(buildRrule(baseSpec({ kind: "monthly", monthDay: 1 }), FIRST)).toBe( "FREQ=MONTHLY;BYMONTHDAY=1", ); }); it("yearly uses BYMONTH and BYMONTHDAY", () => { expect(buildRrule(baseSpec({ kind: "yearly" }), FIRST)).toBe( "FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13", ); }); it("end=after attaches COUNT", () => { expect( buildRrule( baseSpec({ kind: "daily", end: { kind: "after", count: 7 } }), FIRST, ), ).toBe("FREQ=DAILY;COUNT=7"); }); it("end=on attaches UNTIL in UTC", () => { const r = buildRrule( baseSpec({ kind: "daily", end: { kind: "on", until: "2026-06-01" } }), FIRST, ); expect(r).toMatch(/^FREQ=DAILY;UNTIL=2026060[0-2]T235959Z$/); }); it("interval + weekly + count compose correctly", () => { expect( buildRrule( baseSpec({ kind: "weekly", interval: 2, weeklyDays: [1, 3, 5], end: { kind: "after", count: 12 }, }), FIRST, ), ).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=12"); }); }); describe("specFromRrule / kindFromRrule", () => { it("returns the default spec for null/undefined", () => { expect(specFromRrule(null)).toEqual({ kind: "none", interval: 1, weeklyDays: [], monthDay: undefined, end: { kind: "never" }, }); expect(kindFromRrule(undefined)).toBe("none"); }); it("parses daily with interval", () => { expect(specFromRrule("FREQ=DAILY;INTERVAL=3")).toEqual({ kind: "daily", interval: 3, weeklyDays: [], monthDay: undefined, end: { kind: "never" }, }); }); it("parses weekly with BYDAY", () => { expect(specFromRrule("FREQ=WEEKLY;BYDAY=MO,WE,FR")).toMatchObject({ kind: "weekly", weeklyDays: [1, 3, 5], }); }); it("parses monthly with BYMONTHDAY", () => { expect(specFromRrule("FREQ=MONTHLY;BYMONTHDAY=15")).toMatchObject({ kind: "monthly", monthDay: 15, }); }); it("parses COUNT into end=after", () => { expect(specFromRrule("FREQ=DAILY;COUNT=10").end).toEqual({ kind: "after", count: 10, }); }); it("parses UNTIL into end=on (date only)", () => { expect(specFromRrule("FREQ=DAILY;UNTIL=20260601T235959Z").end).toEqual({ kind: "on", until: "2026-06-01", }); }); it("round-trips through buildRrule + specFromRrule for compound rules", () => { const spec = baseSpec({ kind: "weekly", interval: 2, weeklyDays: [1, 3, 5], end: { kind: "after", count: 12 }, }); const rule = buildRrule(spec, FIRST)!; expect(specFromRrule(rule)).toMatchObject({ kind: "weekly", interval: 2, weeklyDays: [1, 3, 5], end: { kind: "after", count: 12 }, }); }); }); describe("cron weekday helper + buildRrule/specFromRrule round-trip", () => { it("isoWeekdayToCron maps Sun (ISO 7) to cron 0 and Mon-Sat unchanged", () => { expect(isoWeekdayToCron(1)).toBe(1); // Mon expect(isoWeekdayToCron(2)).toBe(2); expect(isoWeekdayToCron(6)).toBe(6); // Sat expect(isoWeekdayToCron(7)).toBe(0); // Sun }); it("buildRrule + specFromRrule still round-trip CRON: rules", () => { const spec: RecurrenceSpec = { kind: "cron", interval: 1, weeklyDays: [], cron: "*/15 * * * *", end: { kind: "never" }, }; expect(buildRrule(spec, FIRST)).toBe("CRON:*/15 * * * *"); expect(specFromRrule("CRON:*/15 * * * *")).toMatchObject({ kind: "cron", cron: "*/15 * * * *", }); }); }); describe("describeRecurrence", () => { it("renders a one-off label", () => { expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off"); }); it("renders interval and unit pluralisation", () => { expect(describeRecurrence(baseSpec({ kind: "daily" }), FIRST)).toBe("Every day"); expect(describeRecurrence(baseSpec({ kind: "daily", interval: 2 }), FIRST)).toBe( "Every 2 days", ); }); it("renders weekly days as Short labels in canonical order", () => { expect( describeRecurrence(baseSpec({ kind: "weekly", weeklyDays: [5, 1, 3] }), FIRST), ).toBe("Every week on Mon, Wed, Fri"); }); it("renders monthly with day", () => { expect(describeRecurrence(baseSpec({ kind: "monthly", monthDay: 14 }), FIRST)).toBe( "Every month on day 14", ); }); it("renders yearly with month and day", () => { expect(describeRecurrence(baseSpec({ kind: "yearly" }), FIRST)).toBe( "Every year on May 13", ); }); it("appends end=after as ', N times'", () => { expect( describeRecurrence( baseSpec({ kind: "daily", end: { kind: "after", count: 5 } }), FIRST, ), ).toBe("Every day, 5 times"); }); it("appends end=on as ', until '", () => { expect( describeRecurrence( baseSpec({ kind: "daily", end: { kind: "on", until: "2026-06-01" } }), FIRST, ), ).toBe("Every day, until 2026-06-01"); }); });