1
6
7import { describe, expect, it } from "vitest";
8
9import { presenceColor } from "@factory/shared/presence";
10import { PresenceTracker } from "../src/presence.js";
11
12const T0 = 1_700_000_000_000;
13
14describe("PresenceTracker (TASK-0093)", () => {
15 it("upserts a peer with a server-stamped identity (color from the shared hash)", () => {
16 let t = T0;
17 const p = new PresenceTracker({ now: () => t });
18 const entry = p.upsert(
19 "page:pg1",
20 { id: "u-ana", name: "Ana" },
21 { cursor: { blockId: "b3", offset: 4 } },
22 );
23 expect(entry.userId).toBe("u-ana");
24 expect(entry.name).toBe("Ana");
25 expect(entry.color).toBe(presenceColor("u-ana"));
26 expect(entry.cursor).toEqual({ blockId: "b3", offset: 4 });
27 expect(entry.viewing).toBe(true);
28 expect(entry.lastSeen).toBe(T0);
29 expect(p.count("page:pg1")).toBe(1);
30 });
31
32 it("updates in place (a cursor move keeps the entry; an omitted field keeps the prior value)", () => {
33 let t = T0;
34 const p = new PresenceTracker({ now: () => t });
35 p.upsert(
36 "page:pg1",
37 { id: "u-ana", name: "Ana" },
38 { cursor: { blockId: "b1", offset: 0 }, viewing: false },
39 );
40 t += 1000;
41 const e = p.upsert(
42 "page:pg1",
43 { id: "u-ana", name: "Ana" },
44 { cursor: { blockId: "b2", offset: 3 } },
45 );
46 expect(e.cursor).toEqual({ blockId: "b2", offset: 3 });
47 expect(e.viewing).toBe(false);
48 expect(e.lastSeen).toBe(T0 + 1000);
49 expect(p.count("page:pg1")).toBe(1);
50 });
51
52 it("a cursor clear (null) is distinct from an omitted cursor", () => {
53 const p = new PresenceTracker({ now: () => T0 });
54 p.upsert("page:pg1", { id: "u-ana" }, { cursor: { blockId: "b1", offset: 1 } });
55 const cleared = p.upsert("page:pg1", { id: "u-ana" }, { cursor: null });
56 expect(cleared.cursor).toBeNull();
57 const kept = p.upsert("page:pg1", { id: "u-ana" }, {});
58 expect(kept.cursor).toBeNull();
59 });
60
61 it("separates rooms and users; removeRoom + remove clean up", () => {
62 const p = new PresenceTracker({ now: () => T0 });
63 p.upsert("page:pg1", { id: "u-a" }, {});
64 p.upsert("page:pg1", { id: "u-b" }, {});
65 p.upsert("page:pg2", { id: "u-a" }, {});
66 expect(p.count("page:pg1")).toBe(2);
67 expect(p.count("page:pg2")).toBe(1);
68 expect(p.size).toBe(3);
69 p.remove("page:pg1", "u-a");
70 expect(p.count("page:pg1")).toBe(1);
71 p.removeRoom("page:pg2");
72 expect(p.size).toBe(1);
73 });
74
75 it("falls back to the shared guest name (color is never the sole identifier)", () => {
76 const p = new PresenceTracker({ now: () => T0 });
77 const e = p.upsert("page:pg1", { id: "u-ghost" }, {});
78 expect(e.name).toBe("Guest (shared)");
79 expect(e.color).toBe(presenceColor("u-ghost"));
80 });
81
82 it("broadcastFor stamps the frame with the room key in pageId", () => {
83 const p = new PresenceTracker({ now: () => T0 });
84 const e = p.upsert(
85 "page:pg1",
86 { id: "u-ana", name: "Ana" },
87 { cursor: { blockId: "b3", offset: 4 } },
88 );
89 const f = p.broadcastFor("page:pg1", e);
90 expect(f).toEqual({
91 type: "presence",
92 pageId: "page:pg1",
93 user: { id: "u-ana", name: "Ana", color: presenceColor("u-ana") },
94 cursor: { blockId: "b3", offset: 4 },
95 viewing: true,
96 lastSeen: T0,
97 });
98 });
99
100 it("at exactly the cap (50 peers) the full cursor is still carried", () => {
101 const p = new PresenceTracker({ now: () => T0, peerCap: 3 });
102 for (let i = 0; i < 3; i += 1) p.upsert("page:pg1", { id: `u-${i}` }, {});
103 const e = p.upsert(
104 "page:pg1",
105 { id: "u-hot", name: "Hot" },
106 { cursor: { blockId: "b1", offset: 0 } },
107 );
108
109 const f = p.broadcastFor("page:pg1", e);
110 expect(f.cursor).toBeNull();
111 expect(f.viewing).toBeUndefined();
112 });
113
114 it("over the cap the broadcast is avatars-only (cursor/viewing stripped, user stays)", () => {
115 const p = new PresenceTracker({ now: () => T0, peerCap: 3 });
116 for (let i = 0; i < 4; i += 1)
117 p.upsert(
118 "page:pg1",
119 { id: `u-${i}`, name: `U${i}` },
120 { cursor: { blockId: "b1", offset: i } },
121 );
122 const e = p.peers("page:pg1")![3]!;
123 const f = p.broadcastFor("page:pg1", e);
124 expect(f.cursor).toBeNull();
125 expect(f.viewing).toBeUndefined();
126 expect(f.user).toEqual({ id: e.userId, name: e.name, color: e.color });
127 });
128
129 it("dropping back under the cap carries full cursors again (the strip is per-broadcast, not sticky)", () => {
130 const p = new PresenceTracker({ now: () => T0, peerCap: 3 });
131 for (let i = 0; i < 4; i += 1) p.upsert("page:pg1", { id: `u-${i}` }, {});
132 p.remove("page:pg1", "u-0");
133 const e = p.peers("page:pg1")![0]!;
134 e.cursor = { blockId: "b9", offset: 7 };
135 const f = p.broadcastFor("page:pg1", e);
136 expect(f.cursor).toEqual({ blockId: "b9", offset: 7 });
137 expect(f.viewing).toBe(true);
138 });
139
140 it("prune drops entries silent longer than the threshold (default 90 s)", () => {
141 let t = T0;
142 const p = new PresenceTracker({ now: () => t });
143 p.upsert("page:pg1", { id: "u-a" }, {});
144 p.upsert("page:pg1", { id: "u-b" }, {});
145 t += 60_000;
146 p.upsert("page:pg1", { id: "u-b" }, {});
147 expect(p.prune().size).toBe(0);
148 t += 31_000;
149 const pruned = p.prune();
150 expect([...pruned.keys()]).toEqual(["page:pg1"]);
151 expect(pruned.get("page:pg1")?.map((e) => e.userId)).toEqual(["u-a"]);
152 expect(p.count("page:pg1")).toBe(1);
153 });
154
155 it("prune with a custom threshold (the integration test's short window)", () => {
156 let t = T0;
157 const p = new PresenceTracker({ now: () => t, pruneAfterMs: 5_000 });
158 p.upsert("page:pg1", { id: "u-a" }, {});
159 t += 5_001;
160 expect([...p.prune().values()].flat().map((e) => e.userId)).toEqual(["u-a"]);
161 });
162});
163