Initial Commit
This commit is contained in:
128
packages/foundation/timing/cron/tests/integration.test.ts
Normal file
128
packages/foundation/timing/cron/tests/integration.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CronScheduler } from "../src/index.js";
|
||||
import type { SchedulerTimers } from "../src/scheduler/cron-scheduler.types.js";
|
||||
|
||||
interface PendingTimer {
|
||||
id: number;
|
||||
cb: () => void;
|
||||
fireAt: number;
|
||||
}
|
||||
|
||||
function createHarness(start: number) {
|
||||
const state = { now: start, nextId: 1 };
|
||||
const pending = new Map<number, PendingTimer>();
|
||||
const timers: SchedulerTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
pending.set(id, { id, cb, fireAt: state.now + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
};
|
||||
async function advance(delta: number): Promise<void> {
|
||||
const target = state.now + delta;
|
||||
while (true) {
|
||||
let earliest: PendingTimer | null = null;
|
||||
for (const t of pending.values()) {
|
||||
if (t.fireAt > target) continue;
|
||||
if (earliest === null || t.fireAt < earliest.fireAt)
|
||||
earliest = t;
|
||||
}
|
||||
if (earliest === null) break;
|
||||
state.now = earliest.fireAt;
|
||||
pending.delete(earliest.id);
|
||||
earliest.cb();
|
||||
// Let micro-task queue drain so the handler's finally re-schedules
|
||||
// before we look for the next due timer.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
state.now = target;
|
||||
}
|
||||
return {
|
||||
clock: () => state.now,
|
||||
timers,
|
||||
advance,
|
||||
get pending() {
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("integration — CronScheduler with multiple jobs", () => {
|
||||
it("fires three jobs at their independent intervals", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const harness = createHarness(Date.UTC(2026, 4, 11, 9, 59, 0));
|
||||
const fires = { hourly: 0, fiveMin: 0, daily: 0 };
|
||||
const scheduler = new CronScheduler({
|
||||
clock: harness.clock,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add(
|
||||
"0 * * * *",
|
||||
() => {
|
||||
fires.hourly += 1;
|
||||
},
|
||||
{ id: "hourly" }
|
||||
);
|
||||
scheduler.add(
|
||||
"*/5 * * * *",
|
||||
() => {
|
||||
fires.fiveMin += 1;
|
||||
},
|
||||
{ id: "five" }
|
||||
);
|
||||
scheduler.add(
|
||||
"0 0 * * *",
|
||||
() => {
|
||||
fires.daily += 1;
|
||||
},
|
||||
{ id: "daily" }
|
||||
);
|
||||
scheduler.start();
|
||||
|
||||
// Advance 2 hours and 30 minutes.
|
||||
await harness.advance(2 * 60 * 60 * 1000 + 30 * 60 * 1000);
|
||||
|
||||
expect(fires.hourly).toBeGreaterThanOrEqual(2);
|
||||
expect(fires.fiveMin).toBeGreaterThanOrEqual(20);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("stop() drains in-flight handler", { timeout: 30000 }, async () => {
|
||||
const harness = createHarness(Date.UTC(2026, 4, 11, 9, 59, 0));
|
||||
let resolveHandler!: () => void;
|
||||
const holding = new Promise<void>((r) => {
|
||||
resolveHandler = r;
|
||||
});
|
||||
let completed = false;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: harness.clock,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", async () => {
|
||||
await holding;
|
||||
completed = true;
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
|
||||
const stopPromise = scheduler.stop();
|
||||
let stopResolved = false;
|
||||
const watcher = stopPromise.then(() => {
|
||||
stopResolved = true;
|
||||
});
|
||||
|
||||
// Give the runtime a few microtasks to let the watcher attach.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(stopResolved).toBe(false);
|
||||
|
||||
resolveHandler();
|
||||
await watcher;
|
||||
expect(completed).toBe(true);
|
||||
expect(stopResolved).toBe(true);
|
||||
});
|
||||
});
|
||||
111
packages/foundation/timing/cron/tests/property.test.ts
Normal file
111
packages/foundation/timing/cron/tests/property.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { parseCron } from "../src/index.js";
|
||||
|
||||
const EXPRESSIONS = [
|
||||
"* * * * *",
|
||||
"0 * * * *",
|
||||
"*/5 * * * *",
|
||||
"*/15 * * * *",
|
||||
"0 9 * * *",
|
||||
"0 9 * * MON-FRI",
|
||||
"30 14 * * 0",
|
||||
"0 0 1 * *",
|
||||
"0 0 1 1 *",
|
||||
"0,15,30,45 9-17 * * MON-FRI",
|
||||
"0 10-12/2 * * *",
|
||||
];
|
||||
|
||||
const TIMEZONES = [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"Europe/London",
|
||||
"Australia/Sydney",
|
||||
];
|
||||
|
||||
const timestampArb = fc.integer({
|
||||
min: Date.UTC(2026, 0, 1),
|
||||
max: Date.UTC(2028, 0, 1),
|
||||
});
|
||||
|
||||
describe("property — nextRun > after", () => {
|
||||
it("returns a strictly later instant for every supported expression", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const next = parseCron(e).nextRun(t, tz);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next as number).toBeGreaterThan(t);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — nextRuns strictly monotonic", () => {
|
||||
it("every adjacent pair is strictly ascending", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const runs = parseCron(e).nextRuns(10, t, tz);
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
for (let i = 1; i < runs.length; i += 1) {
|
||||
expect(runs[i]).toBeGreaterThan(runs[i - 1] ?? 0);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — matches(nextRun) is true", () => {
|
||||
it("the returned instant always matches the expression", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const expr = parseCron(e);
|
||||
const next = expr.nextRun(t, tz);
|
||||
expect(expr.matches(next as number, tz)).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — previousRun(nextRun) <= after", () => {
|
||||
it("backward search lands at-or-before the original anchor", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const expr = parseCron(e);
|
||||
const next = expr.nextRun(t, tz) as number;
|
||||
const prev = expr.previousRun(next, tz);
|
||||
expect(prev).not.toBeNull();
|
||||
expect(prev as number).toBeLessThanOrEqual(t);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
1
packages/foundation/timing/cron/tests/setup/polyfills.ts
Normal file
1
packages/foundation/timing/cron/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
251
packages/foundation/timing/cron/tests/vectors.test.ts
Normal file
251
packages/foundation/timing/cron/tests/vectors.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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)
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user