@vuer-ai/vuer-rtc 0.5.2 → 0.5.3
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.
- package/dist/client/coalescence/textDeletes.d.ts +0 -2
- package/dist/client/coalescence/textDeletes.d.ts.map +1 -1
- package/dist/client/coalescence/textDeletes.js +4 -8
- package/dist/client/coalescence/textDeletes.js.map +1 -1
- package/package.json +1 -1
- package/src/client/coalescence/textDeletes.ts +6 -11
- package/tests/client/graph-coalescence.test.ts +219 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"textDeletes.d.ts","sourceRoot":"","sources":["../../../src/client/coalescence/textDeletes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oCAAoC,CAAC;AAGpE,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"textDeletes.d.ts","sourceRoot":"","sources":["../../../src/client/coalescence/textDeletes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oCAAoC,CAAC;AAGpE,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAGlD;AAED,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,SAAS,GAAG,EAAE,IAAI,YAAY,CAMhE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,YAAY,EAAE,EACnB,OAAO,GAAE,eAAoB,GAC5B,YAAY,EAAE,CAgDhB"}
|
|
@@ -10,8 +10,7 @@ import { optimizeDeletions } from './utils.js';
|
|
|
10
10
|
export function isTextDeleteOp(op) {
|
|
11
11
|
return (op.otype === 'text.delete' &&
|
|
12
12
|
Array.isArray(op.deletions) &&
|
|
13
|
-
|
|
14
|
-
typeof op.ts === 'number');
|
|
13
|
+
op.deletions.length > 0);
|
|
15
14
|
}
|
|
16
15
|
/**
|
|
17
16
|
* Coalesce consecutive text delete operations.
|
|
@@ -38,18 +37,15 @@ export function coalesceTextDeletes(ops, options = {}) {
|
|
|
38
37
|
else {
|
|
39
38
|
// Check merge conditions
|
|
40
39
|
const sameTarget = pending.key === op.key && pending.path === op.path;
|
|
41
|
-
// Time threshold
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (sameTarget && withinThreshold) {
|
|
40
|
+
// NOTE: Time threshold is not available for TextDeleteOp (no ts field)
|
|
41
|
+
// We merge based on target (key + path) only
|
|
42
|
+
if (sameTarget) {
|
|
45
43
|
// Merge operations - combine deletion lists
|
|
46
44
|
const merged = {
|
|
47
45
|
otype: 'text.delete',
|
|
48
46
|
key: pending.key,
|
|
49
47
|
path: pending.path,
|
|
50
48
|
deletions: [...pending.deletions, ...op.deletions],
|
|
51
|
-
seq: Math.max(pending.seq, op.seq), // Use max Lamport clock for ordering
|
|
52
|
-
ts: pending.ts, // Keep first timestamp (when sequence started)
|
|
53
49
|
};
|
|
54
50
|
pending = merged;
|
|
55
51
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"textDeletes.js","sourceRoot":"","sources":["../../../src/client/coalescence/textDeletes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAgB/C;;GAEG;AACH,MAAM,UAAU,cAAc,CAAC,EAAa;IAC1C,OAAO,CACL,EAAE,CAAC,KAAK,KAAK,aAAa;QAC1B,KAAK,CAAC,OAAO,CAAE,EAAU,CAAC,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"textDeletes.js","sourceRoot":"","sources":["../../../src/client/coalescence/textDeletes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAgB/C;;GAEG;AACH,MAAM,UAAU,cAAc,CAAC,EAAa;IAC1C,OAAO,CACL,EAAE,CAAC,KAAK,KAAK,aAAa;QAC1B,KAAK,CAAC,OAAO,CAAE,EAAU,CAAC,SAAS,CAAC;QACnC,EAAU,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,CACjC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,mBAAmB,CACjC,GAAmB,EACnB,UAA2B,EAAE;IAE7B,MAAM,EAAE,WAAW,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC;IAEvC,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC;IAEjC,MAAM,MAAM,GAAmB,EAAE,CAAC;IAClC,IAAI,OAAO,GAAwB,IAAI,CAAC;IAExC,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;QACrB,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,2BAA2B;YAC3B,OAAO,GAAG,EAAE,GAAG,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;QACpD,CAAC;aAAM,CAAC;YACN,yBAAyB;YACzB,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,KAAK,EAAE,CAAC,GAAG,IAAI,OAAO,CAAC,IAAI,KAAK,EAAE,CAAC,IAAI,CAAC;YAEtE,uEAAuE;YACvE,6CAA6C;YAC7C,IAAI,UAAU,EAAE,CAAC;gBACf,4CAA4C;gBAC5C,MAAM,MAAM,GAAiB;oBAC3B,KAAK,EAAE,aAAa;oBACpB,GAAG,EAAE,OAAO,CAAC,GAAG;oBAChB,IAAI,EAAE,OAAO,CAAC,IAAI;oBAClB,SAAS,EAAE,CAAC,GAAG,OAAO,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC;iBACnD,CAAC;gBACF,OAAO,GAAG,MAAM,CAAC;YACnB,CAAC;iBAAM,CAAC;gBACN,4CAA4C;gBAC5C,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;gBACrB,OAAO,GAAG,EAAE,GAAG,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;YACpD,CAAC;QACH,CAAC;IACH,CAAC;IAED,qCAAqC;IACrC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACrB,0CAA0C;QAC1C,OAAO,CAAC,SAAS,GAAG,iBAAiB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACzD,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACvB,CAAC;IAED,8DAA8D;IAC9D,KAAK,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;QACxB,EAAE,CAAC,SAAS,GAAG,iBAAiB,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC;IACjD,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/package.json
CHANGED
|
@@ -12,8 +12,8 @@ export interface TextDeleteOp {
|
|
|
12
12
|
key: string;
|
|
13
13
|
path: string;
|
|
14
14
|
deletions: Array<{ id: string; length: number }>; // Array of deletion ranges
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// NOTE: TextDeleteOp does NOT have seq/ts fields (those are only on TextInsertOp)
|
|
16
|
+
// Time-based coalescence is not available for delete operations
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export interface CoalesceOptions {
|
|
@@ -28,8 +28,7 @@ export function isTextDeleteOp(op: Operation): op is TextDeleteOp {
|
|
|
28
28
|
return (
|
|
29
29
|
op.otype === 'text.delete' &&
|
|
30
30
|
Array.isArray((op as any).deletions) &&
|
|
31
|
-
|
|
32
|
-
typeof (op as any).ts === 'number'
|
|
31
|
+
(op as any).deletions.length > 0
|
|
33
32
|
);
|
|
34
33
|
}
|
|
35
34
|
|
|
@@ -63,19 +62,15 @@ export function coalesceTextDeletes(
|
|
|
63
62
|
// Check merge conditions
|
|
64
63
|
const sameTarget = pending.key === op.key && pending.path === op.path;
|
|
65
64
|
|
|
66
|
-
// Time threshold
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (sameTarget && withinThreshold) {
|
|
65
|
+
// NOTE: Time threshold is not available for TextDeleteOp (no ts field)
|
|
66
|
+
// We merge based on target (key + path) only
|
|
67
|
+
if (sameTarget) {
|
|
71
68
|
// Merge operations - combine deletion lists
|
|
72
69
|
const merged: TextDeleteOp = {
|
|
73
70
|
otype: 'text.delete',
|
|
74
71
|
key: pending.key,
|
|
75
72
|
path: pending.path,
|
|
76
73
|
deletions: [...pending.deletions, ...op.deletions],
|
|
77
|
-
seq: Math.max(pending.seq, op.seq), // Use max Lamport clock for ordering
|
|
78
|
-
ts: pending.ts, // Keep first timestamp (when sequence started)
|
|
79
74
|
};
|
|
80
75
|
pending = merged;
|
|
81
76
|
} else {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Tests for Graph Operation Coalescence
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that operation coalescence works end-to-end through
|
|
5
|
+
* the GraphStore commit pipeline, not just at the Rope level.
|
|
6
|
+
*
|
|
7
|
+
* CRITICAL: These tests catch bugs that unit tests miss - like the isTextDeleteOp
|
|
8
|
+
* type guard checking for fields that don't exist!
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from '@jest/globals';
|
|
12
|
+
import { createGraph } from '../../src/client/createGraph.js';
|
|
13
|
+
import type { CRDTMessage } from '../../src/operations/OperationTypes.js';
|
|
14
|
+
|
|
15
|
+
describe('Graph Operation Coalescence (Integration)', () => {
|
|
16
|
+
describe('text.delete coalescence', () => {
|
|
17
|
+
it('should coalesce consecutive text.delete operations', () => {
|
|
18
|
+
const messages: CRDTMessage[] = [];
|
|
19
|
+
|
|
20
|
+
const store = createGraph({
|
|
21
|
+
sessionId: 'alice',
|
|
22
|
+
coalescingEnabled: true,
|
|
23
|
+
coalescingDelayMs: 0,
|
|
24
|
+
coalescingThresholdMs: 1000,
|
|
25
|
+
onSend: (msg) => messages.push(msg),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Initialize scene and text node
|
|
29
|
+
store.edit({ otype: 'node.init', key: 'scene', path: '', value: { tag: 'Scene' } });
|
|
30
|
+
store.commit('init scene');
|
|
31
|
+
|
|
32
|
+
store.edit({ otype: 'node.insert', key: 'scene', path: 'children', value: { key: 'text-doc', tag: 'Text' } });
|
|
33
|
+
store.commit('add text node');
|
|
34
|
+
|
|
35
|
+
store.edit({ otype: 'text.init', key: 'text-doc', path: 'content', value: '' } as any);
|
|
36
|
+
store.commit('init text');
|
|
37
|
+
|
|
38
|
+
// Type "Hello" (5 operations)
|
|
39
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 0, value: 'H' } as any);
|
|
40
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 1, value: 'e' } as any);
|
|
41
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 2, value: 'l' } as any);
|
|
42
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 3, value: 'l' } as any);
|
|
43
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 4, value: 'o' } as any);
|
|
44
|
+
store.commit('type Hello');
|
|
45
|
+
|
|
46
|
+
messages.length = 0; // Clear previous messages
|
|
47
|
+
|
|
48
|
+
// Delete all 5 characters (1 operation)
|
|
49
|
+
store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 0, length: 5 } as any);
|
|
50
|
+
store.commit('delete all');
|
|
51
|
+
|
|
52
|
+
// Verify we sent exactly 1 message
|
|
53
|
+
expect(messages).toHaveLength(1);
|
|
54
|
+
|
|
55
|
+
const msg = messages[0];
|
|
56
|
+
expect(msg.ops).toHaveLength(1);
|
|
57
|
+
expect(msg.ops[0].otype).toBe('text.delete');
|
|
58
|
+
|
|
59
|
+
const deleteOp = msg.ops[0] as any;
|
|
60
|
+
expect(deleteOp.deletions).toBeDefined();
|
|
61
|
+
|
|
62
|
+
// CRITICAL: This verifies the coalescence optimization works!
|
|
63
|
+
// Without the fix, this would have many single-char deletions
|
|
64
|
+
expect(deleteOp.deletions).toHaveLength(1);
|
|
65
|
+
expect(deleteOp.deletions[0].length).toBe(5);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should coalesce multiple consecutive delete operations', () => {
|
|
69
|
+
const messages: CRDTMessage[] = [];
|
|
70
|
+
|
|
71
|
+
const store = createGraph({
|
|
72
|
+
sessionId: 'alice',
|
|
73
|
+
coalescingEnabled: true,
|
|
74
|
+
coalescingDelayMs: 0,
|
|
75
|
+
coalescingThresholdMs: 1000,
|
|
76
|
+
onSend: (msg) => messages.push(msg),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Initialize
|
|
80
|
+
store.edit({ otype: 'node.init', key: 'scene', path: '', value: { tag: 'Scene' } });
|
|
81
|
+
store.commit('init');
|
|
82
|
+
|
|
83
|
+
store.edit({ otype: 'node.insert', key: 'scene', path: 'children', value: { key: 'text-doc', tag: 'Text' } });
|
|
84
|
+
store.commit('add text');
|
|
85
|
+
|
|
86
|
+
store.edit({ otype: 'text.init', key: 'text-doc', path: 'content', value: 'Hello' } as any);
|
|
87
|
+
store.commit('init text with Hello');
|
|
88
|
+
|
|
89
|
+
messages.length = 0;
|
|
90
|
+
|
|
91
|
+
// Simulate rapid backspace (5 separate delete operations)
|
|
92
|
+
store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 4, length: 1 } as any); // 'o'
|
|
93
|
+
store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 3, length: 1 } as any); // 'l'
|
|
94
|
+
store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 2, length: 1 } as any); // 'l'
|
|
95
|
+
store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 1, length: 1 } as any); // 'e'
|
|
96
|
+
store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 0, length: 1 } as any); // 'H'
|
|
97
|
+
store.commit('rapid backspace');
|
|
98
|
+
|
|
99
|
+
// Verify coalescence worked
|
|
100
|
+
expect(messages).toHaveLength(1);
|
|
101
|
+
|
|
102
|
+
const msg = messages[0];
|
|
103
|
+
|
|
104
|
+
// Find the delete operation(s)
|
|
105
|
+
const deleteOps = msg.ops.filter(op => op.otype === 'text.delete');
|
|
106
|
+
expect(deleteOps.length).toBeGreaterThan(0);
|
|
107
|
+
|
|
108
|
+
// CRITICAL TEST: Verify each operation has optimized deletions
|
|
109
|
+
// The key fix is that isTextDeleteOp now accepts operations with deletions arrays
|
|
110
|
+
for (const op of deleteOps) {
|
|
111
|
+
const deleteOp = op as any;
|
|
112
|
+
expect(deleteOp.deletions).toBeDefined();
|
|
113
|
+
expect(Array.isArray(deleteOp.deletions)).toBe(true);
|
|
114
|
+
expect(deleteOp.deletions.length).toBeGreaterThan(0);
|
|
115
|
+
|
|
116
|
+
// Each deletion should be valid
|
|
117
|
+
for (const del of deleteOp.deletions) {
|
|
118
|
+
expect(del.id).toBeDefined();
|
|
119
|
+
expect(typeof del.length).toBe('number');
|
|
120
|
+
expect(del.length).toBeGreaterThan(0);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Verify we're not sending more operations than we created
|
|
125
|
+
// (coalescence may merge some, but shouldn't create more)
|
|
126
|
+
expect(msg.ops.length).toBeLessThanOrEqual(5);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should verify type guard accepts delete operations with deletions', () => {
|
|
130
|
+
const messages: CRDTMessage[] = [];
|
|
131
|
+
|
|
132
|
+
const store = createGraph({
|
|
133
|
+
sessionId: 'alice',
|
|
134
|
+
coalescingEnabled: true,
|
|
135
|
+
coalescingDelayMs: 0,
|
|
136
|
+
coalescingThresholdMs: 1000,
|
|
137
|
+
onSend: (msg) => messages.push(msg),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Initialize
|
|
141
|
+
store.edit({ otype: 'node.init', key: 'scene', path: '', value: { tag: 'Scene' } });
|
|
142
|
+
store.commit('init');
|
|
143
|
+
|
|
144
|
+
store.edit({ otype: 'node.insert', key: 'scene', path: 'children', value: { key: 'text-doc', tag: 'Text' } });
|
|
145
|
+
store.commit('add text');
|
|
146
|
+
|
|
147
|
+
store.edit({ otype: 'text.init', key: 'text-doc', path: 'content', value: 'Test' } as any);
|
|
148
|
+
store.commit('init text');
|
|
149
|
+
|
|
150
|
+
messages.length = 0;
|
|
151
|
+
|
|
152
|
+
// Single delete operation
|
|
153
|
+
store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 0, length: 4 } as any);
|
|
154
|
+
store.commit('delete');
|
|
155
|
+
|
|
156
|
+
expect(messages).toHaveLength(1);
|
|
157
|
+
|
|
158
|
+
const deleteOp = messages[0].ops[0] as any;
|
|
159
|
+
|
|
160
|
+
// CRITICAL: Verify the operation has the deletions field
|
|
161
|
+
// This is what isTextDeleteOp checks for
|
|
162
|
+
expect(deleteOp.otype).toBe('text.delete');
|
|
163
|
+
expect(Array.isArray(deleteOp.deletions)).toBe(true);
|
|
164
|
+
expect(deleteOp.deletions.length).toBeGreaterThan(0);
|
|
165
|
+
|
|
166
|
+
// Verify it does NOT have seq/ts (those are on TextInsertOp only!)
|
|
167
|
+
expect(deleteOp.seq).toBeUndefined();
|
|
168
|
+
expect(deleteOp.ts).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('text.insert coalescence', () => {
|
|
173
|
+
it('should coalesce consecutive text.insert operations', () => {
|
|
174
|
+
const messages: CRDTMessage[] = [];
|
|
175
|
+
|
|
176
|
+
const store = createGraph({
|
|
177
|
+
sessionId: 'alice',
|
|
178
|
+
coalescingEnabled: true,
|
|
179
|
+
coalescingDelayMs: 0,
|
|
180
|
+
coalescingThresholdMs: 1000,
|
|
181
|
+
onSend: (msg) => messages.push(msg),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Initialize
|
|
185
|
+
store.edit({ otype: 'node.init', key: 'scene', path: '', value: { tag: 'Scene' } });
|
|
186
|
+
store.commit('init');
|
|
187
|
+
|
|
188
|
+
store.edit({ otype: 'node.insert', key: 'scene', path: 'children', value: { key: 'text-doc', tag: 'Text' } });
|
|
189
|
+
store.commit('add text');
|
|
190
|
+
|
|
191
|
+
store.edit({ otype: 'text.init', key: 'text-doc', path: 'content', value: '' } as any);
|
|
192
|
+
store.commit('init text');
|
|
193
|
+
|
|
194
|
+
messages.length = 0;
|
|
195
|
+
|
|
196
|
+
// Type "Hello" (5 operations)
|
|
197
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 0, value: 'H' } as any);
|
|
198
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 1, value: 'e' } as any);
|
|
199
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 2, value: 'l' } as any);
|
|
200
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 3, value: 'l' } as any);
|
|
201
|
+
store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 4, value: 'o' } as any);
|
|
202
|
+
store.commit('type Hello');
|
|
203
|
+
|
|
204
|
+
expect(messages).toHaveLength(1);
|
|
205
|
+
|
|
206
|
+
const msg = messages[0];
|
|
207
|
+
|
|
208
|
+
// Verify inserts were coalesced
|
|
209
|
+
expect(msg.ops).toHaveLength(1);
|
|
210
|
+
expect(msg.ops[0].otype).toBe('text.insert');
|
|
211
|
+
|
|
212
|
+
const insertOp = msg.ops[0] as any;
|
|
213
|
+
expect(insertOp.content).toBe('Hello');
|
|
214
|
+
expect(insertOp.id).toBeDefined();
|
|
215
|
+
expect(insertOp.seq).toBeDefined();
|
|
216
|
+
expect(insertOp.ts).toBeDefined();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|