瀏覽代碼

Changed: Added multiple transformation tests to cover all scenarios, introduced several fixes.

Szymon Cofalik 7 年之前
父節點
當前提交
12b097b0d7

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

@@ -130,21 +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 ( !sourceElement.parent ) {
-			/**
-			 * Element to merge is a root.
-			 *
-			 * @error merge-operation-source-position-invalid
-			 */
-			throw new CKEditorError( 'merge-operation-source-position-invalid: Element to merge is a root.' );
-		} else if ( !targetElement || !targetElement.is( 'element' ) ) {
+		} else if ( !targetElement || !targetElement.is( 'element' ) || !targetElement.parent ) {
 			/**
 			 * Merge target position is invalid.
 			 *

+ 82 - 86
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -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,17 +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;
@@ -1543,21 +1528,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 +1580,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 ) ) {
@@ -1844,6 +1836,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 +1976,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 +2025,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 );
@@ -2195,6 +2189,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;
 	}
@@ -2252,7 +2250,7 @@ setTransformation( SplitOperation, MoveOperation, ( a, b, context ) => {
 	// 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.
 	//
-	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 +2259,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 +2267,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 +2299,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 +2336,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 +2353,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 +2399,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 ];
 	}
@@ -2521,16 +2522,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 +2542,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 +2630,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 +2668,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 +2693,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 ];

+ 27 - 13
packages/ckeditor5-engine/src/model/range.js

@@ -547,16 +547,16 @@ export default class Range {
 		}
 
 		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>
@@ -565,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 );
 		}

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

@@ -203,7 +203,7 @@ describe( 'MergeOperation', () => {
 			const serialized = op.toJSON();
 
 			expect( serialized ).to.deep.equal( {
-				__className: 'engine.model.operation.MergeOperation',
+				__className: 'MergeOperation',
 				baseVersion: 0,
 				howMany: 1,
 				sourcePosition: op.sourcePosition.toJSON(),

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

@@ -205,7 +205,7 @@ describe( 'SplitOperation', () => {
 			const serialized = op.toJSON();
 
 			expect( serialized ).to.deep.equal( {
-				__className: 'engine.model.operation.SplitOperation',
+				__className: 'SplitOperation',
 				baseVersion: 0,
 				howMany: 2,
 				position: op.position.toJSON(),
@@ -220,7 +220,7 @@ describe( 'SplitOperation', () => {
 			const serialized = op.toJSON();
 
 			expect( serialized ).to.deep.equal( {
-				__className: 'engine.model.operation.SplitOperation',
+				__className: 'SplitOperation',
 				baseVersion: 0,
 				howMany: 2,
 				position: op.position.toJSON(),

+ 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>'
+				);
+			} );
+		} );
 	} );
 } );

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

@@ -0,0 +1,203 @@
+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>' );
+	} );
+} );

+ 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() ] );
 	} );
 

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

@@ -149,7 +149,7 @@ describe( 'UnwrapOperation', () => {
 			const serialized = op.toJSON();
 
 			expect( serialized ).to.deep.equal( {
-				__className: 'engine.model.operation.UnwrapOperation',
+				__className: 'UnwrapOperation',
 				baseVersion: 0,
 				howMany: 4,
 				position: op.position.toJSON(),