279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
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<number, { at: number; callback: () => 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");
|
|
});
|
|
});
|