import { assertEquals } from "@std/assert"; import type { ConversationId, ConversationView, EntryId } from "@earendil-works/pi-durable"; import { withDom } from "@berry/util/dom.ts"; import { readWhenVisible } from "./read.ts"; import { Scope } from "./ui.ts"; Deno.test("read receipts follow visible settled views without acknowledging unseen turns", async (t) => { await withDom(async (window) => { let visibility = "visible"; Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility, }); const scope = new Scope(); const sent: number[] = []; let acknowledge = (_entryId: number) => Promise.resolve(); const update = readWhenVisible(scope, (entryId) => { sent.push(entryId); return acknowledge(entryId); }); const view = (id: number, docs: ConversationView["docs"] = {}): ConversationView => ({ conversation: { id: 1 as ConversationId }, entries: [ { id: (id - 1) as EntryId, conversationId: 1 as ConversationId, kind: "pi.user" }, { id: id as EntryId, conversationId: 1 as ConversationId, kind: "pi.assistant" }, { id: (id + 1) as EntryId, conversationId: 1 as ConversationId, kind: "berry.event" }, ], docs, }); const settle = () => window.happyDOM.waitUntilComplete(); const visible = (state: string) => { visibility = state; document.dispatchEvent(new window.Event("visibilitychange") as unknown as Event); }; try { await t.step("working views and queued follow-ups do not send read receipts", async () => { update(view(10, { "pi.live": { run: { taskId: 1, inputs: [] } } })); update(view(10, { "pi.inbox": { items: [{ id: 1, mode: "followUp", content: "next" }] } })); await settle(); assertEquals(sent, []); update(view(10)); await settle(); assertEquals(sent, [10]); update(view(10)); await settle(); assertEquals(sent, [10]); }); await t.step("hidden turns wait until the tab becomes visible", async () => { visible("hidden"); update(view(20)); await settle(); assertEquals(sent, [10]); visible("visible"); await settle(); assertEquals(sent, [10, 20]); }); await t.step( "a late read receipt sends only its captured message, then the newer displayed one", async () => { const pending = Promise.withResolvers(); acknowledge = (entryId) => entryId === 30 ? pending.promise : Promise.resolve(); update(view(30)); update(view(40)); assertEquals(sent, [10, 20, 30]); pending.resolve(); await settle(); assertEquals(sent, [10, 20, 30, 40]); }, ); await t.step( "a newer hidden turn is not acknowledged after the older receipt finishes", async () => { const pending = Promise.withResolvers(); acknowledge = () => pending.promise; update(view(50)); visible("hidden"); update(view(60)); pending.resolve(); await settle(); assertEquals(sent, [10, 20, 30, 40, 50]); acknowledge = () => Promise.resolve(); visible("visible"); await settle(); assertEquals(sent, [10, 20, 30, 40, 50, 60]); }, ); await t.step( "returning to an earlier branch cannot move the read watermark backwards", async () => { update(view(20)); await settle(); assertEquals(sent, [10, 20, 30, 40, 50, 60]); }, ); await t.step( "leaving the conversation removes visibility acknowledgements and pending work", async () => { const pending = Promise.withResolvers(); acknowledge = () => pending.promise; update(view(70)); visible("hidden"); update(view(80)); scope.dispose(); visible("visible"); pending.resolve(); await settle(); update(view(90)); assertEquals(sent, [10, 20, 30, 40, 50, 60, 70]); }, ); } finally { scope.dispose(); } }); });