imp(): implement all operations transformations#115
Conversation
|
⏭️ No files to mutate for |
|
⏭️ No files to mutate for |
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
|---|---|---|---|
| 🟢 | Statements | 100% | 0/0 |
| 🟢 | Branches | 100% | 0/0 |
| 🟢 | Functions | 100% | 0/0 |
| 🟢 | Lines | 100% | 0/0 |
Test suite run success
1 tests passing in 1 suite.
Report generated by 🧪jest coverage report action from 6c185cb
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
|---|---|---|---|
| 🟢 | Statements | 99.88% | 856/857 |
| 🟢 | Branches | 99.57% | 232/233 |
| 🟢 | Functions | 98.1% | 207/211 |
| 🟢 | Lines | 99.88% | 823/824 |
Test suite run success
442 tests passing in 25 suites.
Report generated by 🧪jest coverage report action from 6c185cb
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
|---|---|---|---|
| 🟡 | Statements | 70.97% | 22/31 |
| 🔴 | Branches | 20% | 1/5 |
| 🟡 | Functions | 75% | 6/8 |
| 🟡 | Lines | 68.97% | 20/29 |
Test suite run success
4 tests passing in 1 suite.
Report generated by 🧪jest coverage report action from 6c185cb
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
|---|---|---|---|
| 🟡 | Statements | 75.72% (+8.21% 🔼) |
262/346 |
| 🟢 | Branches | 82.35% (+2.35% 🔼) |
98/119 |
| 🟡 | Functions | 71.74% (+14.6% 🔼) |
33/46 |
| 🟡 | Lines | 75.66% (+8.14% 🔼) |
258/341 |
Show new covered files 🐣
St.❔ |
File | Statements | Branches | Functions | Lines |
|---|---|---|---|---|---|
| 🟢 | ... / getRangesIntersectionType.ts |
100% | 100% | 100% | 100% |
| 🟢 | OperationsTransformer.ts | 98.88% | 90.24% | 100% | 98.88% |
| 🟢 | BatchedOperation.ts | 91.67% | 82.35% | 100% | 91.43% |
Show files with reduced coverage 🔻
St.❔ |
File | Statements | Branches | Functions | Lines |
|---|---|---|---|---|---|
| 🟢 | Operation.ts | 96.67% (+0.67% 🔼) |
90% (-6% 🔻) |
100% (+16.67% 🔼) |
96.67% (+0.67% 🔼) |
| 🟢 | CollaborationManager.ts | 82.35% (-1.47% 🔻) |
75% (+1.09% 🔼) |
66.67% (+4.17% 🔼) |
82.14% (-1.68% 🔻) |
Test suite run success
110 tests passing in 6 suites.
Report generated by 🧪jest coverage report action from 6c185cb
There was a problem hiding this comment.
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
packages/collaboration-manager/src/BatchedOperation.ts:13
- [nitpick] The file is named 'BatchedOperation.ts' while the exported class is named 'OperationsBatch', which may lead to confusion. Consider renaming either the file or the class so that they are consistent.
export class OperationsBatch<T extends OperationType> extends Operation<T> {
| public transform<K extends OperationType>(againstOp: Operation<K> | Operation<OperationType.Neutral>): Operation<T | OperationType.Neutral> { | ||
| return this.#transformer.transform(this, againstOp); | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
The transformation logic does not check if the adjusted text range becomes negative. This can cause tests (e.g., expecting null when over-deleting) to fail. Consider adding a validation step in the transformer methods (such as in #transformAgainstTextDelete) to return null if the resulting text range is invalid.
| public transform<K extends OperationType>(againstOp: Operation<K> | Operation<OperationType.Neutral>): Operation<T | OperationType.Neutral> { | |
| return this.#transformer.transform(this, againstOp); | |
| } | |
| /** | |
| public transform<K extends OperationType>(againstOp: Operation<K> | Operation<OperationType.Neutral>): Operation<T | OperationType.Neutral> | null { | |
| const transformedOp = this.#transformer.transform(this, againstOp); | |
| // Validate the resulting operation's index or range | |
| if (!this.isValidOperation(transformedOp)) { | |
| return null; | |
| } | |
| return transformedOp; | |
| } | |
| /** | |
| * Validates the operation to ensure its index or range is valid | |
| * | |
| * @param operation - operation to validate | |
| */ | |
| private isValidOperation(operation: Operation<T | OperationType.Neutral> | null): boolean { | |
| if (!operation) { | |
| return false; | |
| } | |
| // Example validation: Ensure index is non-negative | |
| if (operation.index.start < 0 || operation.index.end < 0) { | |
| return false; | |
| } | |
| // Add additional validation logic as needed | |
| return true; | |
| } | |
| /** |
| * | ||
| * Operations are batched on timeout basis or if batch is terminated from the outside | ||
| */ | ||
| export class OperationsBatch<T extends OperationType> extends Operation<T> { |
| * | ||
| * @param opBatch - operation batch to clone | ||
| */ | ||
| public static from<T extends OperationType>(opBatch: OperationsBatch<T>): OperationsBatch<T>; |
There was a problem hiding this comment.
Static methods should go before instance methods
| /** | ||
| * Every batch should have at least one operation | ||
| */ | ||
| const batch = new OperationsBatch(opBatchOrJSON.operations.shift()!); |
There was a problem hiding this comment.
This way the first operation would be copied by reference not by value
| /** | ||
| * Array of operations to batch | ||
| */ | ||
| operations: (Operation<T> | Operation<OperationType.Neutral>)[] = []; |
| #transformAgainstTextInsert<T extends OperationType>(operation: Operation<T>, againstOp: Operation<OperationType>): Operation<T> | Operation<OperationType.Neutral> { | ||
| const newIndexBuilder = new IndexBuilder().from(operation.index); | ||
|
|
||
| const amountOfInsertedCharacters = againstOp.data.payload!.length; |
There was a problem hiding this comment.
| const amountOfInsertedCharacters = againstOp.data.payload!.length; | |
| const insertedLength = againstOp.data.payload!.length; |
| newIndexBuilder.addTextRange([operation.index.textRange![0], operation.index.textRange![1] + amountOfInsertedCharacters]); | ||
| } | ||
|
|
||
| return new Operation(operation.type, newIndexBuilder.build(), operation.data, operation.userId, operation.rev); |
There was a problem hiding this comment.
const newOp = Operation.from(operation);
newOp.index = newIndexBuilder.build();
return newOp;
|
|
||
| const deletedRightSide = (againstOp.index.textRange![0] > operation.index.textRange![0]) | ||
| && (againstOp.index.textRange![0] < operation.index.textRange![1]) | ||
| && (againstOp.index.textRange![1] <= operation.index.textRange![1]); |
There was a problem hiding this comment.
| && (againstOp.index.textRange![1] <= operation.index.textRange![1]); | |
| && (againstOp.index.textRange![1] >= operation.index.textRange![1]); |
| * Cover case 2.1 | ||
| */ | ||
| if (deletedLeftSide) { | ||
| const deletedFromCurrentOpRange = operation.index.textRange![0] - againstOp.index.textRange![1]; |
There was a problem hiding this comment.
| const deletedFromCurrentOpRange = operation.index.textRange![0] - againstOp.index.textRange![1]; | |
| const deletedFromCurrentOpRange = againstOp.index.textRange![1] - operation.index.textRange![0]; |
| if (deletedLeftSide) { | ||
| const deletedFromCurrentOpRange = operation.index.textRange![0] - againstOp.index.textRange![1]; | ||
|
|
||
| newIndexBuilder.addTextRange([againstOp.index.textRange![0], operation.index.textRange![1] - deletedFromCurrentOpRange]); |
There was a problem hiding this comment.
| newIndexBuilder.addTextRange([againstOp.index.textRange![0], operation.index.textRange![1] - deletedFromCurrentOpRange]); | |
| newIndexBuilder.addTextRange([againstOp.index.textRange![0], operation.index.textRange![1] - deletedAmount]); |
| if (againstIndex.isBlockIndex && currentIndex.blockIndex !== undefined) { | ||
| /** | ||
| * Check that againstOp affects current operation | ||
| */ | ||
| if (againstIndex.blockIndex! <= currentIndex.blockIndex) { | ||
| return this.#transformAgainstBlockOperation(operation, againstOp); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Cover 4 case | ||
| * | ||
| * Check that againstOp is a text operation and current operation is also a text operation | ||
| */ | ||
| if (againstIndex.isTextIndex && currentIndex.isTextIndex) { | ||
| /** | ||
| * Check that againstOp affects current operation (text operation on the same block and same input) | ||
| * and against op happened on the left side or has overlapping range | ||
| */ | ||
| if (currentIndex.dataKey === againstIndex.dataKey && currentIndex.blockIndex === againstIndex.blockIndex && againstIndex.textRange![0] <= currentIndex.textRange![0]) { |
There was a problem hiding this comment.
Replace with
switch (true) {
case againstOp.index.isBlockIndex:
return this.#transformAgainsBlockOperation(...);
case againstOp.index.isTextIndex:
return this.#transformAgainsTextOperation(...);
default:
return Operation.from(operation);
};
And move other conditions into the respective methods
| if (operation instanceof BatchedOperation) { | ||
| operation.operations.forEach((op) => { | ||
| this.applyOperation(op); | ||
| }); | ||
| } else { | ||
| this.applyOperation(operation); | ||
| } |
There was a problem hiding this comment.
Better to move this condition into the applyOperation method since BatchedOperation is also an operation
There was a problem hiding this comment.
Ah, it's already there. So you can remove it here
| * @todo - add debounce timeout 500ms | ||
| */ | ||
| if (!this.#currentBatch.canAdd(operation)) { | ||
| this.#undoRedoManager.put(this.#currentBatch); |
There was a problem hiding this comment.
You can call emptyBatch here to be consistent
| /** | ||
| * If current operation could not be added to the batch, then terminate current batch and create a new one with current operation | ||
| * | ||
| * @todo - add debounce timeout 500ms |
There was a problem hiding this comment.
Would be great if you can implement it in the current PR as this functionality is already in the main branch
|
|
||
|
|
||
| describe('Operation', () => { | ||
| describe('.transform()', () => { |
There was a problem hiding this comment.
Don't delete it, those tests still should pass even tho the functionality in another file now
| /** | ||
| * Puts current batch to the undo stack and clears the batch | ||
| */ | ||
| #emptyBatch(): void { |
There was a problem hiding this comment.
I don't really like the name as it implies the current batch is being emptied. Also it doesn't show that the current batch is being added to the undo redo manager
| * Cover case 2 | ||
| */ | ||
| case OperationType.Delete: | ||
| if (operation.index.blockIndex !== undefined) { |
There was a problem hiding this comment.
You've already checked that on line 90
| if (sameInput && sameBlock && againstIndex.textRange![0] > index.textRange![1]) { | ||
| return Operation.from(operation); | ||
| } |
There was a problem hiding this comment.
You can extend condition here to check if it's not the same input or not the same block
| case (RangeIntersectionType.Right): | ||
| const overlapLength = index.textRange![1] - againstIndex.textRange![0]; | ||
|
|
||
| newIndexBuilder.addTextRange([index.textRange![0], index.textRange![1] - overlapLength]); |
There was a problem hiding this comment.
You can put againstOp.textRange[0] as end index here
| * @param operation - operation to transform against | ||
| * @param stack - stack to transform | ||
| */ | ||
| public transformStack(operation: Operation, stack: Operation[]): void { |
There was a problem hiding this comment.
Make it pure function please.
Also could be private
| * @param stack - stack to transform | ||
| */ | ||
| public transformStack(operation: Operation, stack: Operation[]): void { | ||
| const transformed = stack.flatMap((op) => { |
There was a problem hiding this comment.
Why flatMap? Transform doesn't return null anymore
gohabereg
left a comment
There was a problem hiding this comment.
Couple of minor comments, looks good to me otherwise
| */ | ||
| const batch = new BatchedOperation(Operation.from(opBatchOrJSON.operations[0])); | ||
|
|
||
| opBatchOrJSON.operations.slice(1).forEach((op) => { |
There was a problem hiding this comment.
Perhaps the linter is not set up, but would be great to have chained calls on the new lines. Here and in the other places
| opBatchOrJSON.operations.slice(1).forEach((op) => { | |
| opBatchOrJSON.operations.slice(1) | |
| .forEach((op) => { |
gohabereg
left a comment
There was a problem hiding this comment.
Missed the most important file
| * Cover case 2 | ||
| */ | ||
| case OperationType.Delete: | ||
| if (againstOp.index.blockIndex! >= operation.index.blockIndex!) { |
There was a problem hiding this comment.
Could be just ===, you've checked > on line 104
| /** | ||
| * Cover case 2.1 | ||
| */ | ||
| case (RangeIntersectionType.Left): |
There was a problem hiding this comment.
intersectionType contains the type of current op compared to againstOp. So with RageIntersectionType.Left the current op is on the left — the against op is on the right. It should be RangeIntersectionType.Right I guess
There was a problem hiding this comment.
not quite, RangeIntersectionType.Left means that left side of the operation intersects with right side of againstOp — that means that againstOp is on the left
| /** | ||
| * Cover case 2.2 | ||
| */ | ||
| case (RangeIntersectionType.Right): |
There was a problem hiding this comment.
And here should be RangeIntersectionType.Left
There was a problem hiding this comment.
You should add more test cases to cover all of that
b3d2fef to
2254965
Compare
| expect(getRangesIntersectionType([3, 8], [5, 12])).toBe(RangeIntersectionType.Right); | ||
| }); | ||
|
|
||
| it('returns Left when the left end enters the compared rannge but does not reach its start', () => { |
There was a problem hiding this comment.
Would be good to add edge cases like getRangesIntersectionTypes([3, 8], [5, 8]) and similar. Is it Includes or Left?
| */ | ||
| const batch = new BatchedOperation(Operation.from(opBatchOrJSON.operations[0])); | ||
|
|
||
| opBatchOrJSON.operations.slice(1).forEach((op) => { |
|
|
||
| const userId = 'user'; | ||
|
|
||
| describe('Batch', () => { |
There was a problem hiding this comment.
| describe('Batch', () => { | |
| describe('BatchedOperation', () => { |
50b9c81 to
8510ad0
Compare
0625ed5 to
89766b6
Compare
17be736 to
82f9dd0
Compare
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
|---|---|---|---|
| 🟢 | Statements | 100% | 98/98 |
| 🟢 | Branches | 100% | 36/36 |
| 🟢 | Functions | 100% | 24/24 |
| 🟢 | Lines | 100% | 91/91 |
Test suite run success
66 tests passing in 5 suites.
Report generated by 🧪jest coverage report action from 6c185cb
|
⏭️ No files to mutate for |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 14 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }); | ||
|
|
||
| it('should not change operation if operation is not Block or Text operation', () => { | ||
| it('should throw Unsupppoted index type error if op is not Block or Text operation', () => { |
There was a problem hiding this comment.
Typo in test name: "Unsupppoted" → "Unsupported".
| it('should throw Unsupppoted index type error if op is not Block or Text operation', () => { | |
| it('should throw Unsupported index type error if op is not Block or Text operation', () => { |
| it('returns Right when the right end of range enters the compared range but does not reach its end', () => { | ||
| expect(getRangesIntersectionType([3, 8], [5, 12])).toBe(RangeIntersectionType.Right); |
There was a problem hiding this comment.
There are two identical test cases under RangeIntersectionType.Right (same description and same expectation). This is redundant and doesn't add coverage; consider removing one or changing it to cover a different Right-intersection scenario.
| it('returns Right when the right end of range enters the compared range but does not reach its end', () => { | |
| expect(getRangesIntersectionType([3, 8], [5, 12])).toBe(RangeIntersectionType.Right); | |
| it('returns Right when range starts further left and overlaps only the left part of the compared range', () => { | |
| expect(getRangesIntersectionType([0, 6], [4, 10])).toBe(RangeIntersectionType.Right); |
| const newBatchedOperation = new BatchedOperation<InvertedOperationType<T>>(lastOp.inverse()); | ||
|
|
||
| this.operations.toReversed().slice(1) | ||
| .map(op => newBatchedOperation.add(op.inverse())); | ||
|
|
There was a problem hiding this comment.
inverse() uses .map(...) for side effects (newBatchedOperation.add(...)). This is misleading because the returned array is unused. Prefer forEach (or a simple loop) to make intent clear and avoid accidental misuse later.
| private transformStack(operation: Operation, stack: Operation[]): Operation[] { | ||
| return stack.map((op: Operation) => op.transform(operation)); | ||
| } |
There was a problem hiding this comment.
transformStack() keeps transformed operations even if they become OperationType.Neutral. That can leave no-op entries in the undo/redo stacks (user presses undo and nothing happens). If Neutral is meant to be skipped, consider filtering those out here (or when performing undo/redo) to keep stacks meaningful.
| import { type ModifyOperationData, Operation, OperationType } from './Operation.js'; | ||
| import { UndoRedoManager } from './UndoRedoManager.js'; | ||
|
|
||
| const DEBOUCE_TIMEOUT = 500; |
There was a problem hiding this comment.
The constant name DEBOUCE_TIMEOUT is misspelled, which makes it harder to search/maintain. Consider renaming it to DEBOUNCE_TIMEOUT (and updating its usages).
| const DEBOUCE_TIMEOUT = 500; | |
| const DEBOUNCE_TIMEOUT = 500; |
| holder: document.getElementById('editorjs') as HTMLElement, | ||
| userId: userId, | ||
| documentId: 'test', | ||
| // collaborationServer: 'ws://localhost:8080', | ||
| // collaborationServer: 'wss://lirili-larila.codex.so/', | ||
| collaborationServer: 'ws://localhost:8080', |
There was a problem hiding this comment.
collaborationServer is hardcoded to ws://localhost:8080 while the production-like wss://... is commented out. This makes it easy to accidentally ship a dev endpoint or break the playground depending on environment. Consider wiring this via an environment variable (e.g. import.meta.env) or keeping only one clearly intended default.
| } | ||
|
|
||
| // Disable event handling | ||
| // Disable handling |
There was a problem hiding this comment.
Comment typo: "Disable handling" looks like it lost a word (likely "event handling"). Please fix for clarity.
| // Disable handling | |
| // Disable event handling |
| /** | ||
| * If operation is local, send it to the server | ||
| */ | ||
| if (operation.userId === this.#config.userId) { | ||
| void this.#client?.send(operation); | ||
| } else { | ||
| this.#putBatchToUndo(); | ||
|
|
||
| /** | ||
| * If operation is remote, transform undo/redo stacks | ||
| */ | ||
| this.#undoRedoManager.transformStacks(operation); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| const onBatchTermination = (batch: OperationsBatch, lastOp?: Operation): void => { | ||
| const effectiveOp = batch.getEffectiveOperation(); | ||
| if (!this.#shouldHandleEvents) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
#shouldHandleEvents is checked only after sending local operations to the OT server. During undo()/redo() you set #shouldHandleEvents = false and then call applyOperation(), which triggers model events; with the current flow those events will still be sent to the server, potentially causing remote echo/loops and incorrect collaboration history. Consider moving the if (!this.#shouldHandleEvents) return; guard to the beginning of #handleEvent() (or at least before #client.send).
| console.log('transforming against text insert operation'); | ||
|
|
There was a problem hiding this comment.
There is a console.log(...) left in #transformAgainstTextInsert. This will spam logs in production and may leak document content. Please remove it or gate it behind a debug flag/logger abstraction.
| console.log('transforming against text insert operation'); |
| case (RangeIntersectionType.Includes): | ||
| newIndexBuilder.addTextRange([index.textRange![0], index.textRange![1] - deletedAmount]); | ||
| newPayload = typeof newPayload === 'string' | ||
| ? newPayload.slice(0, againstTextRange[0]) + newPayload.slice(againstTextRange[1]) |
There was a problem hiding this comment.
In the RangeIntersectionType.Includes case, payload slicing uses absolute document offsets (slice(0, againstTextRange[0]), slice(againstTextRange[1])) instead of offsets relative to the operation's own textRange. This will remove the wrong characters whenever textRange[0] !== 0. The slice indices should be computed relative to textRange[0] (similar to the Left/Right cases).
| ? newPayload.slice(0, againstTextRange[0]) + newPayload.slice(againstTextRange[1]) | |
| ? newPayload.slice(0, againstTextRange[0] - textRange[0]) + newPayload.slice(againstTextRange[1] - textRange[0]) |
| Left = 'left', | ||
| Right = 'right', | ||
| Includes = 'includes', | ||
| Included = 'included', |
There was a problem hiding this comment.
| Included = 'included', | |
| IncludedBy = 'included', |
Changes
OperationsBatchnow extendsOperationOperationType.Neutral- operation, that does not affect model on apply, so could be skipped, this type of operations could appear after transformation