@vuer-ai/vuer-rtc 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -239,8 +239,7 @@ describe('coalesceTextOperations', () => {
239
239
  {
240
240
  type: 'delete',
241
241
  op: {
242
- id: 'alice:3',
243
- length: 1,
242
+ deletions: [{ id: 'alice:2', length: 1 }],
244
243
  seq: 3,
245
244
  ts: 1000.1,
246
245
  },
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Tests demonstrating the delete coalescence bug
3
+ *
4
+ * BUG: When user types quickly and then deletes, the deletion array contains
5
+ * many single-character deletions instead of being optimized.
6
+ *
7
+ * ROOT CAUSE: Local typing creates separate items in the rope. Insert coalescence
8
+ * merges operations for network transmission but doesn't update the local rope.
9
+ * When deleting, remove() iterates through many items and creates many deletion entries.
10
+ */
11
+
12
+ import { describe, it, expect } from '@jest/globals';
13
+ import { create, insert, remove, apply, toJSON } from '../../src/crdt/Rope.js';
14
+ import type { InsertOp } from '../../src/crdt/Rope.js';
15
+ import { coalesceTextOperations } from '../../src/client/coalesceTextOperations.js';
16
+ import type { TextOperation } from '../../src/client/textTypes.js';
17
+
18
+ describe('Delete Coalescence Bug', () => {
19
+ describe('Local typing creates many items', () => {
20
+ it('should merge consecutive single-char deletions into one deletion', () => {
21
+ const rope = create('alice');
22
+
23
+ // Simulate typing "Hello" one char at a time
24
+ insert(rope, 0, 'H');
25
+ insert(rope, 1, 'e');
26
+ insert(rope, 2, 'l');
27
+ insert(rope, 3, 'l');
28
+ insert(rope, 4, 'o');
29
+
30
+ // Verify text is correct
31
+ expect(rope.toString()).toBe('Hello');
32
+
33
+ // Now delete all 5 characters
34
+ const deleteOp = remove(rope, 0, 5);
35
+
36
+ // FIXED: Now we get 1 deletion with length 5 instead of 5 separate deletions!
37
+ expect(deleteOp.deletions).toHaveLength(1);
38
+ expect(deleteOp.deletions[0].length).toBe(5);
39
+ expect(deleteOp.deletions[0].id).toMatch(/alice:\d+/);
40
+
41
+ // This is efficient - we're sending 1 deletion entry instead of 5
42
+ });
43
+
44
+ it('should show that individual delete operations each have optimized deletions', () => {
45
+ const rope = create('alice');
46
+
47
+ // Type "Hello"
48
+ insert(rope, 0, 'H');
49
+ insert(rope, 1, 'e');
50
+ insert(rope, 2, 'l');
51
+ insert(rope, 3, 'l');
52
+ insert(rope, 4, 'o');
53
+
54
+ // Press backspace 5 times rapidly
55
+ const delete1 = remove(rope, 4, 1); // Delete 'o'
56
+ const delete2 = remove(rope, 3, 1); // Delete 'l'
57
+ const delete3 = remove(rope, 2, 1); // Delete 'l'
58
+ const delete4 = remove(rope, 1, 1); // Delete 'e'
59
+ const delete5 = remove(rope, 0, 1); // Delete 'H'
60
+
61
+ // Each delete creates 1 deletion entry (expected - only deleting 1 char each time)
62
+ expect(delete1.deletions).toHaveLength(1);
63
+ expect(delete2.deletions).toHaveLength(1);
64
+ expect(delete3.deletions).toHaveLength(1);
65
+ expect(delete4.deletions).toHaveLength(1);
66
+ expect(delete5.deletions).toHaveLength(1);
67
+
68
+ // Note: These are 5 separate delete OPERATIONS, each with 1 deletion
69
+ // Operation-level coalescence (in coalesceTextOperations) will merge these
70
+ // 5 operations into 1 operation with 5 deletions, then optimizeDeletions()
71
+ // will merge those 5 deletions into 1 deletion
72
+ });
73
+ });
74
+
75
+ describe('Remote coalesced operations create efficient items', () => {
76
+ it('should show that applying coalesced InsertOp creates 1 item', () => {
77
+ const rope = create('bob');
78
+
79
+ // Simulate receiving a coalesced operation from Alice
80
+ const coalescedOp: InsertOp = {
81
+ id: 'alice:1',
82
+ content: 'Hello',
83
+ parentId: null,
84
+ seq: 1,
85
+ ts: 1000.0,
86
+ };
87
+
88
+ apply(rope, coalescedOp);
89
+
90
+ expect(rope.toString()).toBe('Hello');
91
+
92
+ // Now delete all 5 characters
93
+ const deleteOp = remove(rope, 0, 5);
94
+
95
+ // GOOD: Only 1 deletion with length 5 (because it's a single item)
96
+ expect(deleteOp.deletions).toHaveLength(1);
97
+ expect(deleteOp.deletions[0].id).toBe('alice:1');
98
+ expect(deleteOp.deletions[0].length).toBe(5);
99
+ });
100
+ });
101
+
102
+ describe('Coalescence works at network layer, deletions optimized at rope layer', () => {
103
+ it('should show that remove() optimizes deletions even though rope has separate items', () => {
104
+ const rope = create('alice');
105
+ const ops: InsertOp[] = [];
106
+
107
+ // Type "Hello" and collect operations
108
+ ops.push(insert(rope, 0, 'H'));
109
+ ops.push(insert(rope, 1, 'e'));
110
+ ops.push(insert(rope, 2, 'l'));
111
+ ops.push(insert(rope, 3, 'l'));
112
+ ops.push(insert(rope, 4, 'o'));
113
+
114
+ // Coalesce operations for network transmission
115
+ const textOps: TextOperation[] = ops.map(op => ({ type: 'insert', op }));
116
+ const coalesced = coalesceTextOperations(textOps, { thresholdMs: 1000 });
117
+
118
+ // Coalescence merged 5 ops into 1 op
119
+ expect(coalesced).toHaveLength(1);
120
+ expect((coalesced[0] as any).op.content).toBe('Hello');
121
+
122
+ // The local rope still has 5 separate items (coalescence doesn't modify the rope)
123
+ // BUT remove() now optimizes the deletions array!
124
+ const deleteOp = remove(rope, 0, 5);
125
+ expect(deleteOp.deletions).toHaveLength(1); // FIXED: 1 deletion instead of 5!
126
+ expect(deleteOp.deletions[0].length).toBe(5);
127
+
128
+ // This is the fix: even though rope has 5 items, remove() merges consecutive
129
+ // deletions from the same agent into a single deletion entry
130
+ });
131
+ });
132
+
133
+ describe('Verified correct behavior after fix', () => {
134
+ it('should merge consecutive deletions from same agent', () => {
135
+ const rope = create('alice');
136
+
137
+ // Type "Hello" - creates alice:0, alice:1, alice:2, alice:3, alice:4
138
+ insert(rope, 0, 'H');
139
+ insert(rope, 1, 'e');
140
+ insert(rope, 2, 'l');
141
+ insert(rope, 3, 'l');
142
+ insert(rope, 4, 'o');
143
+
144
+ // Delete all 5 characters
145
+ const deleteOp = remove(rope, 0, 5);
146
+
147
+ // FIXED: deletions = [{id: alice:0, len:5}] (merged consecutive IDs)
148
+ expect(deleteOp.deletions).toHaveLength(1);
149
+ expect(deleteOp.deletions[0].length).toBe(5);
150
+ });
151
+
152
+ it('should handle mixed local and remote items correctly', () => {
153
+ const rope = create('alice');
154
+
155
+ // Alice types "Hi"
156
+ insert(rope, 0, 'H');
157
+ insert(rope, 1, 'i');
158
+
159
+ // Bob types "ey" (received as coalesced op)
160
+ const bobOp: InsertOp = {
161
+ id: 'bob:1',
162
+ content: 'ey',
163
+ parentId: 'alice:1',
164
+ seq: 3,
165
+ ts: 1000.0,
166
+ };
167
+ apply(rope, bobOp);
168
+
169
+ // YATA ordering determines final text (could be "Hiey" or "eyHi" depending on timestamps)
170
+ const text = rope.toString();
171
+ expect(text.length).toBe(4);
172
+ expect(text).toContain('Hi');
173
+ expect(text).toContain('ey');
174
+
175
+ // Alice deletes all 4 characters
176
+ const deleteOp = remove(rope, 0, 4);
177
+
178
+ // FIXED: 2 deletions (can't merge across agents, but alice's chars are merged)
179
+ // - alice:0-1 merged → 1 deletion with length 2
180
+ // - bob:1 → 1 deletion with length 2
181
+ expect(deleteOp.deletions).toHaveLength(2);
182
+
183
+ // Both deletions should be multi-char
184
+ expect(deleteOp.deletions[0].length).toBe(2);
185
+ expect(deleteOp.deletions[1].length).toBe(2);
186
+
187
+ // One from alice, one from bob
188
+ const agents = deleteOp.deletions.map(d => d.id.split(':')[0]);
189
+ expect(agents).toContain('alice');
190
+ expect(agents).toContain('bob');
191
+ });
192
+ });
193
+
194
+ describe('Real-world scenario from bug report', () => {
195
+ it('should fix the "kkkkkkk" deletion bug', () => {
196
+ const rope = create('Alice');
197
+
198
+ // User types "kkkkkkk" quickly (7 characters)
199
+ for (let i = 0; i < 7; i++) {
200
+ insert(rope, i, 'k');
201
+ }
202
+
203
+ expect(rope.toString()).toBe('kkkkkkk');
204
+
205
+ // User selects all and deletes
206
+ const deleteOp = remove(rope, 0, 7);
207
+
208
+ // FIXED: Creates 1 deletion entry with length 7
209
+ expect(deleteOp.deletions).toHaveLength(1);
210
+ expect(deleteOp.deletions[0].length).toBe(7);
211
+ expect(deleteOp.deletions[0].id).toMatch(/Alice:\d+/);
212
+
213
+ // This is now efficient - 1 deletion entry instead of 7
214
+ });
215
+
216
+ it('should show delete operation coalescence creates efficient operations', () => {
217
+ const rope = create('Alice');
218
+
219
+ // Type "kkkkkkk"
220
+ for (let i = 0; i < 7; i++) {
221
+ insert(rope, i, 'k');
222
+ }
223
+
224
+ // Press backspace 7 times rapidly
225
+ const deleteOps: TextOperation[] = [];
226
+ for (let i = 6; i >= 0; i--) {
227
+ const op = remove(rope, i, 1);
228
+ deleteOps.push({ type: 'delete', op });
229
+ }
230
+
231
+ // Coalesce delete operations
232
+ const coalesced = coalesceTextOperations(deleteOps, { thresholdMs: 1000 });
233
+
234
+ // Operation-level coalescence works: 7 ops → 1 op
235
+ expect(coalesced).toHaveLength(1);
236
+ expect(coalesced[0].type).toBe('delete');
237
+
238
+ // The deletions array should already be efficient from remove() optimization
239
+ // Each individual remove() creates 1 deletion (only deleting 1 char)
240
+ // When coalesceTextOperations merges the 7 ops, it combines the deletions arrays
241
+ const coalescedOp = (coalesced[0] as any).op;
242
+ expect(coalescedOp.deletions).toHaveLength(7);
243
+
244
+ // Note: These 7 deletions are NOT consecutive because we deleted backwards (6→0)
245
+ // So they cannot be merged. If we add optimizeDeletions() to coalesceTextOperations,
246
+ // it would merge them if they were consecutive.
247
+ });
248
+ });
249
+ });