import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CooldownManager, mergeBatchText } from "./cooldown.js"; import type { RawWechatMessage } from "./types.js"; function msg(overrides: Partial = {}): RawWechatMessage { return { msgId: overrides.msgId ?? `m-${Math.random().toString(36).slice(2, 8)}`, fromWxid: overrides.fromWxid ?? "wxid_alice", toWxid: overrides.toWxid ?? "wxid_bot", type: "text", content: overrides.content ?? "hello", timestamp: Date.now(), isGroup: false, ...overrides, }; } describe("CooldownManager", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("dispatches a single message after batchWindowMs", async () => { const flushed: RawWechatMessage[][] = []; const mgr = new CooldownManager({ batchWindowMs: 500, replyCooldownMs: 0, onFlush: async (b) => { flushed.push(b); }, }); mgr.enqueue(msg({ content: "hi" })); expect(flushed).toHaveLength(0); await vi.advanceTimersByTimeAsync(500); expect(flushed).toHaveLength(1); expect(flushed[0]).toHaveLength(1); }); it("merges rapid-fire messages from the same wxid into one batch", async () => { const flushed: RawWechatMessage[][] = []; const mgr = new CooldownManager({ batchWindowMs: 500, replyCooldownMs: 0, onFlush: async (b) => { flushed.push(b); }, }); mgr.enqueue(msg({ content: "one" })); await vi.advanceTimersByTimeAsync(100); mgr.enqueue(msg({ content: "two" })); await vi.advanceTimersByTimeAsync(100); mgr.enqueue(msg({ content: "three" })); expect(flushed).toHaveLength(0); await vi.advanceTimersByTimeAsync(500); expect(flushed).toHaveLength(1); expect(flushed[0]).toHaveLength(3); expect(flushed[0].map((m) => m.content)).toEqual(["one", "two", "three"]); }); it("keeps batches separated per wxid", async () => { const flushed: RawWechatMessage[][] = []; const mgr = new CooldownManager({ batchWindowMs: 500, replyCooldownMs: 0, onFlush: async (b) => { flushed.push(b); }, }); mgr.enqueue(msg({ fromWxid: "wxid_alice", content: "a1" })); mgr.enqueue(msg({ fromWxid: "wxid_bob", content: "b1" })); mgr.enqueue(msg({ fromWxid: "wxid_alice", content: "a2" })); await vi.advanceTimersByTimeAsync(500); expect(flushed).toHaveLength(2); const alice = flushed.find((b) => b[0].fromWxid === "wxid_alice")!; const bob = flushed.find((b) => b[0].fromWxid === "wxid_bob")!; expect(alice.map((m) => m.content)).toEqual(["a1", "a2"]); expect(bob.map((m) => m.content)).toEqual(["b1"]); }); it("delays flush until post-reply cooldown expires", async () => { const flushed: RawWechatMessage[][] = []; const mgr = new CooldownManager({ batchWindowMs: 200, replyCooldownMs: 1000, onFlush: async (b) => { flushed.push(b); }, }); // Simulate a reply that just went out. mgr.noteReply("wxid_alice", Date.now()); mgr.enqueue(msg({ fromWxid: "wxid_alice", content: "after reply" })); // Not yet — cooldown of 1s is still active. await vi.advanceTimersByTimeAsync(500); expect(flushed).toHaveLength(0); // Past the cooldown window, flush fires. await vi.advanceTimersByTimeAsync(600); expect(flushed).toHaveLength(1); expect(flushed[0][0].content).toBe("after reply"); }); it("stop() clears pending timers", async () => { const flushed: RawWechatMessage[][] = []; const mgr = new CooldownManager({ batchWindowMs: 500, replyCooldownMs: 0, onFlush: async (b) => { flushed.push(b); }, }); mgr.enqueue(msg()); mgr.stop(); await vi.advanceTimersByTimeAsync(1000); expect(flushed).toHaveLength(0); }); }); describe("mergeBatchText", () => { it("joins non-empty contents with newlines", () => { const batch = [ msg({ content: "hello" }), msg({ content: "" }), msg({ content: " " }), msg({ content: "world" }), ]; expect(mergeBatchText(batch)).toBe("hello\nworld"); }); it("returns empty string for empty batch", () => { expect(mergeBatchText([])).toBe(""); }); });