Skip to content

Commit a8c2de8

Browse files
committed
test: recover operation tests
1 parent 0093cea commit a8c2de8

File tree

3 files changed

+244
-14
lines changed

3 files changed

+244
-14
lines changed

packages/collaboration-manager/src/CollaborationManager.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export class CollaborationManager {
9191
* Undo last operation in the local stack
9292
*/
9393
public undo(): void {
94-
this.#emptyBatch();
94+
this.#moveBatchToUndo();
9595

9696
const operation = this.#undoRedoManager.undo();
9797

@@ -112,7 +112,7 @@ export class CollaborationManager {
112112
* Redo last undone operation in the local stack
113113
*/
114114
public redo(): void {
115-
this.#emptyBatch();
115+
this.#moveBatchToUndo();
116116

117117
const operation = this.#undoRedoManager.redo();
118118

@@ -123,13 +123,7 @@ export class CollaborationManager {
123123
// Disable event handling
124124
this.#shouldHandleEvents = false;
125125

126-
if (operation instanceof BatchedOperation) {
127-
operation.operations.forEach((op) => {
128-
this.applyOperation(op);
129-
});
130-
} else {
131-
this.applyOperation(operation);
132-
}
126+
this.applyOperation(operation);
133127

134128
// Re-enable event handling
135129
this.#shouldHandleEvents = true;
@@ -265,7 +259,7 @@ export class CollaborationManager {
265259
* @todo - add debounce timeout 500ms
266260
*/
267261
if (!this.#currentBatch.canAdd(operation)) {
268-
this.#undoRedoManager.put(this.#currentBatch);
262+
this.#moveBatchToUndo();
269263

270264
this.#currentBatch = new BatchedOperation(operation);
271265

@@ -278,7 +272,7 @@ export class CollaborationManager {
278272
/**
279273
* Puts current batch to the undo stack and clears the batch
280274
*/
281-
#emptyBatch(): void {
275+
#moveBatchToUndo(): void {
282276
if (this.#currentBatch !== null) {
283277
this.#undoRedoManager.put(this.#currentBatch);
284278

packages/collaboration-manager/src/Operation.spec.ts

Lines changed: 231 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-magic-numbers */
2-
import type { BlockNodeSerialized, DataKey } from '@editorjs/model';
2+
import type { BlockNodeSerialized, DataKey, DocumentIndex } from '@editorjs/model';
33
import { IndexBuilder } from '@editorjs/model';
44
import { describe } from '@jest/globals';
55
import { type InsertOrDeleteOperationData, type ModifyOperationData, Operation, OperationType } from './Operation.js';
@@ -10,7 +10,8 @@ const createOperation = (
1010
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1111
value: string | [ BlockNodeSerialized ] | Record<any, any>,
1212
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13-
prevValue?: Record<any, any>
13+
prevValue?: Record<any, any>,
14+
endIndex?: number
1415
): Operation => {
1516
const index = new IndexBuilder()
1617
.addBlockIndex(0);
@@ -40,6 +41,234 @@ const createOperation = (
4041

4142

4243
describe('Operation', () => {
44+
describe('.transform()', () => {
45+
it('should not change operation if document ids are different', () => {
46+
const receivedOp = createOperation(OperationType.Insert, 0, 'abc');
47+
const localOp = createOperation(OperationType.Insert, 0, 'def');
48+
49+
localOp.index.documentId = 'document2' as DocumentIndex;
50+
const transformedOp = receivedOp.transform(localOp);
51+
52+
expect(transformedOp).toEqual(receivedOp);
53+
});
54+
55+
it('should not change operation if data keys are different', () => {
56+
const receivedOp = createOperation(OperationType.Insert, 0, 'abc');
57+
const localOp = createOperation(OperationType.Insert, 0, 'def');
58+
59+
localOp.index.dataKey = 'dataKey2' as DataKey;
60+
61+
const transformedOp = receivedOp.transform(localOp);
62+
63+
expect(transformedOp).toEqual(receivedOp);
64+
});
65+
66+
it('should throw Unsupppoted index type error if op is not Block or Text operation', () => {
67+
const receivedOp = createOperation(OperationType.Insert, 0, 'abc');
68+
const localOp = createOperation(OperationType.Insert, 0, 'def');
69+
70+
localOp.index.textRange = undefined;
71+
72+
try {
73+
receivedOp.transform(localOp);
74+
} catch (e) {
75+
expect(e).toBeInstanceOf(Error);
76+
expect((e as Error).message).toContain('Unsupported index type');
77+
}
78+
});
79+
80+
it('should throw an error if unsupported operation type is provided', () => {
81+
const receivedOp = createOperation(OperationType.Insert, 0, 'def');
82+
// @ts-expect-error — for test purposes
83+
const localOp = createOperation('unsupported', 0, 'def');
84+
85+
expect(() => receivedOp.transform(localOp)).toThrow('Unsupported operation type');
86+
});
87+
88+
it('should not transform relative to the Modify operation (as Modify operation doesn\'t change index)', () => {
89+
const receivedOp = createOperation(OperationType.Insert, 0, 'abc');
90+
const localOp = createOperation(OperationType.Modify, 0, 'def');
91+
const transformedOp = receivedOp.transform(localOp);
92+
93+
expect(transformedOp).toEqual(receivedOp);
94+
});
95+
96+
describe('Transformation relative to Insert operation', () => {
97+
it('should not change a received operation if it is before a local one', () => {
98+
const receivedOp = createOperation(OperationType.Insert, 0, 'abc');
99+
const localOp = createOperation(OperationType.Insert, 3, 'def');
100+
const transformedOp = receivedOp.transform(localOp);
101+
102+
expect(transformedOp).toEqual(receivedOp);
103+
});
104+
105+
it('should transform an index for a received operation if it is after a local one', () => {
106+
const receivedOp = createOperation(OperationType.Delete, 3, 'def');
107+
const localOp = createOperation(OperationType.Insert, 0, 'abc');
108+
const transformedOp = receivedOp.transform(localOp);
109+
110+
expect(transformedOp.index.textRange).toEqual([6, 6]);
111+
});
112+
113+
it('should transform a received operation if it is at the same position as a local one', () => {
114+
const receivedOp = createOperation(OperationType.Modify, 0, 'abc');
115+
const localOp = createOperation(OperationType.Insert, 0, 'def');
116+
const transformedOp = receivedOp.transform(localOp);
117+
118+
expect(transformedOp.index.textRange).toEqual([3, 3]);
119+
});
120+
121+
it('should not change the text index if local op is a Block operation', () => {
122+
const receivedOp = createOperation(OperationType.Modify, 0, 'abc');
123+
const localOp = createOperation(OperationType.Insert, 0, [ {
124+
name: 'paragraph',
125+
data: { text: 'hello' },
126+
} ]);
127+
const transformedOp = receivedOp.transform(localOp);
128+
129+
expect(transformedOp.index.textRange).toEqual([0, 0]);
130+
});
131+
132+
it('should not change the operation if local op is a Block operation after a received one', () => {
133+
const receivedOp = createOperation(OperationType.Insert, 0, [ {
134+
name: 'paragraph',
135+
data: { text: 'abc' },
136+
} ]);
137+
const localOp = createOperation(OperationType.Insert, 1, [ {
138+
name: 'paragraph',
139+
data: { text: 'hello' },
140+
} ]);
141+
142+
const transformedOp = receivedOp.transform(localOp);
143+
144+
expect(transformedOp).toEqual(receivedOp);
145+
});
146+
147+
it('should adjust the block index if local op is a Block operation before a received one', () => {
148+
const receivedOp = createOperation(OperationType.Insert, 1, [ {
149+
name: 'paragraph',
150+
data: { text: 'abc' },
151+
} ]);
152+
const localOp = createOperation(OperationType.Insert, 0, [ {
153+
name: 'paragraph',
154+
data: { text: 'hello' },
155+
} ]);
156+
157+
const transformedOp = receivedOp.transform(localOp);
158+
159+
expect(transformedOp.index.blockIndex).toEqual(2);
160+
});
161+
162+
it('should adjust the block index if local op is a Block operation at the same index as a received one', () => {
163+
const receivedOp = createOperation(OperationType.Insert, 0, [ {
164+
name: 'paragraph',
165+
data: { text: 'abc' },
166+
} ]);
167+
const localOp = createOperation(OperationType.Insert, 0, [ {
168+
name: 'paragraph',
169+
data: { text: 'hello' },
170+
} ]);
171+
172+
const transformedOp = receivedOp.transform(localOp);
173+
174+
expect(transformedOp.index.blockIndex).toEqual(1);
175+
});
176+
});
177+
178+
describe('Transformation relative to Delete operation', () => {
179+
it('should not change a received operation if it is before a local one', () => {
180+
const receivedOp = createOperation(OperationType.Insert, 0, 'abc');
181+
const localOp = createOperation(OperationType.Delete, 3, 'def');
182+
const transformedOp = receivedOp.transform(localOp);
183+
184+
expect(transformedOp).toEqual(receivedOp);
185+
});
186+
187+
it('should transform an index for a received operation if it is after a local one', () => {
188+
const receivedOp = createOperation(OperationType.Delete, 3, 'def');
189+
const localOp = createOperation(OperationType.Delete, 0, 'abc');
190+
const transformedOp = receivedOp.transform(localOp);
191+
192+
expect(transformedOp.index.textRange).toEqual([0, 0]);
193+
});
194+
195+
it('should transform a received operation if it is at the same position as a local one', () => {
196+
const receivedOp = createOperation(OperationType.Modify, 3, 'abc');
197+
const localOp = createOperation(OperationType.Delete, 0, 'def', undefined, 3);
198+
const transformedOp = receivedOp.transform(localOp);
199+
200+
expect(transformedOp.index.textRange).toEqual([0, 0]);
201+
});
202+
203+
it('should not change the text index if local op is a Block operation', () => {
204+
const receivedOp = createOperation(OperationType.Modify, 1, 'abc');
205+
const localOp = createOperation(OperationType.Delete, 0, [ {
206+
name: 'paragraph',
207+
data: { text: 'hello' },
208+
} ]);
209+
const transformedOp = receivedOp.transform(localOp);
210+
211+
expect(transformedOp.index.textRange).toEqual([1, 1]);
212+
});
213+
214+
it('should not change the text index if local op is a Block operation', () => {
215+
const receivedOp = createOperation(OperationType.Modify, 0, 'abc');
216+
const localOp = createOperation(OperationType.Insert, 0, [ {
217+
name: 'paragraph',
218+
data: { text: 'hello' },
219+
} ]);
220+
const transformedOp = receivedOp.transform(localOp);
221+
222+
expect(transformedOp.index.textRange).toEqual([0, 0]);
223+
});
224+
225+
it('should not change the operation if local op is a Block operation after a received one', () => {
226+
const receivedOp = createOperation(OperationType.Insert, 0, [ {
227+
name: 'paragraph',
228+
data: { text: 'abc' },
229+
} ]);
230+
const localOp = createOperation(OperationType.Delete, 1, [ {
231+
name: 'paragraph',
232+
data: { text: 'hello' },
233+
} ]);
234+
235+
const transformedOp = receivedOp.transform(localOp);
236+
237+
expect(transformedOp).toEqual(receivedOp);
238+
});
239+
240+
it('should adjust the block index if local op is a Block operation before a received one', () => {
241+
const receivedOp = createOperation(OperationType.Insert, 1, [ {
242+
name: 'paragraph',
243+
data: { text: 'abc' },
244+
} ]);
245+
const localOp = createOperation(OperationType.Delete, 0, [ {
246+
name: 'paragraph',
247+
data: { text: 'hello' },
248+
} ]);
249+
250+
const transformedOp = receivedOp.transform(localOp);
251+
252+
expect(transformedOp.index.blockIndex).toEqual(0);
253+
});
254+
255+
it('should return Neutral operation if local op is a Block operation at the same index as a received one', () => {
256+
const receivedOp = createOperation(OperationType.Insert, 1, [ {
257+
name: 'paragraph',
258+
data: { text: 'abc' },
259+
} ]);
260+
const localOp = createOperation(OperationType.Delete, 1, [ {
261+
name: 'paragraph',
262+
data: { text: 'hello' },
263+
} ]);
264+
265+
const transformedOp = receivedOp.transform(localOp);
266+
267+
expect(transformedOp.type).toBe(OperationType.Neutral);
268+
});
269+
});
270+
});
271+
43272
describe('.inverse()', () => {
44273
it('should change the type of Insert operation to Delete operation', () => {
45274
const op = createOperation(OperationType.Insert, 0, 'abc');

packages/collaboration-manager/src/OperationsTransformer.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export class OperationsTransformer {
2020
if (operation.index.documentId !== againstOp.index.documentId) {
2121
return Operation.from(operation);
2222
}
23+
24+
/**
25+
* Throw unsupported operation type error if operation type is not supported
26+
*/
27+
if (!Object.values(OperationType).includes(againstOp.type) || !Object.values(OperationType).includes(operation.type)) {
28+
throw new Error('Unsupported operation type')
29+
}
2330

2431
return this.#applyTransformation<T>(operation, againstOp);
2532
}
@@ -176,7 +183,7 @@ export class OperationsTransformer {
176183
/**
177184
* Check that againstOp affects current operation
178185
*/
179-
if (sameInput && sameBlock && againstIndex.textRange![0] > index.textRange![1]) {
186+
if (!sameInput || !sameBlock || againstIndex.textRange![0] > index.textRange![1]) {
180187
return Operation.from(operation);
181188
}
182189

0 commit comments

Comments
 (0)