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 | 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[] = []; 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[] = []; 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[] = []; 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(); }); }); });