import { describe, expect, it } from "vitest"; import { nextOccurrences, materializeOccurrences } from "./occurrences_extra.js"; import { prepareSchedule } from "./schedule.js"; import { nextOccurrencesPrepared } from "./occurrences.js"; import { InvalidCronError } from "./errors.js"; const ISO = (occ: { fireAtUtc: number }) => new Date(occ.fireAtUtc).toISOString().replace(".000Z", "Z"); describe("UTC passthrough", () => { it("emits exactly the cron slots with no offset math", () => { const out = nextOccurrences({ cron: "0 9 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 3); expect(out.map(ISO)).toEqual([ "2024-03-09T09:00:00Z", "2024-03-10T09:00:00Z", "2024-03-11T09:00:00Z", ]); expect(out.map((o) => o.nominalLocal)).toEqual([ "2024-03-09T09:00", "2024-03-10T09:00", "2024-03-11T09:00", ]); }); it("daily at 02:30 UTC is unaffected by any DST", () => { const out = nextOccurrences({ cron: "30 2 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 3); expect(out.map(ISO)).toEqual([ "2024-03-09T02:30:00Z", "2024-03-10T02:30:00", "2024-03-11T02:30:00Z", ].map((x) => (x.endsWith("Z") ? x : `${x}Z`))); }); it("respects hour and minute fields", () => { const out = nextOccurrences({ cron: "15,45 6 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 4); expect(out.map(ISO)).toEqual([ "2024-03-09T06:15:00Z", "2024-03-09T06:45:00Z", "2024-03-10T06:15:00Z", "2024-03-10T06:45:00Z", ]); }); }); describe("Europe/New_York spring-forward gap (2024-03-10 02:00->03:00)", () => { it("daily 02:30: the gap day fires at the shifted-forward instant 03:00 local (07:00Z)", () => { const out = nextOccurrences({ cron: "30 2 * * *", timezone: "America/New_York" }, "2024-03-08T00:00:00Z", 3); expect(out.map(ISO)).toEqual([ "2024-03-08T07:30:00Z", // 02:30 EST "2024-03-09T07:30:00Z", // 02:30 EST "2024-03-10T07:00:00Z", // 02:30 does not exist; fires 03:00 EDT ]); // The nominal slot is preserved on the gap day even though the instant // shifted — that is what last_fired_nominal_local records. expect(out[2]!.nominalLocal).toBe("2024-03-10T02:30"); }); it("a 02:00-only schedule still fires on the gap day, shifted to 03:00 local", () => { const out = nextOccurrences({ cron: "0 2 * * *", timezone: "America/New_York" }, "2024-03-09T00:00:00Z", 3); expect(out.map(ISO)).toEqual([ "2024-03-09T07:00:00Z", // 02:00 EST "2024-03-10T07:00:00Z", // 02:00 nonexistent -> 03:00 EDT "2024-03-11T06:00:00Z", // 02:00 EDT ]); }); }); describe("collapse-dedup: */15 across the spring-forward hour fires once per instant", () => { it("02:00..02:45 nominal slots collapse; the gap instant appears exactly once", () => { const out = nextOccurrences( { cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-10T05:00:00Z", 8, ); // 03:00 EDT == 07:00Z is the first valid instant after the gap. The slots // 02:00, 02:15, 02:30, 02:45 all materialize there -> ONE occurrence. // 05:00Z == 00:15 local. The next slots are 00:30(05:30Z), 00:45(05:45Z), // 01:00(06:00Z), 01:15(06:15Z), then the four nominal slots 01:30(06:30Z), // 01:45(06:45Z), 02:00, 02:15, 02:30, 02:45 — of which everything in the // gap collapses onto 03:00 local == 07:00Z — ONE occurrence — followed by // the normal 03:15 cadence. expect(out.map(ISO)).toEqual([ "2024-03-10T05:15:00Z", "2024-03-10T05:30:00Z", "2024-03-10T05:45:00Z", "2024-03-10T06:00:00Z", "2024-03-10T06:15:00Z", "2024-03-10T06:30:00Z", "2024-03-10T06:45:00Z", "2024-03-10T07:00:00Z", ]); // The single surviving occurrence at the gap edge carries the LATEST // nominal slot that collapsed onto it — the conservative never-refire label. expect(out[7]!.nominalLocal).toBe("2024-03-10T03:00"); }); it("the collapse instant appears exactly once even when asking for many", () => { const out = nextOccurrences({ cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-10T05:00:00Z", 40); const atEdge = out.filter((o) => o.fireAtUtc === Date.parse("2024-03-10T07:00:00Z")); expect(atEdge.length).toBe(1); }); it("produces globally unique fire instants (occurrence key uniqueness)", () => { const out = nextOccurrences({ cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-09T00:00:00Z", 200); const instants = out.map((o) => o.fireAtUtc); expect(new Set(instants).size).toBe(instants.length); }); it("fold hour: */15 emits each ambiguous quarter-hour once, at the earlier instant", () => { const out = nextOccurrences( { cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-11-03T04:00:00Z", 8, { exclusive: false }, ); expect(out.map(ISO)).toEqual([ "2024-11-03T04:00:00Z", // 00:00 EDT "2024-11-03T04:15:00Z", "2024-11-03T04:30:00Z", "2024-11-03T04:45:00Z", "2024-11-03T05:00:00Z", // 01:00 EDT (earlier reading) "2024-11-03T05:15:00Z", "2024-11-03T05:30:00Z", "2024-11-03T05:45:00Z", ]); }); }); describe("fall-back fold — daily 01:30 fires once at the earlier instant", () => { it("fires 05:30Z (EDT), never 06:30Z, and continues next day", () => { const out = nextOccurrences({ cron: "30 1 * * *", timezone: "America/New_York" }, "2024-11-01T00:00:00Z", 3); expect(out.map(ISO)).toEqual([ "2024-11-01T05:30:00Z", // 01:30 EDT "2024-11-02T05:30:00Z", // 01:30 EDT "2024-11-03T05:30:00Z", // 01:30 exists twice; fires ONCE at EDT (05:30Z) ]); // and never the later EST reading expect(out.map(ISO)).not.toContain("2024-11-03T06:30:00Z"); }); }); describe("cron day-of-month/month handling", () => { it("monthly on the 1st at 09:00", () => { const out = nextOccurrences({ cron: "0 9 1 * *", timezone: "UTC" }, "2024-01-15T00:00:00Z", 3); expect(out.map(ISO)).toEqual([ "2024-02-01T09:00:00Z", "2024-03-01T09:00:00Z", "2024-04-01T09:00:00Z", ]); }); it("monthly last-day-ish: the 31st only in long months", () => { const out = nextOccurrences({ cron: "0 9 31 * *", timezone: "UTC" }, "2024-01-01T00:00:00Z", 4); expect(out.map(ISO)).toEqual([ "2024-01-31T09:00:00Z", "2024-03-31T09:00:00Z", "2024-05-31T09:00:00Z", "2024-07-31T09:00:00Z", ]); }); it("day-of-week: weekly on Monday 09:00", () => { const out = nextOccurrences({ cron: "0 9 * * 1", timezone: "UTC" }, "2024-03-08T00:00:00Z", 3); // 2024-03-08 is a Friday and does not match; the Mondays follow. expect(out.map(ISO)).toEqual([ "2024-03-11T09:00:00Z", "2024-03-18T09:00:00Z", "2024-03-25T09:00:00Z", ]); expect(out.every((o) => new Date(o.fireAtUtc).getUTCDay() === 1)).toBe(true); }); it("dom/dow OR rule: both restricted means either matches", () => { // 13th OR every Friday (2024-03-01 is itself a Friday but sits exactly at // `from`, so the exclusive default drops it; 03-02 would keep it). const out = nextOccurrences({ cron: "0 0 13 * 5", timezone: "UTC" }, "2024-03-01T00:00:00Z", 6); expect(out.map((o) => o.nominalLocal)).toEqual([ "2024-03-08T00:00", "2024-03-13T00:00", "2024-03-15T00:00", "2024-03-22T00:00", "2024-03-29T00:00", "2024-04-05T00:00", ]); }); it("dow-only restriction drives the day when dom is *", () => { // Mondays and Fridays only const out = nextOccurrences({ cron: "0 12 * * 1,5", timezone: "UTC" }, "2024-03-04T00:00:00Z", 4); expect(out.map((o) => o.nominalLocal)).toEqual([ "2024-03-04T12:00", // Monday "2024-03-08T12:00", // Friday "2024-03-11T12:00", // Monday "2024-03-15T12:00", // Friday ]); }); it("hour range with step: 8-18/2 hours", () => { const out = nextOccurrences({ cron: "0 8-18/2 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 7); expect(out.map((o) => o.nominalLocal.slice(11))).toEqual(["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "08:00"]); }); it("month names and dow names", () => { const out = nextOccurrences({ cron: "0 9 1 JAN-MAR *", timezone: "UTC" }, "2024-01-02T00:00:00Z", 2); expect(out.map(ISO)).toEqual(["2024-02-01T09:00:00Z", "2024-03-01T09:00:00Z"]); }); }); describe("Pacific/Apia whole-day skip in generation", () => { it("daily 09:00 skips 2011-12-30 entirely and resumes 12-31", () => { const out = nextOccurrences({ cron: "0 9 * * *", timezone: "Pacific/Apia" }, "2011-12-28T00:00:00Z", 3); expect(out.map((o) => o.nominalLocal)).toEqual([ "2011-12-28T09:00", "2011-12-29T09:00", // The skipped day's slot (12-30 09:00) still fires — shifted forward to // the first valid instant — and keeps its own nominal label so it can // never be refired as a "new" slot later. "2011-12-30T09:00", ]); // Its fire instant is the zone's first valid instant after the skip. expect(new Date(out[2]!.fireAtUtc).toISOString()).toBe("2011-12-30T10:00:00.000Z"); }); }); describe("generation hygiene", () => { it("prepared and unprepared agree", () => { const spec = { cron: "*/20 * * * *", timezone: "America/New_York" }; const a = nextOccurrences(spec, "2024-03-09T00:00:00Z", 50); const b = nextOccurrencesPrepared(prepareSchedule(spec), "2024-03-09T00:00:00Z", 50); expect(a).toEqual(b); }); it("exclusive default: regenerating from the last emitted instant does not re-emit it", () => { const spec = { cron: "0 * * * *", timezone: "UTC" }; const first = nextOccurrences(spec, "2024-03-09T00:00:00Z", 5); const last = first[first.length - 1]!; const second = nextOccurrences(spec, last.fireAtUtc, 5); expect(second[0]!.fireAtUtc).toBeGreaterThan(last.fireAtUtc); expect(second.map(ISO)).toEqual([ "2024-03-09T06:00:00Z", "2024-03-09T07:00:00Z", "2024-03-09T08:00:00Z", "2024-03-09T09:00:00Z", "2024-03-09T10:00:00Z", ]); }); it("inclusive option includes a slot exactly at from", () => { const out = nextOccurrences({ cron: "0 * * * *", timezone: "UTC" }, "2024-03-09T05:00:00Z", 2, { exclusive: false }); expect(out.map(ISO)).toEqual(["2024-03-09T05:00:00Z", "2024-03-09T06:00:00Z"]); }); it("rejects a bad count", () => { expect(() => nextOccurrences({ cron: "* * * * *", timezone: "UTC" }, "2024-01-01T00:00:00Z", 0)).toThrow(RangeError); expect(() => nextOccurrences({ cron: "* * * * *", timezone: "UTC" }, "2024-01-01T00:00:00Z", 1.5)).toThrow(RangeError); }); it("materializeOccurrences is the generation batch", () => { const out = materializeOccurrences({ cron: "0 9 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 2); expect(out.map(ISO)).toEqual(["2024-03-09T09:00:00Z", "2024-03-10T09:00:00Z"]); }); it("rejects invalid cron/zone at generation time", () => { expect(() => nextOccurrences({ cron: "not cron", timezone: "UTC" }, "2024-01-01T00:00:00Z", 1)).toThrow(InvalidCronError); expect(() => nextOccurrences({ cron: "* * * * *", timezone: "local" }, "2024-01-01T00:00:00Z", 1)).toThrow(); }); });