cooldown.test.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { CooldownManager, mergeBatchText } from "./cooldown.js";
  3. import type { RawWechatMessage } from "./types.js";
  4. function msg(overrides: Partial<RawWechatMessage> = {}): RawWechatMessage {
  5. return {
  6. msgId: overrides.msgId ?? `m-${Math.random().toString(36).slice(2, 8)}`,
  7. fromWxid: overrides.fromWxid ?? "wxid_alice",
  8. toWxid: overrides.toWxid ?? "wxid_bot",
  9. type: "text",
  10. content: overrides.content ?? "hello",
  11. timestamp: Date.now(),
  12. isGroup: false,
  13. ...overrides,
  14. };
  15. }
  16. describe("CooldownManager", () => {
  17. beforeEach(() => {
  18. vi.useFakeTimers();
  19. });
  20. afterEach(() => {
  21. vi.useRealTimers();
  22. });
  23. it("dispatches a single message after batchWindowMs", async () => {
  24. const flushed: RawWechatMessage[][] = [];
  25. const mgr = new CooldownManager({
  26. batchWindowMs: 500,
  27. replyCooldownMs: 0,
  28. onFlush: async (b) => {
  29. flushed.push(b);
  30. },
  31. });
  32. mgr.enqueue(msg({ content: "hi" }));
  33. expect(flushed).toHaveLength(0);
  34. await vi.advanceTimersByTimeAsync(500);
  35. expect(flushed).toHaveLength(1);
  36. expect(flushed[0]).toHaveLength(1);
  37. });
  38. it("merges rapid-fire messages from the same wxid into one batch", async () => {
  39. const flushed: RawWechatMessage[][] = [];
  40. const mgr = new CooldownManager({
  41. batchWindowMs: 500,
  42. replyCooldownMs: 0,
  43. onFlush: async (b) => {
  44. flushed.push(b);
  45. },
  46. });
  47. mgr.enqueue(msg({ content: "one" }));
  48. await vi.advanceTimersByTimeAsync(100);
  49. mgr.enqueue(msg({ content: "two" }));
  50. await vi.advanceTimersByTimeAsync(100);
  51. mgr.enqueue(msg({ content: "three" }));
  52. expect(flushed).toHaveLength(0);
  53. await vi.advanceTimersByTimeAsync(500);
  54. expect(flushed).toHaveLength(1);
  55. expect(flushed[0]).toHaveLength(3);
  56. expect(flushed[0].map((m) => m.content)).toEqual(["one", "two", "three"]);
  57. });
  58. it("keeps batches separated per wxid", async () => {
  59. const flushed: RawWechatMessage[][] = [];
  60. const mgr = new CooldownManager({
  61. batchWindowMs: 500,
  62. replyCooldownMs: 0,
  63. onFlush: async (b) => {
  64. flushed.push(b);
  65. },
  66. });
  67. mgr.enqueue(msg({ fromWxid: "wxid_alice", content: "a1" }));
  68. mgr.enqueue(msg({ fromWxid: "wxid_bob", content: "b1" }));
  69. mgr.enqueue(msg({ fromWxid: "wxid_alice", content: "a2" }));
  70. await vi.advanceTimersByTimeAsync(500);
  71. expect(flushed).toHaveLength(2);
  72. const alice = flushed.find((b) => b[0].fromWxid === "wxid_alice")!;
  73. const bob = flushed.find((b) => b[0].fromWxid === "wxid_bob")!;
  74. expect(alice.map((m) => m.content)).toEqual(["a1", "a2"]);
  75. expect(bob.map((m) => m.content)).toEqual(["b1"]);
  76. });
  77. it("delays flush until post-reply cooldown expires", async () => {
  78. const flushed: RawWechatMessage[][] = [];
  79. const mgr = new CooldownManager({
  80. batchWindowMs: 200,
  81. replyCooldownMs: 1000,
  82. onFlush: async (b) => {
  83. flushed.push(b);
  84. },
  85. });
  86. // Simulate a reply that just went out.
  87. mgr.noteReply("wxid_alice", Date.now());
  88. mgr.enqueue(msg({ fromWxid: "wxid_alice", content: "after reply" }));
  89. // Not yet — cooldown of 1s is still active.
  90. await vi.advanceTimersByTimeAsync(500);
  91. expect(flushed).toHaveLength(0);
  92. // Past the cooldown window, flush fires.
  93. await vi.advanceTimersByTimeAsync(600);
  94. expect(flushed).toHaveLength(1);
  95. expect(flushed[0][0].content).toBe("after reply");
  96. });
  97. it("stop() clears pending timers", async () => {
  98. const flushed: RawWechatMessage[][] = [];
  99. const mgr = new CooldownManager({
  100. batchWindowMs: 500,
  101. replyCooldownMs: 0,
  102. onFlush: async (b) => {
  103. flushed.push(b);
  104. },
  105. });
  106. mgr.enqueue(msg());
  107. mgr.stop();
  108. await vi.advanceTimersByTimeAsync(1000);
  109. expect(flushed).toHaveLength(0);
  110. });
  111. });
  112. describe("mergeBatchText", () => {
  113. it("joins non-empty contents with newlines", () => {
  114. const batch = [
  115. msg({ content: "hello" }),
  116. msg({ content: "" }),
  117. msg({ content: " " }),
  118. msg({ content: "world" }),
  119. ];
  120. expect(mergeBatchText(batch)).toBe("hello\nworld");
  121. });
  122. it("returns empty string for empty batch", () => {
  123. expect(mergeBatchText([])).toBe("");
  124. });
  125. });