1
2
7import { describe, expect, it } from "vitest";
8import { PresenceEmitter } from "../presence-emitter";
9import type { OwnPresence } from "../types";
10
11
12function makeClock(t0 = 0) {
13 let t = t0;
14 const queue: { at: number; fn: () => void }[] = [];
15 return {
16 now: () => t,
17 advance: (ms: number) => {
18 t += ms;
19 const due = queue.filter((e) => e.at <= t);
20
21 due.sort((a, b) => a.at - b.at);
22 for (const e of due) e.fn();
23 },
24 setTimeout: (fn: () => void, ms: number) => {
25 const id = { at: t + ms, fn };
26 queue.push(id);
27 return id as unknown as ReturnType<typeof setTimeout>;
28 },
29 clearTimeout: (id: unknown) => {
30 const idx = queue.findIndex((e) => e === id);
31 if (idx >= 0) queue.splice(idx, 1);
32 },
33 pending: () => queue.length,
34 };
35}
36
37function mk(opts?: { minIntervalMs?: number }) {
38 const sent: OwnPresence[] = [];
39 const clock = makeClock();
40 const emitter = new PresenceEmitter({
41 send: (s) => sent.push(s),
42 now: clock.now,
43 minIntervalMs: opts?.minIntervalMs ?? 120,
44 timer: { setTimeout: clock.setTimeout, clearTimeout: clock.clearTimeout },
45 });
46 return { emitter, sent, clock };
47}
48
49const caret = (blockId: string, offset: number) => ({ blockId, offset });
50
51describe("PresenceEmitter — the cursor throttle (doc §5.2, < 500 ms budget)", () => {
52 it("the first caret is sent immediately (no waiting for the throttle window)", () => {
53 const { emitter, sent } = mk();
54 emitter.setCaret(caret("blk_1", 3));
55 expect(sent).toEqual([{ cursor: caret("blk_1", 3), viewing: true }]);
56 });
57
58 it("caret moves within 120 ms are coalesced; the FINAL position is sent (trailing flush)", () => {
59 const { emitter, sent, clock } = mk();
60 emitter.setCaret(caret("blk_1", 3));
61 emitter.setCaret(caret("blk_1", 5));
62 emitter.setCaret(caret("blk_1", 7));
63 expect(sent).toHaveLength(1);
64 expect(clock.pending()).toBe(1);
65 clock.advance(119);
66 expect(sent).toHaveLength(1);
67 clock.advance(1);
68 expect(sent).toHaveLength(2);
69 expect(sent[1]).toEqual({ cursor: caret("blk_1", 7), viewing: true });
70 });
71
72 it("moves spaced ≥ 120 ms apart each send immediately", () => {
73 const { emitter, sent, clock } = mk();
74 emitter.setCaret(caret("blk_1", 3));
75 clock.advance(120);
76 emitter.setCaret(caret("blk_1", 6));
77 expect(sent).toHaveLength(2);
78 expect(sent[1]?.cursor).toEqual(caret("blk_1", 6));
79 expect(clock.pending()).toBe(0);
80 });
81
82 it("an unchanged caret sends nothing", () => {
83 const { emitter, sent } = mk();
84 emitter.setCaret(caret("blk_1", 3));
85 emitter.setCaret(caret("blk_1", 3));
86 expect(sent).toHaveLength(1);
87 });
88
89 it("an immediate (viewing) send cancels the pending cursor flush — the position is delivered once, via the viewing frame", () => {
90 const { emitter, sent, clock } = mk();
91 emitter.setCaret(caret("blk_1", 3));
92 emitter.setCaret(caret("blk_1", 5));
93 emitter.setViewing(false);
94 expect(sent).toHaveLength(2);
95 expect(sent.at(-1)).toEqual({ cursor: caret("blk_1", 5), viewing: false });
96 expect(clock.pending()).toBe(0);
97 clock.advance(10_000);
98 expect(sent).toHaveLength(2);
99 });
100
101 it("dispose cancels a pending trailing flush", () => {
102 const { emitter, sent, clock } = mk();
103 emitter.setCaret(caret("blk_1", 3));
104 emitter.setCaret(caret("blk_1", 5));
105 emitter.dispose();
106 clock.advance(10_000);
107 expect(sent).toHaveLength(1);
108 });
109});
110
111describe("PresenceEmitter — cursor clear + viewing (never throttled)", () => {
112 it("cursor: null (focus left the page) is sent immediately — clears are discrete, not streamed", () => {
113 const { emitter, sent, clock } = mk();
114 emitter.setCaret(caret("blk_1", 3));
115 emitter.setCaret(null);
116 expect(sent).toHaveLength(2);
117 expect(sent.at(-1)).toEqual({ cursor: null, viewing: true });
118 expect(clock.pending()).toBe(0);
119 });
120
121 it("viewing=false (tab hidden) is sent immediately, cursor hidden state kept", () => {
122 const { emitter, sent } = mk();
123 emitter.setCaret(caret("blk_1", 3));
124 emitter.setViewing(false);
125 expect(sent.at(-1)).toEqual({ cursor: caret("blk_1", 3), viewing: false });
126 });
127
128 it("viewing=true (tab shown again) is sent immediately", () => {
129 const { emitter, sent } = mk();
130 emitter.setCaret(caret("blk_1", 3));
131 emitter.setViewing(false);
132 emitter.setViewing(true);
133 expect(sent.at(-1)).toEqual({ cursor: caret("blk_1", 3), viewing: true });
134 });
135
136 it("viewing changes during a pending cursor flush do not delay the flush (and vice versa)", () => {
137 const { emitter, sent, clock } = mk();
138 emitter.setCaret(caret("blk_1", 3));
139 emitter.setViewing(false);
140 emitter.setCaret(caret("blk_1", 5));
141 expect(sent).toHaveLength(2);
142 clock.advance(120);
143 expect(sent).toHaveLength(3);
144 expect(sent.at(-1)).toEqual({ cursor: caret("blk_1", 5), viewing: false });
145 });
146});
147
148describe("PresenceEmitter — the wire payload (identity is server-stamped)", () => {
149 it("frames carry ONLY cursor + viewing (the wire schema forbids identity)", () => {
150 const { emitter, sent } = mk();
151 emitter.setCaret({
152 blockId: "blk_1",
153 offset: 2,
154 selection: { fromBlockId: "blk_1", fromOffset: 0, toBlockId: "blk_1", toOffset: 2 },
155 });
156 const last = sent.at(-1);
157 expect(last).toBeDefined();
158 expect([...Object.keys(last ?? {}).sort()]).toEqual(["cursor", "viewing"]);
159 });
160
161 it("a multi-block selection (selection inside this block's doc) is carried verbatim", () => {
162 const { emitter, sent } = mk();
163 const sel = { fromBlockId: "blk_9", fromOffset: 1, toBlockId: "blk_9", toOffset: 9 };
164 emitter.setCaret({ blockId: "blk_9", offset: 9, selection: sel });
165 expect(sent.at(-1)?.cursor?.selection).toEqual(sel);
166 });
167});
168
169describe("PresenceEmitter — assert() (the reconnect re-send)", () => {
170 it("assert() re-sends the current state immediately (a (re)connect re-asserts)", () => {
171 const { emitter, sent, clock } = mk();
172 emitter.setCaret(caret("blk_1", 3));
173 clock.advance(120);
174 emitter.setCaret(caret("blk_1", 8));
175 expect(sent).toHaveLength(2);
176 emitter.assert();
177 expect(sent).toHaveLength(3);
178 expect(sent.at(-1)).toEqual({ cursor: caret("blk_1", 8), viewing: true });
179 });
180
181 it("assert() on a never-moved caret still sends (the initial avatar frame)", () => {
182 const { emitter, sent } = mk();
183 emitter.assert();
184 expect(sent).toEqual([{ cursor: null, viewing: true }]);
185 });
186
187 it("assert() cancels a pending trailing flush (the re-sent state supersedes it)", () => {
188 const { emitter, sent, clock } = mk();
189 emitter.setCaret(caret("blk_1", 3));
190 emitter.setCaret(caret("blk_1", 5));
191 emitter.assert();
192 expect(sent).toHaveLength(2);
193 expect(sent.at(-1)).toEqual({ cursor: caret("blk_1", 5), viewing: true });
194 expect(clock.pending()).toBe(0);
195 clock.advance(10_000);
196 expect(sent).toHaveLength(2);
197 });
198});
199