-
Notifications
You must be signed in to change notification settings - Fork 2
imp(): implement all operations transformations #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
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 61d4dab
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
---|---|---|---|
🟢 | Statements | 99.65% | 852/855 |
🟢 | Branches | 98.7% | 228/231 |
🟢 | Functions | 98.1% | 207/211 |
🟢 | Lines | 99.64% | 819/822 |
Test suite run success
441 tests passing in 25 suites.
Report generated by 🧪jest coverage report action from a74d393
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 a74d393
Coverage report for
|
St.❔ |
Category | Percentage | Covered / Total |
---|---|---|---|
🟡 | Statements | 71.25% (+3.74% 🔼) |
228/320 |
🟡 | Branches | 77.36% (-2.64% 🔻) |
82/106 |
🟡 | Functions | 68.18% (+11.04% 🔼) |
30/44 |
🟡 | Lines | 71.2% (+3.68% 🔼) |
225/316 |
Show new covered files 🐣
St.❔ |
File | Statements | Branches | Functions | Lines |
---|---|---|---|---|---|
🟢 | ... / getRangesIntersectionType.ts |
94.12% | 86.67% | 100% | 94.12% |
🟢 | OperationsTransformer.ts | 91.3% | 86.21% | 100% | 91.3% |
🟢 | BatchedOperation.ts | 88.89% | 76.47% | 100% | 88.57% |
Show files with reduced coverage 🔻
St.❔ |
File | Statements | Branches | Functions | Lines |
---|---|---|---|---|---|
🟡 | CollaborationManager.ts | 80% (-3.82% 🔻) |
73.08% (-0.84% 🔻) |
55.56% (-6.94% 🔻) |
79.76% (-4.06% 🔻) |
Test suite run success
79 tests passing in 5 suites.
Report generated by 🧪jest coverage report action from a74d393
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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; | |
} | |
/** |
Copilot uses AI. Check for mistakes.
export class OperationsTransformer { | ||
constructor() {} | ||
|
||
public transform<T extends OperationType>(operation: Operation<T>, againstOp: Operation<OperationType>): Operation<T> | Operation<OperationType.Neutral> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
describe logic in jsdoc
* | ||
* Operations are batched on timeout basis or if batch is terminated from the outside | ||
*/ | ||
export class OperationsBatch<T extends OperationType> extends Operation<T> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename pls
* | ||
* @param opBatch - operation batch to clone | ||
*/ | ||
public static from<T extends OperationType>(opBatch: OperationsBatch<T>): OperationsBatch<T>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This way the first operation would be copied by reference not by value
/** | ||
* Array of operations to batch | ||
*/ | ||
operations: (Operation<T> | Operation<OperationType.Neutral>)[] = []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Modifier is missing
#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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
&& (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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Better to move this condition into the applyOperation
method since BatchedOperation is also an operation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be great if you can implement it in the current PR as this functionality is already in the main branch
@@ -40,231 +40,6 @@ const createOperation = ( | |||
|
|||
|
|||
describe('Operation', () => { | |||
describe('.transform()', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why flatMap? Transform doesn't return null anymore
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) => { |
return true; | ||
} | ||
|
||
if (!op.index.isTextIndex || !lastOp.index.isTextIndex) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe leave a todo that we might add other index types in the future
@@ -112,7 +119,7 @@ export class CollaborationManager { | |||
* Redo last undone operation in the local stack | |||
*/ | |||
public redo(): void { | |||
this.#currentBatch?.terminate(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not clear why in both cases (undo and redo) there's a call moveBatchToUndo
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe putBatchToUndo
would be better
#debounce(): void { | ||
clearTimeout(this.#debounceTimer); | ||
|
||
this.#debounceTimer = setTimeout(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please cover untested lines and statements
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could be just ===, you've checked > on line 104
/** | ||
* Cover case 2.1 | ||
*/ | ||
case (RangeIntersectionType.Left): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
/** | ||
* Cover case 2.2 | ||
*/ | ||
case (RangeIntersectionType.Right): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And here should be RangeIntersectionType.Left
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should add more test cases to cover all of that
Changes
OperationsBatch
now extendsOperation
OperationType.Neutral
- operation, that does not affect model on apply, so could be skipped, this type of operations could appear after transformation