import { describe, expect, it } from "@suaveplan/testing/runner"; import { parseCron } from "../src/index.js"; interface Vector { expr: string; after: number; tz: string; expected: number; label: string; } const utc = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number => Date.UTC(y, mo, d, h, mi, s); const vectors: Vector[] = [ // Hourly variants { expr: "0 * * * *", after: utc(2026, 4, 11, 10, 30), tz: "UTC", expected: utc(2026, 4, 11, 11, 0), label: "hourly @00", }, { expr: "*/15 * * * *", after: utc(2026, 4, 11, 10, 7), tz: "UTC", expected: utc(2026, 4, 11, 10, 15), label: "every 15 minutes", }, { expr: "*/15 * * * *", after: utc(2026, 4, 11, 10, 45), tz: "UTC", expected: utc(2026, 4, 11, 11, 0), label: "every 15 wrap", }, { expr: "*/30 * * * *", after: utc(2026, 4, 11, 10, 29), tz: "UTC", expected: utc(2026, 4, 11, 10, 30), label: "every 30 minutes", }, // Daily { expr: "0 9 * * *", after: utc(2026, 4, 11, 8, 0), tz: "UTC", expected: utc(2026, 4, 11, 9, 0), label: "daily 9am", }, { expr: "0 0 * * *", after: utc(2026, 4, 11, 12, 0), tz: "UTC", expected: utc(2026, 4, 12, 0, 0), label: "daily midnight", }, { expr: "@daily", after: utc(2026, 4, 11, 12, 0), tz: "UTC", expected: utc(2026, 4, 12, 0, 0), label: "@daily macro", }, { expr: "@hourly", after: utc(2026, 4, 11, 10, 30), tz: "UTC", expected: utc(2026, 4, 11, 11, 0), label: "@hourly macro", }, // Weekdays / weekends // 2026-05-09 is Saturday. Next 9am weekday = Mon 2026-05-11. { expr: "0 9 * * MON-FRI", after: utc(2026, 4, 9), tz: "UTC", expected: utc(2026, 4, 11, 9, 0), label: "9am weekdays", }, { expr: "0 12 * * 0", after: utc(2026, 4, 11), tz: "UTC", expected: utc(2026, 4, 17, 12, 0), label: "noon Sundays", }, { expr: "0 9 * * 7", after: utc(2026, 4, 11), tz: "UTC", expected: utc(2026, 4, 17, 9, 0), label: "9am Sundays via 7", }, // Monthly / yearly { expr: "0 0 1 * *", after: utc(2026, 4, 11), tz: "UTC", expected: utc(2026, 5, 1, 0, 0), label: "first of month", }, { expr: "@monthly", after: utc(2026, 4, 11), tz: "UTC", expected: utc(2026, 5, 1, 0, 0), label: "@monthly macro", }, { expr: "0 0 1 1 *", after: utc(2026, 0, 2), tz: "UTC", expected: utc(2027, 0, 1, 0, 0), label: "annual Jan 1", }, { expr: "@yearly", after: utc(2026, 0, 2), tz: "UTC", expected: utc(2027, 0, 1, 0, 0), label: "@yearly macro", }, // Month aliases { expr: "0 0 1 JUL *", after: utc(2026, 4, 11), tz: "UTC", expected: utc(2026, 6, 1, 0, 0), label: "month alias JUL", }, // 4 specific minutes { expr: "0,15,30,45 9-17 * * MON-FRI", after: utc(2026, 4, 11, 9, 16), tz: "UTC", expected: utc(2026, 4, 11, 9, 30), label: "quarter-hour business hours", }, { expr: "0,15,30,45 9-17 * * MON-FRI", after: utc(2026, 4, 11, 17, 46), tz: "UTC", expected: utc(2026, 4, 12, 9, 0), label: "wrap to next business day", }, // Range/step variations { expr: "0 10-12/2 * * *", after: utc(2026, 4, 11, 9, 0), tz: "UTC", expected: utc(2026, 4, 11, 10, 0), label: "step in range first", }, { expr: "0 10-12/2 * * *", after: utc(2026, 4, 11, 11, 0), tz: "UTC", expected: utc(2026, 4, 11, 12, 0), label: "step in range second", }, // Leap year { expr: "0 0 29 2 *", after: utc(2026, 0, 1), tz: "UTC", expected: utc(2028, 1, 29, 0, 0), label: "leap day next", }, // DST — America/New_York 2026-03-08 spring-forward { expr: "0 3 * * *", after: utc(2026, 2, 8, 6, 0), tz: "America/New_York", // 03:00 EDT on 2026-03-08 = 07:00 UTC expected: utc(2026, 2, 8, 7, 0), label: "NY 3am day-of-spring-forward", }, // Europe/London non-DST anchor // 2026-01-01 09:00 London = 09:00 UTC (GMT in January) { expr: "0 9 * * *", after: utc(2025, 11, 31, 23, 0), tz: "Europe/London", expected: utc(2026, 0, 1, 9, 0), label: "London winter 9am", }, // Australia/Sydney winter 12:00 local = 02:00 UTC (UTC+10). // after = 2026-05-31T00:00Z = 2026-05-31T10:00 Sydney, next noon // Sydney = 2026-05-31T12:00 local = 2026-05-31T02:00 UTC. { expr: "0 12 * * *", after: utc(2026, 4, 31, 0, 0), tz: "Australia/Sydney", expected: utc(2026, 4, 31, 2, 0), label: "Sydney winter noon", }, // Seconds + Quartz variants (need seconds: true) // Tested separately because parseCron options differ. ]; describe("vectors — 5-field UTC + DST", () => { for (const v of vectors) { it(v.label, { timeout: 5000 }, () => { const expr = parseCron(v.expr); const next = expr.nextRun(v.after, v.tz); expect(next).toBe(v.expected); }); } }); describe("vectors — 6-field seconds", () => { it("every 10 seconds", { timeout: 5000 }, () => { const expr = parseCron("*/10 * * * * *", { seconds: true }); expect(expr.nextRun(utc(2026, 4, 11, 10, 0, 5), "UTC")).toBe( utc(2026, 4, 11, 10, 0, 10) ); }); it("seconds with hour wrap", { timeout: 5000 }, () => { const expr = parseCron("30 0 * * * *", { seconds: true }); expect(expr.nextRun(utc(2026, 4, 11, 10, 0, 30), "UTC")).toBe( utc(2026, 4, 11, 11, 0, 30) ); }); }); describe("vectors — 7-field Quartz year", () => { it("year-constrained next run", { timeout: 5000 }, () => { const expr = parseCron("0 0 12 1 1 * 2030", { seconds: true, years: true, }); expect(expr.nextRun(utc(2026, 0, 1), "UTC")).toBe( utc(2030, 0, 1, 12, 0, 0) ); }); });