1
2
6import { describe, expect, it } from "vitest";
7import { PresenceAnnouncer } from "../presence-announcer";
8import type { Presence } from "../types";
9
10function peer(
11 userId: string,
12 name: string,
13 cursor: Presence["cursor"] = null,
14 viewing?: boolean,
15): Presence {
16 return {
17 userId,
18 name,
19 color: "blue",
20 cursor,
21 ...(viewing !== undefined ? { viewing } : {}),
22 lastSeen: 1,
23 };
24}
25
26function clock(start = 1_000_000) {
27 let t = start;
28 return { now: () => t, advance: (ms: number) => (t += ms) };
29}
30
31describe("PresenceAnnouncer", () => {
32 it("announces a join (highest priority), then nothing on unchanged roster", () => {
33 const c = clock();
34 const a = new PresenceAnnouncer({ now: c.now });
35 expect(a.update([peer("u1", "Ana")])?.text).toBe("Ana joined");
36 expect(a.update([peer("u1", "Ana")])).toBeNull();
37 });
38
39 it("announces editing start/stop only on the transition (not per frame)", () => {
40 const c = clock();
41 const a = new PresenceAnnouncer({ now: c.now });
42 a.update([peer("u1", "Ana")]);
43 c.advance(1100);
44 a.update([peer("u1", "Ana", { blockId: "b1", offset: 2 })]);
45 c.advance(1100);
46
47
48 a.update([peer("u1", "Ana", { blockId: "b1", offset: 9 })]);
49 expect(a.update([peer("u1", "Ana", { blockId: "b1", offset: 14 })])).toBeNull();
50 c.advance(1100);
51 a.update([peer("u1", "Ana", null)]);
52 });
53
54 it("editing-start text is '… is editing this page'; stop is '… stopped editing'", () => {
55 const c = clock();
56 const a = new PresenceAnnouncer({ now: c.now });
57 a.update([peer("u1", "Ana")]);
58 c.advance(1100);
59 expect(a.update([peer("u1", "Ana", { blockId: "b", offset: 1 })])?.text).toBe(
60 "Ana is editing this page",
61 );
62 c.advance(1100);
63 expect(a.update([peer("u1", "Ana", null)])?.text).toBe("Ana stopped editing");
64 });
65
66 it("a backgrounded editor (viewing:false) is NOT 'editing'", () => {
67 const c = clock();
68 const a = new PresenceAnnouncer({ now: c.now });
69 a.update([peer("u1", "Ana")]);
70 c.advance(1100);
71 a.update([peer("u1", "Ana", { blockId: "b", offset: 1 }, false)]);
72 expect(a.update([peer("u1", "Ana", { blockId: "b", offset: 2 }, true)])?.text).toBe(
73 "Ana is editing this page",
74 );
75 });
76
77 it("rate-limits to ≤ 1/s and queues (flushed later)", () => {
78 const c = clock();
79 const a = new PresenceAnnouncer({ now: c.now });
80 a.update([peer("u1", "Ana")]);
81 c.advance(100);
82 a.update([peer("u2", "Bob")]);
83 expect(a.flush()?.text).toBe("Bob joined");
84 expect(a.flush()).toBeNull();
85 });
86
87 it("picks the highest-priority change in one update (join > edit-start)", () => {
88 const c = clock();
89 const a = new PresenceAnnouncer({ now: c.now });
90 a.update([peer("u1", "Ana")]);
91 c.advance(1100);
92
93
94 a.update([peer("u1", "Ana", { blockId: "b", offset: 1 }), peer("u2", "Bob")]);
95 expect(
96 a.update([peer("u1", "Ana", { blockId: "b", offset: 1 }), peer("u2", "Bob")]),
97 ).toBeNull();
98
99 });
100
101 it("announces a leave when a peer drops out of the roster", () => {
102 const c = clock();
103 const a = new PresenceAnnouncer({ now: c.now });
104 a.update([peer("u1", "Ana"), peer("u2", "Bob")]);
105 c.advance(1100);
106 expect(a.update([peer("u1", "Ana")])?.text).toBe("Bob left");
107 });
108
109 it("reset() clears the state map (a page switch re-joins from scratch)", () => {
110 const c = clock();
111 const a = new PresenceAnnouncer({ now: c.now });
112 a.update([peer("u1", "Ana")]);
113 a.reset();
114 expect(a.update([peer("u1", "Ana")])?.text).toBe("Ana joined");
115 });
116});
117