Browse Source

MoveOperation.clone() and MoveOperation OT introduced.

Szymon Cofalik 10 years ago
parent
commit
6c40aadaad

+ 176 - 3
packages/ckeditor5-utils/src/document/operation/moveoperation.js

@@ -7,10 +7,12 @@
 
 CKEDITOR.define( [
 	'document/operation/operation',
-	'document/nodelist',
 	'ckeditorerror',
-	'utils'
-], ( Operation, NodeList, CKEditorError, utils ) => {
+	'utils',
+	'document/operation/nooperation',
+	'document/range',
+	//'document/operation/insertoperation'
+], ( Operation, CKEditorError, utils, NoOperation, Range ) => {
 	/**
 	 * Operation to move list of subsequent nodes from one position in the document to another.
 	 *
@@ -122,6 +124,177 @@ CKEDITOR.define( [
 		getReversed() {
 			return new MoveOperation( this.targetPosition.clone(), this.sourcePosition.clone(), this.howMany, this.baseVersion + 1 );
 		}
+
+		getTransformedBy( operation, isStrong ) {
+			// Circular dependency re-require.
+			const InsertOperation = CKEDITOR.require( 'document/operation/insertoperation' );
+
+			if ( operation instanceof InsertOperation ) {
+				return getTransformedByInsertOperation.call( this, operation, !!isStrong );
+			} else if ( operation instanceof MoveOperation ) {
+				return getTransformedByMoveOperation.call( this, operation, !!isStrong );
+			}
+
+			return [ this.clone( this.baseVersion + 1 ) ];
+		}
+
+		clone( baseVersion ) {
+			if ( !baseVersion ) {
+				baseVersion = this.baseVersion;
+			}
+
+			return new MoveOperation( this.sourcePosition.clone(), this.targetPosition.clone(), this.howMany, baseVersion );
+		}
+	}
+
+	/**
+	 * Returns an array containing the result of transforming this operation by given {document.operation.InsertOperation}.
+	 *
+	 * @method getTransformedByInsertOperation
+	 * @param {document.operation.InsertOperation} insert Operation to transform by.
+	 * @param {Boolean} isStrong Flag indicating whether this operation should be treated as more important
+	 * when resolving conflicts.
+	 * @returns {Array.<document.operation.MoveOperation>} Result of the transformation.
+	 * @private
+	 */
+	function getTransformedByInsertOperation( insert, isStrong ) {
+		// Get a target position from a state "after" nodes are inserted by InsertOperation.
+		const newTargetPosition = this.targetPosition.getTransformedByInsertion( insert.position, insert.nodeList.length, !isStrong );
+
+		// Create a range from MoveOperation properties and transform it by insertion as well.
+		const moveRange = Range.createFromPositionAndOffset( this.sourcePosition, this.howMany );
+		const ranges = moveRange.getTransformedByInsertion( insert.position, insert.nodeList.length, true );
+
+		// Map transformed range(s) to operations and return them.
+		return ranges.map( ( range, i ) => {
+			return new MoveOperation(
+				range.start,
+				newTargetPosition.clone(),
+				range.end.offset - range.start.offset,
+				this.baseVersion + i + 1
+			)
+		} );
+	}
+
+	/**
+	 * Returns an array containing the result of transforming this operation by given {document.operation.MoveOperation}.
+	 *
+	 * @method getTransformedByMoveOperation
+	 * @param {document.operation.MoveOperation} move Operation to transform by.
+	 * @param {Boolean} isStrong Flag indicating whether this operation should be treated as more important
+	 * when resolving conflicts.
+	 * @returns {Array.<document.operation.MoveOperation>} Result of the transformation.
+	 * @private
+	 */
+	function getTransformedByMoveOperation( move, isStrong ) {
+		// There is a special case when both move operations' target positions are inside nodes that are
+		// being moved by the other move operation. So in other words, we move ranges into inside of each other.
+		// This case can't be solved reasonably (on the other hand, it should not happen often).
+		if ( _targetsIntoMoveOperation.call( this, move ) && _targetsIntoMoveOperation.call( move, this ) ) {
+			// Instead of transforming this operation, we return a reverse of the operation that we transform by.
+			// So when the results of this "transformation" will be applied, given MoveOperation will get reversed.
+			return [ move.getReversed() ];
+		}
+
+		// Get a target position from a state "after" nodes from moveRange are "detached".
+		const moveTargetPosition = move.targetPosition.getTransformedByDeletion( move.sourcePosition, move.howMany );
+
+		// This will aggregate transformed ranges.
+		let ranges = [];
+
+		// Create ranges from MoveOperations properties.
+		const thisRange = Range.createFromPositionAndOffset( this.sourcePosition, this.howMany );
+		const moveRange = Range.createFromPositionAndOffset( move.sourcePosition, move.howMany );
+
+		const differenceSet = thisRange.getDifference( moveRange );
+
+		// MoveOperations ranges may intersect.
+		// First, we take care of that part of the range that is only modified by this operation.
+		if ( differenceSet.length > 0 ) {
+			const difference = differenceSet[ 0 ];
+
+			// If two ranges were returned it means that moveRange was inside thisRange.
+			// We will cover that moveRange later. Right now we simply join the ranges and transform them as one.
+			if ( differenceSet.length == 2 ) {
+				difference.end = differenceSet[ 1 ].end.clone();
+			}
+
+			// MoveOperation removes nodes from their original position. We acknowledge this by proper transformation.
+			// Take the start and the end of the range and transform them by deletion of moved nodes.
+			// Note that if moveRange was inside thisRange, only difference.end will be transformed.
+			// This nicely covers the joining simplification we did in the previous step.
+			difference.start = difference.start.getTransformedByDeletion( move.sourcePosition, move.howMany );
+			difference.end = difference.end.getTransformedByDeletion( move.sourcePosition, move.howMany );
+
+			// MoveOperation pastes nodes into target position. We acknowledge this by proper transformation.
+			// Note that since we operate on transformed difference range, we should transform by
+			// previously transformed target position.
+			ranges = difference.getTransformedByInsertion( moveTargetPosition, move.howMany, true );
+		}
+
+		// Then, we have to manage the common part of both move ranges.
+		// If MoveOperations has common range it can be one of two:
+		// * on the same tree level - it means that we move the same nodes into different places
+		// * on deeper tree level - it means that we move nodes that are inside moved nodes
+		// The operations are conflicting only if they try to explicitly move same nodes, so only in the first case.
+		// So, we will handle common range if it is deeper or if this operation is more important.
+		if ( utils.compareArrays( move.sourcePosition.parentPath, this.sourcePosition.parentPath ) == utils.compareArrays.PREFIX || isStrong ) {
+			const common = thisRange.getCommon( moveRange );
+
+			if ( common != null ) {
+				// We substitute original position by the combination of target position and original position.
+				// This reflects that those nodes were moved to another place by MoveOperation.
+				common.start = common.start.getCombined( move.sourcePosition, moveTargetPosition );
+				common.end = common.end.getCombined( move.sourcePosition, moveTargetPosition );
+
+				ranges.push( common );
+			}
+		}
+
+		// At this point we transformed this operation's source range. Now, we need to transform target position.
+
+		// First, transform target position by deletion of the other operation's range.
+		let newTargetPosition = this.targetPosition.getTransformedByDeletion( move.sourcePosition, move.howMany );
+
+		if ( newTargetPosition == null ) {
+			// This operation's target position was inside a node moved by the other MoveOperation.
+			// We substitute that position by the combination of the other move target position and this target position.
+			// This reflects changes done by the other MoveOperation.
+
+			newTargetPosition = this.targetPosition.getCombined( move.sourcePosition, moveTargetPosition );
+		} else {
+			// Here we have the target position after some nodes has been removed by MoveOperation.
+			// Next step is to reflect pasting nodes by MoveOperation, which might further affect the position.
+
+			newTargetPosition = newTargetPosition.getTransformedByInsertion( moveTargetPosition, move.howMany, true );
+		}
+
+		// If we haven't got any ranges this far it means that both operations tried to move the same nodes and
+		// this operation is less important. We return NoOperation in this case.
+		if ( ranges.length == 0 ) {
+			return [ new NoOperation( this.baseVersion + 1 ) ];
+		}
+
+		// At this point we might have multiple ranges. All those ranges will be moved to newTargetPosition.
+		// To keep them in the right order, we need to move them starting from the last one.
+		// [|ABC||DEF||GHI|] ==> [^], [|ABC||DEF|] ==> [^GHI], [|ABC|] ==> [^DEFGHI], [] ==> [ABCDEFGHI]
+		// To achieve this, we sort ranges by their starting position in descending order.
+		ranges.sort( ( a, b ) => b.start.compareWith( a.start ) );
+
+		// Map transformed range(s) to operations and return them.
+		return ranges.map( ( range, i ) => {
+			return new MoveOperation(
+				range.start,
+				newTargetPosition,
+				range.end.offset - range.start.offset,
+				this.baseVersion + i + 1
+			);
+		} );
+	}
+
+	function _targetsIntoMoveOperation( operation ) {
+		var testTarget = this.targetPosition.getTransformedByDeletion( operation.sourcePosition, operation.howMany );
+		return testTarget == null;
 	}
 
 	return MoveOperation;

+ 875 - 1
packages/ckeditor5-utils/tests/document/operation/moveoperation.js

@@ -4,26 +4,40 @@
  */
 
 /* bender-tags: document */
+/* global describe, before, beforeEach, it, expect */
 
 'use strict';
 
 const modules = bender.amd.require(
 	'document/document',
 	'document/operation/moveoperation',
+	'document/operation/insertoperation',
+	'document/operation/changeoperation',
+	'document/operation/nooperation',
+	'document/attribute',
 	'document/position',
+	'document/range',
 	'document/element',
+	'document/node',
 	'document/nodelist',
 	'ckeditorerror'
 );
 
 describe( 'MoveOperation', () => {
-	let Document, MoveOperation, Position, Element, NodeList, CKEditorError;
+	let Document, MoveOperation, InsertOperation, ChangeOperation, NoOperation,
+		Attribute, Position, Range, Element, Node, NodeList, CKEditorError;
 
 	before( () => {
 		Document = modules[ 'document/document' ];
 		MoveOperation = modules[ 'document/operation/moveoperation' ];
+		InsertOperation = modules[ 'document/operation/insertoperation' ];
+		ChangeOperation = modules[ 'document/operation/changeoperation' ];
+		NoOperation = modules[ 'document/operation/nooperation' ];
+		Attribute = modules[ 'document/attribute' ];
 		Position = modules[ 'document/position' ];
+		Range = modules[ 'document/range' ];
 		Element = modules[ 'document/element' ];
+		Node = modules[ 'document/node' ];
 		NodeList = modules[ 'document/nodelist' ];
 		CKEditorError = modules.ckeditorerror;
 	} );
@@ -226,4 +240,864 @@ describe( 'MoveOperation', () => {
 		expect( p.getChildCount() ).to.equal( 1 );
 		expect( p.getChild( 0 ).character ).to.equal( 'b' );
 	} );
+
+	it( 'should create operation with the same parameters when cloned', () => {
+		let sourcePosition = new Position( [ 0 ], root );
+		let targetPosition = new Position( [ 1 ], root );
+		let howMany = 4;
+		let baseVersion = doc.version;
+
+		let op = new MoveOperation( sourcePosition, targetPosition, howMany, baseVersion );
+
+		let 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( MoveOperation );
+		expect( clone.sourcePosition.isEqual( sourcePosition ) ).to.be.true;
+		expect( clone.targetPosition.isEqual( targetPosition ) ).to.be.true;
+		expect( clone.howMany ).to.equal( howMany );
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
+
+	describe( 'getTransformedBy', () => {
+		let nodeA, nodeB, op, baseVersion, sourcePosition, targetPosition, rangeEnd, howMany, expected;
+
+		beforeEach( () => {
+			nodeA = new Node();
+			nodeB = new Node();
+
+			baseVersion = doc.version;
+
+			sourcePosition = new Position( [ 2, 2, 4 ], root );
+			targetPosition = new Position( [ 3, 3, 3 ], root );
+			howMany = 2;
+
+			rangeEnd = sourcePosition.clone();
+			rangeEnd.offset += howMany;
+
+			op = new MoveOperation( sourcePosition, targetPosition, howMany, baseVersion );
+
+			expected = {
+				type: MoveOperation,
+				sourcePosition: sourcePosition.clone(),
+				targetPosition: targetPosition.clone(),
+				howMany: howMany,
+				baseVersion: baseVersion + 1
+			};
+		} );
+
+		function expectOperation( op, params ) {
+			for ( let i in params ) {
+				if ( params.hasOwnProperty( i ) ) {
+					if ( i == 'type' ) {
+						expect( op ).to.be.instanceof( params[ i ] );
+					}
+					else if ( params[ i ] instanceof Array ) {
+						expect( op[ i ].length ).to.equal( params[ i ].length );
+
+						for ( let j = 0; j < params[ i ].length; j++ ) {
+							expect( op[ i ][ j ] ).to.equal( params[ i ][ j ] );
+						}
+					} else if ( params[ i ] instanceof Position || params[ i ] instanceof Range ) {
+						expect( op[ i ].isEqual( params[ i ] ) ).to.be.true;
+					} else {
+						expect( op[ i ] ).to.equal( params[ i ] );
+					}
+				}
+			}
+		}
+
+		describe( 'InsertOperation', () => {
+			// insert in different spots than move op
+			it( 'should not change if origin and destination are different than insert address', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 1, 3, 2 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			// insert inside moved node
+			it( 'should not change if insert was inside moved sub-tree', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 2, 3, 1 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			// insert before to moved node
+			it( 'should increment origin offset if insert was in the same parent but before moved node', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 2, 0 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expected.sourcePosition.offset += 2;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			// insert after to moved node
+			it( 'should not increment offset if insert was in the same parent but after moved node', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 2, 6 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			// insert next to a path to moved node
+			it( 'should update origin path if insert was before a node from that path', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 0 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expected.sourcePosition.path[ 1 ] += 2;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'should not update origin path if insert was after a node from that path', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 3 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			// insert before to moved node
+			it( 'should increment destination offset if insert was in the same parent but before moved node', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3, 2 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expected.targetPosition.offset += 2;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			// insert after to moved node
+			it( 'should not increment destination offset if insert was in the same parent but after moved node', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3, 4 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			// insert next to a path to moved node
+			it( 'should update destination path if insert was before a node from that path', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 0 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expected.targetPosition.path[ 1 ] += 2;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'should not update destination path if insert was after a node from that path', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 6 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'should increment destination offset if insert is on the same position and move operation is weaker', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3, 3 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expected.targetPosition.offset += 2;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'should not increment destination offset if insert is on the same position and move operation is stronger', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3, 3 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy, true );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'should update destination path if insert is at the same offset and move operation is weaker', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expected.targetPosition.path[ 1 ] += 2;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'should be split into two operations if insert was inside the moved range', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 2, 5 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expect( transOp ).to.be.instanceof( Array );
+				expect( transOp.length ).to.equal( 2 );
+
+				expected.sourcePosition.path = [ 2, 2, 7 ];
+				expected.howMany = 1;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.howMany = 1;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+			} );
+		} );
+
+		describe( 'ChangeOperation', () => {
+			it( 'should not get updated', () => {
+				let transformBy = new ChangeOperation(
+					new Range( sourcePosition, rangeEnd ),
+					new Attribute( 'abc', true ),
+					new Attribute( 'abc', false ),
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'MoveOperation', () => {
+			it( 'should not change if both operations are happening in different parts of tree', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 1, 2 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					3,
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			describe( 'when incoming move origin node does not and is not contained by on-site move origin node', () => {
+				it( 'should increment origin offset if affected by on-site move-to', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 4, 1, 0 ], root ),
+						new Position( [ 2, 2, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.sourcePosition.offset += 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should decrement origin offset if affected by on-site move-out', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 0 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.sourcePosition.offset -= 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should not decrement origin offset if on-site moved range is after incoming moved range', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 9 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy, true );
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should update origin path if affected by on-site move-to', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 4, 1, 0 ], root ),
+						new Position( [ 2, 1 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.sourcePosition.path[ 1 ] += 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should not update origin path if on-site moved range is after a node from that path', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 3 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should update destination path if affected by on-site move-to', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 4, 1, 0 ], root ),
+						new Position( [ 3, 1 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.targetPosition.path[ 1 ] += 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should update origin path if affected by on-site move-out', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 0 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.sourcePosition.path[ 1 ] -= 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should update origin path if affected by on-site move-out', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 3, 0 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.targetPosition.path[ 1 ] -= 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should decrement destination offset if affected by on-site move-out', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 3, 3, 0 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.targetPosition.offset -= 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should increment destination offset affected by on-site move-to', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 4, 1, 0 ], root ),
+						new Position( [ 3, 3, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.targetPosition.offset += 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should get split into two operations if on-site move-to was inside incoming move range', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 4, 1, 0 ], root ),
+						new Position( [ 2, 2, 5 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expect( transOp ).to.be.instanceof( Array );
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.howMany = 1;
+					expected.sourcePosition.offset = 7;
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.sourcePosition.offset = 4;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+			} );
+
+			describe( 'when incoming move origin node sub-tree contains on-site move origin', () => {
+				it( 'should increment origin offset if affected by on-site move-to', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5, 1 ], root ),
+						new Position( [ 2, 2, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.sourcePosition.offset += 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should update origin path if affected by on-site move-to', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5, 1 ], root ),
+						new Position( [ 2, 1 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.sourcePosition.path[ 1 ] += 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should update destination path if affected by on-site move-to', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5, 1 ], root ),
+						new Position( [ 3, 1 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.targetPosition.path[ 1 ] += 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should increment destination offset affected by on-site move-to', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5, 1 ], root ),
+						new Position( [ 3, 3, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.targetPosition.offset += 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should get split into two operations if on-site move-to was inside incoming move range', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5, 1 ], root ),
+						new Position( [ 2, 2, 5 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expect( transOp ).to.be.instanceof( Array );
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.howMany = 1;
+					expected.sourcePosition.offset = 7;
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.sourcePosition.offset = 4;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+			} );
+
+			it( 'should not change if on-site move is from non-affecting position to inside of moved sub-tree', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 2, 2, 5, 1 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'should update origin address if on-site move origin node sub-tree includes incoming move origin node', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 1 ], root ),
+					new Position( [ 4, 2 ], root ),
+					3,
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expected.sourcePosition.path = [ 4, 3, 4 ];
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'should update destination address if incoming move destination is inside of on-site moved sub-tree', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 3, 2 ], root ),
+					new Position( [ 0, 1 ], root ),
+					3,
+					baseVersion
+				);
+
+				let transOp = op.getTransformedBy( transformBy );
+
+				expected.targetPosition.path = [ 0, 2, 3 ];
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			describe( 'when both move operations\' destinations are inside of moved sub-trees', () => {
+				it( 'should be changed to operation reversing site-on move', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 3, 2 ], root ),
+						new Position( [ 2, 2, 5, 0 ], root ),
+						3,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+					let reversed = transformBy.getReversed();
+
+					expected.sourcePosition = reversed.sourcePosition;
+					expected.targetPosition = reversed.targetPosition;
+					expected.howMany = reversed.howMany;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+			} );
+
+			describe( 'when both move operations have same range', () => {
+				it( 'should be changed to no-op if incoming operation is weaker', () => {
+					let transformBy = new MoveOperation(
+						op.sourcePosition.clone(),
+						new Position( [ 4, 1, 0 ], root ),
+						op.howMany,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expectOperation( transOp[ 0 ], {
+						type: NoOperation,
+						baseVersion: baseVersion + 1
+					} );
+				} );
+
+				it( 'should have it\'s origin address changed if incoming operation is stronger', () => {
+					let transformBy = new MoveOperation(
+						op.sourcePosition.clone(),
+						new Position( [ 4, 1, 0 ], root ),
+						op.howMany,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy, true );
+
+					expected.sourcePosition.path = [ 4, 1, 0 ];
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+			} );
+
+			describe( 'when incoming range is contained by on-site range', () => {
+				it( 'should be changed to no-op if incoming operation is weaker', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 3 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						4,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expectOperation( transOp[ 0 ], {
+						type: NoOperation,
+						baseVersion: baseVersion + 1
+					} );
+				} );
+
+				it( 'should have it\'s origin address changed if incoming operation has higher site id', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 3 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						4,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy, true );
+
+					expected.sourcePosition.path = [ 4, 1, 1 ];
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+			} );
+
+			describe( 'when incoming range intersects on right-side with on-site range', () => {
+				it( 'should get shrunk if it is weaker', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 3 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.sourcePosition.path = [ 2, 2, 3 ];
+					expected.howMany = 1;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should get split into two operations if it is stronger', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 3 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy, true );
+
+					expect( transOp ).to.be.instanceof( Array );
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.sourcePosition.path = [ 2, 2, 3 ];
+
+					expectOperation( transOp[ 0 ], {
+						type: MoveOperation,
+						sourcePosition: new Position( [ 4, 1, 1 ], root ),
+						targetPosition: expected.targetPosition,
+						howMany: 1,
+						baseVersion: expected.baseVersion
+					} );
+
+					expected.howMany = 1;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+			} );
+
+			describe( 'when incoming range intersects on left-side with on-site range', () => {
+				it( 'should get shrunk if it has lower site id', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.howMany = 1;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should get split into two operations (one of them with updated address and offset) if it has higher site id', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy, true );
+
+					expect( transOp ).to.be.instanceof( Array );
+					expect( transOp.length ).to.equal( 2 );
+
+					expectOperation( transOp[ 0 ], {
+						type: MoveOperation,
+						sourcePosition: new Position( [ 4, 1, 0 ], root ),
+						targetPosition: expected.targetPosition,
+						howMany: 1,
+						baseVersion: expected.baseVersion
+					} );
+
+					expected.howMany = 1;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+			} );
+
+			describe( 'when incoming range contains on-site range', () => {
+				it( 'should get shrunk if it has lower site id', () => {
+					op.howMany = 4;
+
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy );
+
+					expected.howMany = 2;
+
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'should get split into two operations if it has higher site id', () => {
+					op.howMany = 4;
+
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5 ], root ),
+						new Position( [ 4, 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy, true );
+
+					expect( transOp ).to.be.instanceof( Array );
+					expect( transOp.length ).to.equal( 2 );
+
+					expectOperation( transOp[ 0 ], {
+						type: MoveOperation,
+						sourcePosition: new Position( [ 4, 1, 0 ], root ),
+						targetPosition: expected.targetPosition,
+						howMany: 2,
+						baseVersion: expected.baseVersion
+					} );
+
+					expected.howMany = 2;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+
+				it( 'should get split into three operations if it has higher site id and on-site destination is inside moved range', () => {
+					op.howMany = 6;
+
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 2, 5 ], root ),
+						new Position( [ 2, 2, 9 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = op.getTransformedBy( transformBy, true );
+
+					expect( transOp ).to.be.instanceof( Array );
+					expect( transOp.length ).to.equal( 3 );
+
+					expected.sourcePosition.path = [ 2, 2, 9 ];
+					expected.howMany = 1;
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.sourcePosition.path = [ 2, 2, 7 ];
+					expected.howMany = 2;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+
+					expected.sourcePosition.path = [ 2, 2, 4 ];
+					expected.howMany = 3;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 2 ], expected );
+				} );
+			} );
+		} );
+	} );
 } );