import { describe, it, expect } from "vitest"; import { DateTime } from "luxon"; import { buildRrule, describeRecurrence, kindFromRrule, matchPreset, presetDescriptors, presetToSpec, 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("preset shortcuts (Repeats picker)", () => { // FIRST is 2026-05-13 = Wednesday (ISO weekday 3), day 13, May. it("presetToSpec produces the canonical RecurrenceSpec for each shortcut", () => { expect(presetToSpec("none", FIRST)).toMatchObject({ kind: "none" }); expect(presetToSpec("daily", FIRST)).toMatchObject({ kind: "daily", interval: 1 }); expect(presetToSpec("weekdays", FIRST)).toMatchObject({ kind: "weekly", weeklyDays: [1, 2, 3, 4, 5], }); expect(presetToSpec("weekends", FIRST)).toMatchObject({ kind: "weekly", weeklyDays: [6, 7], }); expect(presetToSpec("weekly_same", FIRST)).toMatchObject({ kind: "weekly", weeklyDays: [3], // Wed }); expect(presetToSpec("monthly_same", FIRST)).toMatchObject({ kind: "monthly", monthDay: 13, }); expect(presetToSpec("yearly_same", FIRST)).toMatchObject({ kind: "yearly" }); }); it("matchPreset round-trips through presetToSpec for every preset", () => { for (const id of ["none", "daily", "weekdays", "weekends", "weekly_same", "monthly_same", "yearly_same"] as const) { const spec = presetToSpec(id, FIRST); expect(matchPreset(spec, FIRST)).toBe(id); } }); it("matchPreset returns 'custom' for anything that doesn't fit a shortcut", () => { // Interval > 1 doesn't match any preset. expect( matchPreset( { kind: "daily", interval: 2, weeklyDays: [], end: { kind: "never" } }, FIRST, ), ).toBe("custom"); // Weekly Mon/Wed/Fri isn't a known shortcut. expect( matchPreset( { kind: "weekly", interval: 1, weeklyDays: [1, 3, 5], end: { kind: "never" } }, FIRST, ), ).toBe("custom"); // End=after takes us out of the preset matrix even at interval=1. expect( matchPreset( { kind: "daily", interval: 1, weeklyDays: [], end: { kind: "after", count: 5 } }, FIRST, ), ).toBe("custom"); // Monthly on a different day-of-month than the first fire. expect( matchPreset( { kind: "monthly", interval: 1, weeklyDays: [], monthDay: 1, end: { kind: "never" } }, FIRST, ), ).toBe("custom"); }); it("presetDescriptors returns the full preset list with first-fire-aware labels", () => { const items = presetDescriptors(FIRST); expect(items.map((d) => d.id)).toEqual([ "none", "daily", "weekdays", "weekends", "weekly_same", "monthly_same", "yearly_same", "custom", "cron", ]); // Labels should be parameterised by firstFire. expect(items.find((d) => d.id === "weekly_same")?.label).toBe("Every week on Wed"); expect(items.find((d) => d.id === "monthly_same")?.label).toBe( "Every month on day 13", ); expect(items.find((d) => d.id === "yearly_same")?.label).toBe( "Every year on May 13", ); expect(items.find((d) => d.id === "cron")?.label).toBe("Cron expression…"); }); it("presetToSpec('cron') seeds a daily-at-the-first-fire cron", () => { const spec = presetToSpec("cron", FIRST); expect(spec.kind).toBe("cron"); // FIRST is 09:00, so default cron = "0 9 * * *" (every day at 9:00). expect(spec.cron).toBe("0 9 * * *"); }); it("matchPreset returns 'cron' for any cron-kind spec", () => { expect( matchPreset( { kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } }, FIRST, ), ).toBe("cron"); }); it("buildRrule produces a CRON: prefixed string for cron specs", () => { expect( buildRrule( { kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } }, FIRST, ), ).toBe("CRON:0 9 * * 1-5"); }); it("specFromRrule round-trips a CRON: prefixed rule", () => { 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"); }); });