import { describe, expect, it, vi } from "vitest"; import { createChatReadReceiptBatcher, createChatTypingController, type ChatInteractionScheduler, type ChatVisibilitySource, } from "./chatInteractionControllers"; class FakeScheduler implements ChatInteractionScheduler { private nextHandle = 1; private now = 0; private readonly timers = new Map void }>(); setTimeout(callback: () => void, delayMs: number): number { const handle = this.nextHandle++; this.timers.set(handle, { at: this.now + delayMs, callback }); return handle; } clearTimeout(handle: unknown): void { if (typeof handle === "number") this.timers.delete(handle); } advanceBy(delayMs: number): void { const target = this.now + delayMs; while (true) { const next = [...this.timers.entries()] .filter(([, timer]) => timer.at <= target) .sort((left, right) => left[1].at - right[1].at || left[0] - right[0])[0]; if (!next) break; this.timers.delete(next[0]); this.now = next[1].at; next[1].callback(); } this.now = target; } } class FakeVisibility implements ChatVisibilitySource { visibilityState: DocumentVisibilityState = "visible"; private readonly listeners = new Set<() => void>(); addEventListener(_type: "visibilitychange", listener: () => void): void { this.listeners.add(listener); } removeEventListener(_type: "visibilitychange", listener: () => void): void { this.listeners.delete(listener); } set(next: DocumentVisibilityState): void { this.visibilityState = next; for (const listener of this.listeners) listener(); } } describe("chat interaction controllers", () => { it("debounces typing start and emits stop after inactivity or disposal", () => { const scheduler = new FakeScheduler(); const send = vi.fn(() => true); const controller = createChatTypingController({ chatId: "chat_1", send, scheduler, startDelayMs: 250, idleStopMs: 1_500, refreshMs: 3_000 }); controller.update("H"); scheduler.advanceBy(249); expect(send).not.toHaveBeenCalled(); scheduler.advanceBy(1); expect(send).toHaveBeenLastCalledWith("chat_1", "started"); scheduler.advanceBy(1_000); controller.update("Hallo"); scheduler.advanceBy(1_499); expect(send).toHaveBeenCalledTimes(1); scheduler.advanceBy(1); expect(send).toHaveBeenLastCalledWith("chat_1", "stopped"); controller.update("Noch einmal"); scheduler.advanceBy(250); controller.dispose(); expect(send).toHaveBeenLastCalledWith("chat_1", "stopped"); }); it("never sends a receipt for visibility shorter than one foreground second", () => { const scheduler = new FakeScheduler(); const visibility = new FakeVisibility(); const send = vi.fn(() => ({ requestId: "request_read", sent: true })); const batcher = createChatReadReceiptBatcher({ send, visibility, scheduler }); batcher.beginVisible("chat_1", "message_short", "2026-07-15T08:00:00.000Z"); scheduler.advanceBy(999); batcher.endVisible("chat_1", "message_short"); scheduler.advanceBy(5_000); expect(send).not.toHaveBeenCalled(); batcher.dispose(); }); it("restarts the continuous visibility window after the document was hidden", () => { const scheduler = new FakeScheduler(); const visibility = new FakeVisibility(); const send = vi.fn(() => ({ requestId: "request_read", sent: true })); const batcher = createChatReadReceiptBatcher({ send, visibility, scheduler }); batcher.beginVisible("chat_1", "message_1", "2026-07-15T08:00:00.000Z"); scheduler.advanceBy(500); visibility.set("hidden"); scheduler.advanceBy(5_000); expect(send).not.toHaveBeenCalled(); visibility.set("visible"); scheduler.advanceBy(999); expect(send).not.toHaveBeenCalled(); scheduler.advanceBy(1); batcher.endVisible("chat_1", "message_1"); scheduler.advanceBy(1_999); expect(send).not.toHaveBeenCalled(); scheduler.advanceBy(1); expect(send).toHaveBeenCalledWith("chat_1", "message_1"); batcher.dispose(); }); it("holds qualified receipts while hidden and resumes the two-second cadence", () => { const scheduler = new FakeScheduler(); const visibility = new FakeVisibility(); const send = vi.fn(() => ({ requestId: "request_read", sent: true })); const batcher = createChatReadReceiptBatcher({ send, visibility, scheduler }); batcher.beginVisible("chat_1", "message_pending", "2026-07-15T08:00:00.000Z"); scheduler.advanceBy(1_000); visibility.set("hidden"); scheduler.advanceBy(10_000); expect(send).not.toHaveBeenCalled(); visibility.set("visible"); scheduler.advanceBy(1_999); expect(send).not.toHaveBeenCalled(); scheduler.advanceBy(1); expect(send).toHaveBeenCalledWith("chat_1", "message_pending"); batcher.dispose(); }); it("keeps every qualified receipt and flushes out-of-order callbacks monotonically", () => { const scheduler = new FakeScheduler(); const visibility = new FakeVisibility(); const send = vi.fn(() => ({ requestId: "request_read", sent: true })); const batcher = createChatReadReceiptBatcher({ send, visibility, scheduler }); batcher.beginVisible("chat_1", "message_z", "2026-07-15T08:05:00.000Z"); batcher.beginVisible("chat_1", "message_old", "2026-07-15T08:04:59.000Z"); batcher.beginVisible("chat_1", "message_a", "2026-07-15T08:05:00.000Z"); scheduler.advanceBy(1_000); batcher.endVisible("chat_1", "message_z"); batcher.endVisible("chat_1", "message_old"); batcher.endVisible("chat_1", "message_a"); scheduler.advanceBy(2_000); expect(send.mock.calls).toEqual([ ["chat_1", "message_old"], ["chat_1", "message_a"], ["chat_1", "message_z"], ]); batcher.acknowledge("chat_1", "message_old"); batcher.acknowledge("chat_1", "message_a"); batcher.acknowledge("chat_1", "message_z"); batcher.beginVisible("chat_1", "message_old", "2026-07-15T08:04:59.000Z"); scheduler.advanceBy(5_000); expect(send).toHaveBeenCalledTimes(3); batcher.dispose(); }); it("retains a failed exact receipt and does not overtake it within the same channel", () => { const scheduler = new FakeScheduler(); const visibility = new FakeVisibility(); const send = vi .fn() .mockReturnValueOnce({ requestId: "request_1", sent: false }) .mockReturnValueOnce({ requestId: "request_2", sent: true }) .mockReturnValueOnce({ requestId: "request_1", sent: true }) .mockReturnValueOnce({ requestId: "request_4", sent: true }); const batcher = createChatReadReceiptBatcher({ send, visibility, scheduler }); batcher.beginVisible("chat_1", "message_1", "2026-07-15T08:00:00.000Z"); batcher.beginVisible("chat_1", "message_2", "2026-07-15T08:01:00.000Z"); batcher.beginVisible("chat_2", "message_3", "2026-07-15T08:02:00.000Z"); scheduler.advanceBy(3_000); expect(send.mock.calls).toEqual([ ["chat_1", "message_1"], ["chat_2", "message_3"], ]); scheduler.advanceBy(2_000); expect(send.mock.calls).toEqual([ ["chat_1", "message_1"], ["chat_2", "message_3"], ["chat_1", "message_1", "request_1"], ["chat_1", "message_2"], ]); batcher.acknowledge("chat_1", "message_1"); batcher.acknowledge("chat_1", "message_2"); batcher.acknowledge("chat_2", "message_3"); batcher.dispose(); }); it("sends at most fifty exact receipts per flush", () => { const scheduler = new FakeScheduler(); const visibility = new FakeVisibility(); const send = vi.fn(() => ({ requestId: "request_read", sent: true })); const batcher = createChatReadReceiptBatcher({ send, visibility, scheduler }); const base = Date.parse("2026-07-15T08:00:00.000Z"); for (let index = 54; index >= 0; index -= 1) { batcher.beginVisible("chat_1", `message_${String(index).padStart(2, "0")}`, new Date(base + index).toISOString()); } scheduler.advanceBy(3_000); expect(send).toHaveBeenCalledTimes(50); expect(send.mock.calls[0]).toEqual(["chat_1", "message_00"]); expect(send.mock.calls[49]).toEqual(["chat_1", "message_49"]); for (let index = 0; index < 50; index += 1) { batcher.acknowledge("chat_1", `message_${String(index).padStart(2, "0")}`); } scheduler.advanceBy(2_000); expect(send).toHaveBeenCalledTimes(55); expect(send.mock.calls[54]).toEqual(["chat_1", "message_54"]); batcher.dispose(); }); it("retries an unacknowledged receipt with the same request id after reconnect and stops only on acknowledgement", () => { const scheduler = new FakeScheduler(); const visibility = new FakeVisibility(); const send = vi.fn((_chatId: string, _messageId: string, requestId?: string) => ({ requestId: requestId ?? "request_stable", sent: true })); const batcher = createChatReadReceiptBatcher({ send, visibility, scheduler }); batcher.beginVisible("chat_1", "message_1", "2026-07-15T08:00:00.000Z"); scheduler.advanceBy(3_000); expect(send).toHaveBeenCalledOnce(); expect(send).toHaveBeenLastCalledWith("chat_1", "message_1"); expect(batcher.retryPending()).toBe(1); expect(send).toHaveBeenLastCalledWith("chat_1", "message_1", "request_stable"); batcher.acknowledge("chat_1", "message_1"); expect(batcher.retryPending()).toBe(0); scheduler.advanceBy(20_000); expect(send).toHaveBeenCalledTimes(2); batcher.dispose(); }); it("retries an unacknowledged receipt with the same request id after the acknowledgement timeout", () => { const scheduler = new FakeScheduler(); const visibility = new FakeVisibility(); const send = vi.fn((_chatId: string, _messageId: string, requestId?: string) => ({ requestId: requestId ?? "request_timeout", sent: true })); const batcher = createChatReadReceiptBatcher({ send, visibility, scheduler, acknowledgementTimeoutMs: 3_000 }); batcher.beginVisible("chat_1", "message_1", "2026-07-15T08:00:00.000Z"); scheduler.advanceBy(3_000); expect(send).toHaveBeenCalledOnce(); scheduler.advanceBy(4_999); expect(send).toHaveBeenCalledOnce(); scheduler.advanceBy(1); expect(send).toHaveBeenLastCalledWith("chat_1", "message_1", "request_timeout"); batcher.dispose(); }); it("flushes qualified receipts once when leaving the channel", () => { const scheduler = new FakeScheduler(); const visibility = new FakeVisibility(); const send = vi.fn(() => ({ requestId: "request_read", sent: true })); const batcher = createChatReadReceiptBatcher({ send, visibility, scheduler }); batcher.beginVisible("chat_1", "message_1", "2026-07-15T08:00:00.000Z"); scheduler.advanceBy(1_000); visibility.set("hidden"); batcher.dispose(); expect(send).toHaveBeenCalledOnce(); expect(send).toHaveBeenCalledWith("chat_1", "message_1"); }); });