Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/client/chatInteractionControllers.test.ts
T

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");
});
});