@vuer-ai/vuer-rtc 0.5.3 → 0.6.0

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.
Files changed (36) hide show
  1. package/dist/client/coalesceTextOperations.d.ts.map +1 -1
  2. package/dist/client/coalesceTextOperations.js +25 -0
  3. package/dist/client/coalesceTextOperations.js.map +1 -1
  4. package/dist/client/coalescence/index.d.ts +3 -0
  5. package/dist/client/coalescence/index.d.ts.map +1 -1
  6. package/dist/client/coalescence/index.js +3 -0
  7. package/dist/client/coalescence/index.js.map +1 -1
  8. package/dist/client/coalescence/lwwOperations.d.ts +37 -0
  9. package/dist/client/coalescence/lwwOperations.d.ts.map +1 -0
  10. package/dist/client/coalescence/lwwOperations.js +69 -0
  11. package/dist/client/coalescence/lwwOperations.js.map +1 -0
  12. package/dist/client/coalescence/numberOperations.d.ts +32 -0
  13. package/dist/client/coalescence/numberOperations.d.ts.map +1 -0
  14. package/dist/client/coalescence/numberOperations.js +66 -0
  15. package/dist/client/coalescence/numberOperations.js.map +1 -0
  16. package/dist/client/coalescence/registry.d.ts.map +1 -1
  17. package/dist/client/coalescence/registry.js +20 -0
  18. package/dist/client/coalescence/registry.js.map +1 -1
  19. package/dist/client/coalescence/textDeletes.d.ts.map +1 -1
  20. package/dist/client/coalescence/textDeletes.js +23 -5
  21. package/dist/client/coalescence/textDeletes.js.map +1 -1
  22. package/dist/client/coalescence/vector3Operations.d.ts +32 -0
  23. package/dist/client/coalescence/vector3Operations.d.ts.map +1 -0
  24. package/dist/client/coalescence/vector3Operations.js +71 -0
  25. package/dist/client/coalescence/vector3Operations.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/client/coalesceTextOperations.ts +27 -0
  28. package/src/client/coalescence/index.ts +3 -0
  29. package/src/client/coalescence/lwwOperations.ts +104 -0
  30. package/src/client/coalescence/numberOperations.ts +80 -0
  31. package/src/client/coalescence/registry.ts +22 -0
  32. package/src/client/coalescence/textDeletes.ts +25 -5
  33. package/src/client/coalescence/vector3Operations.ts +85 -0
  34. package/tests/client/coalesce-text-operations.test.ts +99 -0
  35. package/tests/client/delete-coalescence-bug.test.ts +7 -7
  36. package/tests/client/graph-coalescence-phase1.test.ts +357 -0
@@ -14,12 +14,33 @@
14
14
  import type { TextOperation } from './textTypes.js';
15
15
  import type { InsertOp } from '../crdt/Rope.js';
16
16
  import { parseItemId } from '../crdt/Rope.js';
17
+ import { optimizeDeletions, parseItemId as parseId } from './coalescence/utils.js';
17
18
 
18
19
  export interface CoalesceOptions {
19
20
  /** Time threshold in milliseconds (default: 1000ms = 1 second) */
20
21
  thresholdMs?: number;
21
22
  }
22
23
 
24
+ /**
25
+ * Sort and optimize deletions array.
26
+ * Deletions must be sorted in ascending order for optimizeDeletions to work correctly.
27
+ */
28
+ function sortAndOptimizeDeletions(deletions: Array<{ id: string; length: number }>): Array<{ id: string; length: number }> {
29
+ if (deletions.length === 0) return deletions;
30
+
31
+ // Sort deletions by agent, then by sequence number (ascending)
32
+ const sorted = [...deletions].sort((a, b) => {
33
+ const aId = parseId(a.id);
34
+ const bId = parseId(b.id);
35
+ if (aId.agent !== bId.agent) {
36
+ return aId.agent.localeCompare(bId.agent);
37
+ }
38
+ return aId.seq - bId.seq;
39
+ });
40
+
41
+ return optimizeDeletions(sorted);
42
+ }
43
+
23
44
  /**
24
45
  * Coalesce consecutive text insert operations.
25
46
  *
@@ -47,6 +68,8 @@ export function coalesceTextOperations(
47
68
  if (op.type === 'insert') {
48
69
  // Flush any pending delete before starting new insert
49
70
  if (pendingDelete !== null) {
71
+ // Sort and optimize deletions array before flushing
72
+ pendingDelete.op.deletions = sortAndOptimizeDeletions(pendingDelete.op.deletions);
50
73
  result.push(pendingDelete);
51
74
  pendingDelete = null;
52
75
  }
@@ -137,6 +160,8 @@ export function coalesceTextOperations(
137
160
  pendingInsert = null;
138
161
  }
139
162
  if (pendingDelete !== null) {
163
+ // Sort and optimize deletions array before flushing
164
+ pendingDelete.op.deletions = sortAndOptimizeDeletions(pendingDelete.op.deletions);
140
165
  result.push(pendingDelete);
141
166
  pendingDelete = null;
142
167
  }
@@ -149,6 +174,8 @@ export function coalesceTextOperations(
149
174
  result.push(pendingInsert);
150
175
  }
151
176
  if (pendingDelete !== null) {
177
+ // Sort and optimize deletions array before flushing
178
+ pendingDelete.op.deletions = sortAndOptimizeDeletions(pendingDelete.op.deletions);
152
179
  result.push(pendingDelete);
153
180
  }
154
181
 
@@ -16,3 +16,6 @@ export {
16
16
 
17
17
  export { coalesceTextInserts, isTextInsertOp, type TextInsertOp } from './textInserts.js';
18
18
  export { coalesceTextDeletes, isTextDeleteOp, type TextDeleteOp } from './textDeletes.js';
19
+ export { coalesceNumberAdds, isNumberAddOp, type NumberAddOp } from './numberOperations.js';
20
+ export { coalesceVector3Adds, isVector3AddOp, type Vector3AddOp } from './vector3Operations.js';
21
+ export { coalesceLWWSets, isLWWSetOp, type LWWSetOp } from './lwwOperations.js';
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Last-Write-Wins (LWW) Operations Coalescence
3
+ *
4
+ * Generic coalescer for all *.set operations.
5
+ * Set operations follow Last-Write-Wins semantics - only the last value matters.
6
+ *
7
+ * Supported operations:
8
+ * - number.set, string.set, boolean.set
9
+ * - vector3.set, euler.set, quaternion.set, color.set
10
+ * - array.set, object.set
11
+ */
12
+
13
+ import type {
14
+ Operation,
15
+ NumberSetOp,
16
+ StringSetOp,
17
+ BooleanSetOp,
18
+ Vector3SetOp,
19
+ EulerSetOp,
20
+ QuaternionSetOp,
21
+ ColorSetOp,
22
+ ArraySetOp,
23
+ ObjectSetOp,
24
+ } from '../../operations/OperationTypes.js';
25
+ import type { CoalesceOptions } from './registry.js';
26
+
27
+ // Union of all *.set operation types
28
+ export type LWWSetOp =
29
+ | NumberSetOp
30
+ | StringSetOp
31
+ | BooleanSetOp
32
+ | Vector3SetOp
33
+ | EulerSetOp
34
+ | QuaternionSetOp
35
+ | ColorSetOp
36
+ | ArraySetOp
37
+ | ObjectSetOp;
38
+
39
+ /**
40
+ * Check if an operation is a *.set operation
41
+ */
42
+ export function isLWWSetOp(op: Operation): op is LWWSetOp {
43
+ return (
44
+ typeof op.otype === 'string' &&
45
+ op.otype.endsWith('.set') &&
46
+ (op as any).value !== undefined
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Coalesce consecutive *.set operations using Last-Write-Wins semantics.
52
+ *
53
+ * Since set operations follow LWW, only the last value in a sequence matters.
54
+ * We can safely discard all but the last operation for the same target.
55
+ *
56
+ * Example:
57
+ * Input: [set(key:"cube", path:"color", value:"red"), set(key:"cube", path:"color", value:"blue")]
58
+ * Output: [set(key:"cube", path:"color", value:"blue")]
59
+ *
60
+ * @param ops - Array of *.set operations
61
+ * @param options - Coalescence options
62
+ * NOTE: Time threshold is ignored for Last-Write-Wins operations.
63
+ * LWW semantics mean only the final value matters, regardless of timing,
64
+ * so we keep only the last operation on each target.
65
+ * @returns New array with coalesced operations
66
+ */
67
+ export function coalesceLWWSets(
68
+ ops: LWWSetOp[],
69
+ options: CoalesceOptions = {}
70
+ ): LWWSetOp[] {
71
+ if (ops.length === 0) return ops;
72
+
73
+ const result: LWWSetOp[] = [];
74
+ let pending: LWWSetOp | null = null;
75
+
76
+ for (const op of ops) {
77
+ if (pending === null) {
78
+ // Start new pending set
79
+ pending = { ...op };
80
+ } else {
81
+ // Check if operations target the same property AND same operation type
82
+ const sameTarget =
83
+ pending.key === op.key &&
84
+ pending.path === op.path &&
85
+ pending.otype === op.otype;
86
+
87
+ if (sameTarget) {
88
+ // LWW: Replace with newer value
89
+ pending = { ...op };
90
+ } else {
91
+ // Different target or type - flush pending and start new
92
+ result.push(pending);
93
+ pending = { ...op };
94
+ }
95
+ }
96
+ }
97
+
98
+ // Flush any remaining pending set
99
+ if (pending !== null) {
100
+ result.push(pending);
101
+ }
102
+
103
+ return result;
104
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Number Operations Coalescence
3
+ *
4
+ * Merges consecutive number.add operations on the same target.
5
+ * Addition is commutative, so we can safely merge multiple adds into one.
6
+ */
7
+
8
+ import type { Operation, NumberAddOp } from '../../operations/OperationTypes.js';
9
+ import type { CoalesceOptions } from './registry.js';
10
+
11
+ // Re-export type for convenience
12
+ export type { NumberAddOp };
13
+
14
+ /**
15
+ * Check if an operation is a number.add operation
16
+ */
17
+ export function isNumberAddOp(op: Operation): op is NumberAddOp {
18
+ return (
19
+ op.otype === 'number.add' &&
20
+ typeof (op as any).value === 'number'
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Coalesce consecutive number.add operations.
26
+ *
27
+ * Since addition is commutative (a + b = b + a), we can safely merge
28
+ * all consecutive adds into a single operation by summing the values.
29
+ *
30
+ * Example:
31
+ * Input: [add(key:"score", path:"value", value:10), add(key:"score", path:"value", value:5)]
32
+ * Output: [add(key:"score", path:"value", value:15)]
33
+ *
34
+ * @param ops - Array of number.add operations
35
+ * @param options - Coalescence options
36
+ * NOTE: Time threshold is ignored for commutative operations like addition.
37
+ * Mathematically, a+b = b+a regardless of when they occurred, so we merge
38
+ * all consecutive operations on the same target without time restrictions.
39
+ * @returns New array with coalesced operations
40
+ */
41
+ export function coalesceNumberAdds(
42
+ ops: NumberAddOp[],
43
+ options: CoalesceOptions = {}
44
+ ): NumberAddOp[] {
45
+ if (ops.length === 0) return ops;
46
+
47
+ const result: NumberAddOp[] = [];
48
+ let pending: NumberAddOp | null = null;
49
+
50
+ for (const op of ops) {
51
+ if (pending === null) {
52
+ // Start new pending add
53
+ pending = { ...op };
54
+ } else {
55
+ // Check if operations target the same property
56
+ const sameTarget = pending.key === op.key && pending.path === op.path;
57
+
58
+ if (sameTarget) {
59
+ // Merge operations by summing values
60
+ pending = {
61
+ otype: 'number.add',
62
+ key: pending.key,
63
+ path: pending.path,
64
+ value: pending.value + op.value,
65
+ };
66
+ } else {
67
+ // Different target - flush pending and start new
68
+ result.push(pending);
69
+ pending = { ...op };
70
+ }
71
+ }
72
+ }
73
+
74
+ // Flush any remaining pending add
75
+ if (pending !== null) {
76
+ result.push(pending);
77
+ }
78
+
79
+ return result;
80
+ }
@@ -8,6 +8,9 @@
8
8
  import type { Operation } from '../../operations/OperationTypes.js';
9
9
  import { coalesceTextInserts, isTextInsertOp, type TextInsertOp } from './textInserts.js';
10
10
  import { coalesceTextDeletes, isTextDeleteOp, type TextDeleteOp } from './textDeletes.js';
11
+ import { coalesceNumberAdds, isNumberAddOp } from './numberOperations.js';
12
+ import { coalesceVector3Adds, isVector3AddOp } from './vector3Operations.js';
13
+ import { coalesceLWWSets, isLWWSetOp } from './lwwOperations.js';
11
14
 
12
15
  export interface CoalesceOptions {
13
16
  /** Time threshold in milliseconds (default: 1000ms = 1 second) */
@@ -135,3 +138,22 @@ export function coalesceOperations(
135
138
  // Register built-in coalescers
136
139
  registerCoalescer('text.insert', isTextInsertOp as TypeGuard<any>, coalesceTextInserts as CoalesceHandler<any>);
137
140
  registerCoalescer('text.delete', isTextDeleteOp as TypeGuard<any>, coalesceTextDeletes as CoalesceHandler<any>);
141
+ registerCoalescer('number.add', isNumberAddOp as TypeGuard<any>, coalesceNumberAdds as CoalesceHandler<any>);
142
+ registerCoalescer('vector3.add', isVector3AddOp as TypeGuard<any>, coalesceVector3Adds as CoalesceHandler<any>);
143
+
144
+ // Register LWW coalescer for all *.set operations
145
+ const LWW_SET_OPERATIONS = [
146
+ 'number.set',
147
+ 'string.set',
148
+ 'boolean.set',
149
+ 'vector3.set',
150
+ 'euler.set',
151
+ 'quaternion.set',
152
+ 'color.set',
153
+ 'array.set',
154
+ 'object.set',
155
+ ] as const;
156
+
157
+ for (const otype of LWW_SET_OPERATIONS) {
158
+ registerCoalescer(otype, isLWWSetOp as TypeGuard<any>, coalesceLWWSets as CoalesceHandler<any>);
159
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Operation } from '../../operations/OperationTypes.js';
8
- import { optimizeDeletions } from './utils.js';
8
+ import { optimizeDeletions, parseItemId } from './utils.js';
9
9
 
10
10
  export interface TextDeleteOp {
11
11
  otype: 'text.delete';
@@ -21,6 +21,26 @@ export interface CoalesceOptions {
21
21
  thresholdMs?: number;
22
22
  }
23
23
 
24
+ /**
25
+ * Sort and optimize deletions array.
26
+ * Deletions must be sorted in ascending order for optimizeDeletions to work correctly.
27
+ */
28
+ function sortAndOptimizeDeletions(deletions: Array<{ id: string; length: number }>): Array<{ id: string; length: number }> {
29
+ if (deletions.length === 0) return deletions;
30
+
31
+ // Sort deletions by agent, then by sequence number (ascending)
32
+ const sorted = [...deletions].sort((a, b) => {
33
+ const aId = parseItemId(a.id);
34
+ const bId = parseItemId(b.id);
35
+ if (aId.agent !== bId.agent) {
36
+ return aId.agent.localeCompare(bId.agent);
37
+ }
38
+ return aId.seq - bId.seq;
39
+ });
40
+
41
+ return optimizeDeletions(sorted);
42
+ }
43
+
24
44
  /**
25
45
  * Check if an operation is a text delete with CRDT metadata
26
46
  */
@@ -83,14 +103,14 @@ export function coalesceTextDeletes(
83
103
 
84
104
  // Flush any remaining pending delete
85
105
  if (pending !== null) {
86
- // Optimize deletions array before pushing
87
- pending.deletions = optimizeDeletions(pending.deletions);
106
+ // Sort and optimize deletions array before pushing
107
+ pending.deletions = sortAndOptimizeDeletions(pending.deletions);
88
108
  result.push(pending);
89
109
  }
90
110
 
91
- // Also optimize deletions in all previously pushed operations
111
+ // Also sort and optimize deletions in all previously pushed operations
92
112
  for (const op of result) {
93
- op.deletions = optimizeDeletions(op.deletions);
113
+ op.deletions = sortAndOptimizeDeletions(op.deletions);
94
114
  }
95
115
 
96
116
  return result;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Vector3 Operations Coalescence
3
+ *
4
+ * Merges consecutive vector3.add operations on the same target.
5
+ * Component-wise addition is commutative, so we can safely merge multiple adds.
6
+ */
7
+
8
+ import type { Operation, Vector3AddOp } from '../../operations/OperationTypes.js';
9
+ import type { CoalesceOptions } from './registry.js';
10
+
11
+ // Re-export type for convenience
12
+ export type { Vector3AddOp };
13
+
14
+ /**
15
+ * Check if an operation is a vector3.add operation
16
+ */
17
+ export function isVector3AddOp(op: Operation): op is Vector3AddOp {
18
+ return (
19
+ op.otype === 'vector3.add' &&
20
+ Array.isArray((op as any).value) &&
21
+ (op as any).value.length === 3
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Coalesce consecutive vector3.add operations.
27
+ *
28
+ * Since component-wise addition is commutative, we can safely merge
29
+ * all consecutive adds into a single operation by summing the vectors.
30
+ *
31
+ * Example:
32
+ * Input: [add(key:"player", path:"position", value:[1,0,0]), add(key:"player", path:"position", value:[0,2,0])]
33
+ * Output: [add(key:"player", path:"position", value:[1,2,0])]
34
+ *
35
+ * @param ops - Array of vector3.add operations
36
+ * @param options - Coalescence options
37
+ * NOTE: Time threshold is ignored for commutative operations like vector addition.
38
+ * Component-wise addition is commutative regardless of timing, so we merge
39
+ * all consecutive operations on the same target without time restrictions.
40
+ * @returns New array with coalesced operations
41
+ */
42
+ export function coalesceVector3Adds(
43
+ ops: Vector3AddOp[],
44
+ options: CoalesceOptions = {}
45
+ ): Vector3AddOp[] {
46
+ if (ops.length === 0) return ops;
47
+
48
+ const result: Vector3AddOp[] = [];
49
+ let pending: Vector3AddOp | null = null;
50
+
51
+ for (const op of ops) {
52
+ if (pending === null) {
53
+ // Start new pending add (copy value array to avoid mutations)
54
+ pending = { ...op, value: [...op.value] as [number, number, number] };
55
+ } else {
56
+ // Check if operations target the same property
57
+ const sameTarget = pending.key === op.key && pending.path === op.path;
58
+
59
+ if (sameTarget) {
60
+ // Merge operations by summing vectors component-wise
61
+ pending = {
62
+ otype: 'vector3.add',
63
+ key: pending.key,
64
+ path: pending.path,
65
+ value: [
66
+ pending.value[0] + op.value[0],
67
+ pending.value[1] + op.value[1],
68
+ pending.value[2] + op.value[2],
69
+ ],
70
+ };
71
+ } else {
72
+ // Different target - flush pending and start new
73
+ result.push(pending);
74
+ pending = { ...op, value: [...op.value] as [number, number, number] };
75
+ }
76
+ }
77
+ }
78
+
79
+ // Flush any remaining pending add
80
+ if (pending !== null) {
81
+ result.push(pending);
82
+ }
83
+
84
+ return result;
85
+ }
@@ -298,6 +298,105 @@ describe('coalesceTextOperations', () => {
298
298
  });
299
299
  });
300
300
 
301
+ describe('delete operations coalescence', () => {
302
+ it('should merge consecutive delete operations and optimize deletions array', () => {
303
+ // Simulate pressing backspace 7 times (7 separate delete operations)
304
+ const ops: TextOperation[] = [
305
+ {
306
+ type: 'delete',
307
+ op: {
308
+ deletions: [{ id: 'alice:7', length: 1 }],
309
+ },
310
+ } as any,
311
+ {
312
+ type: 'delete',
313
+ op: {
314
+ deletions: [{ id: 'alice:6', length: 1 }],
315
+ },
316
+ } as any,
317
+ {
318
+ type: 'delete',
319
+ op: {
320
+ deletions: [{ id: 'alice:5', length: 1 }],
321
+ },
322
+ } as any,
323
+ {
324
+ type: 'delete',
325
+ op: {
326
+ deletions: [{ id: 'alice:4', length: 1 }],
327
+ },
328
+ } as any,
329
+ {
330
+ type: 'delete',
331
+ op: {
332
+ deletions: [{ id: 'alice:3', length: 1 }],
333
+ },
334
+ } as any,
335
+ {
336
+ type: 'delete',
337
+ op: {
338
+ deletions: [{ id: 'alice:2', length: 1 }],
339
+ },
340
+ } as any,
341
+ {
342
+ type: 'delete',
343
+ op: {
344
+ deletions: [{ id: 'alice:1', length: 1 }],
345
+ },
346
+ } as any,
347
+ ];
348
+
349
+ const result = coalesceTextOperations(ops, { thresholdMs: 1000 });
350
+
351
+ // Should merge into ONE delete operation
352
+ expect(result).toHaveLength(1);
353
+ expect(result[0].type).toBe('delete');
354
+
355
+ // CRITICAL: Deletions array should be optimized (7 entries → 1 entry)
356
+ const deleteOp = result[0] as any;
357
+ expect(deleteOp.op.deletions).toBeDefined();
358
+ expect(deleteOp.op.deletions).toHaveLength(1);
359
+ expect(deleteOp.op.deletions[0]).toEqual({ id: 'alice:1', length: 7 });
360
+ });
361
+
362
+ it('should optimize deletions with gaps', () => {
363
+ // Deletions with non-consecutive IDs (can't be fully merged)
364
+ const ops: TextOperation[] = [
365
+ {
366
+ type: 'delete',
367
+ op: {
368
+ deletions: [
369
+ { id: 'alice:5', length: 1 },
370
+ { id: 'alice:4', length: 1 },
371
+ ],
372
+ },
373
+ } as any,
374
+ {
375
+ type: 'delete',
376
+ op: {
377
+ deletions: [
378
+ { id: 'alice:2', length: 1 }, // Gap! alice:3 is missing
379
+ { id: 'alice:1', length: 1 },
380
+ ],
381
+ },
382
+ } as any,
383
+ ];
384
+
385
+ const result = coalesceTextOperations(ops, { thresholdMs: 1000 });
386
+
387
+ // Should merge into ONE delete operation
388
+ expect(result).toHaveLength(1);
389
+ expect(result[0].type).toBe('delete');
390
+
391
+ // Deletions should be optimized: [alice:4-5 (len 2), alice:1-2 (len 2)]
392
+ const deleteOp = result[0] as any;
393
+ expect(deleteOp.op.deletions).toBeDefined();
394
+ expect(deleteOp.op.deletions).toHaveLength(2); // Two ranges due to gap
395
+ expect(deleteOp.op.deletions).toContainEqual({ id: 'alice:4', length: 2 });
396
+ expect(deleteOp.op.deletions).toContainEqual({ id: 'alice:1', length: 2 });
397
+ });
398
+ });
399
+
301
400
  describe('edge cases', () => {
302
401
  it('should handle empty operations array', () => {
303
402
  const result = coalesceTextOperations([]);
@@ -235,15 +235,15 @@ describe('Delete Coalescence Bug', () => {
235
235
  expect(coalesced).toHaveLength(1);
236
236
  expect(coalesced[0].type).toBe('delete');
237
237
 
238
- // The deletions array should already be efficient from remove() optimization
238
+ // FIXED in v0.5.4: coalesceTextOperations now sorts and optimizes deletions!
239
239
  // Each individual remove() creates 1 deletion (only deleting 1 char)
240
- // When coalesceTextOperations merges the 7 ops, it combines the deletions arrays
240
+ // When coalesceTextOperations merges the 7 ops, it:
241
+ // 1. Combines the deletions arrays: [{id:Alice:6,len:1}, ..., {id:Alice:0,len:1}]
242
+ // 2. Sorts them: [{id:Alice:0,len:1}, ..., {id:Alice:6,len:1}]
243
+ // 3. Optimizes them: [{id:Alice:0,len:7}]
241
244
  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.
245
+ expect(coalescedOp.deletions).toHaveLength(1);
246
+ expect(coalescedOp.deletions[0]).toEqual({ id: 'Alice:0', length: 7 });
247
247
  });
248
248
  });
249
249
  });