浏览代码

Introduced multiple special cases for deltas transformation, documented and tested delta transformations.

Szymon Cofalik 9 年之前
父节点
当前提交
e08d873e69

+ 346 - 26
packages/ckeditor5-engine/src/treemodel/delta/transform.js

@@ -7,84 +7,241 @@
 
 
 import OT from '../operation/transform.js';
 import OT from '../operation/transform.js';
 
 
+import Range from '../range.js';
+
+import AttributeOperation from '../operation/attributeoperation.js';
+
+import Delta from './delta.js';
 import AttributeDelta from './attributedelta.js';
 import AttributeDelta from './attributedelta.js';
 import InsertDelta from './insertdelta.js';
 import InsertDelta from './insertdelta.js';
 import MergeDelta from './mergedelta.js';
 import MergeDelta from './mergedelta.js';
 import MoveDelta from './movedelta.js';
 import MoveDelta from './movedelta.js';
 import RemoveDelta from './removedelta.js';
 import RemoveDelta from './removedelta.js';
 import SplitDelta from './splitdelta.js';
 import SplitDelta from './splitdelta.js';
-import Delta from './delta.js';
+import WeakInsertDelta from './weakinsertdelta.js';
+import WrapDelta from './wrapdelta.js';
+import UnwrapDelta from './unwrapdelta.js';
 
 
 import arrayUtils from '../../lib/lodash/array.js';
 import arrayUtils from '../../lib/lodash/array.js';
 import utils from '../../utils.js';
 import utils from '../../utils.js';
 
 
-const baseDeltas = [ AttributeDelta, InsertDelta, MoveDelta, RemoveDelta ];
+/**
+ * @namespace core.treeModel.delta.transform
+ */
+
+/**
+ * When it comes to delta transformations, delta has their priorities. Priorities mean which delta should be a
+ * stronger one when conflicting situation arises. These depend only on delta type. The higher the priority, the stronger
+ * the delta is. For example, then MoveDelta tries to move some nodes and SplitDelta tries to move the some of those
+ * nodes to a new parent, MoveDelta should be treated as stronger and the result of MoveDelta should overwrite SplitDelta.
+ * When deltas have same priorities, `isStrong` parameter is used to decide which one is stronger. This parameter should
+ * be arbitrary set, i.e. depending on the source of the delta (i.e. during collaborative editing deltas coming from
+ * server might be more important than deltas produced on client).
+ *
+ * @external core.treeModel.delta.transform
+ * @member core.treeModel.delta.transform.priorities
+ * @type {Map}
+ */
+export const priorities = new Map();
+priorities.set( MergeDelta, 0 );
+priorities.set( UnwrapDelta, 1 );
+priorities.set( WrapDelta, 1 );
+priorities.set( SplitDelta, 1 );
+priorities.set( AttributeDelta, 2 );
+priorities.set( InsertDelta, 2 );
+priorities.set( MoveDelta, 2 );
+priorities.set( RemoveDelta, 3 );
+
 const specialCases = new Map();
 const specialCases = new Map();
 
 
-export default function transform( a, b, isStrong ) {
+/**
+ * Transforms given {@link core.treeModel.delta.Delta delta} by another {@link core.treeModel.delta.Delta delta} and
+ * returns the result of that transformation as an array containing one or more {@link core.treeModel.delta.Delta delta}
+ * instances.
+ *
+ * Delta transformations heavily base on {@link core.treeModel.operation.transform operational transformations}. Since
+ * delta is a list of operations most situations can be handled thanks to operational transformation. Unfortunately,
+ * deltas are more complicated than operations and have they semantic meaning, as they represent user's editing intentions.
+ *
+ * Sometimes, simple operational transformation on deltas' operations might result in some unexpected results. Those
+ * results would be fine from OT point of view, but would not reflect user's intentions. Because of such conflicts
+ * we need to handle transformations in special cases in a custom way.
+ *
+ * The function itself looks whether two given delta types have a special case function registered. If so, the deltas are
+ * transformed using that function. If not, {@link core.treeModel.delta.defaultTransform default transformation algorithm}
+ * is used.
+ *
+ * @see core.treeModel.operation.transform
+ *
+ * @external core.treeModel.delta.transform
+ * @function core.treeModel.delta.transform.transform
+ * @param {core.treeModel.delta.Delta} a Delta that will be transformed.
+ * @param {core.treeModel.delta.Delta} b Delta to transform by.
+ * @param {Boolean} isAMoreImportantThanB Flag indicating whether the delta which will be transformed (`a`) should be treated
+ * as more important when resolving conflicts. Note that this flag is used only if provided deltas have same
+ * {@link core.treeModel.delta.priorities priority}. If deltas have different priorities, their importance is resolved
+ * automatically and overwrites this flag.
+ * @returns {Array.<core.treeModel.delta.Delta>} Result of the transformation.
+ */
+export default function transform( a, b, isAMoreImportantThanB ) {
 	let transformAlgorithm = defaultTransform;
 	let transformAlgorithm = defaultTransform;
 
 
 	let casesA = specialCases.get( a.constructor );
 	let casesA = specialCases.get( a.constructor );
 
 
+	if ( !casesA ) {
+		let cases = specialCases.keys();
+
+		for ( let caseClass of cases ) {
+			if ( a instanceof caseClass ) {
+				casesA = specialCases.get( caseClass );
+			}
+		}
+	}
+
 	if ( casesA ) {
 	if ( casesA ) {
 		let caseB = casesA.get( b.constructor );
 		let caseB = casesA.get( b.constructor );
 		transformAlgorithm = caseB || transformAlgorithm;
 		transformAlgorithm = caseB || transformAlgorithm;
 	}
 	}
 
 
-	let transformed = transformAlgorithm( a, b, isStrong );
+	let transformed = transformAlgorithm( a, b, isAMoreImportantThanB );
 	let baseVersion = arrayUtils.last( b.operations ).baseVersion;
 	let baseVersion = arrayUtils.last( b.operations ).baseVersion;
 
 
 	return updateBaseVersion( baseVersion, transformed );
 	return updateBaseVersion( baseVersion, transformed );
 }
 }
 
 
-export function defaultTransform( a, b, isStrong ) {
-	isStrong = getPriority( a, b, isStrong );
-
+/**
+ * The default delta transformation function. It is used for those deltas that are not in special case conflict.
+ *
+ * This algorithm is similar to popular `dOPT` algorithm used in operational transformation, as we are in fact
+ * transforming two sets of operations by each other.
+ *
+ * @param {core.treeModel.delta.Delta} a Delta that will be transformed.
+ * @param {core.treeModel.delta.Delta} b Delta to transform by.
+ * @param {Boolean} isAMoreImportantThanB Flag indicating whether the delta which will be transformed (`a`) should be treated
+ * as more important when resolving conflicts. Note that this flag is used only if provided deltas have same
+ * {@link core.treeModel.delta.priorities priority}. If deltas have different priorities, their importance is resolved
+ * automatically and overwrites this flag.
+ * @returns {Array.<core.treeModel.delta.Delta>} Result of the transformation, that is an array with single delta instance.
+ */
+export function defaultTransform( a, b, isAMoreImportantThanB ) {
+	// First, resolve the flag real value.
+	isAMoreImportantThanB = getPriority( a.constructor, b.constructor, isAMoreImportantThanB );
+
+	// Create a new delta instance. Make sure that the new delta is of same type as transformed delta.
+	// We will transform operations in that delta but it doesn't mean the delta's "meaning" which is connected to
+	// the delta's type. Since the delta's type is heavily used in transformations and probably other parts
+	// of system it is important to keep proper delta type through all transformation process.
 	const transformed = new a.constructor();
 	const transformed = new a.constructor();
 
 
+	// Array containing operations that we will transform by. At the beginning these are just operations from
 	let byOps = b.operations;
 	let byOps = b.operations;
+
+	// This array is storing operations from `byOps` which got transformed by operation from delta `a`.
 	let newByOps = [];
 	let newByOps = [];
 
 
+	// We take each operation from original set of operations to transform.
 	for ( let opA of a.operations ) {
 	for ( let opA of a.operations ) {
+		// We wrap the operation in the array. This is important, because operation transformation algorithm returns
+		// an array of operations so we need to make sure that our algorithm is ready to handle arrays.
 		let ops = [ opA ];
 		let ops = [ opA ];
 
 
+		// Now the real algorithm takes place.
 		for ( let opB of byOps ) {
 		for ( let opB of byOps ) {
+			// For each operation that we need transform by...
 			for ( let i = 0; i < ops.length; i++ ) {
 			for ( let i = 0; i < ops.length; i++ ) {
+				// We take each operation to transform...
 				let op = ops[ i ];
 				let op = ops[ i ];
 
 
+				// And transform both of them by themselves.
+
+				// The result of transforming operation from delta B by operation from delta A is saved in
+				// `newByOps` array. We will use that array for transformations in next loops. We need delta B
+				// operations after transformed by delta A operations to get correct results of transformations
+				// of next operations from delta A.
+				//
+				// It's like this because 2nd operation from delta A assumes that 1st operation from delta A
+				// is "already applied". When we transform 2nd operation from delta A by operations from delta B
+				// we have to be sure that operations from delta B are in a state that acknowledges 1st operation
+				// from delta A.
+				//
+				// This can be easier understood when operations sets to transform are represented by diamond diagrams:
+				// http://www.codecommit.com/blog/java/understanding-and-applying-operational-transformation
+
 				// Using push.apply because OT function is returning an array with one or multiple results.
 				// Using push.apply because OT function is returning an array with one or multiple results.
-				Array.prototype.push.apply( newByOps, OT( opB, op, !isStrong ) );
+				Array.prototype.push.apply( newByOps, OT( opB, op, !isAMoreImportantThanB ) );
+
+				// Then, we transform operation from delta A by operation from delta B.
+				let results = OT( op, opB, isAMoreImportantThanB );
 
 
-				// Using splice.apply for the same reason.
-				let results = OT( op, opB, isStrong );
+				// We replace currently processed operation from `ops` array by the results of transformation.
+				// Note, that we process single operation but the OT result might be an array, so we might
+				// splice-in more operations. We will process them further in next iterations. Right now we
+				// just save them in `ops` array and move `i` pointer by proper offset.
 				Array.prototype.splice.apply( ops, [ i, 1 ].concat( results ) );
 				Array.prototype.splice.apply( ops, [ i, 1 ].concat( results ) );
 
 
 				i += results.length - 1;
 				i += results.length - 1;
 			}
 			}
+
+			// At this point a single operation from delta A got transformed by a single operation from delta B.
+			// The transformation result is in `ops` array and it may be one or more operations. This was just the first step.
+			// Operation from delta A has to be further transformed by the other operations from delta B.
+			// So in next iterator loop we will take another operation from delta B and use transformed delta A (`ops`)
+			// to transform it further.
 		}
 		}
 
 
+		// We got through all delta B operations and have a final transformed state of an operation from delta A.
+
+		// As previously mentioned, we substitute operations from delta B by their transformed equivalents.
 		byOps = newByOps;
 		byOps = newByOps;
 		newByOps = [];
 		newByOps = [];
 
 
+		// We add transformed operation from delta A to newly created delta.
+		// Remember that transformed operation from delta A may consist of multiple operations.
 		for ( let op of ops ) {
 		for ( let op of ops ) {
 			transformed.addOperation( op );
 			transformed.addOperation( op );
 		}
 		}
+
+		// In next loop, we will take another operation from delta A and transform it through (transformed) operations
+		// from delta B...
 	}
 	}
 
 
 	return [ transformed ];
 	return [ transformed ];
 }
 }
 
 
-export function addSpecialCase( a, b, resolver ) {
-	let casesA = specialCases.get( a );
+/**
+ * Adds a special case callback for given delta classes.
+ *
+ * @param {Function} A Delta constructor which instance will get transformed.
+ * @param {Function} B Delta constructor which instance will be transformed by.
+ * @param {Function} resolver A callback that will handle custom special case transformation for instances of given delta classes.
+ * @external core.treeModel.delta.transform
+ * @function core.treeModel.delta.transform.addSpecialCase
+ */
+export function addSpecialCase( A, B, resolver ) {
+	let casesA = specialCases.get( A );
 
 
 	if ( !casesA ) {
 	if ( !casesA ) {
 		casesA = new Map();
 		casesA = new Map();
-		specialCases.set( a, casesA );
+		specialCases.set( A, casesA );
 	}
 	}
 
 
-	casesA.set( b, resolver );
+	casesA.set( B, resolver );
 }
 }
 
 
+// Add special case for AttributeDelta x WeakInsertDelta transformation.
+addSpecialCase( AttributeDelta, WeakInsertDelta, ( a, b, isStrong ) => {
+	// If nodes are weak-inserted into attribute delta range, we need to apply changes from attribute delta on them.
+	// So first we do the normal transformation and if this special cases happens, we will add an extra delta.
+	let deltas = defaultTransform( a, b, isStrong );
+
+	if ( a.range.containsPosition( b.position ) ) {
+		deltas.push( _getComplementaryAttrDelta( b, a ) );
+	}
+
+	return deltas;
+} );
+
+// Add special case for InsertDelta x MergeDelta transformation.
 addSpecialCase( InsertDelta, MergeDelta, ( a, b, isStrong ) => {
 addSpecialCase( InsertDelta, MergeDelta, ( a, b, isStrong ) => {
 	// If insert is applied at the same position where merge happened, we reverse the merge (we treat it like it
 	// If insert is applied at the same position where merge happened, we reverse the merge (we treat it like it
 	// didn't happen) and then apply the original insert operation. This is "mirrored" in MergeDelta x InsertDelta
 	// didn't happen) and then apply the original insert operation. This is "mirrored" in MergeDelta x InsertDelta
@@ -99,6 +256,26 @@ addSpecialCase( InsertDelta, MergeDelta, ( a, b, isStrong ) => {
 	return defaultTransform( a, b, isStrong );
 	return defaultTransform( a, b, isStrong );
 } );
 } );
 
 
+// Add special case for MoveDelta x MergeDelta transformation.
+addSpecialCase( MoveDelta, MergeDelta, ( a, b, isStrong ) => {
+	// If move delta is supposed to move a node that has been merged, we reverse the merge (we treat it like it
+	// didn't happen) and then apply the original move operation. This is "mirrored" in MergeDelta x MoveDelta
+	// transformation below, where we simply do not apply MergeDelta.
+
+	let operateInSameParent = utils.compareArrays( a.sourcePosition.getParentPath(), b.position.getParentPath() ) === 'SAME';
+	let mergeInsideMoveRange = a.sourcePosition.offset <= b.position.offset && a.sourcePosition.offset + a.howMany > b.position.offset;
+
+	if ( operateInSameParent && mergeInsideMoveRange ) {
+		return [
+			b.getReversed(),
+			a.clone()
+		];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for MergeDelta x InsertDelta transformation.
 addSpecialCase( MergeDelta, InsertDelta, ( a, b, isStrong ) => {
 addSpecialCase( MergeDelta, InsertDelta, ( a, b, isStrong ) => {
 	// If merge is applied at the same position where we inserted a range of nodes we cancel the merge as it's results
 	// If merge is applied at the same position where we inserted a range of nodes we cancel the merge as it's results
 	// may be unexpected and very weird. Even if we do some "magic" we don't know what really are users' expectations.
 	// may be unexpected and very weird. Even if we do some "magic" we don't know what really are users' expectations.
@@ -111,6 +288,24 @@ addSpecialCase( MergeDelta, InsertDelta, ( a, b, isStrong ) => {
 	return defaultTransform( a, b, isStrong );
 	return defaultTransform( a, b, isStrong );
 } );
 } );
 
 
+// Add special case for MergeDelta x MoveDelta transformation.
+addSpecialCase( MergeDelta, MoveDelta, ( a, b, isStrong ) => {
+	// If merge is applied at the position between moved nodes we cancel the merge as it's results may be unexpected and
+	// very weird. Even if we do some "magic" we don't know what really are users' expectations.
+
+	let operateInSameParent = utils.compareArrays( a.position.getParentPath(), b.sourcePosition.getParentPath() ) === 'SAME';
+	let mergeInsideMoveRange = b.sourcePosition.offset <= a.position.offset && b.sourcePosition.offset + b.howMany > a.position.offset;
+
+	if ( operateInSameParent && mergeInsideMoveRange ) {
+		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
+		// It is used when we don't want to apply changes but still we need to return a delta.
+		return [ new Delta() ];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for SplitDelta x SplitDelta transformation.
 addSpecialCase( SplitDelta, SplitDelta, ( a, b, isStrong ) => {
 addSpecialCase( SplitDelta, SplitDelta, ( a, b, isStrong ) => {
 	let pathA = a.position.getParentPath();
 	let pathA = a.position.getParentPath();
 	let pathB = b.position.getParentPath();
 	let pathB = b.position.getParentPath();
@@ -126,20 +321,20 @@ addSpecialCase( SplitDelta, SplitDelta, ( a, b, isStrong ) => {
 			// This means that we have to update `howMany` parameter of `MoveOperation` for that delta.
 			// This means that we have to update `howMany` parameter of `MoveOperation` for that delta.
 
 
 			let delta = a.clone();
 			let delta = a.clone();
-			delta.moveOperation.howMany = b.position.offset - a.position.offset;
+			delta._moveOperation.howMany = b.position.offset - a.position.offset;
 
 
 			return [ delta ];
 			return [ delta ];
-		} else if ( a.position.offset > b.position.offset ) {
+		} else {
 			// Incoming split delta splits at further offset. We have to simulate that we are not splitting the
 			// Incoming split delta splits at further offset. We have to simulate that we are not splitting the
 			// original split node but the node after it, which got created by the other split delta.
 			// original split node but the node after it, which got created by the other split delta.
 			// To do so, we increment offsets so it looks like the split delta was created in the next node.
 			// To do so, we increment offsets so it looks like the split delta was created in the next node.
 
 
 			let delta = a.clone();
 			let delta = a.clone();
 
 
-			delta.cloneOperation.position.offset++;
-			delta.moveOperation.sourcePosition.path[ delta.moveOperation.sourcePosition.path.length - 2 ]++;
-			delta.moveOperation.targetPosition.path[ delta.moveOperation.targetPosition.path.length - 2 ]++;
-			delta.moveOperation.sourcePosition.offset = a.position.offset - b.position.offset;
+			delta._cloneOperation.position.offset++;
+			delta._moveOperation.sourcePosition.path[ delta._moveOperation.sourcePosition.path.length - 2 ]++;
+			delta._moveOperation.targetPosition.path[ delta._moveOperation.targetPosition.path.length - 2 ]++;
+			delta._moveOperation.sourcePosition.offset = a.position.offset - b.position.offset;
 
 
 			return [ delta ];
 			return [ delta ];
 		}
 		}
@@ -148,17 +343,142 @@ addSpecialCase( SplitDelta, SplitDelta, ( a, b, isStrong ) => {
 	return defaultTransform( a, b, isStrong );
 	return defaultTransform( a, b, isStrong );
 } );
 } );
 
 
-function getPriority( a, b, isStrong ) {
-	let aIsBase = baseDeltas.indexOf( a ) > -1;
-	let bIsBase = baseDeltas.indexOf( b ) > -1;
+// Add special case for SplitDelta x UnwrapDelta transformation.
+addSpecialCase( SplitDelta, UnwrapDelta, ( a, b, isStrong ) => {
+	// If incoming split delta tries to split a node that just got unwrapped, there is actually nothing to split,
+	// so we discard that delta.
+	if ( utils.compareArrays( b.position.path, a.position.getParentPath() ) === 'SAME' ) {
+		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
+		// It is used when we don't want to apply changes but still we need to return a delta.
+		return [ new Delta() ];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for SplitDelta x WrapDelta transformation.
+addSpecialCase( SplitDelta, WrapDelta, ( a, b, isStrong ) => {
+	// If split is applied at the position between wrapped nodes, we cancel the split as it's results may be unexpected and
+	// very weird. Even if we do some "magic" we don't know what really are users' expectations.
+
+	let operateInSameParent = utils.compareArrays( a.position.getParentPath(), b.range.start.getParentPath() ) === 'SAME';
+	let splitInsideWrapRange = b.range.start.offset < a.position.offset && b.range.end.offset >= a.position.offset;
+
+	if ( operateInSameParent && splitInsideWrapRange ) {
+		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
+		// It is used when we don't want to apply changes but still we need to return a delta.
+		return [ new Delta() ];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for UnwrapDelta x SplitDelta transformation.
+addSpecialCase( UnwrapDelta, SplitDelta, ( a, b, isStrong ) => {
+	// If incoming unwrap delta tries to unwrap node that got split we should unwrap the original node and the split copy.
+	// This can be achieved either by reverting split and applying unwrap to singular node, or creating additional unwrap delta.
+	if ( utils.compareArrays( a.position.path, b.position.getParentPath() ) === 'SAME' ) {
+		return [
+			b.getReversed(),
+			a.clone()
+		];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for WrapDelta x SplitDelta transformation.
+addSpecialCase( WrapDelta, SplitDelta, ( a, b, isStrong ) => {
+	// If incoming wrap delta tries to wrap range that contains split position, we have to cancel the split and apply
+	// the wrap. Since split was already applied, we have to revert it.
+
+	let operateInSameParent = utils.compareArrays( a.range.start.getParentPath(), b.position.getParentPath() ) === 'SAME';
+	let splitInsideWrapRange = a.range.start.offset < b.position.offset && a.range.end.offset >= b.position.offset;
+
+	if ( operateInSameParent && splitInsideWrapRange ) {
+		return [
+			b.getReversed(),
+			a.clone()
+		];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for WeakInsertDelta x AttributeDelta transformation.
+addSpecialCase( WeakInsertDelta, AttributeDelta, ( a, b, isStrong ) => {
+	// If nodes are weak-inserted into attribute delta range, we need to apply changes from attribute delta on them.
+	// So first we do the normal transformation and if this special cases happens, we will add an extra delta.
+	let deltas = defaultTransform( a, b, isStrong );
+
+	if ( b.range.containsPosition( a.position ) ) {
+		deltas.push( _getComplementaryAttrDelta( a, b ) );
+	}
+
+	return deltas;
+} );
+
+// Creates an attribute delta that sets attribute from given `attributeDelta` on nodes from given `weakInsertDelta`.
+function _getComplementaryAttrDelta( weakInsertDelta, attributeDelta ) {
+	let complementaryAttrDelta = new AttributeDelta();
+
+	// At the beginning we store the attribute value from the first node on `weakInsertDelta` node list.
+	let val = weakInsertDelta.nodeList.get( 0 ).getAttribute( attributeDelta.key );
+
+	// This stores the last index of `weakInsertDelta` node list where the attribute value was different
+	// than in the previous node. We need it to create separate `AttributeOperation`s for nodes with different attributes.
+	let lastIndex = 0;
+
+	for ( let i = 0; i < weakInsertDelta.nodeList.length; i++ ) {
+		let node = weakInsertDelta.nodeList.get( i );
+		let nodeAttrVal = node.getAttribute( attributeDelta.key );
+
+		// If previous node has different attribute value, we will create an operation to the point before current node.
+		// So all nodes with the same attributes up to this point will be included in one `AttributeOperation`.
+		if ( nodeAttrVal != val ) {
+			// New operation is created only when it is needed. If given node already has proper value for this
+			// attribute we simply skip it without adding a new operation.
+			if ( val != attributeDelta.value ) {
+				let range = new Range( weakInsertDelta.position.getShiftedBy( lastIndex ), weakInsertDelta.position.getShiftedBy( i ) );
+
+				// We don't care about base version because it will be updated after transformations anyway.
+				let attrOperation = new AttributeOperation( range, attributeDelta.key, val, attributeDelta.value, 0 );
+				complementaryAttrDelta.addOperation( attrOperation );
+			}
+
+			val = nodeAttrVal;
+			lastIndex = i;
+		}
+	}
+
+	// At the end we have to add additional `AttributeOperation` for the last part of node list. If all nodes on the
+	// node list had same attributes, this will be the only operation added to the delta.
+	let range = new Range(
+		weakInsertDelta.position.getShiftedBy( lastIndex ),
+		weakInsertDelta.position.getShiftedBy( weakInsertDelta.nodeList.length )
+	);
+
+	complementaryAttrDelta.addOperation( new AttributeOperation( range, attributeDelta.key, val, attributeDelta.value, 0 ) );
+
+	return complementaryAttrDelta;
+}
+
+// Checks priorities of passed constructors and decides which one is more important.
+// If both priorities are same, value passed in `isAMoreImportantThanB` parameter is used.
+function getPriority( A, B, isAMoreImportantThanB ) {
+	let aPriority = priorities.get( A );
+	let bPriority = priorities.get( B );
 
 
-	if ( aIsBase != bIsBase ) {
-		return aIsBase;
+	if ( aPriority > bPriority ) {
+		return true;
+	} else if ( aPriority < bPriority ) {
+		return false;
 	} else {
 	} else {
-		return isStrong;
+		return isAMoreImportantThanB;
 	}
 	}
 }
 }
 
 
+// Updates base versions of operations inside deltas (which are the results of delta transformation).
 function updateBaseVersion( baseVersion, deltas ) {
 function updateBaseVersion( baseVersion, deltas ) {
 	for ( let delta of deltas ) {
 	for ( let delta of deltas ) {
 		for ( let op of delta.operations ) {
 		for ( let op of delta.operations ) {

+ 0 - 458
packages/ckeditor5-engine/tests/treemodel/delta/transform.js

@@ -1,458 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* bender-tags: treemodel, operation */
-
-'use strict';
-
-import transform from '/ckeditor5/core/treemodel/delta/transform.js';
-
-import Document from '/ckeditor5/core/treemodel/document.js';
-import Element from '/ckeditor5/core/treemodel/element.js';
-import Position from '/ckeditor5/core/treemodel/position.js';
-import Range from '/ckeditor5/core/treemodel/range.js';
-
-import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
-import InsertDelta from '/ckeditor5/core/treemodel/delta/insertdelta.js';
-import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
-import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
-
-import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
-import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
-import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
-import ReinsertOperation from '/ckeditor5/core/treemodel/operation/reinsertoperation.js';
-
-import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
-const getNodesAndText = treeModelTestUtils.getNodesAndText;
-
-function getInsertDelta( position, nodes, version ) {
-	let delta = new InsertDelta();
-	delta.addOperation( new InsertOperation( position, nodes, version ) );
-
-	return delta;
-}
-
-function getMergeDelta( position, howManyInPrev, howManyInNext, version ) {
-	let delta = new MergeDelta();
-
-	let sourcePosition = Position.createFromPosition( position );
-	sourcePosition.path.push( 0 );
-
-	let targetPosition = Position.createFromPosition( position );
-	targetPosition.offset--;
-	targetPosition.path.push( howManyInPrev );
-
-	delta.addOperation( new MoveOperation( sourcePosition, howManyInNext, targetPosition, version ) );
-	delta.addOperation( new RemoveOperation( position, 1, version + 1 ) );
-
-	return delta;
-}
-
-function getSplitDelta( position, nodeCopy, howManyMove, version ) {
-	let delta = new SplitDelta();
-
-	let insertPosition = Position.createFromPosition( position );
-	insertPosition.path = insertPosition.getParentPath();
-	insertPosition.offset++;
-
-	let targetPosition = Position.createFromPosition( insertPosition );
-	targetPosition.path.push( 0 );
-
-	delta.addOperation( new InsertOperation( insertPosition, [ nodeCopy ], version ) );
-	delta.addOperation( new MoveOperation( position, howManyMove, targetPosition, version + 1 ) );
-
-	return delta;
-}
-
-function expectDelta( delta, expected ) {
-	expect( delta ).to.be.instanceof( expected.type );
-	expect( delta.operations.length ).to.equal( expected.operations.length );
-
-	for ( let i = 0; i < delta.operations.length; i++ ) {
-		expectOperation( delta.operations[ i ], expected.operations[ i ] );
-	}
-}
-
-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 ( i == 'nodes' ) {
-				expect( op.nodeList._nodes ).to.deep.equal( params[ i ] );
-			} 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 ] );
-			}
-		}
-	}
-}
-
-function applyDelta( delta, document ) {
-	for ( let op of delta.operations ) {
-		document.applyOperation( op );
-	}
-}
-
-describe( 'transform', () => {
-	let doc, root, gy, baseVersion;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot( 'root' );
-
-		root.insertChildren( 0, [
-			new Element( 'x' ),
-			new Element( 'x' ),
-			new Element( 'x', [], 'a' ),
-			new Element( 'div', [], [
-				new Element( 'x' ),
-				new Element( 'x' ),
-				new Element( 'x', [], 'a' ),
-				new Element( 'div', [], [
-					new Element( 'x' ),
-					new Element( 'x' ),
-					new Element( 'x', [], 'abcd' ),
-					new Element( 'p', [], 'abcfoobarxyz' )
-				] )
-			] )
-		] );
-
-		gy = doc.graveyard;
-		baseVersion = doc.version;
-	} );
-
-	describe( 'InsertDelta by', () => {
-		let insertDelta, insertPosition, nodeA, nodeB;
-
-		beforeEach( () => {
-			nodeA = new Element( 'a' );
-			nodeB = new Element( 'b' );
-
-			insertPosition = new Position( root, [ 3, 3, 3 ] );
-			insertDelta = getInsertDelta( insertPosition, [ nodeA, nodeB ], baseVersion );
-		} );
-
-		describe( 'MergeDelta', () => {
-			it( 'merge in same position as insert', () => {
-				let mergeDelta = getMergeDelta( insertPosition, 4, 12, baseVersion );
-				let transformed = transform( insertDelta, mergeDelta );
-
-				baseVersion = mergeDelta.operations.length;
-
-				// Expected: MergeOperation gets reversed (by special case of SplitDelta that has ReinsertOperation
-				// instead of InsertOperation. Then InsertDelta is applied as is.
-
-				expect( transformed.length ).to.equal( 2 );
-
-				expectDelta( transformed[ 0 ], {
-					type: SplitDelta,
-					operations: [
-						{
-							type: ReinsertOperation,
-							sourcePosition: new Position( gy, [ 0 ] ),
-							howMany: 1,
-							targetPosition: new Position( root, [ 3, 3, 3 ] ),
-							baseVersion: baseVersion
-						},
-						{
-							type: MoveOperation,
-							sourcePosition: new Position( root, [ 3, 3, 2, 4 ] ),
-							howMany: 12,
-							targetPosition: new Position( root, [ 3, 3, 3, 0 ] ),
-							baseVersion: baseVersion + 1
-						}
-					]
-				} );
-
-				expectDelta( transformed[ 1 ], {
-					type: InsertDelta,
-					operations: [
-						{
-							type: InsertOperation,
-							position: Position.createFromPosition( insertPosition ),
-							nodes: [ nodeA, nodeB ],
-							baseVersion: baseVersion + 2
-						}
-					]
-				} );
-
-				// Test if deltas do what they should after applying transformed delta.
-
-				applyDelta( mergeDelta, doc );
-				applyDelta( transformed[ 0 ], doc );
-				applyDelta( transformed[ 1 ], doc );
-
-				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
-
-				// Merge between X with "abcd" and P with "abcfoobarxyz" should be reversed and AB should be inserted between X and P.
-				expect( nodesAndText ).to.equal( 'XXXXXabcdXAABBPabcfoobarxyzP' );
-			} );
-
-			it( 'merge the node that is parent of insert position (sticky move test)', () => {
-				let mergeDelta = getMergeDelta( new Position( root, [ 3, 3 ] ), 1, 4, baseVersion );
-				let transformed = transform( insertDelta, mergeDelta );
-
-				baseVersion = mergeDelta.operations.length;
-
-				// Expected: InsertOperation in InsertDelta has it's path updated.
-
-				expect( transformed.length ).to.equal( 1 );
-
-				expectDelta( transformed[ 0 ], {
-					type: InsertDelta,
-					operations: [
-						{
-							type: InsertOperation,
-							position: new Position( root, [ 3, 2, 4 ] ),
-							nodes: [ nodeA, nodeB ],
-							baseVersion: baseVersion
-						}
-					]
-				} );
-
-				// Test if deltas do what they should after applying transformed delta.
-
-				applyDelta( mergeDelta, doc );
-				applyDelta( transformed[ 0 ], doc );
-
-				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 2, 0 ] ), 7 ) );
-
-				// Merge between X with "a" and DIV should be applied. AB should be inserted in new, correct position.
-				expect( nodesAndText ).to.equal( 'aXXXXXabcdXAABBPabcfoobarxyzP' );
-			} );
-
-			it( 'merge at affected position but resolved by default OT', () => {
-				let mergeDelta = getMergeDelta( new Position( root, [ 3 ] ), 1, 4, baseVersion );
-				let transformed = transform( insertDelta, mergeDelta );
-
-				baseVersion = mergeDelta.operations.length;
-
-				// Expected: InsertOperation in InsertDelta has it's path updated.
-
-				expect( transformed.length ).to.equal( 1 );
-
-				expectDelta( transformed[ 0 ], {
-					type: InsertDelta,
-					operations: [
-						{
-							type: InsertOperation,
-							position: new Position( root, [ 2, 4, 3 ] ),
-							nodes: [ nodeA, nodeB ],
-							baseVersion: baseVersion
-						}
-					]
-				} );
-
-				// Test if deltas do what they should after applying transformed delta.
-
-				applyDelta( mergeDelta, doc );
-				applyDelta( transformed[ 0 ], doc );
-
-				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 2, 0 ] ), 5 ) );
-
-				// Merge between X with "a" and DIV should be applied. AB should be inserted in new, correct position.
-				expect( nodesAndText ).to.equal( 'aXXXXXaXDIVXXXXXabcdXAABBPabcfoobarxyzPDIV' );
-			} );
-		} );
-	} );
-
-	describe( 'MergeDelta by', () => {
-		let mergeDelta, mergePosition;
-
-		beforeEach( () => {
-			mergePosition = new Position( root, [ 3, 3, 3 ] );
-			mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
-		} );
-
-		describe( 'InsertDelta', () => {
-			let nodeA, nodeB;
-
-			beforeEach( () => {
-				nodeA = new Element( 'a' );
-				nodeB = new Element( 'b' );
-			} );
-
-			it( 'insert at same position as merge', () => {
-				let insertDelta = getInsertDelta( mergePosition, [ nodeA, nodeB ], baseVersion );
-				let transformed = transform( mergeDelta, insertDelta );
-
-				// Expected: MergeDelta gets ignored and is not applied.
-
-				expect( transformed.length ).to.equal( 1 );
-
-				expectDelta( transformed[ 0 ], {
-					type: Delta,
-					operations: []
-				} );
-
-				// Test if deltas do what they should after applying transformed delta.
-
-				applyDelta( insertDelta, doc );
-				applyDelta( transformed[ 0 ], doc );
-
-				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
-
-				// InsertDelta is applied. Merge between X and P is discarded.
-				expect( nodesAndText ).to.equal( 'XXXXXabcdXAABBPabcfoobarxyzP' );
-			} );
-
-			it( 'insert inside merged node (sticky move test)', () => {
-				let insertDelta = getInsertDelta( new Position( root, [ 3, 3, 3, 12 ] ), [ nodeA, nodeB ], baseVersion );
-				let transformed = transform( mergeDelta, insertDelta );
-
-				baseVersion = insertDelta.operations.length;
-
-				expect( transformed.length ).to.equal( 1 );
-
-				// Expected: MoveOperation in MergeDelta has it's "range" expanded.
-
-				expectDelta( transformed[ 0 ], {
-					type: MergeDelta,
-					operations: [
-						{
-							type: MoveOperation,
-							sourcePosition: new Position( root, [ 3, 3, 3, 0 ] ),
-							howMany: 14,
-							targetPosition: new Position( root, [ 3, 3, 2, 4 ] ),
-							baseVersion: baseVersion
-						},
-						{
-							// This is `MoveOperation` instead of `RemoveOperation` because during OT,
-							// `RemoveOperation` may get converted to `MoveOperation`. Still, this expectation is
-							// correct because `RemoveOperation` is deriving from `MoveOperation`. So we can expect
-							// that something that was `RemoveOperation` is a `MoveOperation`.
-							type: MoveOperation,
-							sourcePosition: new Position( root, [ 3, 3, 3 ] ),
-							howMany: 1,
-							targetPosition: new Position( gy, [ 0 ] ),
-							baseVersion: baseVersion + 1
-						}
-					]
-				} );
-
-				// Test if deltas do what they should after applying transformed delta.
-
-				applyDelta( insertDelta, doc );
-				applyDelta( transformed[ 0 ], doc );
-
-				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 3 ) );
-
-				// InsertDelta is applied. Merge between X and P is discarded.
-				expect( nodesAndText ).to.equal( 'XXXXXabcdabcfoobarxyzAABBX' );
-			} );
-		} );
-	} );
-
-	describe( 'SplitDelta by', () => {
-		let splitDeltaA, splitPosition;
-
-		beforeEach( () => {
-			splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
-			splitDeltaA = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
-		} );
-
-		describe( 'SplitDelta', () => {
-			it( 'split in same parent and offset', () => {
-				let splitDeltaB = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
-				let transformed = transform( splitDeltaA, splitDeltaB );
-
-				expect( transformed.length ).to.equal( 1 );
-
-				expectDelta( transformed[ 0 ], {
-					type: Delta,
-					operations: []
-				} );
-
-				// Test if deltas do what they should after applying transformed delta.
-
-				applyDelta( splitDeltaB, doc );
-				applyDelta( transformed[ 0 ], doc );
-
-				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 5 ) );
-
-				// Incoming split delta is discarded. Only one new element is created after applying both split deltas.
-				// There are no empty P elements.
-				expect( nodesAndText ).to.equal( 'XXXXXabcdXPabcPPfoobarxyzP' );
-			} );
-
-			it( 'split in same parent, incoming delta splits closer', () => {
-				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 5 ] ), new Element( 'p' ), 7, baseVersion );
-				let transformed = transform( splitDeltaA, splitDeltaB );
-
-				baseVersion = splitDeltaB.operations.length;
-
-				expect( transformed.length ).to.equal( 1 );
-
-				expectDelta( transformed[ 0 ], {
-					type: SplitDelta,
-					operations: [
-						{
-							type: InsertOperation,
-							position: new Position( root, [ 3, 3, 4 ] ),
-							baseVersion: baseVersion
-						},
-						{
-							type: MoveOperation,
-							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
-							howMany: 2,
-							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
-							baseVersion: baseVersion + 1
-						}
-					]
-				} );
-
-				// Test if deltas do what they should after applying transformed delta.
-
-				applyDelta( splitDeltaB, doc );
-				applyDelta( transformed[ 0 ], doc );
-
-				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
-
-				// P element is correctly split, there are three P elements, letters in P elements are in correct order.
-				expect( nodesAndText ).to.equal( 'XXXXXabcdXPabcPPfoPPobarxyzP' );
-			} );
-
-			it( 'split in same parent, incoming delta splits further', () => {
-				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 1 ] ), new Element( 'p' ), 11, baseVersion );
-				let transformed = transform( splitDeltaA, splitDeltaB );
-
-				baseVersion = splitDeltaB.operations.length;
-
-				expect( transformed.length ).to.equal( 1 );
-
-				expectDelta( transformed[ 0 ], {
-					type: SplitDelta,
-					operations: [
-						{
-							type: InsertOperation,
-							position: new Position( root, [ 3, 3, 5 ] ),
-							baseVersion: baseVersion
-						},
-						{
-							type: MoveOperation,
-							sourcePosition: new Position( root, [ 3, 3, 4, 2 ] ),
-							howMany: 9,
-							targetPosition: new Position( root, [ 3, 3, 5, 0 ] ),
-							baseVersion: baseVersion + 1
-						}
-					]
-				} );
-
-				// Test if deltas do what they should after applying transformed delta.
-
-				applyDelta( splitDeltaB, doc );
-				applyDelta( transformed[ 0 ], doc );
-
-				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
-
-				// P element is correctly split, there are three P elements, letters in P elements are in correct order.
-				expect( nodesAndText ).to.equal( 'XXXXXabcdXPaPPbcPPfoobarxyzP' );
-			} );
-		} );
-	} );
-} );

+ 190 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/_utils/utils.js

@@ -0,0 +1,190 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import Document from '/ckeditor5/core/treemodel/document.js';
+import Element from '/ckeditor5/core/treemodel/element.js';
+
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import AttributeDelta from '/ckeditor5/core/treemodel/delta/attributedelta.js';
+import InsertDelta from '/ckeditor5/core/treemodel/delta/insertdelta.js';
+import WeakInsertDelta from '/ckeditor5/core/treemodel/delta/weakinsertdelta.js';
+import RemoveDelta from '/ckeditor5/core/treemodel/delta/removedelta.js';
+import MoveDelta from '/ckeditor5/core/treemodel/delta/movedelta.js';
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+import WrapDelta from '/ckeditor5/core/treemodel/delta/wrapdelta.js';
+import UnwrapDelta from '/ckeditor5/core/treemodel/delta/unwrapdelta.js';
+
+import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
+
+export function getAttributeDelta( range, key, oldValue, newValue, version ) {
+	let delta = new AttributeDelta();
+	delta.addOperation( new AttributeOperation( range, key, oldValue, newValue, version ) );
+
+	return delta;
+}
+
+export function getInsertDelta( position, nodes, version ) {
+	let delta = new InsertDelta();
+	delta.addOperation( new InsertOperation( position, nodes, version ) );
+
+	return delta;
+}
+
+export function getWeakInsertDelta( position, nodes, version ) {
+	let delta = new WeakInsertDelta();
+	delta.addOperation( new InsertOperation( position, nodes, version ) );
+
+	return delta;
+}
+
+export function getMergeDelta( position, howManyInPrev, howManyInNext, version ) {
+	let delta = new MergeDelta();
+
+	let sourcePosition = Position.createFromPosition( position );
+	sourcePosition.path.push( 0 );
+
+	let targetPosition = Position.createFromPosition( position );
+	targetPosition.offset--;
+	targetPosition.path.push( howManyInPrev );
+
+	delta.addOperation( new MoveOperation( sourcePosition, howManyInNext, targetPosition, version ) );
+	delta.addOperation( new RemoveOperation( position, 1, version + 1 ) );
+
+	return delta;
+}
+
+export function getMoveDelta( sourcePosition, howMany, targetPosition, baseVersion ) {
+	let delta = new MoveDelta();
+
+	let move = new MoveOperation( sourcePosition, howMany, targetPosition, baseVersion );
+	delta.addOperation( move );
+
+	return delta;
+}
+
+export function getRemoveDelta( sourcePosition, howMany, baseVersion ) {
+	let delta = new RemoveDelta();
+
+	let remove = new RemoveOperation( sourcePosition, howMany, baseVersion );
+	delta.addOperation( remove );
+
+	return delta;
+}
+
+export function getSplitDelta( position, nodeCopy, howManyMove, version ) {
+	let delta = new SplitDelta();
+
+	let insertPosition = Position.createFromPosition( position );
+	insertPosition.path = insertPosition.getParentPath();
+	insertPosition.offset++;
+
+	let targetPosition = Position.createFromPosition( insertPosition );
+	targetPosition.path.push( 0 );
+
+	delta.addOperation( new InsertOperation( insertPosition, [ nodeCopy ], version ) );
+	delta.addOperation( new MoveOperation( position, howManyMove, targetPosition, version + 1 ) );
+
+	return delta;
+}
+
+export function getWrapDelta( range, element, version ) {
+	let delta = new WrapDelta();
+
+	let insert = new InsertOperation( range.end, element, version );
+
+	let targetPosition = Position.createFromPosition( range.end );
+	targetPosition.path.push( 0 );
+	let move = new MoveOperation( range.start, range.end.offset - range.start.offset, targetPosition, version + 1 );
+
+	delta.addOperation( insert );
+	delta.addOperation( move );
+
+	return delta;
+}
+
+export function getUnwrapDelta( positionBefore, howManyChildren, version ) {
+	let delta = new UnwrapDelta();
+
+	let sourcePosition = Position.createFromPosition( positionBefore );
+	sourcePosition.path.push( 0 );
+
+	let move = new MoveOperation( sourcePosition, howManyChildren, positionBefore, version );
+
+	let removePosition = Position.createFromPosition( positionBefore );
+	removePosition.offset += howManyChildren;
+
+	let remove = new RemoveOperation( removePosition, 1, version + 1 );
+
+	delta.addOperation( move );
+	delta.addOperation( remove );
+
+	return delta;
+}
+
+export function expectDelta( delta, expected ) {
+	expect( delta ).to.be.instanceof( expected.type );
+	expect( delta.operations.length ).to.equal( expected.operations.length );
+
+	for ( let i = 0; i < delta.operations.length; i++ ) {
+		expectOperation( delta.operations[ i ], expected.operations[ i ] );
+	}
+}
+
+export 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 ( i == 'nodes' ) {
+				expect( op.nodeList._nodes ).to.deep.equal( params[ i ] );
+			} 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 ] );
+			}
+		}
+	}
+}
+
+export function applyDelta( delta, document ) {
+	for ( let op of delta.operations ) {
+		document.applyOperation( op );
+	}
+}
+
+export function getFilledDocument() {
+	const doc = new Document();
+	const root = doc.createRoot( 'root' );
+
+	root.insertChildren( 0, [
+		new Element( 'x' ),
+		new Element( 'x' ),
+		new Element( 'x', [], 'a' ),
+		new Element( 'div', [], [
+			new Element( 'x' ),
+			new Element( 'x' ),
+			new Element( 'x', [], 'a' ),
+			new Element( 'div', [], [
+				new Element( 'x' ),
+				new Element( 'x' ),
+				new Element( 'x', [], 'abcd' ),
+				new Element( 'p', [], 'abcfoobarxyz' )
+			] )
+		] )
+	] );
+
+	return doc;
+}

+ 142 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/attributedelta.js

@@ -0,0 +1,142 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Text from '/ckeditor5/core/treemodel/text.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import AttributeDelta from '/ckeditor5/core/treemodel/delta/attributedelta.js';
+import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
+
+import {
+	expectDelta,
+	getFilledDocument,
+	getAttributeDelta,
+	getWeakInsertDelta,
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		baseVersion = doc.version;
+	} );
+
+	describe( 'AttributeDelta by', () => {
+		let attrDelta;
+
+		beforeEach( () => {
+			let attrRange = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) );
+			attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
+		} );
+
+		describe( 'WeakInsertDelta', () => {
+			it( 'weak insert inside attribute range should "fix" splitting the range', () => {
+				let insertPosition = new Position( root, [ 3, 3, 0 ] );
+				let insertDelta = getWeakInsertDelta(
+					insertPosition,
+					[
+						'a',
+						new Text( 'b', { key: 'new' } ),
+						new Text( 'c', { key: 'different' } ),
+						'de'
+					],
+					baseVersion
+				);
+
+				let transformed = transform( attrDelta, insertDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = insertDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 5 ] ), new Position( root, [ 3, 3, 8, 9 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 0 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 0 ] ), new Position( root, [ 3, 3, 1 ] ) ),
+							key: 'key',
+							oldValue: undefined,
+							newValue: 'new',
+							baseVersion: baseVersion + 2
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 3 ] ) ),
+							key: 'key',
+							oldValue: 'different',
+							newValue: 'new',
+							baseVersion: baseVersion + 3
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 3 ] ), new Position( root, [ 3, 3, 5 ] ) ),
+							key: 'key',
+							oldValue: undefined,
+							newValue: 'new',
+							baseVersion: baseVersion + 4
+						}
+					]
+				} );
+			} );
+
+			it( 'should be normally transformed if weak insert is not in the attribute range', () => {
+				let insertPosition = new Position( root, [ 5 ] );
+				let insertDelta = getWeakInsertDelta( insertPosition, 'abc', baseVersion );
+
+				let transformed = transform( attrDelta, insertDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = insertDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion
+						}
+					]
+				} );
+			} );
+		} );
+	} );
+} );

+ 72 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/delta.js

@@ -0,0 +1,72 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Position from '/ckeditor5/core/treemodel/position.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
+
+import {
+	expectDelta,
+	getFilledDocument,
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		baseVersion = doc.version;
+	} );
+
+	it( 'should transform delta by transforming it\'s operations', () => {
+		let deltaA = new Delta();
+		let deltaB = new Delta();
+
+		deltaA.addOperation( new MoveOperation( new Position( root, [ 1, 2, 3 ] ), 4, new Position( root, [ 4, 0 ] ), baseVersion ) );
+		deltaB.addOperation( new MoveOperation( new Position( root, [ 1, 2, 0 ] ), 2, new Position( root, [ 4, 1 ] ), baseVersion ) );
+
+		let deltaAbyB = transform( deltaA, deltaB );
+		let deltaBbyA = transform( deltaB, deltaA );
+
+		expect( deltaAbyB.length ).to.equal( 1 );
+
+		expectDelta( deltaAbyB[ 0 ], {
+			type: Delta,
+			operations: [
+				{
+					type: MoveOperation,
+					sourcePosition: new Position( root, [ 1, 2, 1 ] ),
+					howMany: 4,
+					targetPosition: new Position( root, [ 4, 0 ] ),
+					baseVersion: 1
+				}
+			]
+		} );
+
+		expect( deltaBbyA.length ).to.equal( 1 );
+
+		expectDelta( deltaBbyA[ 0 ], {
+			type: Delta,
+			operations: [
+				{
+					type: MoveOperation,
+					sourcePosition: new Position( root, [ 1, 2, 0 ] ),
+					howMany: 2,
+					targetPosition: new Position( root, [ 4, 5 ] ),
+					baseVersion: 1
+				}
+			]
+		} );
+	} );
+} );

+ 201 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/insertdelta.js

@@ -0,0 +1,201 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import InsertDelta from '/ckeditor5/core/treemodel/delta/insertdelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import ReinsertOperation from '/ckeditor5/core/treemodel/operation/reinsertoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getInsertDelta,
+	getMergeDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'InsertDelta by', () => {
+		let insertDelta, insertPosition, nodeA, nodeB;
+
+		beforeEach( () => {
+			nodeA = new Element( 'a' );
+			nodeB = new Element( 'b' );
+
+			insertPosition = new Position( root, [ 3, 3, 3 ] );
+			insertDelta = getInsertDelta( insertPosition, [ nodeA, nodeB ], baseVersion );
+		} );
+
+		describe( 'InsertDelta', () => {
+			it( 'should be resolved in a same way as two insert operations', () => {
+				let insertPositionB = new Position( root, [ 3, 1 ] );
+				let insertDeltaB = getInsertDelta( insertPositionB, [ new Element( 'c' ), new Element( 'd' ) ], baseVersion );
+
+				let transformed = transform( insertDelta, insertDeltaB );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 5, 3 ] ),
+							nodes: [ nodeA, nodeB ],
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+		} );
+
+		describe( 'MergeDelta', () => {
+			it( 'merge in same position as insert', () => {
+				let mergeDelta = getMergeDelta( insertPosition, 4, 12, baseVersion );
+				let transformed = transform( insertDelta, mergeDelta );
+
+				baseVersion = mergeDelta.operations.length;
+
+				// Expected: MergeOperation gets reversed (by special case of SplitDelta that has ReinsertOperation
+				// instead of InsertOperation. Then InsertDelta is applied as is.
+
+				expect( transformed.length ).to.equal( 2 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: ReinsertOperation,
+							sourcePosition: new Position( gy, [ 0 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 4 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: Position.createFromPosition( insertPosition ),
+							nodes: [ nodeA, nodeB ],
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
+
+				// Merge between X with "abcd" and P with "abcfoobarxyz" should be reversed and AB should be inserted between X and P.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXAABBPabcfoobarxyzP' );
+			} );
+
+			it( 'merge the node that is parent of insert position (sticky move test)', () => {
+				let mergeDelta = getMergeDelta( new Position( root, [ 3, 3 ] ), 1, 4, baseVersion );
+				let transformed = transform( insertDelta, mergeDelta );
+
+				baseVersion = mergeDelta.operations.length;
+
+				// Expected: InsertOperation in InsertDelta has it's path updated.
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 2, 4 ] ),
+							nodes: [ nodeA, nodeB ],
+							baseVersion: baseVersion
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 2, 0 ] ), 7 ) );
+
+				// Merge between X with "a" and DIV should be applied. AB should be inserted in new, correct position.
+				expect( nodesAndText ).to.equal( 'aXXXXXabcdXAABBPabcfoobarxyzP' );
+			} );
+
+			it( 'merge at affected position but resolved by default OT', () => {
+				let mergeDelta = getMergeDelta( new Position( root, [ 3 ] ), 1, 4, baseVersion );
+				let transformed = transform( insertDelta, mergeDelta );
+
+				baseVersion = mergeDelta.operations.length;
+
+				// Expected: InsertOperation in InsertDelta has it's path updated.
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 2, 4, 3 ] ),
+							nodes: [ nodeA, nodeB ],
+							baseVersion: baseVersion
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 2, 0 ] ), 5 ) );
+
+				// Merge between X with "a" and DIV should be applied. AB should be inserted in new, correct position.
+				expect( nodesAndText ).to.equal( 'aXXXXXaXDIVXXXXXabcdXAABBPabcfoobarxyzPDIV' );
+			} );
+		} );
+	} );
+} );

+ 194 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/mergedelta.js

@@ -0,0 +1,194 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getInsertDelta,
+	getMergeDelta,
+	getMoveDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'MergeDelta by', () => {
+		let mergeDelta, mergePosition;
+
+		beforeEach( () => {
+			mergePosition = new Position( root, [ 3, 3, 3 ] );
+			mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
+		} );
+
+		describe( 'InsertDelta', () => {
+			let nodeA, nodeB;
+
+			beforeEach( () => {
+				nodeA = new Element( 'a' );
+				nodeB = new Element( 'b' );
+			} );
+
+			it( 'insert at same position as merge', () => {
+				let insertDelta = getInsertDelta( mergePosition, [ nodeA, nodeB ], baseVersion );
+				let transformed = transform( mergeDelta, insertDelta );
+
+				// Expected: MergeDelta gets ignored and is not applied.
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( insertDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
+
+				// InsertDelta is applied. Merge between X and P is discarded.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXAABBPabcfoobarxyzP' );
+			} );
+
+			it( 'insert inside merged node (sticky move test)', () => {
+				let insertDelta = getInsertDelta( new Position( root, [ 3, 3, 3, 12 ] ), [ nodeA, nodeB ], baseVersion );
+				let transformed = transform( mergeDelta, insertDelta );
+
+				baseVersion = insertDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				// Expected: MoveOperation in MergeDelta has it's "range" expanded.
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							howMany: 14,
+							targetPosition: new Position( root, [ 3, 3, 2, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							// This is `MoveOperation` instead of `RemoveOperation` because during OT,
+							// `RemoveOperation` may get converted to `MoveOperation`. Still, this expectation is
+							// correct because `RemoveOperation` is deriving from `MoveOperation`. So we can expect
+							// that something that was `RemoveOperation` is a `MoveOperation`.
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( insertDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 3 ) );
+
+				// InsertDelta is applied. Merge between X and P is discarded.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdabcfoobarxyzAABBX' );
+			} );
+		} );
+
+		describe( 'by MoveDelta', () => {
+			it( 'node on the right side of merge was moved', () => {
+				let moveDelta = getMoveDelta( new Position( root, [ 3, 3, 3 ] ), 1, new Position( root, [ 3, 3, 0 ] ), baseVersion );
+				let transformed = transform( mergeDelta, moveDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( moveDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// MoveDelta is applied. MergeDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVPabcfoobarxyzPXXXXXabcdXDIV' );
+			} );
+
+			it( 'node on the left side of merge was moved', () => {
+				let moveDelta = getMoveDelta( new Position( root, [ 3, 3, 2 ] ), 1, new Position( root, [ 3, 3, 0 ] ), baseVersion );
+				let transformed = transform( mergeDelta, moveDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = moveDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 0, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							// This is `MoveOperation` instead of `RemoveOperation` because during OT,
+							// `RemoveOperation` may get converted to `MoveOperation`. Still, this expectation is
+							// correct because `RemoveOperation` is deriving from `MoveOperation`. So we can expect
+							// that something that was `RemoveOperation` is a `MoveOperation`.
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( moveDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// MoveDelta is applied. MergeDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVXabcdabcfoobarxyzXXXXXDIV' );
+			} );
+		} );
+	} );
+} );

+ 138 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/movedelta.js

@@ -0,0 +1,138 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import MoveDelta from '/ckeditor5/core/treemodel/delta/movedelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getMergeDelta,
+	getMoveDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'MoveDelta by', () => {
+		let moveDelta;
+
+		beforeEach( () => {
+			let sourcePosition = new Position( root, [ 3, 3, 3 ] );
+			let howMany = 1;
+			let targetPosition = new Position( root, [ 3, 3, 0 ] );
+
+			moveDelta = getMoveDelta( sourcePosition, howMany, targetPosition, baseVersion );
+		} );
+
+		describe( 'MergeDelta', () => {
+			it( 'node on the right side of merge was moved', () => {
+				let mergePosition = new Position( root, [ 3, 3, 3 ] );
+				let mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
+
+				let transformed = transform( moveDelta, mergeDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							// This `SplitDelta` got created through reversing `MergeDelta`. It means that operations in
+							// a `MergeDelta` had been reversed. One of them is `RemoveOperation` which got reversed into
+							// `ReinsertOperation` because we want to get back the node from graveyard. `ReinsertOperation`
+							// is treated in OT as `MoveOperation` and might be converted to it. This is why we have to
+							// check whether the operation type is `MoveOperation`. This is all perfectly valid.
+							type: MoveOperation,
+							sourcePosition: new Position( gy, [ 0 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 4 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: moveDelta._moveOperation.sourcePosition,
+							howMany: moveDelta._moveOperation.howMany,
+							targetPosition: moveDelta._moveOperation.targetPosition,
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// MoveDelta is applied. MergeDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVPabcfoobarxyzPXXXXXabcdXDIV' );
+			} );
+
+			it( 'move range in merged node', () => {
+				let mergePosition = new Position( root, [ 3, 3 ] );
+				let mergeDelta = getMergeDelta( mergePosition, 1, 4, baseVersion );
+
+				let transformed = transform( moveDelta, mergeDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 2, 4 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 2, 1 ] ),
+							baseVersion: baseVersion
+						}
+					]
+				} );
+			} );
+		} );
+	} );
+} );

+ 109 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/removedelta.js

@@ -0,0 +1,109 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import RemoveDelta from '/ckeditor5/core/treemodel/delta/movedelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getMergeDelta,
+	getRemoveDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'RemoveDelta by', () => {
+		let removeDelta;
+
+		beforeEach( () => {
+			let sourcePosition = new Position( root, [ 3, 3, 3 ] );
+			let howMany = 1;
+
+			removeDelta = getRemoveDelta( sourcePosition, howMany, baseVersion );
+		} );
+
+		describe( 'MergeDelta', () => {
+			it( 'node on the right side of merge was removed', () => {
+				// This special case should be handled by MoveDelta x MergeDelta special case.
+
+				let mergePosition = new Position( root, [ 3, 3, 3 ] );
+				let mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
+
+				let transformed = transform( removeDelta, mergeDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( gy, [ 0 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 4 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: RemoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: removeDelta._moveOperation.sourcePosition,
+							howMany: removeDelta._moveOperation.howMany,
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// RemoveDelta is applied. MergeDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXDIV' );
+			} );
+		} );
+	} );
+} );

+ 313 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/splitdelta.js

@@ -0,0 +1,313 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getSplitDelta,
+	getWrapDelta,
+	getUnwrapDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'SplitDelta by', () => {
+		let splitDelta, splitPosition;
+
+		beforeEach( () => {
+			splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
+			splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+		} );
+
+		describe( 'SplitDelta', () => {
+			it( 'split in same parent and offset', () => {
+				let splitDeltaB = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( splitDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 5 ) );
+
+				// Incoming split delta is discarded. Only one new element is created after applying both split deltas.
+				// There are no empty P elements.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXPabcPPfoobarxyzP' );
+			} );
+
+			it( 'split in same parent, incoming delta splits closer', () => {
+				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 5 ] ), new Element( 'p' ), 7, baseVersion );
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 2,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( splitDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
+
+				// P element is correctly split, there are three P elements, letters in P elements are in correct order.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXPabcPPfoPPobarxyzP' );
+			} );
+
+			it( 'split in same parent, incoming delta splits further', () => {
+				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 1 ] ), new Element( 'p' ), 11, baseVersion );
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 5 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 2 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 5, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( splitDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
+
+				// P element is correctly split, there are three P elements, letters in P elements are in correct order.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXPaPPbcPPfoobarxyzP' );
+			} );
+
+			it( 'split in split parent', () => {
+				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3 ] ), new Element( 'div' ), 1, baseVersion );
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 4, 1 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 4, 0, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 4, 1, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( splitDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 2 ) );
+
+				// DIV and P elements are correctly split.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXDIVDIVPabcPPfoobarxyzPDIV' );
+			} );
+		} );
+
+		describe( 'UnwrapDelta', () => {
+			it( 'split position directly in unwrapped node', () => {
+				let unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3, 3 ] ), 12, baseVersion );
+				let transformed = transform( splitDelta, unwrapDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( unwrapDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// UnwrapDelta is applied. SplitDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXabcfoobarxyzDIV' );
+			} );
+
+			it( 'split position indirectly in unwrapped node', () => {
+				let unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3 ] ), 4, baseVersion );
+
+				let transformed = transform( splitDelta, unwrapDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = unwrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 7 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 6, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 7, 0 ] ),
+							baseVersion:  baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( unwrapDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3 ] ), 1 ) );
+
+				// UnwrapDelta and SplitDelta are correctly applied.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXaXXXXXXabcdXPabcPPfoobarxyzPDIV' );
+			} );
+		} );
+
+		describe( 'WrapDelta', () => {
+			it( 'split position is between wrapped nodes', () => {
+				let wrapRange = new Range( new Position( root, [ 3, 3, 3, 1 ] ), new Position( root, [ 3, 3, 3, 5 ] ) );
+				let wrapElement = new Element( 'E' );
+				let wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+
+				let transformed = transform( splitDelta, wrapDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( wrapDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 3 ] ), 1 ) );
+
+				// WrapDelta is applied. SplitDelta is discarded.
+				expect( nodesAndText ).to.equal( 'PaEbcfoEobarxyzP' );
+			} );
+
+			it( 'split position is inside wrapped node', () => {
+				let wrapRange = new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 4 ] ) );
+				let wrapElement = new Element( 'E' );
+				let wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+
+				let transformed = transform( splitDelta, wrapDelta, true );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = wrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 2, 2 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 1, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 2, 2, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( wrapDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 2 ] ), 1 ) );
+
+				// WrapDelta and SplitDelta are correctly applied.
+				expect( nodesAndText ).to.equal( 'EXabcdXPabcPPfoobarxyzPE' );
+			} );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/unwrapdelta.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+import UnwrapDelta from '/ckeditor5/core/treemodel/delta/unwrapdelta.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getSplitDelta,
+	getUnwrapDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'UnwrapDelta by', () => {
+		let unwrapDelta;
+
+		beforeEach( () => {
+			unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3, 3 ] ), 12, baseVersion );
+		} );
+
+		describe( 'SplitDelta', () => {
+			it( 'split position directly in unwrapped node', () => {
+				let splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+
+				let transformed = transform( unwrapDelta, splitDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: UnwrapDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 3 ] ),
+							baseVersion: baseVersion + 2
+						},
+						{
+							// `RemoveOperation` as `MoveOperation`
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 15 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 3
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( splitDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// UnwrapDelta is applied. SplitDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXabcfoobarxyzDIV' );
+			} );
+
+			it( 'split position before unwrapped node', () => {
+				let splitPosition = new Position( root, [ 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'div' ), 1, baseVersion );
+
+				let transformed = transform( unwrapDelta, splitDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: UnwrapDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 4, 0, 0 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 4, 0 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 4, 12 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( splitDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 2 ) );
+
+				// UnwrapDelta and SplitDelta are applied.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXDIVDIVabcfoobarxyzDIV' );
+			} );
+		} );
+	} );
+} );

+ 131 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/weakinsertdelta.js

@@ -0,0 +1,131 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Text from '/ckeditor5/core/treemodel/text.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import WeakInsertDelta from '/ckeditor5/core/treemodel/delta/weakinsertdelta.js';
+import AttributeDelta from '/ckeditor5/core/treemodel/delta/attributedelta.js';
+
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
+
+import {
+	expectDelta,
+	getFilledDocument,
+	getAttributeDelta,
+	getWeakInsertDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		baseVersion = doc.version;
+	} );
+
+	describe( 'AttributeDelta by', () => {
+		let insertDelta;
+
+		beforeEach( () => {
+			let insertPosition = new Position( root, [ 3, 3, 0 ] );
+			insertDelta = getWeakInsertDelta(
+				insertPosition,
+				[
+					'a',
+					new Text( 'b', { key: 'new' } ),
+					new Text( 'c', { key: 'different' } ),
+					'de'
+				],
+				baseVersion
+			);
+		} );
+
+		describe( 'WeakInsertDelta', () => {
+			it( 'weak insert inside attribute range should "fix" splitting the range', () => {
+				let attrRange = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) );
+				let attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
+
+				let transformed = transform( insertDelta, attrDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = attrDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: WeakInsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 0 ] ),
+							baseVersion: baseVersion
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 0 ] ), new Position( root, [ 3, 3, 1 ] ) ),
+							key: 'key',
+							oldValue: undefined,
+							newValue: 'new',
+							baseVersion: baseVersion + 1
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 3 ] ) ),
+							key: 'key',
+							oldValue: 'different',
+							newValue: 'new',
+							baseVersion: baseVersion + 2
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 3 ] ), new Position( root, [ 3, 3, 5 ] ) ),
+							key: 'key',
+							oldValue: undefined,
+							newValue: 'new',
+							baseVersion: baseVersion + 3
+						}
+					]
+				} );
+			} );
+
+			it( 'should be normally transformed if weak insert is not in the attribute range', () => {
+				let attrRange = new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) );
+				let attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
+
+				let transformed = transform( insertDelta, attrDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = attrDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: WeakInsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 0 ] ),
+							baseVersion: baseVersion
+						}
+					]
+				} );
+			} );
+		} );
+	} );
+} );

+ 152 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/wrapdelta.js

@@ -0,0 +1,152 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+import WrapDelta from '/ckeditor5/core/treemodel/delta/wrapdelta.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getSplitDelta,
+	getWrapDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'WrapDelta by', () => {
+		let wrapDelta;
+
+		beforeEach( () => {
+			let wrapRange = new Range( new Position( root, [ 3, 3, 3, 1 ] ), new Position( root, [ 3, 3, 3, 5 ] ) );
+			let wrapElement = new Element( 'E' );
+
+			wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+		} );
+
+		describe( 'SplitDelta', () => {
+			it( 'split position is between wrapped nodes', () => {
+				let splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+
+				let transformed = transform( wrapDelta, splitDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: WrapDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 3, 5 ] ),
+							baseVersion: baseVersion + 2
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 1 ] ),
+							howMany: 4,
+							targetPosition: new Position( root, [ 3, 3, 3, 5, 0 ] ),
+							baseVersion: baseVersion + 3
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( splitDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 3 ] ), 1 ) );
+
+				// WrapDelta is applied. SplitDelta is discarded.
+				expect( nodesAndText ).to.equal( 'PaEbcfoEobarxyzP' );
+			} );
+
+			it( 'split position is before wrapped nodes', () => {
+				let splitPosition = new Position( root, [ 3, 3, 3, 1 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 11, baseVersion );
+
+				let transformed = transform( wrapDelta, splitDelta, true );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = wrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: WrapDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							howMany: 4,
+							targetPosition: new Position( root, [ 3, 3, 4, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( splitDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 3 ] ), 2 ) );
+
+				// WrapDelta and SplitDelta are correctly applied.
+				expect( nodesAndText ).to.equal( 'PaPPEbcfoEobarxyzP' );
+			} );
+		} );
+	} );
+} );