Files
hongjr03 ad65d93b2e feat: add message burst debouncing (MessageBatcher)
- Create MessageBatcher: debounce 600ms, adaptive delay for long chunks,
  max 8 messages/4000 chars before immediate flush, per (chatId, sender) key
- Integrate into trigger: text messages enqueued, slash commands and
  file/image bypass batching, locked projects respond immediately
- Add unit tests (8) for batcher lifecycle and edge cases
2026-07-08 16:40:39 +08:00

115 lines
3.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageBatcher, messageBatchKey } from "../../src/feishu/messageBatcher.js";
describe("MessageBatcher", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("flushes a single message after the debounce period", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 25 });
await batcher.enqueue("chat-1", "sender-1", "hello");
expect(flushed).toEqual([]);
await vi.advanceTimersByTimeAsync(24);
expect(flushed).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(flushed).toEqual(["hello"]);
});
it("merges two quick messages with a newline", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 25 });
await batcher.enqueue("chat-1", "sender-1", "first");
await batcher.enqueue("chat-1", "sender-1", "second");
await vi.advanceTimersByTimeAsync(25);
expect(flushed).toEqual(["first\nsecond"]);
});
it("flushes immediately when the max message count is reached", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 1000, maxMessages: 2 });
await batcher.enqueue("chat-1", "sender-1", "first");
expect(flushed).toEqual([]);
await batcher.enqueue("chat-1", "sender-1", "second");
expect(flushed).toEqual(["first\nsecond"]);
});
it("flushes immediately when the max character count is reached", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 1000, maxChars: 5 });
await batcher.enqueue("chat-1", "sender-1", "abc");
expect(flushed).toEqual([]);
await batcher.enqueue("chat-1", "sender-1", "d");
expect(flushed).toEqual(["abc\nd"]);
});
it("uses the extended debounce for a chunk near the split threshold", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 10, splitThreshold: 5, extendedDebounceMs: 50 });
await batcher.enqueue("chat-1", "sender-1", "12345");
await vi.advanceTimersByTimeAsync(49);
expect(flushed).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(flushed).toEqual(["12345"]);
});
it("keeps different chat/sender pairs in separate batches", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 25 });
await batcher.enqueue("chat-1", "sender-1", "one");
await batcher.enqueue("chat-1", "sender-2", "two");
await batcher.enqueue("chat-2", "sender-1", "three");
await vi.advanceTimersByTimeAsync(25);
expect(flushed.sort()).toEqual(["one", "three", "two"]);
});
it("flushAll flushes every pending batch", async () => {
const flushed: string[] = [];
const batcher = new MessageBatcher(async (text) => {
flushed.push(text);
}, { debounceMs: 1000 });
await batcher.enqueue("chat-1", "sender-1", "one");
await batcher.enqueue("chat-2", "sender-2", "two");
await batcher.flushAll();
expect(flushed.sort()).toEqual(["one", "two"]);
});
it("builds keys from chat and sender open ids", () => {
expect(messageBatchKey("chat-1", "ou-1")).toBe("chat-1:ou-1");
});
// Slash-command bypass is owned by trigger.ts, not MessageBatcher. Existing
// trigger integration tests cover /new, /resume, /reset as immediate paths.
});