Initial commit: Discord-Claude Gateway with event-driven agent runtime
This commit is contained in:
176
tests/unit/heartbeat-scheduler.test.ts
Normal file
176
tests/unit/heartbeat-scheduler.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { HeartbeatScheduler, type HeartbeatCheck } from "../../src/heartbeat-scheduler.js";
|
||||
import type { Event } from "../../src/event-queue.js";
|
||||
|
||||
type EnqueueFn = (event: Omit<Event, "id" | "timestamp">) => Event | null;
|
||||
|
||||
describe("HeartbeatScheduler", () => {
|
||||
let scheduler: HeartbeatScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
scheduler = new HeartbeatScheduler();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("parseConfig", () => {
|
||||
it("parses a single check definition", () => {
|
||||
const content = `## check-email
|
||||
Interval: 300
|
||||
Instruction: Check my inbox for urgent items`;
|
||||
|
||||
const checks = scheduler.parseConfig(content);
|
||||
expect(checks).toEqual([
|
||||
{ name: "check-email", instruction: "Check my inbox for urgent items", intervalSeconds: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple check definitions", () => {
|
||||
const content = `## check-email
|
||||
Interval: 300
|
||||
Instruction: Check my inbox for urgent items
|
||||
|
||||
## check-calendar
|
||||
Interval: 600
|
||||
Instruction: Review upcoming calendar events`;
|
||||
|
||||
const checks = scheduler.parseConfig(content);
|
||||
expect(checks).toHaveLength(2);
|
||||
expect(checks[0]).toEqual({ name: "check-email", instruction: "Check my inbox for urgent items", intervalSeconds: 300 });
|
||||
expect(checks[1]).toEqual({ name: "check-calendar", instruction: "Review upcoming calendar events", intervalSeconds: 600 });
|
||||
});
|
||||
|
||||
it("returns empty array for empty content", () => {
|
||||
expect(scheduler.parseConfig("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips incomplete check definitions (missing instruction)", () => {
|
||||
const content = `## incomplete-check
|
||||
Interval: 300`;
|
||||
|
||||
expect(scheduler.parseConfig(content)).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips incomplete check definitions (missing interval)", () => {
|
||||
const content = `## incomplete-check
|
||||
Instruction: Do something`;
|
||||
|
||||
expect(scheduler.parseConfig(content)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("start", () => {
|
||||
it("starts timers for valid checks and enqueues heartbeat events", () => {
|
||||
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
|
||||
const enqueue: EnqueueFn = (event) => {
|
||||
enqueued.push(event);
|
||||
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
|
||||
};
|
||||
|
||||
const checks: HeartbeatCheck[] = [
|
||||
{ name: "check-email", instruction: "Check inbox", intervalSeconds: 120 },
|
||||
];
|
||||
|
||||
scheduler.start(checks, enqueue);
|
||||
|
||||
// No events yet — timer hasn't fired
|
||||
expect(enqueued).toHaveLength(0);
|
||||
|
||||
// Advance time by 120 seconds
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(enqueued).toHaveLength(1);
|
||||
expect(enqueued[0]).toEqual({
|
||||
type: "heartbeat",
|
||||
payload: { instruction: "Check inbox", checkName: "check-email" },
|
||||
source: "heartbeat-scheduler",
|
||||
});
|
||||
|
||||
// Advance another 120 seconds
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(enqueued).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects checks with interval < 60 seconds with a warning", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const enqueue: EnqueueFn = () => null;
|
||||
|
||||
const checks: HeartbeatCheck[] = [
|
||||
{ name: "too-fast", instruction: "Do something", intervalSeconds: 30 },
|
||||
];
|
||||
|
||||
scheduler.start(checks, enqueue);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("too-fast")
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("below the minimum")
|
||||
);
|
||||
|
||||
// Advance time — no events should be enqueued
|
||||
vi.advanceTimersByTime(60_000);
|
||||
// No way to check enqueue wasn't called since it returns null, but the warn confirms rejection
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("starts valid checks and skips invalid ones in the same batch", () => {
|
||||
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
|
||||
const enqueue: EnqueueFn = (event) => {
|
||||
enqueued.push(event);
|
||||
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
|
||||
};
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const checks: HeartbeatCheck[] = [
|
||||
{ name: "too-fast", instruction: "Bad check", intervalSeconds: 10 },
|
||||
{ name: "valid-check", instruction: "Good check", intervalSeconds: 60 },
|
||||
];
|
||||
|
||||
scheduler.start(checks, enqueue);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(enqueued).toHaveLength(1);
|
||||
expect(enqueued[0].payload).toEqual({ instruction: "Good check", checkName: "valid-check" });
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop", () => {
|
||||
it("clears all timers so no more events are enqueued", () => {
|
||||
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
|
||||
const enqueue: EnqueueFn = (event) => {
|
||||
enqueued.push(event);
|
||||
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
|
||||
};
|
||||
|
||||
const checks: HeartbeatCheck[] = [
|
||||
{ name: "check-a", instruction: "Do A", intervalSeconds: 60 },
|
||||
{ name: "check-b", instruction: "Do B", intervalSeconds: 120 },
|
||||
];
|
||||
|
||||
scheduler.start(checks, enqueue);
|
||||
|
||||
// Fire one tick for check-a
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(enqueued).toHaveLength(1);
|
||||
|
||||
scheduler.stop();
|
||||
|
||||
// Advance more time — no new events
|
||||
vi.advanceTimersByTime(300_000);
|
||||
expect(enqueued).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is safe to call stop when no timers are running", () => {
|
||||
expect(() => scheduler.stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user