Browse Source

Changed: Removed unreachable branch in `model.Range#_getTransformedByMergeOperation`.
Added: Extra validations in some operations.
Tests: Introduced tests for changes done during OT refactor.

Szymon Cofalik 7 years ago
parent
commit
97744b7f6b

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

@@ -137,6 +137,13 @@ export default class MergeOperation extends Operation {
 			 * @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' ) ) {
 			/**
 			 * 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.' );
 		}
 	}
 

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

+ 3 - 10
packages/ckeditor5-engine/src/model/range.js

@@ -540,15 +540,10 @@ 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 ) ) {
@@ -745,8 +740,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: 'engine.model.operation.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: 'engine.model.operation.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: 'engine.model.operation.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 );
+		} );
+	} );
+} );

+ 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: 'engine.model.operation.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', () => {