// @vitest-environment node /** * PresenceAnnouncer (TASK-0093, spec R5) — the ≤ 1/s, state-change-only * live-region text machine. Pure + injected clock (deterministic). */ import { describe, expect, it } from "vitest"; import { PresenceAnnouncer } from "../presence-announcer"; import type { Presence } from "../types"; function peer( userId: string, name: string, cursor: Presence["cursor"] = null, viewing?: boolean, ): Presence { return { userId, name, color: "blue", cursor, ...(viewing !== undefined ? { viewing } : {}), lastSeen: 1, }; } function clock(start = 1_000_000) { let t = start; return { now: () => t, advance: (ms: number) => (t += ms) }; } describe("PresenceAnnouncer", () => { it("announces a join (highest priority), then nothing on unchanged roster", () => { const c = clock(); const a = new PresenceAnnouncer({ now: c.now }); expect(a.update([peer("u1", "Ana")])?.text).toBe("Ana joined"); expect(a.update([peer("u1", "Ana")])).toBeNull(); // no state change }); it("announces editing start/stop only on the transition (not per frame)", () => { const c = clock(); const a = new PresenceAnnouncer({ now: c.now }); a.update([peer("u1", "Ana")]); c.advance(1100); a.update([peer("u1", "Ana", { blockId: "b1", offset: 2 })]); c.advance(1100); // Same cursor, repeated frames → NO announcement (typing is not a state // change). a.update([peer("u1", "Ana", { blockId: "b1", offset: 9 })]); expect(a.update([peer("u1", "Ana", { blockId: "b1", offset: 14 })])).toBeNull(); c.advance(1100); a.update([peer("u1", "Ana", null)]); }); it("editing-start text is '… is editing this page'; stop is '… stopped editing'", () => { const c = clock(); const a = new PresenceAnnouncer({ now: c.now }); a.update([peer("u1", "Ana")]); c.advance(1100); expect(a.update([peer("u1", "Ana", { blockId: "b", offset: 1 })])?.text).toBe( "Ana is editing this page", ); c.advance(1100); expect(a.update([peer("u1", "Ana", null)])?.text).toBe("Ana stopped editing"); }); it("a backgrounded editor (viewing:false) is NOT 'editing'", () => { const c = clock(); const a = new PresenceAnnouncer({ now: c.now }); a.update([peer("u1", "Ana")]); c.advance(1100); a.update([peer("u1", "Ana", { blockId: "b", offset: 1 }, false)]); expect(a.update([peer("u1", "Ana", { blockId: "b", offset: 2 }, true)])?.text).toBe( "Ana is editing this page", ); }); it("rate-limits to ≤ 1/s and queues (flushed later)", () => { const c = clock(); const a = new PresenceAnnouncer({ now: c.now }); a.update([peer("u1", "Ana")]); // t=0 → "Ana joined" c.advance(100); // inside the 1 s window a.update([peer("u2", "Bob")]); // queued, not announced expect(a.flush()?.text).toBe("Bob joined"); expect(a.flush()).toBeNull(); // nothing left }); it("picks the highest-priority change in one update (join > edit-start)", () => { const c = clock(); const a = new PresenceAnnouncer({ now: c.now }); a.update([peer("u1", "Ana")]); c.advance(1100); // Both changes in a single update: Bob joins (rank 0) + Ana starts // editing (rank 1) → the JOIN wins. a.update([peer("u1", "Ana", { blockId: "b", offset: 1 }), peer("u2", "Bob")]); expect( a.update([peer("u1", "Ana", { blockId: "b", offset: 1 }), peer("u2", "Bob")]), ).toBeNull(); // The edit-start was consumed in that update (no re-detection). }); it("announces a leave when a peer drops out of the roster", () => { const c = clock(); const a = new PresenceAnnouncer({ now: c.now }); a.update([peer("u1", "Ana"), peer("u2", "Bob")]); c.advance(1100); expect(a.update([peer("u1", "Ana")])?.text).toBe("Bob left"); }); it("reset() clears the state map (a page switch re-joins from scratch)", () => { const c = clock(); const a = new PresenceAnnouncer({ now: c.now }); a.update([peer("u1", "Ana")]); a.reset(); expect(a.update([peer("u1", "Ana")])?.text).toBe("Ana joined"); }); });