Explorar o código

Merge pull request #1524 from ckeditor/t/1474

Other: Added new OT tests, reached 100% code coverage, fixed multiple OT scenarios, removed unreachable code. Closes #1474.
Piotr Jasiun %!s(int64=7) %!d(string=hai) anos
pai
achega
0c833c1baf
Modificáronse 27 ficheiros con 2462 adicións e 339 borrados
  1. 2 2
      packages/ckeditor5-engine/src/model/operation/mergeoperation.js
  2. 14 0
      packages/ckeditor5-engine/src/model/operation/splitoperation.js
  3. 110 279
      packages/ckeditor5-engine/src/model/operation/transform.js
  4. 2 2
      packages/ckeditor5-engine/src/model/operation/unwrapoperation.js
  5. 18 5
      packages/ckeditor5-engine/src/model/operation/wrapoperation.js
  6. 30 23
      packages/ckeditor5-engine/src/model/range.js
  7. 228 1
      packages/ckeditor5-engine/tests/model/differ.js
  8. 20 0
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  9. 229 0
      packages/ckeditor5-engine/tests/model/operation/mergeoperation.js
  10. 255 0
      packages/ckeditor5-engine/tests/model/operation/splitoperation.js
  11. 20 1
      packages/ckeditor5-engine/tests/model/operation/transform/attribute.js
  12. 3 1
      packages/ckeditor5-engine/tests/model/operation/transform/delete.js
  13. 3 1
      packages/ckeditor5-engine/tests/model/operation/transform/insert.js
  14. 3 1
      packages/ckeditor5-engine/tests/model/operation/transform/marker.js
  15. 164 1
      packages/ckeditor5-engine/tests/model/operation/transform/merge.js
  16. 19 1
      packages/ckeditor5-engine/tests/model/operation/transform/move.js
  17. 3 1
      packages/ckeditor5-engine/tests/model/operation/transform/remove.js
  18. 3 1
      packages/ckeditor5-engine/tests/model/operation/transform/rename.js
  19. 81 3
      packages/ckeditor5-engine/tests/model/operation/transform/split.js
  20. 222 0
      packages/ckeditor5-engine/tests/model/operation/transform/undo.js
  21. 183 4
      packages/ckeditor5-engine/tests/model/operation/transform/unwrap.js
  22. 4 0
      packages/ckeditor5-engine/tests/model/operation/transform/utils.js
  23. 3 1
      packages/ckeditor5-engine/tests/model/operation/transform/wrap.js
  24. 173 0
      packages/ckeditor5-engine/tests/model/operation/unwrapoperation.js
  25. 184 7
      packages/ckeditor5-engine/tests/model/operation/wrapoperation.js
  26. 342 2
      packages/ckeditor5-engine/tests/model/position.js
  27. 144 2
      packages/ckeditor5-engine/tests/model/range.js

+ 2 - 2
packages/ckeditor5-engine/src/model/operation/mergeoperation.js

@@ -130,14 +130,14 @@ export default class MergeOperation extends Operation {
 		const targetElement = this.targetPosition.parent;
 
 		// Validate whether merge operation has correct parameters.
-		if ( !sourceElement || !sourceElement.is( 'element' ) ) {
+		if ( !sourceElement || !sourceElement.is( 'element' ) || !sourceElement.parent ) {
 			/**
 			 * Merge source position is invalid.
 			 *
 			 * @error merge-operation-source-position-invalid
 			 */
 			throw new CKEditorError( 'merge-operation-source-position-invalid: Merge source position is invalid.' );
-		} else if ( !targetElement || !targetElement.is( 'element' ) ) {
+		} else if ( !targetElement || !targetElement.is( 'element' ) || !targetElement.parent ) {
 			/**
 			 * Merge target position is invalid.
 			 *

+ 14 - 0
packages/ckeditor5-engine/src/model/operation/splitoperation.js

@@ -155,6 +155,13 @@ export default class SplitOperation extends Operation {
 			 * @error split-operation-position-invalid
 			 */
 			throw new CKEditorError( 'split-operation-position-invalid: Split position is invalid.' );
+		} else if ( !element.parent ) {
+			/**
+			 * Cannot split root element.
+			 *
+			 * @error split-operation-split-in-root
+			 */
+			throw new CKEditorError( 'split-operation-split-in-root: Cannot split root element.' );
 		} else if ( this.howMany != element.maxOffset - this.position.offset ) {
 			/**
 			 * Split operation specifies wrong number of nodes to move.
@@ -162,6 +169,13 @@ export default class SplitOperation extends Operation {
 			 * @error split-operation-how-many-invalid
 			 */
 			throw new CKEditorError( 'split-operation-how-many-invalid: Split operation specifies wrong number of nodes to move.' );
+		} else if ( this.graveyardPosition && !this.graveyardPosition.nodeAfter ) {
+			/**
+			 * Graveyard position invalid.
+			 *
+			 * @error split-operation-graveyard-position-invalid
+			 */
+			throw new CKEditorError( 'split-operation-graveyard-position-invalid: Graveyard position invalid.' );
 		}
 	}
 

+ 110 - 279
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -99,7 +99,7 @@ export function transform( a, b, context = {} ) {
 
 		return transformationFunction( a, b, context );
 	} catch ( e ) {
-		log.error( 'Error during operation transformation!' );
+		log.error( 'Error during operation transformation!', e.message );
 		log.error( 'Transformed operation', a );
 		log.error( 'Operation transformed by', b );
 		log.error( 'context.aIsStrong', context.aIsStrong );
@@ -533,7 +533,6 @@ function initializeContext( operationsA, operationsB, options ) {
  * @param {Object} context
  */
 function updateRelations( opA, opB, context ) {
-	//
 	// The use of relations is described in a bigger detail in transformation functions.
 	//
 	// In brief, this function, for specified pairs of operation types, checks how positions defined in those operations relate.
@@ -547,7 +546,6 @@ function updateRelations( opA, opB, context ) {
 				case MergeOperation: {
 					if ( opA.targetPosition.isEqual( opB.sourcePosition ) || opB.movedRange.containsPosition( opA.targetPosition ) ) {
 						setRelation( context, opA, opB, 'insertAtSource' );
-						setRelation( context, opB, opA, 'splitBefore' );
 					} else if ( opA.targetPosition.isEqual( opB.deletionPosition ) ) {
 						setRelation( context, opA, opB, 'insertBetween' );
 					}
@@ -558,17 +556,15 @@ function updateRelations( opA, opB, context ) {
 				case MoveOperation: {
 					if ( opA.targetPosition.isEqual( opB.sourcePosition ) || opA.targetPosition.isBefore( opB.sourcePosition ) ) {
 						setRelation( context, opA, opB, 'insertBefore' );
-						setRelation( context, opB, opA, 'insertAfter' );
 					} else {
 						setRelation( context, opA, opB, 'insertAfter' );
-						setRelation( context, opB, opA, 'insertBefore' );
 					}
 
 					break;
 				}
 
 				case UnwrapOperation: {
-					const isInside = opA.targetPosition.hasSameParentAs( opB.targetPosition );
+					const isInside = opA.targetPosition.hasSameParentAs( opB.position );
 
 					if ( isInside ) {
 						setRelation( context, opA, opB, 'insertInside' );
@@ -594,51 +590,6 @@ function updateRelations( opA, opB, context ) {
 				case MoveOperation: {
 					if ( opA.position.isEqual( opB.sourcePosition ) || opA.position.isBefore( opB.sourcePosition ) ) {
 						setRelation( context, opA, opB, 'splitBefore' );
-						setRelation( context, opB, opA, 'insertAtSource' );
-					}
-
-					break;
-				}
-
-				case UnwrapOperation: {
-					const isInside = opA.position.hasSameParentAs( opB.position );
-
-					if ( isInside ) {
-						setRelation( context, opA, opB, 'splitInside' );
-					}
-
-					break;
-				}
-			}
-
-			break;
-		}
-
-		case InsertOperation: {
-			switch ( opB.constructor ) {
-				case MergeOperation: {
-					if ( opA.position.isEqual( opB.sourcePosition ) || opB.movedRange.containsPosition( opA.position ) ) {
-						setRelation( context, opA, opB, 'insertAtSource' );
-					} else if ( opA.position.isEqual( opB.deletionPosition ) ) {
-						setRelation( context, opA, opB, 'insertBetween' );
-					}
-
-					break;
-				}
-
-				case MoveOperation: {
-					if ( opA.position.isEqual( opB.sourcePosition ) || opA.position.isBefore( opB.sourcePosition ) ) {
-						setRelation( context, opA, opB, 'insertBefore' );
-					}
-
-					break;
-				}
-
-				case UnwrapOperation: {
-					const isInside = opA.position.hasSameParentAs( opB.position );
-
-					if ( isInside ) {
-						setRelation( context, opA, opB, 'insertInside' );
 					}
 
 					break;
@@ -800,11 +751,13 @@ setTransformation( AttributeOperation, InsertOperation, ( a, b ) => {
 			// <p>Fo[z{<$text highlight="red">}x</$text>b]ar</p>    <--- Change range `{}` from `red` to `null`.
 			// <p>Fo[zxb]ar</p>                                     <--- Now change from `null` to `yellow` is completely fine.
 			//
-			// Of course, there might be multiple extra operations added -- one for each unfitting node.
-			//
 
-			// Generate those operations. Be sure to add them before the original operation.
-			result.unshift( ..._getComplementaryAttributeOperations( b, a.key, a.oldValue ) );
+			// Generate complementary attribute operation. Be sure to add it before the original operation.
+			const op = _getComplementaryAttributeOperations( b, a.key, a.oldValue );
+
+			if ( op ) {
+				result.unshift( op );
+			}
 		}
 
 		// If nodes should not receive new attribute, we are done here.
@@ -820,65 +773,28 @@ setTransformation( AttributeOperation, InsertOperation, ( a, b ) => {
 /**
  * Helper function for `AttributeOperation` x `InsertOperation` (and reverse) transformation.
  *
- * For given `insertOperation` it checks the inserted nodes if they have an attribute `key` set to a value different
- * than `newValue`. If so, for such each node, it generates an `AttributeOperation` which changes the value of
- * `key` attribute to `newValue`.
+ * For given `insertOperation` it checks the inserted node if it has an attribute `key` set to a value different
+ * than `newValue`. If so, it generates an `AttributeOperation` which changes the value of `key` attribute to `newValue`.
  *
  * @private
  * @param {module:engine/model/operation/insertoperation~InsertOperation} insertOperation
  * @param {String} key
  * @param {*} newValue
- * @returns {Array.<module:engine/model/operation/attributeoperation~AttributeOperation>}
+ * @returns {module:engine/model/operation/attributeoperation~AttributeOperation|null}
  */
 function _getComplementaryAttributeOperations( insertOperation, key, newValue ) {
 	const nodes = insertOperation.nodes;
-	const result = [];
 
 	// At the beginning we store the attribute value from the first node.
-	let val = nodes.getNode( 0 ).getAttribute( key );
-
-	// This stores the offset in the node list where the attribute value has changed.
-	let startOffset = 0;
-
-	// Sum of offsets of already processed nodes.
-	let offsetSum = nodes.getNode( 0 ).offsetSize;
+	const insertValue = nodes.getNode( 0 ).getAttribute( key );
 
-	for ( let i = 1; i < nodes.length; i++ ) {
-		const node = nodes.getNode( i );
-
-		// If previous node has different attribute value, we will create an attribute operation with the range from `startOffset`
-		// to the position before the current node.
-		if ( node.getAttribute( key ) != val ) {
-			// New operation is created only when it is needed. If given node already has a proper value for this
-			// attribute we simply skip it without adding a new operation.
-			if ( val != newValue ) {
-				addOperation();
-			}
-
-			// Update "current" value and move the `startOffset` so that the next operation will start from this node.
-			val = node.getAttribute( key );
-			startOffset = offsetSum;
-		}
-
-		offsetSum = offsetSum + node.offsetSize;
+	if ( insertValue == newValue ) {
+		return null;
 	}
 
-	// At the end we have to add additional `AttributeOperation` for the last part of node list.
-	// If all nodes on the node list had same attributes, this will be the only returned operation.
-	addOperation();
+	const range = new Range( insertOperation.position, insertOperation.position.getShiftedBy( insertOperation.howMany ) );
 
-	return result;
-
-	function addOperation() {
-		const range = new Range(
-			insertOperation.position.getShiftedBy( startOffset ),
-			insertOperation.position.getShiftedBy( offsetSum )
-		);
-
-		result.push(
-			new AttributeOperation( range, key, val, newValue, 0 )
-		);
-	}
+	return new AttributeOperation( range, key, insertValue, newValue, 0 );
 }
 
 setTransformation( AttributeOperation, MergeOperation, ( a, b ) => {
@@ -1126,7 +1042,11 @@ setTransformation( InsertOperation, AttributeOperation, ( a, b ) => {
 	// inserted nodes and that's it.
 	//
 	if ( a.shouldReceiveAttributes && a.position.hasSameParentAs( b.range.start ) && b.range.containsPosition( a.position ) ) {
-		result.push( ..._getComplementaryAttributeOperations( a, b.key, b.newValue ) );
+		const op = _getComplementaryAttributeOperations( a, b.key, b.newValue );
+
+		if ( op ) {
+			result.push( op );
+		}
 	}
 
 	// The default case is: do nothing.
@@ -1156,29 +1076,7 @@ setTransformation( InsertOperation, InsertOperation, ( a, b, context ) => {
 	return [ a ];
 } );
 
-setTransformation( InsertOperation, MoveOperation, ( a, b, context ) => {
-	// Case 1:
-	//
-	// Operations insert nodes at the same position, similarly as above. This time these are different operations,
-	// so we can just decide that either insert or move will always be before and the other will be always after.
-	// And we decide that that `InsertOperation` will be stronger -- so if operations have equal insertion positions,
-	// insert operation position will not be transformed.
-	//
-	// However, in case of move operation, we might have additional contextual information to help in
-	// resolving this conflict. Move operation might be a result of undo. Because of that, we need to check if
-	// there is a relation between the operations.
-	//
-	// This relation checks if `b` is undoing operation, if so, it looks at the undone operation. That undone
-	// operation from the past is also a move operation, but the source and target position are flipped. This
-	// enables us to check if the insert operation targeted before the moved nodes or after. This gives us
-	// context on what to do now.
-	//
-	// Of course the relations are evaluated and saved earlier, now they are only read and used.
-	//
-	if ( a.position.isEqual( b.targetPosition ) && context.abRelation == 'insertBefore' ) {
-		return [ a ];
-	}
-
+setTransformation( InsertOperation, MoveOperation, ( a, b ) => {
 	// The default case.
 	//
 	a.position = a.position._getTransformedByMoveOperation( b );
@@ -1186,18 +1084,7 @@ setTransformation( InsertOperation, MoveOperation, ( a, b, context ) => {
 	return [ a ];
 } );
 
-setTransformation( InsertOperation, SplitOperation, ( a, b, context ) => {
-	// Case 1:
-	//
-	// Insert puts nodes at the split position. We need to decide if the nodes should be inserted at the end of
-	// the split element or at the beginning of the new element.
-	//
-	if ( a.position.isEqual( b.position ) && context.abRelation == 'insertAtSource' ) {
-		a.position = b.moveTargetPosition;
-
-		return [ a ];
-	}
-
+setTransformation( InsertOperation, SplitOperation, ( a, b ) => {
 	// The default case.
 	//
 	a.position = a.position._getTransformedBySplitOperation( b );
@@ -1211,45 +1098,7 @@ setTransformation( InsertOperation, MergeOperation, ( a, b ) => {
 	return [ a ];
 } );
 
-setTransformation( InsertOperation, WrapOperation, ( a, b, context ) => {
-	// Case 1:
-	//
-	// Insert puts nodes at the beginning of wrapped range. Normally, we don't want the inserted nodes to be
-	// accidentally wrapped. However, if the wrap operation comes from undo, and it undoes an unwrap operation
-	// and the insert actually was putting nodes inside the unwrapped element, then we need the insert operation
-	// to put nodes back to the once-unwrapped element.
-	//
-	// Example. There is a block quote with a paragraph and new paragraph is inserted at `^`. Before that,
-	// block quote is unwrapped, and then this is undone:
-	//
-	// <blockQuote>^<p>Foo</p></blockQuote>
-	// ^<p>Foo</p>
-	//
-	// This transformation and this conflict is shown before. `<p>Foo</p>` needs to be wrapped, but should the
-	// inserted nodes be "included" in the wrap or not?
-	//
-	// [^<p>Foo</p>] or ^[<p>Foo</p>]?
-	//
-	// According to the state at the beginning, the intention was to insert nodes inside the `<blockQuote>`,
-	// so we should take that into consideration and put the inserted nodes into the wrapped node.
-	//
-	// In contrary, this is an alternative scenario:
-	//
-	// ^<blockQuote><p>Foo</p></blockQuote>
-	// ^<p>Foo</p>
-	//
-	// As can be seen, different scenario led to the same situation and the same question:
-	//
-	// [^<p>Foo</p>] or ^[<p>Foo</p>]?
-	//
-	// However this time we should not include the inserted nodes into the wrapped element.
-	//
-	if ( a.position.isEqual( b.position ) && context.abRelation == 'insertInside' ) {
-		a.position = b.targetPosition;
-
-		return [ a ];
-	}
-
+setTransformation( InsertOperation, WrapOperation, ( a, b ) => {
 	// The default case.
 	//
 	a.position = a.position._getTransformedByWrapOperation( b );
@@ -1543,21 +1392,6 @@ setTransformation( MergeOperation, WrapOperation, ( a, b ) => {
 		a.howMany = a.howMany + 1 - b.howMany;
 	}
 
-	// Case 1:
-	//
-	// Wrap is with an element from graveyard, which also is merge operation target. This happens
-	// in some undo scenarios. In this case, the target position needs to be properly transformed.
-	//
-	const path = a.targetPosition.path.slice( 0, -1 );
-	const posBeforeTargetElement = new Position( a.targetPosition.root, path );
-
-	if ( b.graveyardPosition && b.graveyardPosition.isEqual( posBeforeTargetElement ) ) {
-		a.sourcePosition = a.sourcePosition._getTransformedByWrapOperation( b );
-		a.targetPosition = b.targetPosition.getShiftedBy( b.howMany );
-
-		return [ a ];
-	}
-
 	// Case 2:
 	//
 	// Merged element is wrapped and this is the last (only) element in the wrap.
@@ -1610,6 +1444,28 @@ setTransformation( MergeOperation, UnwrapOperation, ( a, b, context ) => {
 		return [ a ];
 	}
 
+	// Case 2:	The element to merge into got unwrapped.
+	//
+	//			In this case there is nothing to merge into. We can imagine, that the merge operation was a little earlier
+	//			than unwrap operation, so first elements got merged, and then both were unwrapped. To simulate this,
+	//			we will change this merge into unwrap.
+	//
+	//			We have this:
+	//			<blockQuote><p>Foo</p></blockQuote><blockQuote><p>Bar</p></blockQuote>
+	//
+	//			Unwrap already happened:
+	//			<p>Foo</p><blockQuote><p>Bar</p></blockQuote>
+	//
+	//			Let's act like merge happened first and lead to this state:
+	//			<p>Foo</p><p>Bar</p>
+	//
+	if ( a.targetPosition.hasSameParentAs( b.position ) ) {
+		const graveyard = a.sourcePosition.root.document.graveyard;
+		const graveyardPosition = new Position( graveyard, [ 0 ] );
+
+		return [ new UnwrapOperation( a.sourcePosition, a.howMany, graveyardPosition, 0 ) ];
+	}
+
 	// The default case.
 	//
 	if ( a.sourcePosition.hasSameParentAs( b.targetPosition ) ) {
@@ -1630,7 +1486,7 @@ setTransformation( MergeOperation, UnwrapOperation, ( a, b, context ) => {
 
 // -----------------------
 
-setTransformation( MoveOperation, InsertOperation, ( a, b, context ) => {
+setTransformation( MoveOperation, InsertOperation, ( a, b ) => {
 	const moveRange = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
 	const transformed = moveRange._getTransformedByInsertOperation( b, false )[ 0 ];
 
@@ -1643,7 +1499,7 @@ setTransformation( MoveOperation, InsertOperation, ( a, b, context ) => {
 	// `MoveOperation` is considered weaker, so it is always transformed, unless there was a certain relation
 	// between operations.
 	//
-	if ( !a.targetPosition.isEqual( b.position ) || context.abRelation == 'insertBefore' ) {
+	if ( !a.targetPosition.isEqual( b.position ) ) {
 		a.targetPosition = a.targetPosition._getTransformedByInsertOperation( b );
 	}
 
@@ -1844,6 +1700,8 @@ setTransformation( MoveOperation, SplitOperation, ( a, b, context ) => {
 	//
 	// In this case the default range transformation will not work correctly as the element created by
 	// split operation would be outside the range. The range to move needs to be fixed manually.
+	// Do it only if this is a "natural" split, not a one that comes from undo.
+	// TODO: this should be done on relations
 	//
 	const moveRange = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
 
@@ -1982,6 +1840,15 @@ setTransformation( MoveOperation, WrapOperation, ( a, b, context ) => {
 
 	// Case 1:
 	//
+	// Move operation targets to the beginning of the wrapped range. We need to decide if the moved nodes
+	// should also be wrapped.
+	//
+	if ( a.targetPosition.isEqual( b.position ) && context.abRelation == 'insertInside' ) {
+		newTargetPosition = b.targetPosition;
+	}
+
+	// Case 2:
+	//
 	// Some of the nodes to move got wrapped. In this case multiple ranges to move might need to be generated.
 	//
 	// First paragraph and the image should are wrapped, while the two images are moved after the last paragraph:
@@ -2022,15 +1889,6 @@ setTransformation( MoveOperation, WrapOperation, ( a, b, context ) => {
 		return result;
 	}
 
-	// Case 2:
-	//
-	// Move operation targets to the beginning of the wrapped range. We need to decide if the moved nodes
-	// should also be wrapped.
-	//
-	if ( a.targetPosition.isEqual( b.position ) && context.abRelation == 'insertInside' ) {
-		newTargetPosition = b.targetPosition;
-	}
-
 	// The default case.
 	//
 	const transformed = moveRange._getTransformedByWrapOperation( b );
@@ -2164,25 +2022,7 @@ setTransformation( RootAttributeOperation, RootAttributeOperation, ( a, b, conte
 
 // -----------------------
 
-setTransformation( SplitOperation, InsertOperation, ( a, b, context ) => {
-	// Case 1:
-	//
-	// Split is at the position where nodes were inserted.
-	//
-	// This is a mirror situation to `InsertOperation` x `SplitOperation` handling of this case. We need to
-	// decide if inserted nodes should be put into original node or into the new node. From the "split operation
-	// point of view", it means that it either changes split position, or number of nodes to split.
-	//
-	// If there is relation and during merge operation from the past, the insert was at the source element,
-	// we need to insert those nodes "after" the split position. So split position is not transformed,
-	// only the number of nodes to move to the new element is changed.
-	//
-	if ( a.position.isEqual( b.position ) && context.baRelation == 'insertAtSource' ) {
-		a.howMany += b.howMany;
-
-		return [ a ];
-	}
-
+setTransformation( SplitOperation, InsertOperation, ( a, b ) => {
 	// The default case.
 	//
 	if ( a.position.hasSameParentAs( b.position ) && a.position.offset < b.position.offset ) {
@@ -2195,6 +2035,10 @@ setTransformation( SplitOperation, InsertOperation, ( a, b, context ) => {
 } );
 
 setTransformation( SplitOperation, MergeOperation, ( a, b ) => {
+	if ( a.position.hasSameParentAs( b.deletionPosition ) && !a.position.isAfter( b.deletionPosition ) ) {
+		a.howMany--;
+	}
+
 	if ( a.position.hasSameParentAs( b.targetPosition ) ) {
 		a.howMany += b.howMany;
 	}
@@ -2250,9 +2094,9 @@ setTransformation( SplitOperation, MoveOperation, ( a, b, context ) => {
 	// Split is at a position where nodes were moved.
 	//
 	// This is a scenario described in `MoveOperation` x `SplitOperation` transformation but from the
-	// "split operation point of view". See also `SplitOperation` x `InsertOperation` for similar case.
+	// "split operation point of view".
 	//
-	if ( a.position.isEqual( b.targetPosition ) && context.abRelation == 'splitBefore' ) {
+	if ( a.position.isEqual( b.targetPosition ) && ( context.baRelation == 'insertAtSource' || context.abRelation == 'splitBefore' ) ) {
 		a.howMany += b.howMany;
 		a.position = a.position._getTransformedByDeletion( b.sourcePosition, b.howMany );
 
@@ -2261,7 +2105,7 @@ setTransformation( SplitOperation, MoveOperation, ( a, b, context ) => {
 
 	// The default case.
 	//
-	if ( a.position.hasSameParentAs( b.sourcePosition ) && a.position.offset < b.sourcePosition.offset ) {
+	if ( a.position.hasSameParentAs( b.sourcePosition ) && a.position.offset <= b.sourcePosition.offset ) {
 		a.howMany -= b.howMany;
 	}
 
@@ -2269,7 +2113,10 @@ setTransformation( SplitOperation, MoveOperation, ( a, b, context ) => {
 		a.howMany += b.howMany;
 	}
 
+	// Change position stickiness to force a correct transformation.
+	a.position.stickiness = 'toNone';
 	a.position = a.position._getTransformedByMoveOperation( b );
+	a.position.stickiness = 'toNext';
 
 	return [ a ];
 } );
@@ -2298,6 +2145,10 @@ setTransformation( SplitOperation, SplitOperation, ( a, b, context ) => {
 		}
 	}
 
+	if ( a.graveyardPosition && b.graveyardPosition ) {
+		a.graveyardPosition = a.graveyardPosition._getTransformedByDeletion( b.graveyardPosition, 1 );
+	}
+
 	// Case 2:
 	//
 	// Position where operation `b` inserted a new node after split is the same as the operation `a` split position.
@@ -2331,14 +2182,14 @@ setTransformation( SplitOperation, SplitOperation, ( a, b, context ) => {
 
 	a.position = a.position._getTransformedBySplitOperation( b );
 
-	if ( a.graveyardPosition && b.graveyardPosition ) {
-		a.graveyardPosition = a.graveyardPosition._getTransformedByDeletion( b.graveyardPosition, 1 );
-	}
-
 	return [ a ];
 } );
 
-setTransformation( SplitOperation, WrapOperation, ( a, b, context ) => {
+setTransformation( SplitOperation, WrapOperation, ( a, b ) => {
+	if ( a.graveyardPosition ) {
+		a.graveyardPosition = a.graveyardPosition._getTransformedByWrapOperation( b );
+	}
+
 	// Case 1:
 	//
 	// If split position has been wrapped, reverse the wrapping so that split can be applied as intended.
@@ -2348,28 +2199,18 @@ setTransformation( SplitOperation, WrapOperation, ( a, b, context ) => {
 	if ( a.position.hasSameParentAs( b.position ) && b.wrappedRange.containsPosition( a.position ) ) {
 		const reversed = b.getReversed();
 
-		// Unwrap operation (reversed wrap) always puts a node into a graveyard. Not every wrap operation pulls a node
-		// from the graveyard, though. This means that after reversing a wrap operation, there might be a need to
-		// update a position in graveyard.
-		if ( !b.graveyardPosition && a.graveyardPosition ) {
-			a.graveyardPosition = a.graveyardPosition._getTransformedByInsertion( reversed.graveyardPosition, 1 );
-		}
-
 		return [ reversed, a ];
 	}
 
-	if ( a.graveyardPosition ) {
-		a.graveyardPosition = a.graveyardPosition._getTransformedByWrapOperation( b );
-	}
-
 	// Case 2:
 	//
-	// Split position is at the beginning of wrapped range. We need to decide if split should be inside the
-	// wrapper element, or before the wrapper element.
+	// Split position is in a wrapper element in graveyard. This is a case that happens in undo. Earlier, `SplitOperation`
+	// was transformed by `UnwrapOperation` and it was left in the unwrapped node. Now the unwrapped node will be re-used
+	// and we need to fix `howMany` property in the `SplitOperation`.
 	//
-	if ( a.position.isEqual( b.position ) && context.abRelation == 'splitInside' ) {
-		a.position = b.targetPosition;
-		a.howMany = b.howMany;
+	if ( b.graveyardPosition && a.insertionPosition.isEqual( b.graveyardPosition.getShiftedBy( 1 ) ) ) {
+		a.position = a.position._getCombined( b.graveyardPosition, b.position );
+		a.howMany = a.howMany + b.howMany;
 
 		return [ a ];
 	}
@@ -2404,12 +2245,18 @@ setTransformation( SplitOperation, UnwrapOperation, ( a, b, context ) => {
 	//
 	const splitInside = a.position.hasSameParentAs( b.position );
 
-	if ( splitInside && !context.bWasUndone ) {
+	if ( splitInside ) {
 		const path = b.graveyardPosition.path.slice();
 		path.push( 0 );
 
 		a.position = new Position( b.graveyardPosition.root, path );
-		a.howMany = 0;
+
+		if ( !context.bWasUndone ) {
+			a.howMany = 0;
+		} else {
+			// New `howMany` will be 0 or below. Will be fixed in SplitOperation x WrapOperation.
+			a.howMany = a.howMany - b.howMany;
+		}
 
 		return [ a ];
 	}
@@ -2427,18 +2274,7 @@ setTransformation( SplitOperation, UnwrapOperation, ( a, b, context ) => {
 
 // -----------------------
 
-setTransformation( WrapOperation, InsertOperation, ( a, b, context ) => {
-	// Case 1:
-	//
-	// Insert position is at the beginning of the range to wrap. We need to decide if wrap operation should
-	// also wrap those new nodes. See also `InsertOperation` x `WrapOperation`.
-	//
-	if ( a.position.isEqual( b.position ) && context.baRelation == 'insertInside' ) {
-		a.howMany += b.howMany;
-
-		return [ a ];
-	}
-
+setTransformation( WrapOperation, InsertOperation, ( a, b ) => {
 	// The default case.
 	//
 	const transformed = a.wrappedRange._getTransformedByInsertOperation( b, false )[ 0 ];
@@ -2521,16 +2357,14 @@ setTransformation( WrapOperation, MoveOperation, ( a, b, context ) => {
 	return [ a ];
 } );
 
-setTransformation( WrapOperation, SplitOperation, ( a, b, context ) => {
+setTransformation( WrapOperation, SplitOperation, ( a, b ) => {
 	// Case 1:
 	//
-	// If range to wrap got split cancel the wrapping.
-	// Do that only if this is not undo mode. If `b` operation was earlier transformed by unwrap operation
-	// and the split position was inside the unwrapped range, then proceed without special case.
+	// If range to wrap got split cancel the wrapping. See `SplitOperation` x `WrapOperation`.
 	//
 	const isInside = a.position.hasSameParentAs( b.position ) && a.wrappedRange.containsPosition( b.position );
 
-	if ( isInside && context.baRelation !== 'splitInside' ) {
+	if ( isInside ) {
 		// We cannot just return no-op in this case, because in the mirror case scenario the wrap is reversed, which
 		// might introduce a new node in the graveyard (if the wrap didn't have `graveyardPosition`, then the wrap
 		// created a new element which was put to the graveyard when the wrap was reversed).
@@ -2543,7 +2377,13 @@ setTransformation( WrapOperation, SplitOperation, ( a, b, context ) => {
 
 			return [ new InsertOperation( graveyardPosition, a.element, 0 ) ];
 		} else {
-			return [ new NoOperation( 0 ) ];
+			// If `WrapOperation` comes from undo then the, in `SplitOperation` x `WrapOperation`, after it is reversed
+			// that element will be moved to graveyard, at offset `0`. We should reflect it here to avoid different
+			// graveyard root states.
+			//
+			const gyPos = new Position( a.graveyardPosition.root, [ 0 ] );
+
+			return [ new MoveOperation( a.graveyardPosition, 1, gyPos, 0 ) ];
 		}
 	}
 
@@ -2625,11 +2465,8 @@ setTransformation( WrapOperation, WrapOperation, ( a, b, context ) => {
 				// If ranges are equal and `a` is a stronger operation, reverse `b` operation and then apply `a` operation.
 				const reversed = b.getReversed();
 
-				// Unwrap operation (reversed wrap) always puts a node into a graveyard. Not every wrap operation pulls a node
-				// from the graveyard, though. This means that after reversing a wrap operation, there might be a need to
-				// update a position in graveyard.
-				if ( !b.graveyardPosition && a.graveyardPosition ) {
-					a.graveyardPosition = a.graveyardPosition._getTransformedByInsertion( reversed.graveyardPosition, 1 );
+				if ( a.graveyardPosition ) {
+					a.graveyardPosition = a.graveyardPosition._getTransformedByUnwrapOperation( reversed );
 				}
 
 				return [ reversed, a ];
@@ -2666,11 +2503,8 @@ setTransformation( WrapOperation, WrapOperation, ( a, b, context ) => {
 					//
 					const reversed = b.getReversed();
 
-					// Unwrap operation (reversed wrap) always puts a node into a graveyard. Not every wrap operation pulls a node
-					// from the graveyard, though. This means that after reversing a wrap operation, there might be a need to
-					// update a position in graveyard.
-					if ( !b.graveyardPosition && a.graveyardPosition ) {
-						a.graveyardPosition = a.graveyardPosition._getTransformedByInsertion( reversed.graveyardPosition, 1 );
+					if ( a.graveyardPosition ) {
+						a.graveyardPosition = a.graveyardPosition._getTransformedByUnwrapOperation( reversed );
 					}
 
 					const bOnlyRange = b.wrappedRange.getDifference( a.wrappedRange )[ 0 ];
@@ -2694,11 +2528,8 @@ setTransformation( WrapOperation, WrapOperation, ( a, b, context ) => {
 			else {
 				const reversed = b.getReversed();
 
-				// Unwrap operation (reversed wrap) always puts a node into a graveyard. Not every wrap operation pulls a node
-				// from the graveyard, though. This means that after reversing a wrap operation, there might be a need to
-				// update a position in graveyard.
-				if ( !b.graveyardPosition && a.graveyardPosition ) {
-					a.graveyardPosition = a.graveyardPosition._getTransformedByInsertion( reversed.graveyardPosition, 1 );
+				if ( a.graveyardPosition ) {
+					a.graveyardPosition = a.graveyardPosition._getTransformedByUnwrapOperation( reversed );
 				}
 
 				return [ reversed, a ];

+ 2 - 2
packages/ckeditor5-engine/src/model/operation/unwrapoperation.js

@@ -125,9 +125,9 @@ export default class UnwrapOperation extends Operation {
 			/**
 			 * Operation specifies incorrect number of nodes to unwrap.
 			 *
-			 * @error unwrap-operation-incorrect-how-many
+			 * @error unwrap-operation-how-many-invalid
 			 */
-			throw new CKEditorError( 'unwrap-operation-incorrect-how-many: Operation specifies incorrect number of nodes to unwrap.' );
+			throw new CKEditorError( 'unwrap-operation-how-many-invalid: Operation specifies incorrect number of nodes to unwrap.' );
 		}
 	}
 

+ 18 - 5
packages/ckeditor5-engine/src/model/operation/wrapoperation.js

@@ -135,16 +135,29 @@ export default class WrapOperation extends Operation {
 	 */
 	_validate() {
 		const element = this.position.parent;
-		const offset = this.position.offset;
 
 		// Validate whether wrap operation has correct parameters.
-		if ( !element || offset + this.howMany > element.maxOffset ) {
+		if ( !element || this.position.offset > element.maxOffset ) {
 			/**
-			 * Wrap range is invalid.
+			 * Wrap position is invalid.
 			 *
-			 * @error wrap-operation-range-invalid
+			 * @error wrap-operation-position-invalid
 			 */
-			throw new CKEditorError( 'wrap-operation-range-invalid: Wrap range is invalid.' );
+			throw new CKEditorError( 'wrap-operation-position-invalid: Wrap position is invalid.' );
+		} else if ( this.position.offset + this.howMany > element.maxOffset ) {
+			/**
+			 * Invalid number of nodes to wrap.
+			 *
+			 * @error wrap-operation-how-many-invalid
+			 */
+			throw new CKEditorError( 'wrap-operation-how-many-invalid: Invalid number of nodes to wrap.' );
+		} else if ( this.graveyardPosition && !this.graveyardPosition.nodeAfter ) {
+			/**
+			 * Graveyard position invalid.
+			 *
+			 * @error wrap-operation-graveyard-position-invalid
+			 */
+			throw new CKEditorError( 'wrap-operation-graveyard-position-invalid: Graveyard position invalid.' );
 		}
 	}
 

+ 30 - 23
packages/ckeditor5-engine/src/model/range.js

@@ -540,28 +540,23 @@ export default class Range {
 		let end = this.end._getTransformedByMergeOperation( operation );
 
 		if ( start.root != end.root ) {
-			// This happens when only start or end was next to the merged (deleted) element. In this case we need to fix
+			// This happens when the end position was next to the merged (deleted) element.
+			// Then, the end position was moved to the graveyard root. In this case we need to fix
 			// the range cause its boundaries would be in different roots.
-			if ( start.root != this.root ) {
-				// Fix start position root at it was the only one that was moved.
-				start = this.start;
-			} else {
-				// Fix end position root.
-				end = this.end.getShiftedBy( -1 );
-			}
+			end = this.end.getShiftedBy( -1 );
 		}
 
 		if ( start.isAfter( end ) ) {
-			// This happens in the following two, similar cases:
+			// This happens in three following cases:
 			//
-			// Case 1: Range start is directly before merged node.
-			//         Resulting range should include only nodes from the merged element:
+			// Case 1: Merge operation source position is before the target position (due to some transformations, OT, etc.)
+			//         This means that start can be moved before the end of the range.
 			//
-			// Before: <p>aa</p>{<p>b}b</p><p>cc</p>
-			// Merge:  <p>aab}b</p>{<p>cc</p>
-			// Fix:    <p>aa{b}b</p><p>cc</p>
+			// Before: <p>a{a</p><p>b}b</p><p>cc</p>
+			// Merge:  <p>b}b</p><p>cca{a</p>
+			// Fix:    <p>{b}b</p><p>ccaa</p>
 			//
-			// Case 2: Range start is not directly before merged node.
+			// Case 2: Range start is before merged node but not directly.
 			//         Result should include all nodes that were in the original range.
 			//
 			// Before: <p>aa</p>{<p>cc</p><p>b}b</p>
@@ -570,13 +565,27 @@ export default class Range {
 			//
 			//         The range is expanded by an additional `b` letter but it is better than dropping the whole `cc` paragraph.
 			//
-			if ( !operation.deletionPosition.isEqual( start ) ) {
-				// Case 2.
-				end = operation.deletionPosition;
-			}
+			// Case 3: Range start is directly before merged node.
+			//         Resulting range should include only nodes from the merged element:
+			//
+			// Before: <p>aa</p>{<p>b}b</p><p>cc</p>
+			// Merge:  <p>aab}b</p>{<p>cc</p>
+			// Fix:    <p>aa{b}b</p><p>cc</p>
+			//
 
-			// In both cases start is at the end of the merge-to element.
-			start = operation.targetPosition;
+			if ( operation.sourcePosition.isBefore( operation.targetPosition ) ) {
+				// Case 1.
+				start = Position.createFromPosition( end );
+				start.offset = 0;
+			} else {
+				if ( !operation.deletionPosition.isEqual( start ) ) {
+					// Case 2.
+					end = operation.deletionPosition;
+				}
+
+				// In both case 2 and 3 start is at the end of the merge-to element.
+				start = operation.targetPosition;
+			}
 
 			return new Range( start, end );
 		}
@@ -745,8 +754,6 @@ export default class Range {
 
 			if ( result.length == 2 ) {
 				result.splice( 1, 0, transformedCommon );
-			} else if ( result.length > 0 && common.start.isBefore( result[ 0 ].start ) ) {
-				result.unshift( transformedCommon );
 			} else {
 				result.push( transformedCommon );
 			}

+ 228 - 1
packages/ckeditor5-engine/tests/model/differ.js

@@ -13,6 +13,10 @@ import InsertOperation from '../../src/model/operation/insertoperation';
 import MoveOperation from '../../src/model/operation/moveoperation';
 import RenameOperation from '../../src/model/operation/renameoperation';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
+import SplitOperation from '../../src/model/operation/splitoperation';
+import MergeOperation from '../../src/model/operation/mergeoperation';
+import WrapOperation from '../../src/model/operation/wrapoperation';
+import UnwrapOperation from '../../src/model/operation/unwrapoperation';
 
 describe( 'Differ', () => {
 	let doc, differ, root, model;
@@ -300,7 +304,7 @@ describe( 'Differ', () => {
 				remove( position, 1 );
 
 				expectChanges( [
-					{ type: 'remove', name: '$text', length: 1, position: new Position( root, [ 0, 1 ] ) }
+					{ type: 'remove', name: '$text', length: 1, position }
 				] );
 			} );
 		} );
@@ -1254,6 +1258,202 @@ describe( 'Differ', () => {
 		} );
 	} );
 
+	describe( 'split', () => {
+		it( 'split an element', () => {
+			const position = new Position( root, [ 0, 2 ] );
+
+			model.change( () => {
+				split( position );
+
+				expectChanges( [
+					{ type: 'remove', name: '$text', length: 1, position },
+					{ type: 'insert', name: 'paragraph', length: 1, position: new Position( root, [ 1 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'split a new element', () => {
+			model.change( () => {
+				insert(
+					new Element( 'paragraph', null, new Text( 'Ab' ) ),
+					new Position( root, [ 0 ] )
+				);
+
+				split( new Position( root, [ 0, 1 ] ) );
+
+				expectChanges( [
+					{ type: 'insert', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+					{ type: 'insert', name: 'paragraph', length: 1, position: new Position( root, [ 1 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'split an element inside a new element', () => {
+			model.change( () => {
+				insert(
+					new Element( 'blockQuote', null, new Element( 'paragraph', null, new Text( 'Ab' ) ) ),
+					new Position( root, [ 0 ] )
+				);
+
+				split( new Position( root, [ 0, 0, 1 ] ) );
+
+				expectChanges( [
+					{ type: 'insert', name: 'blockQuote', length: 1, position: new Position( root, [ 0 ] ) }
+				] );
+			} );
+		} );
+	} );
+
+	describe( 'merge', () => {
+		it( 'merge two elements', () => {
+			model.change( () => {
+				merge( new Position( root, [ 1, 0 ] ), new Position( root, [ 0, 3 ] ) );
+
+				expectChanges( [
+					{ type: 'insert', name: '$text', length: 3, position: new Position( root, [ 0, 3 ] ) },
+					{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 1 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'merge a new element', () => {
+			model.change( () => {
+				insert( new Element( 'paragraph', null, new Text( 'Ab' ) ), new Position( root, [ 1 ] ) );
+				merge( new Position( root, [ 1, 0 ] ), new Position( root, [ 0, 3 ] ) );
+
+				expectChanges( [
+					{ type: 'insert', name: '$text', length: 2, position: new Position( root, [ 0, 3 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'merge into a new element', () => {
+			model.change( () => {
+				insert( new Element( 'paragraph', null, new Text( 'Ab' ) ), new Position( root, [ 0 ] ) );
+				merge( new Position( root, [ 1, 0 ] ), new Position( root, [ 0, 2 ] ) );
+
+				expectChanges( [
+					{ type: 'insert', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+					{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 1 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'merge elements inside a new element', () => {
+			model.change( () => {
+				insert(
+					new Element( 'blockQuote', null, [
+						new Element( 'paragraph', null, new Text( 'Ab' ) ),
+						new Element( 'paragraph', null, new Text( 'Xyz' ) )
+					] ),
+					new Position( root, [ 0 ] )
+				);
+
+				merge( new Position( root, [ 0, 1, 0 ] ), new Position( root, [ 0, 0, 2 ] ) );
+
+				expectChanges( [
+					{ type: 'insert', name: 'blockQuote', length: 1, position: new Position( root, [ 0 ] ) }
+				] );
+			} );
+		} );
+	} );
+
+	describe( 'wrap', () => {
+		it( 'wrap elements', () => {
+			model.change( () => {
+				wrap( new Position( root, [ 0 ] ), 2, new Element( 'blockQuote' ) );
+
+				expectChanges( [
+					{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+					{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+					{ type: 'insert', name: 'blockQuote', length: 1, position: new Position( root, [ 0 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'wrap old and new elements', () => {
+			model.change( () => {
+				insert( new Element( 'paragraph' ), new Position( root, [ 0 ] ) );
+				wrap( new Position( root, [ 0 ] ), 2, new Element( 'blockQuote' ) );
+
+				expectChanges( [
+					{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+					{ type: 'insert', name: 'blockQuote', length: 1, position: new Position( root, [ 0 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'wrap inside a new element', () => {
+			model.change( () => {
+				insert(
+					new Element( 'div', null, [
+						new Element( 'paragraph' ),
+						new Element( 'paragraph' )
+					] ),
+					new Position( root, [ 0 ] )
+				);
+				wrap( new Position( root, [ 0, 0 ] ), 2, new Element( 'blockQuote' ) );
+
+				expectChanges( [
+					{ type: 'insert', name: 'div', length: 1, position: new Position( root, [ 0 ] ) }
+				] );
+			} );
+		} );
+	} );
+
+	describe( 'unwrap', () => {
+		it( 'unwrap elements', () => {
+			model.change( () => {
+				unwrap( new Position( root, [ 0, 0 ] ) );
+
+				expectChanges( [
+					{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+					{ type: 'insert', name: '$text', length: 3, position: new Position( root, [ 0 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'unwrap a new element', () => {
+			model.change( () => {
+				insert( new Element( 'paragraph', null, new Text( 'Ab' ) ), new Position( root, [ 0 ] ) );
+				unwrap( new Position( root, [ 0, 0 ] ) );
+
+				expectChanges( [
+					{ type: 'insert', name: '$text', length: 2, position: new Position( root, [ 0 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'unwrap element with new nodes', () => {
+			model.change( () => {
+				insert( new Text( 'Ab' ), new Position( root, [ 0, 1 ] ) );
+				unwrap( new Position( root, [ 0, 0 ] ) );
+
+				expectChanges( [
+					{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+					{ type: 'insert', name: '$text', length: 5, position: new Position( root, [ 0 ] ) }
+				] );
+			} );
+		} );
+
+		it( 'unwrap element inside a new element', () => {
+			model.change( () => {
+				insert(
+					new Element( 'blockQuote', null, [
+						new Element( 'paragraph', null, new Text( 'Ab' ) )
+					] ),
+					new Position( root, [ 0 ] )
+				);
+
+				unwrap( new Position( root, [ 0, 0, 0 ] ) );
+
+				expectChanges( [
+					{ type: 'insert', name: 'blockQuote', length: 1, position: new Position( root, [ 0 ] ) }
+				] );
+			} );
+		} );
+	} );
+
 	describe( 'markers', () => {
 		let range, rangeB;
 
@@ -1614,6 +1814,33 @@ describe( 'Differ', () => {
 		model.applyOperation( operation );
 	}
 
+	function split( position ) {
+		const howMany = position.parent.maxOffset - position.offset;
+		const operation = new SplitOperation( position, howMany, null, doc.version );
+
+		model.applyOperation( operation );
+	}
+
+	function merge( source, target ) {
+		const howMany = source.parent.maxOffset;
+		const operation = new MergeOperation( source, howMany, target, new Position( doc.graveyard, [ 0 ] ), doc.version );
+
+		model.applyOperation( operation );
+	}
+
+	function wrap( position, howMany, element ) {
+		const operation = new WrapOperation( position, howMany, element, doc.version );
+
+		model.applyOperation( operation );
+	}
+
+	function unwrap( position ) {
+		const howMany = position.parent.maxOffset;
+		const operation = new UnwrapOperation( position, howMany, new Position( doc.graveyard, [ 0 ] ), doc.version );
+
+		model.applyOperation( operation );
+	}
+
 	function remove( sourcePosition, howMany ) {
 		const targetPosition = Position.createAt( doc.graveyard, doc.graveyard.maxOffset );
 		const operation = new MoveOperation( sourcePosition, howMany, targetPosition, doc.version );

+ 20 - 0
packages/ckeditor5-engine/tests/model/operation/attributeoperation.js

@@ -5,6 +5,7 @@
 
 import Model from '../../../src/model/model';
 import Text from '../../../src/model/text';
+import Element from '../../../src/model/element';
 import AttributeOperation from '../../../src/model/operation/attributeoperation';
 import Position from '../../../src/model/position';
 import Range from '../../../src/model/range';
@@ -245,6 +246,25 @@ describe( 'AttributeOperation', () => {
 				operation._validate();
 			} ).to.not.throw();
 		} );
+
+		it( 'should throw for a non-flat range', () => {
+			root._insertChild( 0, [
+				new Element( 'paragraph', null, new Text( 'Foo' ) ),
+				new Element( 'paragraph', null, new Text( 'Bar' ) )
+			] );
+
+			expect( () => {
+				const operation = new AttributeOperation(
+					new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 1, 1 ] ) ),
+					'x',
+					null,
+					2,
+					doc.version
+				);
+
+				operation._validate();
+			} ).to.throw( CKEditorError, /attribute-operation-range-not-flat/ );
+		} );
 	} );
 
 	it( 'should create an AttributeOperation as a reverse', () => {

+ 229 - 0
packages/ckeditor5-engine/tests/model/operation/mergeoperation.js

@@ -0,0 +1,229 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Model from '../../../src/model/model';
+import MergeOperation from '../../../src/model/operation/mergeoperation';
+import SplitOperation from '../../../src/model/operation/splitoperation';
+import Position from '../../../src/model/position';
+import Element from '../../../src/model/element';
+import Text from '../../../src/model/text';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'MergeOperation', () => {
+	let model, doc, root, gy, gyPos;
+
+	beforeEach( () => {
+		model = new Model();
+		doc = model.document;
+		root = doc.createRoot();
+		gy = doc.graveyard;
+		gyPos = new Position( gy, [ 0 ] );
+	} );
+
+	it( 'should have proper type', () => {
+		const merge = new MergeOperation( new Position( root, [ 1, 0 ] ), 2, new Position( root, [ 0, 1 ] ), gyPos, 1 );
+
+		expect( merge.type ).to.equal( 'merge' );
+	} );
+
+	it( 'should have proper deletionPosition', () => {
+		const merge = new MergeOperation( new Position( root, [ 1, 0 ] ), 2, new Position( root, [ 0, 1 ] ), gyPos, 1 );
+
+		expect( merge.deletionPosition.path ).to.deep.equal( [ 1 ] );
+	} );
+
+	it( 'should have proper movedRange', () => {
+		const merge = new MergeOperation( new Position( root, [ 1, 0 ] ), 2, new Position( root, [ 0, 1 ] ), gyPos, 1 );
+
+		expect( merge.movedRange.start.path ).to.deep.equal( [ 1, 0 ] );
+		expect( merge.movedRange.end.path ).to.deep.equal( [ 1, Number.POSITIVE_INFINITY ] );
+	} );
+
+	it( 'should merge two nodes together', () => {
+		const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+		const p2 = new Element( 'p2', null, new Text( 'bar' ) );
+
+		root._insertChild( 0, [ p1, p2 ] );
+
+		model.applyOperation(
+			new MergeOperation(
+				new Position( root, [ 1, 0 ] ),
+				3,
+				new Position( root, [ 0, 3 ] ),
+				gyPos,
+				doc.version
+			)
+		);
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.maxOffset ).to.equal( 1 );
+		expect( root.getChild( 0 ).name ).to.equal( 'p1' );
+		expect( p1.maxOffset ).to.equal( 6 );
+		expect( p1.getChild( 0 ).data ).to.equal( 'Foobar' );
+	} );
+
+	it( 'should create a proper SplitOperation as a reverse', () => {
+		const sourcePosition = new Position( root, [ 1, 0 ] );
+		const targetPosition = new Position( root, [ 0, 3 ] );
+
+		const operation = new MergeOperation( sourcePosition, 2, targetPosition, gyPos, doc.version );
+		const reverse = operation.getReversed();
+
+		expect( reverse ).to.be.an.instanceof( SplitOperation );
+		expect( reverse.baseVersion ).to.equal( 1 );
+		expect( reverse.howMany ).to.equal( 2 );
+		expect( reverse.position.isEqual( targetPosition ) ).to.be.true;
+		expect( reverse.graveyardPosition.isEqual( gyPos ) ).to.be.true;
+	} );
+
+	it( 'should undo merge by applying reverse operation', () => {
+		const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+		const p2 = new Element( 'p2', null, new Text( 'bar' ) );
+
+		root._insertChild( 0, [ p1, p2 ] );
+
+		const operation = new MergeOperation(
+			new Position( root, [ 1, 0 ] ),
+			3,
+			new Position( root, [ 0, 3 ] ),
+			gyPos,
+			doc.version
+		);
+
+		model.applyOperation( operation );
+		model.applyOperation( operation.getReversed() );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.maxOffset ).to.equal( 2 );
+		expect( p1.maxOffset ).to.equal( 3 );
+		expect( p1.getChild( 0 ).data ).to.equal( 'Foo' );
+		expect( p2.maxOffset ).to.equal( 3 );
+		expect( p2.getChild( 0 ).data ).to.equal( 'bar' );
+	} );
+
+	describe( '_validate()', () => {
+		it( 'should throw an error if source position is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+			const p2 = new Element( 'p2', null, new Text( 'bar' ) );
+
+			root._insertChild( 0, [ p1, p2 ] );
+
+			const operation = new MergeOperation(
+				new Position( root, [ 0, 3 ] ),
+				3,
+				new Position( root, [ 2, 0 ] ),
+				gyPos,
+				doc.version
+			);
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /merge-operation-target-position-invalid/ );
+		} );
+
+		it( 'should throw an error if source position is in root', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+			const p2 = new Element( 'p2', null, new Text( 'bar' ) );
+
+			root._insertChild( 0, [ p1, p2 ] );
+
+			const operation = new MergeOperation(
+				new Position( root, [ 0 ] ),
+				3,
+				new Position( root, [ 0, 3 ] ),
+				gyPos,
+				doc.version
+			);
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /merge-operation-source-position-invalid/ );
+		} );
+
+		it( 'should throw an error if target position is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+			const p2 = new Element( 'p2', null, new Text( 'bar' ) );
+
+			root._insertChild( 0, [ p1, p2 ] );
+
+			const operation = new MergeOperation(
+				new Position( root, [ 2, 3 ] ),
+				3,
+				new Position( root, [ 1, 0 ] ),
+				gyPos,
+				doc.version
+			);
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /merge-operation-source-position-invalid/ );
+		} );
+
+		it( 'should throw an error if number of nodes to move is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+			const p2 = new Element( 'p2', null, new Text( 'bar' ) );
+
+			root._insertChild( 0, [ p1, p2 ] );
+
+			const operation = new MergeOperation(
+				new Position( root, [ 0, 3 ] ),
+				5,
+				new Position( root, [ 1, 0 ] ),
+				gyPos,
+				doc.version
+			);
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /merge-operation-how-many-invalid/ );
+		} );
+	} );
+
+	it( 'should create MergeOperation with the same parameters when cloned', () => {
+		const sourcePosition = new Position( root, [ 1, 0 ] );
+		const targetPosition = new Position( root, [ 0, 3 ] );
+		const howMany = 4;
+		const baseVersion = doc.version;
+
+		const op = new MergeOperation( sourcePosition, howMany, targetPosition, gyPos, baseVersion );
+
+		const clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( MergeOperation );
+		expect( clone.sourcePosition.isEqual( sourcePosition ) ).to.be.true;
+		expect( clone.targetPosition.isEqual( targetPosition ) ).to.be.true;
+		expect( clone.howMany ).to.equal( howMany );
+		expect( clone.graveyardPosition.isEqual( gyPos ) ).to.be.true;
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
+
+	describe( 'toJSON', () => {
+		it( 'should create proper json object', () => {
+			const sourcePosition = new Position( root, [ 1, 0 ] );
+			const targetPosition = new Position( root, [ 0, 3 ] );
+			const op = new MergeOperation( sourcePosition, 1, targetPosition, gyPos, doc.version );
+
+			const serialized = op.toJSON();
+
+			expect( serialized ).to.deep.equal( {
+				__className: 'MergeOperation',
+				baseVersion: 0,
+				howMany: 1,
+				sourcePosition: op.sourcePosition.toJSON(),
+				targetPosition: op.targetPosition.toJSON(),
+				graveyardPosition: gyPos.toJSON()
+			} );
+		} );
+	} );
+
+	describe( 'fromJSON', () => {
+		it( 'should create proper MergeOperation from json object', () => {
+			const sourcePosition = new Position( root, [ 1, 0 ] );
+			const targetPosition = new Position( root, [ 0, 3 ] );
+			const op = new MergeOperation( sourcePosition, 1, targetPosition, gyPos, doc.version );
+
+			const serialized = op.toJSON();
+
+			const deserialized = MergeOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
+	} );
+} );

+ 255 - 0
packages/ckeditor5-engine/tests/model/operation/splitoperation.js

@@ -0,0 +1,255 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Model from '../../../src/model/model';
+import SplitOperation from '../../../src/model/operation/splitoperation';
+import MergeOperation from '../../../src/model/operation/mergeoperation';
+import Position from '../../../src/model/position';
+import Element from '../../../src/model/element';
+import Text from '../../../src/model/text';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'SplitOperation', () => {
+	let model, doc, root, gy, gyPos;
+
+	beforeEach( () => {
+		model = new Model();
+		doc = model.document;
+		root = doc.createRoot();
+		gy = doc.graveyard;
+		gyPos = new Position( gy, [ 0 ] );
+	} );
+
+	it( 'should have proper type', () => {
+		const split = new SplitOperation( new Position( root, [ 1, 3 ] ), 2, null, 1 );
+
+		expect( split.type ).to.equal( 'split' );
+	} );
+
+	it( 'should have proper insertionPosition', () => {
+		const split = new SplitOperation( new Position( root, [ 1, 3 ] ), 2, null, 1 );
+
+		expect( split.insertionPosition.path ).to.deep.equal( [ 2 ] );
+	} );
+
+	it( 'should have proper moveTargetPosition', () => {
+		const split = new SplitOperation( new Position( root, [ 1, 3 ] ), 2, null, 1 );
+
+		expect( split.moveTargetPosition.path ).to.deep.equal( [ 2, 0 ] );
+	} );
+
+	it( 'should have proper movedRange', () => {
+		const split = new SplitOperation( new Position( root, [ 1, 3 ] ), 2, null, 1 );
+
+		expect( split.movedRange.start.path ).to.deep.equal( [ 1, 3 ] );
+		expect( split.movedRange.end.path ).to.deep.equal( [ 1, Number.POSITIVE_INFINITY ] );
+	} );
+
+	it( 'should split an element', () => {
+		const p1 = new Element( 'p1', null, new Text( 'Foobar' ) );
+
+		root._insertChild( 0, [ p1 ] );
+
+		model.applyOperation( new SplitOperation( new Position( root, [ 0, 3 ] ), 3, null, doc.version ) );
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.maxOffset ).to.equal( 2 );
+		expect( root.getChild( 0 ).name ).to.equal( 'p1' );
+		expect( root.getChild( 1 ).name ).to.equal( 'p1' );
+
+		expect( p1.maxOffset ).to.equal( 3 );
+		expect( p1.getChild( 0 ).data ).to.equal( 'Foo' );
+
+		expect( root.getChild( 1 ).maxOffset ).to.equal( 3 );
+		expect( root.getChild( 1 ).getChild( 0 ).data ).to.equal( 'bar' );
+	} );
+
+	it( 'should split an element using graveyard element', () => {
+		const p1 = new Element( 'p1', null, new Text( 'Foobar' ) );
+		const p2 = new Element( 'p2' );
+
+		root._insertChild( 0, [ p1 ] );
+		gy._insertChild( 0, [ p2 ] );
+
+		model.applyOperation( new SplitOperation( new Position( root, [ 0, 3 ] ), 3, gyPos, doc.version ) );
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.maxOffset ).to.equal( 2 );
+		expect( root.getChild( 0 ).name ).to.equal( 'p1' );
+		expect( root.getChild( 1 ).name ).to.equal( 'p2' );
+
+		expect( p1.maxOffset ).to.equal( 3 );
+		expect( p1.getChild( 0 ).data ).to.equal( 'Foo' );
+
+		expect( root.getChild( 1 ).maxOffset ).to.equal( 3 );
+		expect( root.getChild( 1 ).getChild( 0 ).data ).to.equal( 'bar' );
+
+		expect( gy.maxOffset ).to.equal( 0 );
+	} );
+
+	it( 'should create a proper MergeOperation as a reverse', () => {
+		const operation = new SplitOperation( new Position( root, [ 1, 3 ] ), 3, null, doc.version );
+		const reverse = operation.getReversed();
+
+		expect( reverse ).to.be.an.instanceof( MergeOperation );
+		expect( reverse.baseVersion ).to.equal( 1 );
+		expect( reverse.howMany ).to.equal( 3 );
+		expect( reverse.sourcePosition.isEqual( new Position( root, [ 2, 0 ] ) ) ).to.be.true;
+		expect( reverse.targetPosition.isEqual( new Position( root, [ 1, 3 ] ) ) ).to.be.true;
+		expect( reverse.graveyardPosition.isEqual( gyPos ) ).to.be.true;
+	} );
+
+	it( 'should undo split by applying reverse operation', () => {
+		const p1 = new Element( 'p1', null, new Text( 'Foobar' ) );
+
+		root._insertChild( 0, [ p1 ] );
+
+		const operation = new SplitOperation( new Position( root, [ 0, 3 ] ), 3, null, doc.version );
+
+		model.applyOperation( operation );
+		model.applyOperation( operation.getReversed() );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.maxOffset ).to.equal( 1 );
+		expect( p1.maxOffset ).to.equal( 6 );
+		expect( p1.getChild( 0 ).data ).to.equal( 'Foobar' );
+	} );
+
+	describe( '_validate()', () => {
+		it( 'should throw an error if split position is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foobar' ) );
+
+			root._insertChild( 0, [ p1 ] );
+
+			const operation = new SplitOperation( new Position( root, [ 0, 8 ] ), 3, null, doc.version );
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /split-operation-position-invalid/ );
+		} );
+
+		it( 'should throw an error if split position is in root', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foobar' ) );
+
+			root._insertChild( 0, [ p1 ] );
+
+			const operation = new SplitOperation( new Position( root, [ 1 ] ), 3, null, doc.version );
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /split-operation-split-in-root/ );
+		} );
+
+		it( 'should throw an error if number of nodes to move is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foobar' ) );
+
+			root._insertChild( 0, [ p1 ] );
+
+			const operation = new SplitOperation( new Position( root, [ 0, 2 ] ), 6, null, doc.version );
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /split-operation-how-many-invalid/ );
+		} );
+
+		it( 'should throw an error if graveyard position is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foobar' ) );
+
+			root._insertChild( 0, [ p1 ] );
+
+			const operation = new SplitOperation( new Position( root, [ 0, 2 ] ), 4, gyPos, doc.version );
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /split-operation-graveyard-position-invalid/ );
+		} );
+	} );
+
+	it( 'should create SplitOperation with the same parameters when cloned #1', () => {
+		const position = new Position( root, [ 1, 2 ] );
+		const howMany = 4;
+		const baseVersion = doc.version;
+
+		const op = new SplitOperation( position, howMany, null, baseVersion );
+
+		const clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( SplitOperation );
+		expect( clone.position.isEqual( position ) ).to.be.true;
+		expect( clone.howMany ).to.equal( howMany );
+		expect( clone.graveyardPosition ).to.be.null;
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
+
+	it( 'should create SplitOperation with the same parameters when cloned #2', () => {
+		const position = new Position( root, [ 1, 2 ] );
+		const howMany = 4;
+		const baseVersion = doc.version;
+
+		const op = new SplitOperation( position, howMany, gyPos, baseVersion );
+
+		const clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( SplitOperation );
+		expect( clone.position.isEqual( position ) ).to.be.true;
+		expect( clone.howMany ).to.equal( howMany );
+		expect( clone.graveyardPosition.isEqual( gyPos ) ).to.be.true;
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
+
+	describe( 'toJSON', () => {
+		it( 'should create proper json object #1', () => {
+			const position = new Position( root, [ 0, 3 ] );
+			const op = new SplitOperation( position, 2, null, doc.version );
+
+			const serialized = op.toJSON();
+
+			expect( serialized ).to.deep.equal( {
+				__className: 'SplitOperation',
+				baseVersion: 0,
+				howMany: 2,
+				position: op.position.toJSON(),
+				graveyardPosition: null
+			} );
+		} );
+
+		it( 'should create proper json object #2', () => {
+			const position = new Position( root, [ 0, 3 ] );
+			const op = new SplitOperation( position, 2, gyPos, doc.version );
+
+			const serialized = op.toJSON();
+
+			expect( serialized ).to.deep.equal( {
+				__className: 'SplitOperation',
+				baseVersion: 0,
+				howMany: 2,
+				position: op.position.toJSON(),
+				graveyardPosition: op.graveyardPosition.toJSON()
+			} );
+		} );
+	} );
+
+	describe( 'fromJSON', () => {
+		it( 'should create proper SplitOperation from json object #1', () => {
+			const position = new Position( root, [ 0, 3 ] );
+			const op = new SplitOperation( position, 2, null, doc.version );
+
+			const serialized = op.toJSON();
+
+			const deserialized = SplitOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
+
+		it( 'should create proper SplitOperation from json object #2', () => {
+			const position = new Position( root, [ 0, 3 ] );
+			const op = new SplitOperation( position, 2, gyPos, doc.version );
+
+			const serialized = op.toJSON();
+
+			const deserialized = SplitOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
+	} );
+} );

+ 20 - 1
packages/ckeditor5-engine/tests/model/operation/transform/attribute.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,6 +11,8 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 
@@ -1436,6 +1438,23 @@ describe( 'transform', () => {
 				syncClients();
 				expectClients( '<paragraph>Foo</paragraph><paragraph bold="true">Bar</paragraph>' );
 			} );
+
+			it( 'element in same path', () => {
+				john.setData( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>[<paragraph>Baz</paragraph>]' );
+				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph><paragraph>Baz</paragraph>' );
+
+				john.setAttribute( 'bold', 'true' );
+				kate.merge();
+
+				syncClients();
+				expectClients( '<paragraph>FooBar</paragraph><paragraph bold="true">Baz</paragraph>' );
+
+				kate.undo();
+				john.undo();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Baz</paragraph>' );
+			} );
 		} );
 	} );
 

+ 3 - 1
packages/ckeditor5-engine/tests/model/operation/transform/delete.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,6 +11,8 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 

+ 3 - 1
packages/ckeditor5-engine/tests/model/operation/transform/insert.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,6 +11,8 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 

+ 3 - 1
packages/ckeditor5-engine/tests/model/operation/transform/marker.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,6 +11,8 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 

+ 164 - 1
packages/ckeditor5-engine/tests/model/operation/transform/merge.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,6 +11,8 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 
@@ -27,6 +29,19 @@ describe( 'transform', () => {
 
 				expectClients( '<paragraph>FooBarAbc</paragraph>' );
 			} );
+
+			it.skip( 'same element with undo', () => {
+				// Unnecessary SplitOperation.
+				john.setData( '<paragraph>Foo</paragraph>[]<paragraph></paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph></paragraph>' );
+
+				john.merge();
+				kate.merge();
+				kate.undo();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph>' );
+			} );
 		} );
 
 		describe( 'by delete', () => {
@@ -42,5 +57,153 @@ describe( 'transform', () => {
 				expectClients( '<paragraph>For</paragraph>' );
 			} );
 		} );
+
+		describe( 'by wrap', () => {
+			it( 'wrap merged element', () => {
+				john.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
+
+				john.merge();
+				kate.wrap( 'blockQuote' );
+
+				syncClients();
+				expectClients( '<paragraph>FooBar</paragraph>' );
+
+				john.undo();
+				kate.undo();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'wrap in merged element', () => {
+				// This is pretty weird case. Right now it cannot be reproduced with the features that we have.
+				john.editor.model.schema.extend( 'paragraph', { allowIn: 'listItem' } );
+				john.editor.model.schema.extend( 'blockQuote', { allowIn: 'listItem' } );
+
+				kate.editor.model.schema.extend( 'paragraph', { allowIn: 'listItem' } );
+				kate.editor.model.schema.extend( 'blockQuote', { allowIn: 'listItem' } );
+
+				john.setData(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+					'</listItem>' +
+					'[]' +
+					'<listItem>' +
+						'<paragraph>B</paragraph>' +
+						'<paragraph>C</paragraph>' +
+					'</listItem>'
+				);
+
+				kate.setData(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+					'</listItem>' +
+					'<listItem>' +
+						'[<paragraph>B</paragraph>' +
+						'<paragraph>C</paragraph>]' +
+					'</listItem>'
+				);
+
+				john.merge();
+				kate.wrap( 'blockQuote' );
+
+				syncClients();
+
+				expectClients(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+						'<blockQuote>' +
+							'<paragraph>B</paragraph>' +
+							'<paragraph>C</paragraph>' +
+						'</blockQuote>' +
+					'</listItem>'
+				);
+			} );
+		} );
+
+		describe( 'by unwrap', () => {
+			it( 'merge to unwrapped element', () => {
+				john.setData( '<blockQuote><paragraph>Foo</paragraph></blockQuote>[]<blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+
+				john.merge();
+				kate.unwrap();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'merge to unwrapped element with undo #1', () => {
+				john.setData( '<blockQuote><paragraph>Foo</paragraph></blockQuote>[]<blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+
+				john.merge();
+				john.undo();
+				kate.unwrap();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'merge to unwrapped element with undo #2', () => {
+				john.setData( '<blockQuote><paragraph>Foo</paragraph></blockQuote>[]<blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+
+				john.merge();
+				kate.unwrap();
+				kate.undo();
+
+				syncClients();
+				expectClients( '<blockQuote><paragraph>Foo</paragraph></blockQuote><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'unwrap in merged element', () => {
+				// This is pretty weird case. Right now it cannot be reproduced with the features that we have.
+				john.editor.model.schema.extend( 'paragraph', { allowIn: 'listItem' } );
+				john.editor.model.schema.extend( 'blockQuote', { allowIn: 'listItem' } );
+
+				kate.editor.model.schema.extend( 'paragraph', { allowIn: 'listItem' } );
+				kate.editor.model.schema.extend( 'blockQuote', { allowIn: 'listItem' } );
+
+				john.setData(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+					'</listItem>' +
+					'[]' +
+					'<listItem>' +
+						'<blockQuote>' +
+							'<paragraph>B</paragraph>' +
+							'<paragraph>C</paragraph>' +
+						'</blockQuote>' +
+					'</listItem>'
+				);
+
+				kate.setData(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+					'</listItem>' +
+					'<listItem>' +
+						'<blockQuote>' +
+							'[]<paragraph>B</paragraph>' +
+							'<paragraph>C</paragraph>' +
+						'</blockQuote>' +
+					'</listItem>'
+				);
+
+				john.merge();
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+						'<paragraph>B</paragraph>' +
+						'<paragraph>C</paragraph>' +
+					'</listItem>'
+				);
+			} );
+		} );
 	} );
 } );

+ 19 - 1
packages/ckeditor5-engine/tests/model/operation/transform/move.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,6 +11,8 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 
@@ -591,6 +593,22 @@ describe( 'transform', () => {
 				expectClients( '<paragraph>FooBar</paragraph>' );
 			} );
 
+			it( 'move multiple elements, then undo', () => {
+				john.setData( '<paragraph>A</paragraph>[<paragraph>B</paragraph><paragraph>C</paragraph>]' );
+				kate.setData( '<paragraph>A</paragraph>[]<paragraph>B</paragraph><paragraph>C</paragraph>' );
+
+				john.move( [ 0 ] );
+				kate.merge();
+
+				syncClients();
+				expectClients( '<paragraph>C</paragraph><paragraph>AB</paragraph>' );
+
+				john.undo();
+
+				syncClients();
+				expectClients( '<paragraph>AB</paragraph><paragraph>C</paragraph>' );
+			} );
+
 			it( 'moved text', () => {
 				john.setData( '<paragraph>Foo</paragraph><paragraph>B[ar]</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );

+ 3 - 1
packages/ckeditor5-engine/tests/model/operation/transform/remove.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,6 +11,8 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 

+ 3 - 1
packages/ckeditor5-engine/tests/model/operation/transform/rename.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,6 +11,8 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 

+ 81 - 3
packages/ckeditor5-engine/tests/model/operation/transform/split.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,12 +11,14 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 
 	describe( 'split', () => {
 		describe( 'by wrap', () => {
-			it( 'element in same path', () => {
+			it( 'split inside wrapped element', () => {
 				john.setData( '<paragraph>F[]oo</paragraph>' );
 				kate.setData( '[<paragraph>Foo</paragraph>]' );
 
@@ -33,7 +35,7 @@ describe( 'transform', () => {
 				);
 			} );
 
-			it( 'element in same path, then undo', () => {
+			it( 'split inside wrapped element, then undo', () => {
 				john.setData( '<paragraph>F[]oo</paragraph>' );
 				kate.setData( '[<paragraph>Foo</paragraph>]' );
 
@@ -52,6 +54,47 @@ describe( 'transform', () => {
 				);
 			} );
 
+			it( 'element in same path, then undo', () => {
+				// This is pretty weird case. Right now it cannot be reproduced with the features that we have.
+				john.editor.model.schema.extend( 'paragraph', { allowIn: 'listItem' } );
+				john.editor.model.schema.extend( 'blockQuote', { allowIn: 'listItem' } );
+
+				kate.editor.model.schema.extend( 'paragraph', { allowIn: 'listItem' } );
+				kate.editor.model.schema.extend( 'blockQuote', { allowIn: 'listItem' } );
+
+				john.setData( '<listItem><paragraph>A</paragraph>[]<paragraph>B</paragraph><paragraph>C</paragraph></listItem>' );
+				kate.setData( '<listItem><paragraph>A</paragraph><paragraph>B</paragraph>[<paragraph>C</paragraph>]</listItem>' );
+
+				john.split();
+				kate.wrap( 'blockQuote' );
+
+				syncClients();
+				expectClients(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+					'</listItem>' +
+					'<listItem>' +
+						'<paragraph>B</paragraph>' +
+						'<blockQuote>' +
+							'<paragraph>C</paragraph>' +
+						'</blockQuote>' +
+					'</listItem>'
+				);
+
+				john.undo();
+				kate.undo();
+
+				syncClients();
+
+				expectClients(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+						'<paragraph>B</paragraph>' +
+						'<paragraph>C</paragraph>' +
+					'</listItem>'
+				);
+			} );
+
 			it( 'multiple elements', () => {
 				john.setData( '<paragraph>F[]oo</paragraph><paragraph>Bar</paragraph>' );
 				kate.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]' );
@@ -160,6 +203,23 @@ describe( 'transform', () => {
 				expectClients( '<paragraph>Foo</paragraph>' );
 			} );
 
+			it( 'element in same path, then undo both', () => {
+				john.setData( '<blockQuote><paragraph>F[]oo</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
+
+				john.split();
+				kate.unwrap();
+
+				syncClients();
+
+				john.undo();
+				kate.undo();
+
+				syncClients();
+
+				expectClients( '<blockQuote><paragraph>Foo</paragraph></blockQuote>' );
+			} );
+
 			it( 'text in same path, then undo', () => {
 				john.setData( '<blockQuote><paragraph>F[]oo</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
@@ -385,5 +445,23 @@ describe( 'transform', () => {
 				);
 			} );
 		} );
+
+		describe( 'by move', () => {
+			it( 'move inside split element after split position', () => {
+				john.setData( '<paragraph>F[]oo</paragraph><paragraph>Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph>[Ba]r</paragraph>' );
+
+				john.split();
+				kate.move( [ 0, 3 ] );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F</paragraph>' +
+					'<paragraph>ooBa</paragraph>' +
+					'<paragraph>r</paragraph>'
+				);
+			} );
+		} );
 	} );
 } );

+ 222 - 0
packages/ckeditor5-engine/tests/model/operation/transform/undo.js

@@ -0,0 +1,222 @@
+import { Client, expectClients, clearBuffer } from './utils.js';
+
+describe( 'transform', () => {
+	let john;
+
+	beforeEach( () => {
+		return Client.get( 'john' ).then( client => ( john = client ) );
+	} );
+
+	afterEach( () => {
+		clearBuffer();
+
+		return john.destroy();
+	} );
+
+	it( 'split, remove, undo, undo', () => {
+		john.setData( '<paragraph>Foo[]Bar</paragraph>' );
+
+		john.split();
+		john.setSelection( [ 1 ], [ 2 ] );
+		john.remove();
+		john.undo();
+		john.undo();
+
+		expectClients( '<paragraph>FooBar</paragraph>' );
+	} );
+
+	it( 'move, merge, undo, undo', () => {
+		john.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
+
+		john.move( [ 2 ] );
+		john.setSelection( [ 1 ] );
+		john.merge();
+		john.undo();
+		john.undo();
+
+		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+	} );
+
+	it.skip( 'move multiple, merge, undo, undo', () => {
+		john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Xyz</paragraph>' );
+
+		john.move( [ 3 ] );
+
+		expectClients( '<paragraph>Xyz</paragraph><paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+		john.setSelection( [ 1 ] );
+		john.merge();
+
+		expectClients( '<paragraph>XyzFoo</paragraph><paragraph>Bar</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>Xyz</paragraph><paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+		john.undo();
+
+		// Wrong move is done.
+		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Xyz</paragraph>' );
+	} );
+
+	it( 'move inside unwrapped content then undo', () => {
+		john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph></blockQuote>' );
+
+		john.move( [ 0, 2 ] );
+		john.setSelection( [ 0, 0 ] );
+		john.unwrap();
+		john.undo();
+		john.undo();
+
+		expectClients(
+			'<blockQuote>' +
+				'<paragraph>Foo</paragraph>' +
+				'<paragraph>Bar</paragraph>' +
+			'</blockQuote>'
+		);
+	} );
+
+	it( 'remove node, merge then undo', () => {
+		john.setData( '<paragraph>Foo</paragraph><paragraph>[Bar]</paragraph>' );
+
+		john.remove();
+		john.setSelection( [ 1 ] );
+		john.merge();
+		john.undo();
+		john.undo();
+
+		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+	} );
+
+	it( 'merge, merge then undo #1', () => {
+		john.setData(
+			'<blockQuote>' +
+				'<paragraph>Foo</paragraph>' +
+				'<paragraph>Bar</paragraph>' +
+			'</blockQuote>' +
+			'[]' +
+			'<blockQuote>' +
+				'<paragraph>Xyz</paragraph>' +
+			'</blockQuote>'
+		);
+
+		john.merge();
+		john.setSelection( [ 0, 2 ] );
+		john.merge();
+
+		expectClients(
+			'<blockQuote>' +
+				'<paragraph>Foo</paragraph>' +
+				'<paragraph>BarXyz</paragraph>' +
+			'</blockQuote>'
+		);
+
+		john.undo();
+		john.undo();
+
+		expectClients(
+			'<blockQuote>' +
+				'<paragraph>Foo</paragraph>' +
+				'<paragraph>Bar</paragraph>' +
+			'</blockQuote>' +
+			'<blockQuote>' +
+				'<paragraph>Xyz</paragraph>' +
+			'</blockQuote>'
+		);
+	} );
+
+	it( 'merge, merge then undo #2', () => {
+		john.setData(
+			'<blockQuote>' +
+				'<paragraph>Foo</paragraph>' +
+			'</blockQuote>' +
+			'[]' +
+			'<blockQuote>' +
+				'<paragraph>Bar</paragraph>' +
+				'<paragraph>Xyz</paragraph>' +
+			'</blockQuote>'
+		);
+
+		john.merge();
+		john.setSelection( [ 0, 1 ] );
+		john.merge();
+
+		expectClients(
+			'<blockQuote>' +
+				'<paragraph>FooBar</paragraph>' +
+				'<paragraph>Xyz</paragraph>' +
+			'</blockQuote>'
+		);
+
+		john.undo();
+		john.undo();
+
+		expectClients(
+			'<blockQuote>' +
+				'<paragraph>Foo</paragraph>' +
+			'</blockQuote>' +
+			'<blockQuote>' +
+				'<paragraph>Bar</paragraph>' +
+				'<paragraph>Xyz</paragraph>' +
+			'</blockQuote>'
+		);
+	} );
+
+	it( 'merge, unwrap then undo', () => {
+		john.setData( '<paragraph></paragraph>[]<paragraph>Foo</paragraph>' );
+
+		john.merge();
+		john.setSelection( [ 0, 0 ] );
+		john.unwrap();
+
+		john.undo();
+		john.undo();
+
+		expectClients( '<paragraph></paragraph><paragraph>Foo</paragraph>' );
+	} );
+
+	it( 'remove node at the split position then undo #1', () => {
+		john.setData( '<paragraph>Ab</paragraph>[]<paragraph>Xy</paragraph>' );
+
+		john.merge();
+		john.setSelection( [ 0, 1 ], [ 0, 2 ] );
+		john.remove();
+
+		john.undo();
+		john.undo();
+
+		expectClients( '<paragraph>Ab</paragraph><paragraph>Xy</paragraph>' );
+	} );
+
+	it( 'remove node at the split position then undo #2', () => {
+		john.setData( '<paragraph>Ab</paragraph>[]<paragraph>Xy</paragraph>' );
+
+		john.merge();
+		john.setSelection( [ 0, 2 ], [ 0, 3 ] );
+		john.remove();
+
+		john.undo();
+		john.undo();
+
+		expectClients( '<paragraph>Ab</paragraph><paragraph>Xy</paragraph>' );
+	} );
+
+	it( 'undoing split after the element created by split has been removed', () => {
+		// This example is ported here from ckeditor5-undo to keep 100% CC in ckeditor5-engine alone.
+		john.setData( '<paragraph>Foo[]bar</paragraph>' );
+
+		john.split();
+		john.setSelection( [ 0, 3 ], [ 1, 3 ] );
+		john.delete();
+
+		expectClients( '<paragraph>Foo</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>Foo</paragraph><paragraph>bar</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>Foobar</paragraph>' );
+	} );
+} );

+ 183 - 4
packages/ckeditor5-engine/tests/model/operation/transform/unwrap.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,20 +11,22 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 
 	describe( 'unwrap', () => {
 		describe( 'by unwrap', () => {
-			it( 'element in different path', () => {
+			it( 'unwrap two siblings', () => {
 				john.setData(
-					'<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' +
+					'<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' +
 					'<blockQuote><paragraph>Bar</paragraph></blockQuote>'
 				);
 
 				kate.setData(
 					'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
-					'<blockQuote>[<paragraph>Bar</paragraph>]</blockQuote>'
+					'<blockQuote>[]<paragraph>Bar</paragraph></blockQuote>'
 				);
 
 				john.unwrap();
@@ -38,6 +40,32 @@ describe( 'transform', () => {
 				);
 			} );
 
+			it( 'unwrap two siblings then undo', () => {
+				john.setData(
+					'<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' +
+					'<blockQuote><paragraph>Bar</paragraph></blockQuote>'
+				);
+
+				kate.setData(
+					'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
+					'<blockQuote>[]<paragraph>Bar</paragraph></blockQuote>'
+				);
+
+				john.unwrap();
+				kate.unwrap();
+
+				syncClients();
+
+				john.undo();
+				kate.undo();
+
+				syncClients();
+				expectClients(
+					'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
+					'<blockQuote><paragraph>Bar</paragraph></blockQuote>'
+				);
+			} );
+
 			it( 'text in different path', () => {
 				john.setData(
 					'<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' +
@@ -198,5 +226,156 @@ describe( 'transform', () => {
 				);
 			} );
 		} );
+
+		describe( 'by split', () => {
+			it( 'unwrap, then split and undo', () => {
+				// This is pretty weird case. Right now it cannot be reproduced with the features that we have.
+				john.editor.model.schema.extend( 'paragraph', { allowIn: 'listItem' } );
+				john.editor.model.schema.extend( 'blockQuote', { allowIn: 'listItem' } );
+
+				kate.editor.model.schema.extend( 'paragraph', { allowIn: 'listItem' } );
+				kate.editor.model.schema.extend( 'blockQuote', { allowIn: 'listItem' } );
+
+				john.setData(
+					'<listItem>' +
+						'<blockQuote>' +
+							'[]' +
+							'<paragraph>A</paragraph>' +
+							'<paragraph>B</paragraph>' +
+						'</blockQuote>' +
+					'</listItem>'
+				);
+
+				kate.setData(
+					'<listItem>' +
+						'<blockQuote>' +
+							'[]' +
+							'<paragraph>A</paragraph>' +
+							'<paragraph>B</paragraph>' +
+						'</blockQuote>' +
+					'</listItem>'
+				);
+
+				john.unwrap();
+
+				syncClients();
+				expectClients(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+						'<paragraph>B</paragraph>' +
+					'</listItem>'
+				);
+
+				john.undo();
+				kate.setSelection( [ 0, 1 ] );
+				kate.split();
+
+				syncClients();
+				expectClients(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+					'</listItem>' +
+					'<listItem>' +
+						'<paragraph>B</paragraph>' +
+					'</listItem>'
+				);
+
+				kate.undo();
+
+				syncClients();
+				expectClients(
+					'<listItem>' +
+						'<paragraph>A</paragraph>' +
+						'<paragraph>B</paragraph>' +
+					'</listItem>'
+				);
+			} );
+		} );
+
+		describe( 'by wrap', () => {
+			it( 'unwrap, then undo and wrap #1', () => {
+				john.setData(
+					'<blockQuote>' +
+						'[]' +
+						'<paragraph>A</paragraph>' +
+						'<paragraph>B</paragraph>' +
+						'<paragraph>C</paragraph>' +
+					'</blockQuote>'
+				);
+
+				kate.setData(
+					'<blockQuote>' +
+						'[]' +
+						'<paragraph>A</paragraph>' +
+						'<paragraph>B</paragraph>' +
+						'<paragraph>C</paragraph>' +
+					'</blockQuote>'
+				);
+
+				john.unwrap();
+
+				syncClients();
+				expectClients(
+					'<paragraph>A</paragraph>' +
+					'<paragraph>B</paragraph>' +
+					'<paragraph>C</paragraph>'
+				);
+
+				john.undo();
+				kate.setSelection( [ 1 ], [ 2 ] );
+				kate.wrap( 'blockQuote' );
+
+				syncClients();
+				expectClients(
+					'<blockQuote>' +
+						'<paragraph>A</paragraph>' +
+						'<paragraph>B</paragraph>' +
+						'<paragraph>C</paragraph>' +
+					'</blockQuote>'
+				);
+			} );
+
+			it( 'unwrap, then undo and wrap #2', () => {
+				john.setData(
+					'<paragraph>A</paragraph>' +
+					'<blockQuote>' +
+						'[]' +
+						'<paragraph>B</paragraph>' +
+					'</blockQuote>' +
+					'<paragraph>C</paragraph>'
+				);
+
+				kate.setData(
+					'<paragraph>A</paragraph>' +
+					'<blockQuote>' +
+						'[]' +
+						'<paragraph>B</paragraph>' +
+					'</blockQuote>' +
+					'<paragraph>C</paragraph>'
+				);
+
+				john.unwrap();
+
+				syncClients();
+				expectClients(
+					'<paragraph>A</paragraph>' +
+					'<paragraph>B</paragraph>' +
+					'<paragraph>C</paragraph>'
+				);
+
+				john.undo();
+				kate.setSelection( [ 1 ], [ 2 ] );
+				kate.wrap( 'blockQuote' );
+
+				syncClients();
+				expectClients(
+					'<paragraph>A</paragraph>' +
+					'<blockQuote>' +
+						'<paragraph>B</paragraph>' +
+					'</blockQuote>' +
+					'<paragraph>C</paragraph>'
+				);
+			} );
+		} );
 	} );
 } );

+ 4 - 0
packages/ckeditor5-engine/tests/model/operation/transform/utils.js

@@ -325,3 +325,7 @@ export function expectClients( expectedModelString ) {
 		expect( client.syncedVersion, client.name + ' version' ).to.equal( syncedVersion );
 	}
 }
+
+export function clearBuffer() {
+	bufferedOperations.clear();
+}

+ 3 - 1
packages/ckeditor5-engine/tests/model/operation/transform/wrap.js

@@ -1,4 +1,4 @@
-import { Client, syncClients, expectClients } from './utils.js';
+import { Client, syncClients, expectClients, clearBuffer } from './utils.js';
 
 describe( 'transform', () => {
 	let john, kate;
@@ -11,6 +11,8 @@ describe( 'transform', () => {
 	} );
 
 	afterEach( () => {
+		clearBuffer();
+
 		return Promise.all( [ john.destroy(), kate.destroy() ] );
 	} );
 

+ 173 - 0
packages/ckeditor5-engine/tests/model/operation/unwrapoperation.js

@@ -0,0 +1,173 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Model from '../../../src/model/model';
+import UnwrapOperation from '../../../src/model/operation/unwrapoperation';
+import WrapOperation from '../../../src/model/operation/wrapoperation';
+import Position from '../../../src/model/position';
+import Element from '../../../src/model/element';
+import Text from '../../../src/model/text';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'UnwrapOperation', () => {
+	let model, doc, root, gy, gyPos;
+
+	beforeEach( () => {
+		model = new Model();
+		doc = model.document;
+		root = doc.createRoot();
+		gy = doc.graveyard;
+		gyPos = new Position( gy, [ 0 ] );
+	} );
+
+	it( 'should have proper type', () => {
+		const unwrap = new UnwrapOperation( new Position( root, [ 1, 0 ] ), 2, gyPos, 1 );
+
+		expect( unwrap.type ).to.equal( 'unwrap' );
+	} );
+
+	it( 'should have proper targetPosition', () => {
+		const unwrap = new UnwrapOperation( new Position( root, [ 1, 0 ] ), 2, gyPos, 1 );
+
+		expect( unwrap.targetPosition.path ).to.deep.equal( [ 1 ] );
+	} );
+
+	it( 'should have proper unwrappedRange', () => {
+		const unwrap = new UnwrapOperation( new Position( root, [ 1, 0 ] ), 2, gyPos, 1 );
+
+		expect( unwrap.unwrappedRange.start.path ).to.deep.equal( [ 1, 0 ] );
+		expect( unwrap.unwrappedRange.end.path ).to.deep.equal( [ 1, 2 ] );
+	} );
+
+	it( 'should unwrap an element', () => {
+		const bq = new Element( 'blockQuote', null, [
+			new Element( 'paragraph', null, new Text( 'Foo' ) ),
+			new Element( 'listItem', null, new Text( 'bar' ) )
+		] );
+
+		root._insertChild( 0, [ bq ] );
+
+		const operation = new UnwrapOperation( new Position( root, [ 0, 0 ] ), 2, gyPos, doc.version );
+
+		model.applyOperation( operation );
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.maxOffset ).to.equal( 2 );
+		expect( root.getChild( 0 ).name ).to.equal( 'paragraph' );
+		expect( root.getChild( 1 ).name ).to.equal( 'listItem' );
+	} );
+
+	it( 'should create a proper WrapOperation as a reverse', () => {
+		const operation = new UnwrapOperation( new Position( root, [ 1, 0 ] ), 2, gyPos, doc.version );
+		const reverse = operation.getReversed();
+
+		expect( reverse ).to.be.an.instanceof( WrapOperation );
+		expect( reverse.baseVersion ).to.equal( 1 );
+		expect( reverse.position.isEqual( new Position( root, [ 1 ] ) ) ).to.be.true;
+		expect( reverse.howMany ).to.equal( 2 );
+		expect( reverse.element ).to.be.null;
+		expect( reverse.graveyardPosition.isEqual( gyPos ) ).to.be.true;
+	} );
+
+	it( 'should undo unwrap by applying reverse operation', () => {
+		const bq = new Element( 'blockQuote', null, [
+			new Element( 'paragraph', null, new Text( 'Foo' ) ),
+			new Element( 'listItem', null, new Text( 'bar' ) )
+		] );
+
+		root._insertChild( 0, [ bq ] );
+
+		const operation = new UnwrapOperation( new Position( root, [ 0, 0 ] ), 2, gyPos, doc.version );
+
+		model.applyOperation( operation );
+		model.applyOperation( operation.getReversed() );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.maxOffset ).to.equal( 1 );
+		expect( root.getChild( 0 ).name ).to.equal( 'blockQuote' );
+		expect( root.getChild( 0 ).maxOffset ).to.equal( 2 );
+	} );
+
+	describe( '_validate()', () => {
+		it( 'should throw an error if position is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+
+			root._insertChild( 0, [ p1 ] );
+
+			const operation = new UnwrapOperation(
+				new Position( root, [ 1, 0 ] ),
+				3,
+				gyPos,
+				doc.version
+			);
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /unwrap-operation-position-invalid/ );
+		} );
+
+		it( 'should throw an error if number of nodes to unwrap is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+
+			root._insertChild( 0, [ p1 ] );
+
+			const operation = new UnwrapOperation(
+				new Position( root, [ 0, 0 ] ),
+				5,
+				gyPos,
+				doc.version
+			);
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /unwrap-operation-how-many-invalid/ );
+		} );
+	} );
+
+	it( 'should create UnwrapOperation with the same parameters when cloned', () => {
+		const position = new Position( root, [ 1, 0 ] );
+		const howMany = 4;
+		const baseVersion = doc.version;
+
+		const op = new UnwrapOperation( position, howMany, gyPos, baseVersion );
+
+		const clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( UnwrapOperation );
+		expect( clone.position.isEqual( position ) ).to.be.true;
+		expect( clone.howMany ).to.equal( howMany );
+		expect( clone.graveyardPosition.isEqual( gyPos ) ).to.be.true;
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
+
+	describe( 'toJSON', () => {
+		it( 'should create proper json object', () => {
+			const position = new Position( root, [ 1, 0 ] );
+			const op = new UnwrapOperation( position, 4, gyPos, doc.version );
+
+			const serialized = op.toJSON();
+
+			expect( serialized ).to.deep.equal( {
+				__className: 'UnwrapOperation',
+				baseVersion: 0,
+				howMany: 4,
+				position: op.position.toJSON(),
+				graveyardPosition: gyPos.toJSON()
+			} );
+		} );
+	} );
+
+	describe( 'fromJSON', () => {
+		it( 'should create proper UnwrapOperation from json object', () => {
+			const position = new Position( root, [ 1, 0 ] );
+			const op = new UnwrapOperation( position, 4, gyPos, doc.version );
+
+			const serialized = op.toJSON();
+
+			const deserialized = UnwrapOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
+	} );
+} );

+ 184 - 7
packages/ckeditor5-engine/tests/model/operation/wrapoperation.js

@@ -5,31 +5,194 @@
 
 import Model from '../../../src/model/model';
 import Element from '../../../src/model/element';
+import Text from '../../../src/model/text';
 import WrapOperation from '../../../src/model/operation/wrapoperation';
+import UnwrapOperation from '../../../src/model/operation/unwrapoperation';
 import Position from '../../../src/model/position';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 describe( 'WrapOperation', () => {
-	let model, doc, root;
+	let model, doc, root, gy, gyPos;
 
 	beforeEach( () => {
 		model = new Model();
 		doc = model.document;
 		root = doc.createRoot();
+		gy = doc.graveyard;
+		gyPos = new Position( gy, [ 0 ] );
 	} );
 
-	describe( 'type', () => {
-		it( 'should be wrap', () => {
-			const op = new WrapOperation(
+	it( 'should have proper type', () => {
+		const wrap = new WrapOperation( new Position( root, [ 1 ] ), 2, new Element( 'paragraph' ), 1 );
+
+		expect( wrap.type ).to.equal( 'wrap' );
+	} );
+
+	it( 'should have proper targetPosition', () => {
+		const wrap = new WrapOperation( new Position( root, [ 1 ] ), 2, new Element( 'paragraph' ), 1 );
+
+		expect( wrap.targetPosition.path ).to.deep.equal( [ 1, 0 ] );
+	} );
+
+	it( 'should have proper wrappedRange', () => {
+		const wrap = new WrapOperation( new Position( root, [ 1 ] ), 2, new Element( 'paragraph' ), 1 );
+
+		expect( wrap.wrappedRange.start.path ).to.deep.equal( [ 1 ] );
+		expect( wrap.wrappedRange.end.path ).to.deep.equal( [ 3 ] );
+	} );
+
+	it( 'should wrap nodes into an element', () => {
+		root._insertChild( 0, [
+			new Element( 'paragraph', null, new Text( 'Foo' ) ),
+			new Element( 'listItem', null, new Text( 'bar' ) )
+		] );
+
+		const operation = new WrapOperation( new Position( root, [ 0 ] ), 2, new Element( 'blockQuote' ), doc.version );
+
+		model.applyOperation( operation );
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.maxOffset ).to.equal( 1 );
+		expect( root.getChild( 0 ).name ).to.equal( 'blockQuote' );
+		expect( root.getChild( 0 ).maxOffset ).to.equal( 2 );
+		expect( root.getChild( 0 ).getChild( 0 ).name ).to.equal( 'paragraph' );
+		expect( root.getChild( 0 ).getChild( 1 ).name ).to.equal( 'listItem' );
+	} );
+
+	it( 'should wrap nodes into an element from graveyard', () => {
+		root._insertChild( 0, [
+			new Element( 'paragraph', null, new Text( 'Foo' ) ),
+			new Element( 'listItem', null, new Text( 'bar' ) )
+		] );
+
+		gy._insertChild( 0, [ new Element( 'blockQuote' ) ] );
+
+		const operation = new WrapOperation( new Position( root, [ 0 ] ), 2, gyPos, doc.version );
+
+		model.applyOperation( operation );
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.maxOffset ).to.equal( 1 );
+		expect( root.getChild( 0 ).name ).to.equal( 'blockQuote' );
+		expect( root.getChild( 0 ).maxOffset ).to.equal( 2 );
+		expect( root.getChild( 0 ).getChild( 0 ).name ).to.equal( 'paragraph' );
+		expect( root.getChild( 0 ).getChild( 1 ).name ).to.equal( 'listItem' );
+		expect( gy.maxOffset ).to.equal( 0 );
+	} );
+
+	it( 'should create a proper UnwrapOperation as a reverse', () => {
+		const operation = new WrapOperation( new Position( root, [ 1 ] ), 2, new Element( 'blockQuote' ), doc.version );
+		const reverse = operation.getReversed();
+
+		expect( reverse ).to.be.an.instanceof( UnwrapOperation );
+		expect( reverse.baseVersion ).to.equal( 1 );
+		expect( reverse.position.isEqual( new Position( root, [ 1, 0 ] ) ) ).to.be.true;
+		expect( reverse.howMany ).to.equal( 2 );
+		expect( reverse.graveyardPosition.isEqual( gyPos ) ).to.be.true;
+	} );
+
+	it( 'should undo wrap by applying reverse operation', () => {
+		root._insertChild( 0, [
+			new Element( 'paragraph', null, new Text( 'Foo' ) ),
+			new Element( 'listItem', null, new Text( 'bar' ) )
+		] );
+
+		const operation = new WrapOperation( new Position( root, [ 0 ] ), 2, new Element( 'blockQuote' ), doc.version );
+
+		model.applyOperation( operation );
+		model.applyOperation( operation.getReversed() );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.maxOffset ).to.equal( 2 );
+		expect( root.getChild( 0 ).name ).to.equal( 'paragraph' );
+		expect( root.getChild( 1 ).name ).to.equal( 'listItem' );
+	} );
+
+	describe( '_validate()', () => {
+		it( 'should throw an error if position is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+
+			root._insertChild( 0, [ p1 ] );
+
+			const operation = new WrapOperation(
+				new Position( root, [ 4 ] ),
+				3,
+				new Element( 'blockQuote' ),
+				doc.version
+			);
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /wrap-operation-position-invalid/ );
+		} );
+
+		it( 'should throw an error if number of nodes to wrap is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+
+			root._insertChild( 0, [ p1 ] );
+
+			const operation = new WrapOperation(
+				new Position( root, [ 0 ] ),
+				5,
+				new Element( 'blockQuote' ),
+				doc.version
+			);
+
+			expect( () => operation._validate() ).to.throw( CKEditorError, /wrap-operation-how-many-invalid/ );
+		} );
+
+		it( 'should throw an error if graveyard position is invalid', () => {
+			const p1 = new Element( 'p1', null, new Text( 'Foo' ) );
+
+			root._insertChild( 0, [ p1 ] );
+
+			const operation = new WrapOperation(
 				new Position( root, [ 0 ] ),
 				1,
-				new Position( doc.graveyard, [ 0 ] ),
+				gyPos,
 				doc.version
 			);
 
-			expect( op.type ).to.equal( 'wrap' );
+			expect( () => operation._validate() ).to.throw( CKEditorError, /wrap-operation-graveyard-position-invalid/ );
 		} );
 	} );
 
+	it( 'should create WrapOperation with the same parameters when cloned #1', () => {
+		const position = new Position( root, [ 1, 0 ] );
+		const howMany = 4;
+		const baseVersion = doc.version;
+		const element = new Element( 'blockQuote' );
+
+		const op = new WrapOperation( position, howMany, element, baseVersion );
+
+		const clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( WrapOperation );
+		expect( clone.position.isEqual( position ) ).to.be.true;
+		expect( clone.element ).not.to.equal( element );
+		expect( clone.element ).to.deep.equal( element );
+		expect( clone.graveyardPosition ).to.be.null;
+		expect( clone.howMany ).to.equal( howMany );
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
+
+	it( 'should create WrapOperation with the same parameters when cloned #2', () => {
+		const position = new Position( root, [ 1, 0 ] );
+		const howMany = 4;
+		const baseVersion = doc.version;
+
+		const op = new WrapOperation( position, howMany, gyPos, baseVersion );
+
+		const clone = op.clone();
+
+		expect( clone.position.isEqual( position ) ).to.be.true;
+		expect( clone.graveyardPosition.isEqual( gyPos ) ).to.be.true;
+		expect( clone.element ).to.be.null;
+		expect( clone.howMany ).to.equal( howMany );
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object #1', () => {
 			const op = new WrapOperation(
@@ -71,7 +234,7 @@ describe( 'WrapOperation', () => {
 	} );
 
 	describe( 'fromJSON', () => {
-		it( 'should create proper WrapOperation from json object', () => {
+		it( 'should create proper WrapOperation from json object #1', () => {
 			const op = new WrapOperation(
 				new Position( root, [ 0 ] ),
 				1,
@@ -84,5 +247,19 @@ describe( 'WrapOperation', () => {
 
 			expect( deserialized ).to.deep.equal( op );
 		} );
+
+		it( 'should create proper WrapOperation from json object #2', () => {
+			const op = new WrapOperation(
+				new Position( root, [ 0 ] ),
+				1,
+				new Element( 'blockQuote' ),
+				doc.version
+			);
+
+			const serialized = op.toJSON();
+			const deserialized = WrapOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
 	} );
 } );

+ 342 - 2
packages/ckeditor5-engine/tests/model/position.js

@@ -9,11 +9,21 @@ import Element from '../../src/model/element';
 import Text from '../../src/model/text';
 import TextProxy from '../../src/model/textproxy';
 import Position from '../../src/model/position';
+import Range from '../../src/model/range';
+import MarkerOperation from '../../src/model/operation/markeroperation';
+import AttributeOperation from '../../src/model/operation/attributeoperation';
+import InsertOperation from '../../src/model/operation/insertoperation';
+import MoveOperation from '../../src/model/operation/moveoperation';
+import RenameOperation from '../../src/model/operation/renameoperation';
+import MergeOperation from '../../src/model/operation/mergeoperation';
+import SplitOperation from '../../src/model/operation/splitoperation';
+import WrapOperation from '../../src/model/operation/wrapoperation';
+import UnwrapOperation from '../../src/model/operation/unwrapoperation';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
 describe( 'Position', () => {
-	let doc, root, otherRoot, p, ul, li1, li2, f, o, z, b, a, r, foz, bar;
+	let doc, model, root, otherRoot, p, ul, li1, li2, f, o, z, b, a, r, foz, bar;
 
 	testUtils.createSinonSandbox();
 
@@ -29,7 +39,7 @@ describe( 'Position', () => {
 	//        |- a   Before: [ 1, 1, 1 ] After: [ 1, 1, 2 ]
 	//        |- r   Before: [ 1, 1, 2 ] After: [ 1, 1, 3 ]
 	before( () => {
-		const model = new Model();
+		model = new Model();
 
 		doc = model.document;
 		root = doc.createRoot();
@@ -495,6 +505,32 @@ describe( 'Position', () => {
 		} );
 	} );
 
+	describe( 'hasSameParentAs()', () => {
+		it( 'should return false if positions have different roots', () => {
+			const posA = new Position( root, [ 1, 2 ] );
+			const posB = new Position( doc.graveyard, [ 1, 0 ] );
+
+			expect( posA.hasSameParentAs( posB ) ).to.be.false;
+			expect( posB.hasSameParentAs( posA ) ).to.be.false;
+		} );
+
+		it( 'should return false if positions have different parents', () => {
+			const posA = new Position( root, [ 0, 1 ] );
+			const posB = new Position( root, [ 1, 1 ] );
+
+			expect( posA.hasSameParentAs( posB ) ).to.be.false;
+			expect( posB.hasSameParentAs( posA ) ).to.be.false;
+		} );
+
+		it( 'should return true if positions have same parent', () => {
+			const posA = new Position( root, [ 0, 4, 8 ] );
+			const posB = new Position( root, [ 0, 4, 2 ] );
+
+			expect( posA.hasSameParentAs( posB ) ).to.be.true;
+			expect( posB.hasSameParentAs( posA ) ).to.be.true;
+		} );
+	} );
+
 	describe( 'isAtStart()', () => {
 		it( 'should return true if position is at the beginning of its parent', () => {
 			expect( new Position( root, [ 0 ] ).isAtStart ).to.be.true;
@@ -604,6 +640,287 @@ describe( 'Position', () => {
 		} );
 	} );
 
+	// Note: We don't create model element structure in these tests because this method
+	// is used by OT so it must not check the structure.
+	describe( 'getTransformedByOperation()', () => {
+		let pos;
+
+		beforeEach( () => {
+			pos = new Position( root, [ 3, 2 ] );
+		} );
+
+		describe( 'by AttributeOperation', () => {
+			it( 'nothing should change', () => {
+				const op = new AttributeOperation( Range.createFromParentsAndOffsets( root, 1, root, 6 ), 'key', true, false, 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+		} );
+
+		describe( 'by InsertOperation', () => {
+			it( 'should use _getTransformedByInsertion', () => {
+				sinon.spy( pos, '_getTransformedByInsertion' );
+
+				const op = new InsertOperation( new Position( root, [ 1 ] ), [ new Element( 'paragraph' ) ], 1 );
+				pos.getTransformedByOperation( op );
+
+				expect( pos._getTransformedByInsertion.calledWithExactly( op.position, op.howMany ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'by MarkerOperation', () => {
+			it( 'nothing should change', () => {
+				const op = new MarkerOperation(
+					'marker', null, Range.createFromParentsAndOffsets( root, 1, root, 6 ), model.markers, 1, true
+				);
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+		} );
+
+		describe( 'by MoveOperation', () => {
+			it( 'should use _getTransformedByMove', () => {
+				sinon.spy( pos, '_getTransformedByMove' );
+
+				const op = new MoveOperation( new Position( root, [ 1 ] ), 2, new Position( root, [ 5 ] ), 1 );
+				pos.getTransformedByOperation( op );
+
+				expect( pos._getTransformedByMove.calledWithExactly( op.sourcePosition, op.targetPosition, op.howMany ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'by RenameOperation', () => {
+			it( 'nothing should change', () => {
+				const op = new RenameOperation( new Position( root, [ 3 ] ), 'old', 'new', 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+		} );
+
+		describe( 'by SplitOperation', () => {
+			it( 'transformed position is at the split position', () => {
+				const op = new SplitOperation( new Position( root, [ 3, 2 ] ), 3, null, 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'transformed position is after the split position', () => {
+				const op = new SplitOperation( new Position( root, [ 3, 1 ] ), 3, null, 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 4, 1 ] );
+			} );
+
+			it( 'transformed position is before the split position', () => {
+				const op = new SplitOperation( new Position( root, [ 3, 3 ] ), 3, null, 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'transformed position is after the split element', () => {
+				const op = new SplitOperation( new Position( root, [ 3, 1, 5 ] ), 3, null, 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 3 ] );
+			} );
+
+			it( 'transformed position is before the split element', () => {
+				const op = new SplitOperation( new Position( root, [ 3, 3, 5 ] ), 3, null, 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'transformed position is in graveyard and split position uses graveyard element', () => {
+				pos = new Position( doc.graveyard, [ 1 ] );
+
+				const op = new SplitOperation( new Position( root, [ 3, 2 ] ), 3, new Position( doc.graveyard, [ 0 ] ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 0 ] );
+			} );
+		} );
+
+		describe( 'by MergeOperation', () => {
+			it( 'position is inside merged element', () => {
+				const op = new MergeOperation(
+					new Position( root, [ 3, 0 ] ), 3, new Position( root, [ 2, 2 ] ), new Position( doc.graveyard, [ 0 ] ), 1
+				);
+
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 2, 4 ] );
+			} );
+
+			it( 'position is inside merged-to element', () => {
+				const op = new MergeOperation(
+					new Position( root, [ 4, 0 ] ), 3, new Position( root, [ 3, 5 ] ), new Position( doc.graveyard, [ 0 ] ), 1
+				);
+
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'position is before merged element', () => {
+				const op = new MergeOperation(
+					new Position( root, [ 3, 2, 0 ] ), 3, new Position( root, [ 3, 1, 2 ] ), new Position( doc.graveyard, [ 0 ] ), 1
+				);
+
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'position is after merged element', () => {
+				const op = new MergeOperation(
+					new Position( root, [ 3, 1, 0 ] ), 3, new Position( root, [ 3, 0, 2 ] ), new Position( doc.graveyard, [ 0 ] ), 1
+				);
+
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 1 ] );
+			} );
+
+			it( 'position is inside graveyard', () => {
+				pos = new Position( doc.graveyard, [ 0 ] );
+
+				const op = new MergeOperation(
+					new Position( root, [ 3, 1, 0 ] ), 3, new Position( root, [ 3, 0, 2 ] ), new Position( doc.graveyard, [ 0 ] ), 1
+				);
+
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 1 ] );
+			} );
+
+			it( 'merge source position is before merge target position and position is in merged element', () => {
+				const op = new MergeOperation(
+					new Position( root, [ 3, 0 ] ), 3, new Position( root, [ 4, 5 ] ), new Position( doc.graveyard, [ 0 ] ), 1
+				);
+
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 7 ] );
+			} );
+		} );
+
+		describe( 'by WrapOperation', () => {
+			it( 'position is before the wrapped range', () => {
+				const op = new WrapOperation( new Position( root, [ 3, 3 ] ), 3, new Element( 'paragraph' ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'position is at the beginning of wrapped range and sticks to previous', () => {
+				pos.stickiness = 'toPrevious';
+
+				const op = new WrapOperation( new Position( root, [ 3, 2 ] ), 3, new Element( 'paragraph' ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'position is at the beginning of wrapped range and sticks to next', () => {
+				pos.stickiness = 'toNext';
+
+				const op = new WrapOperation( new Position( root, [ 3, 2 ] ), 3, new Element( 'paragraph' ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2, 0 ] );
+			} );
+
+			it( 'position is inside wrapped range', () => {
+				const op = new WrapOperation( new Position( root, [ 3, 1 ] ), 3, new Element( 'paragraph' ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 1, 1 ] );
+			} );
+
+			it( 'position is at the end of wrapped range and sticks to previous', () => {
+				pos.stickiness = 'toPrevious';
+
+				const op = new WrapOperation( new Position( root, [ 3, 0 ] ), 2, new Element( 'paragraph' ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 0, 2 ] );
+			} );
+
+			it( 'position is at the end of wrapped range and sticks to next', () => {
+				pos.stickiness = 'toNext';
+
+				const op = new WrapOperation( new Position( root, [ 3, 0 ] ), 2, new Element( 'paragraph' ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 1 ] );
+			} );
+
+			it( 'position is after the wrapped range', () => {
+				const op = new WrapOperation( new Position( root, [ 3, 0 ] ), 1, new Element( 'paragraph' ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'position is inside the graveyard and the operation uses element from graveyard', () => {
+				pos = new Position( doc.graveyard, [ 1 ] );
+
+				const op = new WrapOperation( new Position( root, [ 3, 0 ] ), 1, new Position( doc.graveyard, [ 0 ] ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 0 ] );
+			} );
+		} );
+
+		describe( 'by UnwrapOperation', () => {
+			it( 'position is before unwrapped element', () => {
+				const op = new UnwrapOperation( new Position( root, [ 3, 2, 0 ] ), 3, new Position( doc.graveyard, [ 0 ] ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'position is inside unwrapped element', () => {
+				const op = new UnwrapOperation( new Position( root, [ 3, 0 ] ), 3, new Position( doc.graveyard, [ 0 ] ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 5 ] );
+			} );
+
+			it( 'position is after unwrapped element', () => {
+				const op = new UnwrapOperation( new Position( root, [ 3, 1, 0 ] ), 3, new Position( doc.graveyard, [ 0 ] ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 3, 4 ] );
+			} );
+
+			it( 'position is in graveyard', () => {
+				pos = new Position( doc.graveyard, [ 0 ] );
+
+				const op = new UnwrapOperation( new Position( root, [ 3, 2, 0 ] ), 3, new Position( doc.graveyard, [ 0 ] ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 1 ] );
+			} );
+
+			it( 'unwrap is in the graveyard and position is before the unwrapped node', () => {
+				// This is an edge case scenario for unwrap operation that is unwrapping an element which is already in graveyard.
+				pos = new Position( doc.graveyard, [ 0 ] );
+
+				const op = new UnwrapOperation( new Position( doc.graveyard, [ 0, 0 ] ), 0, new Position( doc.graveyard, [ 0 ] ), 1 );
+				const transformed = pos.getTransformedByOperation( op );
+
+				expect( transformed.path ).to.deep.equal( [ 0 ] );
+			} );
+		} );
+	} );
+
 	describe( '_getTransformedByInsertion()', () => {
 		it( 'should return a new Position instance', () => {
 			const position = new Position( root, [ 0 ] );
@@ -663,6 +980,13 @@ describe( 'Position', () => {
 
 			expect( transformed.path ).to.deep.equal( [ 1, 2, 3 ] );
 		} );
+
+		it( 'should not update if insertion is in different path', () => {
+			const position = new Position( root, [ 1, 1 ] );
+			const transformed = position._getTransformedByInsertion( new Position( root, [ 2, 0 ] ), 2 );
+
+			expect( transformed.path ).to.deep.equal( [ 1, 1 ] );
+		} );
 	} );
 
 	describe( '_getTransformedByDeletion()', () => {
@@ -761,6 +1085,22 @@ describe( 'Position', () => {
 
 			expect( transformed.path ).to.deep.equal( [ 2, 2, 3 ] );
 		} );
+
+		it( 'should not update if targetPosition is equal to sourcePosition (because nothing is really moving)', () => {
+			const position = new Position( root, [ 3 ] );
+			const transformed = position._getTransformedByMove( new Position( root, [ 3 ] ), new Position( root, [ 3 ] ), 3, false );
+
+			expect( transformed.path ).to.deep.equal( [ 3 ] );
+		} );
+
+		it( 'should update if position is before moved range and sticks to next node', () => {
+			const position = new Position( root, [ 2, 1 ] );
+			position.stickiness = 'toNext';
+
+			const transformed = position._getTransformedByMove( new Position( root, [ 2, 1 ] ), new Position( root, [ 3, 3 ] ), 2, false );
+
+			expect( transformed.path ).to.deep.equal( [ 3, 3 ] );
+		} );
 	} );
 
 	describe( '_getCombined()', () => {

+ 144 - 2
packages/ckeditor5-engine/tests/model/range.js

@@ -9,6 +9,7 @@ import Element from '../../src/model/element';
 import Text from '../../src/model/text';
 import Model from '../../src/model/model';
 import TreeWalker from '../../src/model/treewalker';
+import MarkerOperation from '../../src/model/operation/markeroperation';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
 import InsertOperation from '../../src/model/operation/insertoperation';
 import MoveOperation from '../../src/model/operation/moveoperation';
@@ -20,10 +21,10 @@ import UnwrapOperation from '../../src/model/operation/unwrapoperation';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 describe( 'Range', () => {
-	let doc, range, start, end, root, otherRoot, gy;
+	let doc, range, start, end, root, otherRoot, gy, model;
 
 	beforeEach( () => {
-		const model = new Model();
+		model = new Model();
 
 		doc = model.document;
 		root = doc.createRoot();
@@ -670,6 +671,39 @@ describe( 'Range', () => {
 		} );
 	} );
 
+	describe( '_getTransformedByDeletion()', () => {
+		it( 'should return a transformed range', () => {
+			const range = new Range( new Position( root, [ 3 ] ), new Position( root, [ 5 ] ) );
+			const transformed = range._getTransformedByDeletion( new Position( root, [ 1 ] ), 1 );
+
+			expect( transformed.start.offset ).to.equal( 2 );
+			expect( transformed.end.offset ).to.equal( 4 );
+		} );
+
+		it( 'should shrink the range if removed range was intersecting #1', () => {
+			const range = new Range( new Position( root, [ 3 ] ), new Position( root, [ 5 ] ) );
+			const transformed = range._getTransformedByDeletion( new Position( root, [ 2 ] ), 2 );
+
+			expect( transformed.start.offset ).to.equal( 2 );
+			expect( transformed.end.offset ).to.equal( 3 );
+		} );
+
+		it( 'should shrink the range if removed range was intersecting #2', () => {
+			const range = new Range( new Position( root, [ 3 ] ), new Position( root, [ 5 ] ) );
+			const transformed = range._getTransformedByDeletion( new Position( root, [ 4 ] ), 2 );
+
+			expect( transformed.start.offset ).to.equal( 3 );
+			expect( transformed.end.offset ).to.equal( 4 );
+		} );
+
+		it( 'should return null if the transformed range was contained in the removed range', () => {
+			const range = new Range( new Position( root, [ 3 ] ), new Position( root, [ 5 ] ) );
+			const transformed = range._getTransformedByDeletion( new Position( root, [ 2 ] ), 7 );
+
+			expect( transformed ).to.be.null;
+		} );
+	} );
+
 	describe( 'getDifference()', () => {
 		let range;
 
@@ -813,6 +847,18 @@ describe( 'Range', () => {
 			} );
 		} );
 
+		describe( 'by MarkerOperation', () => {
+			it( 'nothing should change', () => {
+				const op = new MarkerOperation(
+					'marker', null, Range.createFromParentsAndOffsets( root, 1, root, 6 ), model.markers, 1, true
+				);
+
+				const transformed = range.getTransformedByOperation( op );
+
+				expectRange( transformed[ 0 ], 2, 5 );
+			} );
+		} );
+
 		describe( 'by MoveOperation', () => {
 			it( 'move before range', () => {
 				const start = new Position( root, [ 0 ] );
@@ -1058,6 +1104,102 @@ describe( 'Range', () => {
 				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 0, 1 ] );
 				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 1, 1 ] );
 			} );
+
+			it( 'merge at the beginning of the range', () => {
+				const range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+
+				const op = new MergeOperation(
+					new Position( root, [ 2, 0 ] ),
+					4,
+					new Position( root, [ 1, 1 ] ),
+					gyPos,
+					1
+				);
+
+				const transformed = range.getTransformedByOperation( op );
+
+				expect( transformed.length ).to.equal( 1 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 2 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3 ] );
+			} );
+
+			it( 'merge at the end of the range', () => {
+				const range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+
+				const op = new MergeOperation(
+					new Position( root, [ 3, 0 ] ),
+					4,
+					new Position( root, [ 2, 1 ] ),
+					gyPos,
+					1
+				);
+
+				const transformed = range.getTransformedByOperation( op );
+
+				expect( transformed.length ).to.equal( 1 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 2 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3 ] );
+			} );
+
+			it( 'merged element is the only node in the range', () => {
+				const range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 3 ] ) );
+
+				const op = new MergeOperation(
+					new Position( root, [ 2, 0 ] ),
+					4,
+					new Position( root, [ 1, 1 ] ),
+					gyPos,
+					1
+				);
+
+				const transformed = range.getTransformedByOperation( op );
+
+				expect( transformed.length ).to.equal( 1 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 2 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 2 ] );
+			} );
+
+			it( 'range start is between merged elements #1', () => {
+				// <p>aa</p>{<p>b}b</p><p>cc</p>
+				const range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 1, 1 ] ) );
+
+				const op = new MergeOperation(
+					new Position( root, [ 1, 0 ] ),
+					2,
+					new Position( root, [ 0, 2 ] ),
+					gyPos,
+					1
+				);
+
+				const transformed = range.getTransformedByOperation( op );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				// <p>aa{b}b</p><p>cc</p>
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 3 ] );
+			} );
+
+			it( 'range start is between merged elements #2', () => {
+				// <p>aa</p>{<p>cc</p><p>b}b</p>
+				const range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2, 1 ] ) );
+
+				const op = new MergeOperation(
+					new Position( root, [ 2, 0 ] ),
+					2,
+					new Position( root, [ 0, 2 ] ),
+					gyPos,
+					1
+				);
+
+				const transformed = range.getTransformedByOperation( op );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				// <p>aa{bb</p><p>cc</p>}
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 2 ] );
+			} );
 		} );
 
 		describe( 'by WrapOperation', () => {