Files
2026-05-27 23:47:53 +01:00

134 lines
4.3 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type { ExecutionTimers } from "../src/execution/job-execution.js";
import { createJobScheduler } from "../src/scheduler/job-scheduler.js";
import type { JobSchedulerEventMap } from "../src/scheduler/job-scheduler.types.js";
interface TimerState {
now: number;
nextId: number;
pending: Map<number, { fireAt: number; cb: () => void }>;
}
function harness(start: number): {
state: TimerState;
timers: ExecutionTimers;
advance: (delta: number) => Promise<void>;
bus: EventBusLike<JobSchedulerEventMap>;
logger: LoggerLike;
} {
const state: TimerState = { now: start, nextId: 1, pending: new Map() };
const timers: ExecutionTimers = {
setTimer: (cb, ms) => {
const id = state.nextId++;
state.pending.set(id, { fireAt: state.now + ms, cb });
return id;
},
clearTimer: (h) => {
state.pending.delete(h as number);
},
};
const advance = async (delta: number): Promise<void> => {
const target = state.now + delta;
for (;;) {
let id: number | undefined;
let at = Number.POSITIVE_INFINITY;
for (const [k, v] of state.pending) {
if (v.fireAt <= target && v.fireAt < at) {
id = k;
at = v.fireAt;
}
}
if (id === undefined) break;
const entry = state.pending.get(id);
if (entry === undefined) break;
state.pending.delete(id);
state.now = entry.fireAt;
entry.cb();
await Promise.resolve();
await Promise.resolve();
}
state.now = target;
};
const bus: EventBusLike<JobSchedulerEventMap> = {
on: () => () => undefined,
emit: () => undefined,
};
const logger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return { state, timers, advance, bus, logger };
}
describe("integration: three cron jobs over 10 simulated minutes", () => {
it("fires every-minute, every-2-minutes, every-5-minutes the expected counts", {
timeout: 30_000,
}, async () => {
const startAt = Date.UTC(2026, 4, 12, 9, 0, 0);
const h = harness(startAt);
const s = createJobScheduler({
clock: () => h.state.now,
timers: h.timers,
events: h.bus,
logger: h.logger,
tickIntervalMs: 100,
});
const counts = { every1: 0, every2: 0, every5: 0 };
s.schedule("* * * * *", async () => {
counts.every1 += 1;
});
s.schedule("*/2 * * * *", async () => {
counts.every2 += 1;
});
s.schedule("*/5 * * * *", async () => {
counts.every5 += 1;
});
s.start();
await h.advance(10 * 60_000 + 500);
await s.stop();
// Over a 10-minute window starting at HH:00:00.5, every-minute
// fires 10 times (HH:01 through HH:10), every-2-minutes fires 5
// times (02, 04, 06, 08, 10), every-5-minutes fires 2 times
// (05, 10).
expect(counts.every1).toBe(10);
expect(counts.every2).toBe(5);
expect(counts.every5).toBe(2);
});
});
describe("integration: stop() drains a mid-run job", () => {
it("waits for a slow handler to finish before resolving", {
timeout: 30_000,
}, async () => {
const h = harness(Date.UTC(2026, 4, 12, 9, 0, 0));
const s = createJobScheduler({
clock: () => h.state.now,
timers: h.timers,
events: h.bus,
logger: h.logger,
tickIntervalMs: 50,
});
let resolved = false;
let resolver: () => void = () => undefined;
s.queue(
() =>
new Promise<void>((res) => {
resolver = () => {
resolved = true;
res();
};
}),
{ delayMs: 0 }
);
s.start();
await h.advance(100);
const stopP = s.stop();
resolver();
await stopP;
expect(resolved).toBe(true);
});
});