Bladeren bron

Merge pull request #733 from ckeditor/t/732

Schema check algorithm should be more precise.
Piotrek Koszuliński 9 jaren geleden
bovenliggende
commit
5ec1adecbc

+ 2 - 2
packages/ckeditor5-engine/src/dev-utils/model.js

@@ -354,7 +354,7 @@ function convertToModelElement() {
 		};
 
 		if ( !conversionApi.schema.check( schemaQuery ) ) {
-			throw new Error( `Element '${ schemaQuery.name }' not allowed in context.` );
+			throw new Error( `Element '${ schemaQuery.name }' not allowed in context ${ JSON.stringify( data.context ) }.` );
 		}
 
 		// View attribute value is a string so we want to typecast it to the original type.
@@ -380,7 +380,7 @@ function convertToModelText( withAttributes = false ) {
 		};
 
 		if ( !conversionApi.schema.check( schemaQuery ) ) {
-			throw new Error( `Element '${ schemaQuery.name }' not allowed in context.` );
+			throw new Error( `Element '${ schemaQuery.name }' not allowed in context ${ JSON.stringify( data.context ) }.` );
 		}
 
 		let node;

+ 48 - 34
packages/ckeditor5-engine/src/model/schema.js

@@ -501,47 +501,19 @@ export class SchemaItem {
 	}
 
 	/**
-	 * Checks whether this item has any registered path of given type that matches provided path.
+	 * Checks whether this item has any registered path of given type that matches the provided path.
 	 *
 	 * @protected
 	 * @param {String} type Paths' type. Possible values are `allow` or `disallow`.
-	 * @param {Array.<String>} checkPath Path to check.
+	 * @param {Array.<String>} pathToCheck Path to check.
 	 * @param {String} [attribute] If set, only paths registered for given attribute will be checked.
 	 * @returns {Boolean} `true` if item has any registered matching path, `false` otherwise.
 	 */
-	_hasMatchingPath( type, checkPath, attribute ) {
-		const itemPaths = this._getPaths( type, attribute );
-
-		// We check every path registered (possibly with given attribute) in the item.
-		for ( let itemPath of itemPaths ) {
-			// Pointer to last found item from `itemPath`.
-			let i = 0;
-
-			// Now we have to check every item name from the path to check.
-			for ( let checkName of checkPath ) {
-				// Don't check items that are not registered in schema.
-				if ( !this._schema.hasItem( checkName ) ) {
-					continue;
-				}
-
-				// Every item name is expanded to all names of items that item is extending.
-				// So, if on item path, there is an item that is extended by item from checked path, it will
-				// also be treated as matching.
-				const chain = this._schema._extensionChains.get( checkName );
-
-				// Since our paths have to match in given order, we always check against first item from item path.
-				// So, if item path is: B D E
-				// And checked path is: A B C D E
-				// It will be matching (A won't match, B will match, C won't match, D and E will match)
-				if ( chain.indexOf( itemPath[ i ] ) > -1 ) {
-					// Move pointer as we found element under index `i`.
-					i++;
-				}
-			}
+	_hasMatchingPath( type, pathToCheck, attribute ) {
+		const registeredPaths = this._getPaths( type, attribute );
 
-			// If `itemPath` has no items it means that we removed all of them, so we matched all of them.
-			// This means that we found a matching path.
-			if ( i === itemPath.length ) {
+		for ( const registeredPathPath of registeredPaths ) {
+			if ( matchPaths( this._schema, pathToCheck, registeredPathPath ) ) {
 				return true;
 			}
 		}
@@ -581,3 +553,45 @@ export class SchemaItem {
  * @typedef {String|Array.<String|module:engine/model/element~Element>|module:engine/model/position~Position}
  * module:engine/model/schema~SchemaPath
  */
+
+// Checks whether the given pathToCheck and registeredPath right ends match.
+//
+// pathToCheck: C, D
+// registeredPath: A, B, C, D
+// result: OK
+//
+// pathToCheck: A, B, C
+// registeredPath: A, B, C, D
+// result: NOK
+//
+// Note – when matching paths, element extension chains (inheritance) are taken into consideration.
+//
+// @param {Schema} schema
+// @param {Array.<String>} pathToCheck
+// @param {Array.<String>} registeredPath
+function matchPaths( schema, pathToCheck, registeredPath ) {
+	// Start checking from the right end of both tables.
+	let registeredPathIndex = registeredPath.length - 1;
+	let pathToCheckIndex = pathToCheck.length - 1;
+
+	// And finish once reaching an end of the shorter table.
+	while ( registeredPathIndex >= 0 && pathToCheckIndex >= 0 ) {
+		const checkName = pathToCheck[ pathToCheckIndex ];
+
+		// Fail when checking a path which contains element which aren't even registered to the schema.
+		if ( !schema.hasItem( checkName ) ) {
+			return false;
+		}
+
+		const extChain = schema._extensionChains.get( checkName );
+
+		if ( extChain.includes( registeredPath[ registeredPathIndex ] ) ) {
+			registeredPathIndex--;
+			pathToCheckIndex--;
+		} else {
+			return false;
+		}
+	}
+
+	return true;
+}

+ 8 - 2
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -339,7 +339,10 @@ describe( 'DataController', () => {
 	describe( 'stringify', () => {
 		beforeEach( () => {
 			modelDocument.schema.registerItem( 'paragraph', '$block' );
-			modelDocument.schema.registerItem( 'div', '$block' );
+			modelDocument.schema.registerItem( 'div' );
+
+			modelDocument.schema.allow( { name: '$block', inside: 'div' } );
+			modelDocument.schema.allow( { name: 'div', inside: '$root' } );
 
 			buildModelConverter().for( data.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
 		} );
@@ -360,7 +363,10 @@ describe( 'DataController', () => {
 	describe( 'toView', () => {
 		beforeEach( () => {
 			modelDocument.schema.registerItem( 'paragraph', '$block' );
-			modelDocument.schema.registerItem( 'div', '$block' );
+			modelDocument.schema.registerItem( 'div' );
+
+			modelDocument.schema.allow( { name: '$block', inside: 'div' } );
+			modelDocument.schema.allow( { name: 'div', inside: '$root' } );
 
 			buildModelConverter().for( data.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
 		} );

+ 1 - 0
packages/ckeditor5-engine/tests/controller/deletecontent.js

@@ -157,6 +157,7 @@ describe( 'DataController', () => {
 				schema.registerItem( 'image', '$inline' );
 
 				schema.allow( { name: 'pchild', inside: 'paragraph' } );
+				schema.allow( { name: '$text', inside: 'pchild' } );
 				schema.allow( { name: 'paragraph', attributes: [ 'align' ] } );
 			} );
 

+ 2 - 0
packages/ckeditor5-engine/tests/controller/insertcontent.js

@@ -48,6 +48,7 @@ describe( 'DataController', () => {
 				schema.allow( { name: 'image', inside: '$root' } );
 				// Otherwise it won't be passed to the temporary model fragment used inside insert().
 				schema.allow( { name: 'disallowedElement', inside: '$clipboardHolder' } );
+				doc.schema.allow( { name: '$text', inside: 'disallowedElement' } );
 
 				schema.allow( { name: '$inline', attributes: [ 'bold' ] } );
 				schema.allow( { name: '$inline', attributes: [ 'italic' ] } );
@@ -513,6 +514,7 @@ describe( 'DataController', () => {
 
 				schema.allow( { name: 'table', inside: '$clipboardHolder' } );
 				schema.allow( { name: 'td', inside: '$clipboardHolder' } );
+				schema.allow( { name: 'td', inside: 'table' } );
 				schema.allow( { name: '$block', inside: 'td' } );
 				schema.allow( { name: '$text', inside: 'td' } );
 

+ 6 - 0
packages/ckeditor5-engine/tests/controller/modifyselection.js

@@ -18,7 +18,13 @@ describe( 'DataController', () => {
 		document.schema.registerItem( 'p', '$block' );
 		document.schema.registerItem( 'x', '$block' );
 		document.schema.registerItem( 'img', '$inline' );
+
 		document.schema.allow( { name: '$text', inside: '$root' } );
+		document.schema.allow( { name: '$text', inside: 'img' } );
+		document.schema.allow( { name: '$text', inside: 'obj' } );
+		document.schema.allow( { name: '$text', inside: 'inlineObj' } );
+		document.schema.allow( { name: 'x', inside: 'p' } );
+
 		document.createRoot();
 	} );
 

+ 566 - 564
packages/ckeditor5-engine/tests/conversion/advanced-converters.js

@@ -42,718 +42,720 @@ import { convertToModelFragment, convertText } from 'ckeditor5/engine/conversion
 
 import { createRangeOnElementOnly } from 'tests/engine/model/_utils/utils.js';
 
-let modelDoc, modelRoot, viewRoot, mapper, modelDispatcher, viewDispatcher;
+describe( 'advanced-converters', () => {
+	let modelDoc, modelRoot, viewRoot, mapper, modelDispatcher, viewDispatcher;
 
-beforeEach( () => {
-	modelDoc = new ModelDocument();
-	modelRoot = modelDoc.createRoot();
-	viewRoot = new ViewContainerElement( 'div' );
-
-	mapper = new Mapper();
-	mapper.bindElements( modelRoot, viewRoot );
-
-	modelDispatcher = new ModelConversionDispatcher( { mapper } );
-	// Schema is mocked up because we don't care about it in those tests.
-	viewDispatcher = new ViewConversionDispatcher( { schema: { check: () => true } } );
-
-	modelDispatcher.on( 'insert:$text', insertText() );
-	modelDispatcher.on( 'move', move() );
-	modelDispatcher.on( 'remove', remove() );
-	viewDispatcher.on( 'text', convertText() );
-	viewDispatcher.on( 'documentFragment', convertToModelFragment() );
-} );
+	beforeEach( () => {
+		modelDoc = new ModelDocument();
+		modelRoot = modelDoc.createRoot();
+		viewRoot = new ViewContainerElement( 'div' );
+
+		mapper = new Mapper();
+		mapper.bindElements( modelRoot, viewRoot );
+
+		modelDispatcher = new ModelConversionDispatcher( { mapper } );
+		// Schema is mocked up because we don't care about it in those tests.
+		viewDispatcher = new ViewConversionDispatcher( { schema: { check: () => true } } );
+
+		modelDispatcher.on( 'insert:$text', insertText() );
+		modelDispatcher.on( 'move', move() );
+		modelDispatcher.on( 'remove', remove() );
+		viewDispatcher.on( 'text', convertText() );
+		viewDispatcher.on( 'documentFragment', convertToModelFragment() );
+	} );
 
-function viewAttributesToString( item ) {
-	let result = '';
+	function viewAttributesToString( item ) {
+		let result = '';
 
-	for ( let key of item.getAttributeKeys() ) {
-		let value = item.getAttribute( key );
+		for ( let key of item.getAttributeKeys() ) {
+			let value = item.getAttribute( key );
 
-		if ( value ) {
-			result += ' ' + key + '="' + value + '"';
+			if ( value ) {
+				result += ' ' + key + '="' + value + '"';
+			}
 		}
+
+		return result;
 	}
 
-	return result;
-}
+	function viewToString( item ) {
+		let result = '';
 
-function viewToString( item ) {
-	let result = '';
+		if ( item instanceof ViewText ) {
+			result = item.data;
+		} else {
+			// ViewElement or ViewDocumentFragment.
+			for ( let child of item.getChildren() ) {
+				result += viewToString( child );
+			}
 
-	if ( item instanceof ViewText ) {
-		result = item.data;
-	} else {
-		// ViewElement or ViewDocumentFragment.
-		for ( let child of item.getChildren() ) {
-			result += viewToString( child );
+			if ( item instanceof ViewElement ) {
+				result = '<' + item.name + viewAttributesToString( item ) + '>' + result + '</' + item.name + '>';
+			}
 		}
 
-		if ( item instanceof ViewElement ) {
-			result = '<' + item.name + viewAttributesToString( item ) + '>' + result + '</' + item.name + '>';
-		}
+		return result;
 	}
 
-	return result;
-}
+	function modelAttributesToString( item ) {
+		let result = '';
 
-function modelAttributesToString( item ) {
-	let result = '';
+		for ( let attr of item.getAttributes() ) {
+			result += ' ' + attr[ 0 ] + '="' + attr[ 1 ] + '"';
+		}
 
-	for ( let attr of item.getAttributes() ) {
-		result += ' ' + attr[ 0 ] + '="' + attr[ 1 ] + '"';
+		return result;
 	}
 
-	return result;
-}
+	function modelToString( item ) {
+		let result = '';
 
-function modelToString( item ) {
-	let result = '';
-
-	if ( item instanceof ModelTextProxy ) {
-		let attributes = modelAttributesToString( item );
-
-		result = attributes ? '<$text' + attributes + '>' + item.data + '</$text>' : item.data;
-	} else {
-		let walker = new ModelWalker( { boundaries: ModelRange.createIn( item ), shallow: true } );
-
-		for ( let value of walker ) {
-			result += modelToString( value.item );
-		}
-
-		if ( item instanceof ModelElement ) {
+		if ( item instanceof ModelTextProxy ) {
 			let attributes = modelAttributesToString( item );
 
-			result = '<' + item.name + attributes + '>' + result + '</' + item.name + '>';
-		}
-	}
-
-	return result;
-}
-
-// Converter for custom `image` element that might have a `caption` element inside which changes
-// how the image is displayed in the view:
-//
-// Model:
-//
-// [image {src="foo.jpg" title="foo"}]
-//   └─ [caption]
-//       ├─ f
-//       ├─ o
-//       └─ o
-//
-// [image {src="bar.jpg" title="bar"}]
-//
-// View:
-//
-// <figure>
-//   ├─ <img src="foo.jpg" title="foo" />
-//   └─ <caption>
-//       └─ foo
-//
-// <img src="bar.jpg" title="bar" />
-describe( 'image with caption converters', () => {
-	beforeEach( () => {
-		const modelImageConverter = function( evt, data, consumable, conversionApi ) {
-			// First, consume the `image` element.
-			consumable.consume( data.item, 'insert' );
-
-			// Just create normal image element for the view.
-			// Maybe it will be "decorated" later.
-			const viewImage = new ViewContainerElement( 'img' );
-			const insertPosition = conversionApi.mapper.toViewPosition( data.range.start );
-
-			// Check if the `image` element has children.
-			if ( data.item.childCount > 0 ) {
-				const modelCaption = data.item.getChild( 0 );
-
-				// `modelCaption` insertion change is consumed from consumable values.
-				// It will not be converted by other converters, but it's children (probably some text) will be.
-				// Through mapping, converters for text will know where to insert contents of `modelCaption`.
-				if ( consumable.consume( modelCaption, 'insert' ) ) {
-					const viewCaption = new ViewContainerElement( 'figcaption' );
-
-					const viewImageHolder = new ViewContainerElement( 'figure', null, [ viewImage, viewCaption ] );
-
-					conversionApi.mapper.bindElements( modelCaption, viewCaption );
-					conversionApi.mapper.bindElements( data.item, viewImageHolder );
-					viewWriter.insert( insertPosition, viewImageHolder );
-				}
-			} else {
-				conversionApi.mapper.bindElements( data.item, viewImage );
-				viewWriter.insert( insertPosition, viewImage );
-			}
-
-			evt.stop();
-		};
+			result = attributes ? '<$text' + attributes + '>' + item.data + '</$text>' : item.data;
+		} else {
+			let walker = new ModelWalker( { boundaries: ModelRange.createIn( item ), shallow: true } );
 
-		const modelImageAttributesConverter = function( evt, data, consumable, conversionApi ) {
-			if ( data.item.name != 'image' ) {
-				return;
+			for ( let value of walker ) {
+				result += modelToString( value.item );
 			}
 
-			let viewElement = conversionApi.mapper.toViewElement( data.item );
+			if ( item instanceof ModelElement ) {
+				let attributes = modelAttributesToString( item );
 
-			if ( viewElement.name == 'figure' ) {
-				viewElement = viewElement.getChild( 0 );
+				result = '<' + item.name + attributes + '>' + result + '</' + item.name + '>';
 			}
+		}
 
-			consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+		return result;
+	}
 
-			if ( !data.attributeNewValue ) {
-				viewElement.removeAttribute( data.attributeKey );
-			} else {
-				viewElement.setAttribute( data.attributeKey, data.attributeNewValue );
-			}
+	// Converter for custom `image` element that might have a `caption` element inside which changes
+	// how the image is displayed in the view:
+	//
+	// Model:
+	//
+	// [image {src="foo.jpg" title="foo"}]
+	//   └─ [caption]
+	//       ├─ f
+	//       ├─ o
+	//       └─ o
+	//
+	// [image {src="bar.jpg" title="bar"}]
+	//
+	// View:
+	//
+	// <figure>
+	//   ├─ <img src="foo.jpg" title="foo" />
+	//   └─ <caption>
+	//       └─ foo
+	//
+	// <img src="bar.jpg" title="bar" />
+	describe( 'image with caption converters', () => {
+		beforeEach( () => {
+			const modelImageConverter = function( evt, data, consumable, conversionApi ) {
+				// First, consume the `image` element.
+				consumable.consume( data.item, 'insert' );
+
+				// Just create normal image element for the view.
+				// Maybe it will be "decorated" later.
+				const viewImage = new ViewContainerElement( 'img' );
+				const insertPosition = conversionApi.mapper.toViewPosition( data.range.start );
+
+				// Check if the `image` element has children.
+				if ( data.item.childCount > 0 ) {
+					const modelCaption = data.item.getChild( 0 );
+
+					// `modelCaption` insertion change is consumed from consumable values.
+					// It will not be converted by other converters, but it's children (probably some text) will be.
+					// Through mapping, converters for text will know where to insert contents of `modelCaption`.
+					if ( consumable.consume( modelCaption, 'insert' ) ) {
+						const viewCaption = new ViewContainerElement( 'figcaption' );
+
+						const viewImageHolder = new ViewContainerElement( 'figure', null, [ viewImage, viewCaption ] );
+
+						conversionApi.mapper.bindElements( modelCaption, viewCaption );
+						conversionApi.mapper.bindElements( data.item, viewImageHolder );
+						viewWriter.insert( insertPosition, viewImageHolder );
+					}
+				} else {
+					conversionApi.mapper.bindElements( data.item, viewImage );
+					viewWriter.insert( insertPosition, viewImage );
+				}
 
-			evt.stop();
-		};
+				evt.stop();
+			};
 
-		const viewFigureConverter = function( evt, data, consumable, conversionApi ) {
-			if ( consumable.consume( data.input, { name: true } ) ) {
-				const modelImage = conversionApi.convertItem( data.input.getChild( 0 ), consumable );
-				const modelCaption = conversionApi.convertItem( data.input.getChild( 1 ), consumable );
+			const modelImageAttributesConverter = function( evt, data, consumable, conversionApi ) {
+				if ( data.item.name != 'image' ) {
+					return;
+				}
 
-				modelImage.appendChildren( modelCaption );
+				let viewElement = conversionApi.mapper.toViewElement( data.item );
 
-				data.output = modelImage;
-			}
-		};
+				if ( viewElement.name == 'figure' ) {
+					viewElement = viewElement.getChild( 0 );
+				}
 
-		const viewImageConverter = function( evt, data, consumable ) {
-			if ( consumable.consume( data.input, { name: true } ) ) {
-				const modelImage = new ModelElement( 'image' );
+				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
 
-				for ( let attributeKey of data.input.getAttributeKeys() ) {
-					modelImage.setAttribute( attributeKey, data.input.getAttribute( attributeKey ) );
+				if ( !data.attributeNewValue ) {
+					viewElement.removeAttribute( data.attributeKey );
+				} else {
+					viewElement.setAttribute( data.attributeKey, data.attributeNewValue );
 				}
 
-				data.output = modelImage;
-			}
-		};
+				evt.stop();
+			};
 
-		const viewFigcaptionConverter = function( evt, data, consumable, conversionApi ) {
-			if ( consumable.consume( data.input, { name: true } ) ) {
-				const modelCaption = new ModelElement( 'caption' );
-				const children = conversionApi.convertChildren( data.input, consumable );
+			const viewFigureConverter = function( evt, data, consumable, conversionApi ) {
+				if ( consumable.consume( data.input, { name: true } ) ) {
+					const modelImage = conversionApi.convertItem( data.input.getChild( 0 ), consumable );
+					const modelCaption = conversionApi.convertItem( data.input.getChild( 1 ), consumable );
 
-				modelCaption.appendChildren( children );
+					modelImage.appendChildren( modelCaption );
 
-				data.output = modelCaption;
-			}
-		};
-
-		modelDispatcher.on( 'insert:image', modelImageConverter );
-		modelDispatcher.on( 'addAttribute', modelImageAttributesConverter );
-		modelDispatcher.on( 'changeAttribute', modelImageAttributesConverter );
-		modelDispatcher.on( 'removeAttribute', modelImageAttributesConverter );
-		viewDispatcher.on( 'element:figure', viewFigureConverter );
-		viewDispatcher.on( 'element:img', viewImageConverter );
-		viewDispatcher.on( 'element:figcaption', viewFigcaptionConverter );
-	} );
+					data.output = modelImage;
+				}
+			};
 
-	it( 'should convert model images changes without caption to view', () => {
-		let modelElement = new ModelElement( 'image', { src: 'bar.jpg', title: 'bar' } );
-		modelRoot.appendChildren( modelElement );
-		modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			const viewImageConverter = function( evt, data, consumable ) {
+				if ( consumable.consume( data.input, { name: true } ) ) {
+					const modelImage = new ModelElement( 'image' );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><img src="bar.jpg" title="bar"></img></div>' );
+					for ( let attributeKey of data.input.getAttributeKeys() ) {
+						modelImage.setAttribute( attributeKey, data.input.getAttribute( attributeKey ) );
+					}
 
-		modelElement.setAttribute( 'src', 'new.jpg' );
-		modelElement.removeAttribute( 'title' );
-		modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
-		modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null );
+					data.output = modelImage;
+				}
+			};
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><img src="new.jpg"></img></div>' );
-	} );
+			const viewFigcaptionConverter = function( evt, data, consumable, conversionApi ) {
+				if ( consumable.consume( data.input, { name: true } ) ) {
+					const modelCaption = new ModelElement( 'caption' );
+					const children = conversionApi.convertChildren( data.input, consumable );
 
-	it( 'should convert model images changes with caption to view', () => {
-		let modelElement = new ModelElement( 'image', { src: 'foo.jpg', title: 'foo' }, [
-			new ModelElement( 'caption', {}, new ModelText( 'foobar' ) )
-		] );
-		modelRoot.appendChildren( modelElement );
-		modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+					modelCaption.appendChildren( children );
 
-		expect( viewToString( viewRoot ) ).to.equal(
-			'<div><figure><img src="foo.jpg" title="foo"></img><figcaption>foobar</figcaption></figure></div>'
-		);
+					data.output = modelCaption;
+				}
+			};
 
-		modelElement.setAttribute( 'src', 'new.jpg' );
-		modelElement.removeAttribute( 'title' );
-		modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
-		modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null );
+			modelDispatcher.on( 'insert:image', modelImageConverter );
+			modelDispatcher.on( 'addAttribute', modelImageAttributesConverter );
+			modelDispatcher.on( 'changeAttribute', modelImageAttributesConverter );
+			modelDispatcher.on( 'removeAttribute', modelImageAttributesConverter );
+			viewDispatcher.on( 'element:figure', viewFigureConverter );
+			viewDispatcher.on( 'element:img', viewImageConverter );
+			viewDispatcher.on( 'element:figcaption', viewFigcaptionConverter );
+		} );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><figure><img src="new.jpg"></img><figcaption>foobar</figcaption></figure></div>' );
-	} );
+		it( 'should convert model images changes without caption to view', () => {
+			let modelElement = new ModelElement( 'image', { src: 'bar.jpg', title: 'bar' } );
+			modelRoot.appendChildren( modelElement );
+			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-	it( 'should convert view image to model', () => {
-		let viewElement = new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } );
-		let modelElement = viewDispatcher.convert( viewElement );
-		// Attaching to tree so tree walker works fine in `modelToString`.
-		modelRoot.appendChildren( modelElement );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><img src="bar.jpg" title="bar"></img></div>' );
 
-		expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"></image>' );
-	} );
+			modelElement.setAttribute( 'src', 'new.jpg' );
+			modelElement.removeAttribute( 'title' );
+			modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
+			modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null );
 
-	it( 'should convert view figure to model', () => {
-		let viewElement = new ViewContainerElement(
-			'figure',
-			null,
-			[
-				new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } ),
-				new ViewContainerElement( 'figcaption', null, new ViewText( 'foobar' ) )
-			]
-		);
-		let modelElement = viewDispatcher.convert( viewElement );
-		// Attaching to tree so tree walker works fine in `modelToString`.
-		modelRoot.appendChildren( modelElement );
-
-		expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"><caption>foobar</caption></image>' );
-	} );
-} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><img src="new.jpg"></img></div>' );
+		} );
 
-// Converter overwrites default attribute converter for `linkHref` and `linkTitle` attribute is set on `quote` element.
-//
-// Model:
-//
-// [quote {linkHref='foo.html' linkTitle='Foo source'}]
-//   ├─ f
-//   ├─ o
-//   └─ o
-//
-// foo {linkHref='foo.html' linkTitle='Foo title'}
-//
-// View:
-//
-// <blockquote>
-//	 ├─ foo
-//	 └─ <a href="foo.html" title="Foo source">
-//	 	  └─ see source
-//
-// <a href="foo.html" title="Foo title">
-//	 └─ foo
-describe( 'custom attribute handling for given element', () => {
-	beforeEach( () => {
-		// NORMAL LINK MODEL TO VIEW CONVERTERS
-		modelDispatcher.on( 'addAttribute:linkHref', wrap( ( value ) => new ViewAttributeElement( 'a', { href: value } ) ) );
-		modelDispatcher.on( 'addAttribute:linkTitle', wrap( ( value ) => new ViewAttributeElement( 'a', { title: value } ) ) );
+		it( 'should convert model images changes with caption to view', () => {
+			let modelElement = new ModelElement( 'image', { src: 'foo.jpg', title: 'foo' }, [
+				new ModelElement( 'caption', {}, new ModelText( 'foobar' ) )
+			] );
+			modelRoot.appendChildren( modelElement );
+			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		const changeLinkAttribute = function( elementCreator ) {
-			return ( evt, data, consumable, conversionApi ) => {
-				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div><figure><img src="foo.jpg" title="foo"></img><figcaption>foobar</figcaption></figure></div>'
+			);
 
-				const viewRange = conversionApi.mapper.toViewRange( data.range );
-				const viewOldA = elementCreator( data.attributeOldValue );
-				const viewNewA = elementCreator( data.attributeNewValue );
+			modelElement.setAttribute( 'src', 'new.jpg' );
+			modelElement.removeAttribute( 'title' );
+			modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
+			modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null );
 
-				viewWriter.unwrap( viewRange, viewOldA, evt.priority );
-				viewWriter.wrap( viewRange, viewNewA, evt.priority );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><figure><img src="new.jpg"></img><figcaption>foobar</figcaption></figure></div>' );
+		} );
 
-				evt.stop();
-			};
-		};
+		it( 'should convert view image to model', () => {
+			let viewElement = new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } );
+			let modelElement = viewDispatcher.convert( viewElement );
+			// Attaching to tree so tree walker works fine in `modelToString`.
+			modelRoot.appendChildren( modelElement );
 
-		modelDispatcher.on(
-			'changeAttribute:linkHref',
-			changeLinkAttribute( ( value ) => new ViewAttributeElement( 'a', { href: value } ) )
-		);
+			expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"></image>' );
+		} );
 
-		modelDispatcher.on(
-			'changeAttribute:linkTitle',
-			changeLinkAttribute( ( value ) => new ViewAttributeElement( 'a', { title: value } ) )
-		);
+		it( 'should convert view figure to model', () => {
+			let viewElement = new ViewContainerElement(
+				'figure',
+				null,
+				[
+					new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } ),
+					new ViewContainerElement( 'figcaption', null, new ViewText( 'foobar' ) )
+				]
+			);
+			let modelElement = viewDispatcher.convert( viewElement );
+			// Attaching to tree so tree walker works fine in `modelToString`.
+			modelRoot.appendChildren( modelElement );
+
+			expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"><caption>foobar</caption></image>' );
+		} );
+	} );
 
-		modelDispatcher.on(
-			'removeAttribute:linkHref',
-			unwrap( ( value ) => new ViewAttributeElement( 'a', { href: value } ) )
-		);
+	// Converter overwrites default attribute converter for `linkHref` and `linkTitle` attribute is set on `quote` element.
+	//
+	// Model:
+	//
+	// [quote {linkHref='foo.html' linkTitle='Foo source'}]
+	//   ├─ f
+	//   ├─ o
+	//   └─ o
+	//
+	// foo {linkHref='foo.html' linkTitle='Foo title'}
+	//
+	// View:
+	//
+	// <blockquote>
+	//	 ├─ foo
+	//	 └─ <a href="foo.html" title="Foo source">
+	//	 	  └─ see source
+	//
+	// <a href="foo.html" title="Foo title">
+	//	 └─ foo
+	describe( 'custom attribute handling for given element', () => {
+		beforeEach( () => {
+			// NORMAL LINK MODEL TO VIEW CONVERTERS
+			modelDispatcher.on( 'addAttribute:linkHref', wrap( ( value ) => new ViewAttributeElement( 'a', { href: value } ) ) );
+			modelDispatcher.on( 'addAttribute:linkTitle', wrap( ( value ) => new ViewAttributeElement( 'a', { title: value } ) ) );
+
+			const changeLinkAttribute = function( elementCreator ) {
+				return ( evt, data, consumable, conversionApi ) => {
+					consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+
+					const viewRange = conversionApi.mapper.toViewRange( data.range );
+					const viewOldA = elementCreator( data.attributeOldValue );
+					const viewNewA = elementCreator( data.attributeNewValue );
+
+					viewWriter.unwrap( viewRange, viewOldA, evt.priority );
+					viewWriter.wrap( viewRange, viewNewA, evt.priority );
+
+					evt.stop();
+				};
+			};
 
-		modelDispatcher.on(
-			'removeAttribute:linkTitle',
-			unwrap( ( value ) => new ViewAttributeElement( 'a', { title: value } ) )
-		);
+			modelDispatcher.on(
+				'changeAttribute:linkHref',
+				changeLinkAttribute( ( value ) => new ViewAttributeElement( 'a', { href: value } ) )
+			);
+
+			modelDispatcher.on(
+				'changeAttribute:linkTitle',
+				changeLinkAttribute( ( value ) => new ViewAttributeElement( 'a', { title: value } ) )
+			);
+
+			modelDispatcher.on(
+				'removeAttribute:linkHref',
+				unwrap( ( value ) => new ViewAttributeElement( 'a', { href: value } ) )
+			);
+
+			modelDispatcher.on(
+				'removeAttribute:linkTitle',
+				unwrap( ( value ) => new ViewAttributeElement( 'a', { title: value } ) )
+			);
+
+			// NORMAL LINK VIEW TO MODEL CONVERTERS
+			viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
+				if ( consumable.consume( data.input, { name: true, attribute: 'href' } ) ) {
+					if ( !data.output ) {
+						data.output = conversionApi.convertChildren( data.input, consumable );
+					}
 
-		// NORMAL LINK VIEW TO MODEL CONVERTERS
-		viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
-			if ( consumable.consume( data.input, { name: true, attribute: 'href' } ) ) {
-				if ( !data.output ) {
-					data.output = conversionApi.convertChildren( data.input, consumable );
+					for ( let child of data.output ) {
+						child.setAttribute( 'linkHref', data.input.getAttribute( 'href' ) );
+					}
 				}
+			} );
 
-				for ( let child of data.output ) {
-					child.setAttribute( 'linkHref', data.input.getAttribute( 'href' ) );
-				}
-			}
-		} );
+			viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
+				if ( consumable.consume( data.input, { attribute: 'title' } ) ) {
+					if ( !data.output ) {
+						data.output = conversionApi.convertChildren( data.input, consumable );
+					}
 
-		viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
-			if ( consumable.consume( data.input, { attribute: 'title' } ) ) {
-				if ( !data.output ) {
-					data.output = conversionApi.convertChildren( data.input, consumable );
+					for ( let child of data.output ) {
+						child.setAttribute( 'linkTitle', data.input.getAttribute( 'title' ) );
+					}
 				}
+			} );
 
-				for ( let child of data.output ) {
-					child.setAttribute( 'linkTitle', data.input.getAttribute( 'title' ) );
-				}
-			}
-		} );
+			// QUOTE MODEL TO VIEW CONVERTERS
+			modelDispatcher.on( 'insert:quote', ( evt, data, consumable, conversionApi ) => {
+				consumable.consume( data.item, 'insert' );
 
-		// QUOTE MODEL TO VIEW CONVERTERS
-		modelDispatcher.on( 'insert:quote', ( evt, data, consumable, conversionApi ) => {
-			consumable.consume( data.item, 'insert' );
+				const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+				const viewElement = new ViewContainerElement( 'blockquote' );
 
-			const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-			const viewElement = new ViewContainerElement( 'blockquote' );
+				conversionApi.mapper.bindElements( data.item, viewElement );
+				viewWriter.insert( viewPosition, viewElement );
 
-			conversionApi.mapper.bindElements( data.item, viewElement );
-			viewWriter.insert( viewPosition, viewElement );
+				if ( consumable.consume( data.item, 'addAttribute:linkHref' ) ) {
+					const viewA = new ViewAttributeElement( 'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' ) );
 
-			if ( consumable.consume( data.item, 'addAttribute:linkHref' ) ) {
-				const viewA = new ViewAttributeElement( 'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' ) );
+					if ( consumable.consume( data.item, 'addAttribute:linkTitle' ) ) {
+						viewA.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) );
+					}
 
-				if ( consumable.consume( data.item, 'addAttribute:linkTitle' ) ) {
-					viewA.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) );
+					viewWriter.insert( new ViewPosition( viewElement, viewElement.childCount ), viewA );
 				}
 
-				viewWriter.insert( new ViewPosition( viewElement, viewElement.childCount ), viewA );
-			}
+				evt.stop();
+			}, { priority: 'high' } );
 
-			evt.stop();
-		}, { priority: 'high' } );
+			const modelChangeLinkAttrQuoteConverter = function( evt, data, consumable, conversionApi ) {
+				let viewKey = data.attributeKey.substr( 4 ).toLowerCase();
 
-		const modelChangeLinkAttrQuoteConverter = function( evt, data, consumable, conversionApi ) {
-			let viewKey = data.attributeKey.substr( 4 ).toLowerCase();
+				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
 
-			consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+				const viewElement = conversionApi.mapper.toViewElement( data.item );
+				const viewA = viewElement.getChild( viewElement.childCount - 1 );
 
-			const viewElement = conversionApi.mapper.toViewElement( data.item );
-			const viewA = viewElement.getChild( viewElement.childCount - 1 );
+				if ( data.attributeNewValue !== null ) {
+					viewA.setAttribute( viewKey, data.attributeNewValue );
+				} else {
+					viewA.removeAttribute( viewKey );
+				}
 
-			if ( data.attributeNewValue !== null ) {
-				viewA.setAttribute( viewKey, data.attributeNewValue );
-			} else {
-				viewA.removeAttribute( viewKey );
-			}
+				evt.stop();
+			};
 
-			evt.stop();
-		};
+			modelDispatcher.on( 'changeAttribute:linkHref:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
+			modelDispatcher.on( 'changeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
 
-		modelDispatcher.on( 'changeAttribute:linkHref:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
-		modelDispatcher.on( 'changeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
+			modelDispatcher.on( 'removeAttribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => {
+				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
 
-		modelDispatcher.on( 'removeAttribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => {
-			consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+				const viewElement = conversionApi.mapper.toViewElement( data.item );
+				const viewA = viewElement.getChild( viewElement.childCount - 1 );
+				const aIndex = viewA.index;
 
-			const viewElement = conversionApi.mapper.toViewElement( data.item );
-			const viewA = viewElement.getChild( viewElement.childCount - 1 );
-			const aIndex = viewA.index;
+				viewWriter.remove( ViewRange.createFromParentsAndOffsets( viewElement, aIndex, viewElement, aIndex + 1 ) );
 
-			viewWriter.remove( ViewRange.createFromParentsAndOffsets( viewElement, aIndex, viewElement, aIndex + 1 ) );
+				evt.stop();
+			}, { priority: 'high' } );
+			modelDispatcher.on( 'removeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
 
-			evt.stop();
-		}, { priority: 'high' } );
-		modelDispatcher.on( 'removeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
+			// QUOTE VIEW TO MODEL CONVERTERS
+			viewDispatcher.on( 'element:blockquote', ( evt, data, consumable, conversionApi ) => {
+				if ( consumable.consume( data.input, { name: true } ) ) {
+					data.output = new ModelElement( 'quote' );
 
-		// QUOTE VIEW TO MODEL CONVERTERS
-		viewDispatcher.on( 'element:blockquote', ( evt, data, consumable, conversionApi ) => {
-			if ( consumable.consume( data.input, { name: true } ) ) {
-				data.output = new ModelElement( 'quote' );
+					const viewA = data.input.getChild( data.input.childCount - 1 );
 
-				const viewA = data.input.getChild( data.input.childCount - 1 );
+					// Convert the special "a" first, before converting all children.
+					if ( viewA instanceof ViewElement && viewA.name == 'a' && consumable.consume( viewA, { name: true } ) ) {
+						if ( consumable.consume( viewA, { attribute: 'href' } ) ) {
+							data.output.setAttribute( 'linkHref', viewA.getAttribute( 'href' ) );
+						}
 
-				// Convert the special "a" first, before converting all children.
-				if ( viewA instanceof ViewElement && viewA.name == 'a' && consumable.consume( viewA, { name: true } ) ) {
-					if ( consumable.consume( viewA, { attribute: 'href' } ) ) {
-						data.output.setAttribute( 'linkHref', viewA.getAttribute( 'href' ) );
+						if ( consumable.consume( viewA, { attribute: 'title' } ) ) {
+							data.output.setAttribute( 'linkTitle', viewA.getAttribute( 'title' ) );
+						}
 					}
 
-					if ( consumable.consume( viewA, { attribute: 'title' } ) ) {
-						data.output.setAttribute( 'linkTitle', viewA.getAttribute( 'title' ) );
-					}
+					const children = conversionApi.convertChildren( data.input, consumable );
+					data.output.appendChildren( children );
 				}
-
-				const children = conversionApi.convertChildren( data.input, consumable );
-				data.output.appendChildren( children );
-			}
+			} );
 		} );
-	} );
 
-	it( 'should convert model text with linkHref and linkTitle to view', () => {
-		const modelText = new ModelText( 'foo', { linkHref: 'foo.html', linkTitle: 'Foo title' } );
-		modelRoot.appendChildren( modelText );
+		it( 'should convert model text with linkHref and linkTitle to view', () => {
+			const modelText = new ModelText( 'foo', { linkHref: 'foo.html', linkTitle: 'Foo title' } );
+			modelRoot.appendChildren( modelText );
 
-		let range = ModelRange.createIn( modelRoot );
+			let range = ModelRange.createIn( modelRoot );
 
-		modelDispatcher.convertInsertion( range );
+			modelDispatcher.convertInsertion( range );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="foo.html" title="Foo title">foo</a></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="foo.html" title="Foo title">foo</a></div>' );
 
-		// Let's change link's attributes.
-		modelWriter.setAttribute( range, 'linkHref', 'bar.html' );
-		modelWriter.setAttribute( range, 'linkTitle', 'Bar title' );
-		modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkHref', 'foo.html', 'bar.html' );
-		modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkTitle', 'Foo title', 'Bar title' );
+			// Let's change link's attributes.
+			modelWriter.setAttribute( range, 'linkHref', 'bar.html' );
+			modelWriter.setAttribute( range, 'linkTitle', 'Bar title' );
+			modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkHref', 'foo.html', 'bar.html' );
+			modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkTitle', 'Foo title', 'Bar title' );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">foo</a></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">foo</a></div>' );
 
-		const removed = modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
-		modelDoc.graveyard.appendChildren( removed );
-		modelDispatcher.convertRemove(
-			ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
-			ModelRange.createIn( modelDoc.graveyard )
-		);
+			const removed = modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
+			modelDoc.graveyard.appendChildren( removed );
+			modelDispatcher.convertRemove(
+				ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
+				ModelRange.createIn( modelDoc.graveyard )
+			);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">oo</a></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">oo</a></div>' );
 
-		range = ModelRange.createIn( modelRoot );
+			range = ModelRange.createIn( modelRoot );
 
-		// Let's remove just one attribute.
-		modelWriter.removeAttribute( range, 'linkTitle' );
-		modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkTitle', 'Bar title', null );
+			// Let's remove just one attribute.
+			modelWriter.removeAttribute( range, 'linkTitle' );
+			modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkTitle', 'Bar title', null );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html">oo</a></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html">oo</a></div>' );
 
-		// Let's remove the other attribute.
-		modelWriter.removeAttribute( range, 'linkHref' );
-		modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkHref', 'bar.html', null );
+			// Let's remove the other attribute.
+			modelWriter.removeAttribute( range, 'linkHref' );
+			modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkHref', 'bar.html', null );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div>oo</div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div>oo</div>' );
+		} );
 
-	it( 'should convert a view element to model', () => {
-		let viewElement = new ViewAttributeElement( 'a', { href: 'foo.html', title: 'Foo title' }, new ViewText( 'foo' ) );
+		it( 'should convert a view element to model', () => {
+			let viewElement = new ViewAttributeElement( 'a', { href: 'foo.html', title: 'Foo title' }, new ViewText( 'foo' ) );
 
-		let modelText = viewDispatcher.convert( viewElement )[ 0 ];
+			let modelText = viewDispatcher.convert( viewElement )[ 0 ];
 
-		expect( modelText ).to.be.instanceof( ModelText );
-		expect( modelText.data ).to.equal( 'foo' );
-		expect( modelText.getAttribute( 'linkHref' ) ).to.equal( 'foo.html' );
-		expect( modelText.getAttribute( 'linkTitle' ) ).to.equal( 'Foo title' );
-	} );
+			expect( modelText ).to.be.instanceof( ModelText );
+			expect( modelText.data ).to.equal( 'foo' );
+			expect( modelText.getAttribute( 'linkHref' ) ).to.equal( 'foo.html' );
+			expect( modelText.getAttribute( 'linkTitle' ) ).to.equal( 'Foo title' );
+		} );
 
-	it( 'should convert quote model element with linkHref and linkTitle attribute to view', () => {
-		let modelElement = new ModelElement( 'quote', { linkHref: 'foo.html', linkTitle: 'Foo source' }, new ModelText( 'foo' ) );
-		modelRoot.appendChildren( modelElement );
-		modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+		it( 'should convert quote model element with linkHref and linkTitle attribute to view', () => {
+			let modelElement = new ModelElement( 'quote', { linkHref: 'foo.html', linkTitle: 'Foo source' }, new ModelText( 'foo' ) );
+			modelRoot.appendChildren( modelElement );
+			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		let expected = '<div><blockquote>foo<a href="foo.html" title="Foo source">see source</a></blockquote></div>';
-		expect( viewToString( viewRoot ) ).to.equal( expected );
+			let expected = '<div><blockquote>foo<a href="foo.html" title="Foo source">see source</a></blockquote></div>';
+			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-		modelDispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
-		modelDispatcher.on( 'changeAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
-		modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'changeAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewAttributeElement( 'strong' ) ) );
 
-		modelElement.appendChildren( new ModelText( 'bar', { bold: true } ) );
-		modelDispatcher.convertInsertion( ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 6 ) );
+			modelElement.appendChildren( new ModelText( 'bar', { bold: true } ) );
+			modelDispatcher.convertInsertion( ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 6 ) );
 
-		expected = '<div><blockquote>foo<strong>bar</strong><a href="foo.html" title="Foo source">see source</a></blockquote></div>';
-		expect( viewToString( viewRoot ) ).to.equal( expected );
+			expected = '<div><blockquote>foo<strong>bar</strong><a href="foo.html" title="Foo source">see source</a></blockquote></div>';
+			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-		modelElement.removeAttribute( 'linkTitle' );
-		modelElement.setAttribute( 'linkHref', 'bar.html' );
+			modelElement.removeAttribute( 'linkTitle' );
+			modelElement.setAttribute( 'linkHref', 'bar.html' );
 
-		modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'linkTitle', 'Foo source', null );
-		modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'linkHref', 'foo.html', 'bar.html' );
+			modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'linkTitle', 'Foo source', null );
+			modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'linkHref', 'foo.html', 'bar.html' );
 
-		expected = '<div><blockquote>foo<strong>bar</strong><a href="bar.html">see source</a></blockquote></div>';
-		expect( viewToString( viewRoot ) ).to.equal( expected );
+			expected = '<div><blockquote>foo<strong>bar</strong><a href="bar.html">see source</a></blockquote></div>';
+			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-		modelElement.removeAttribute( 'linkHref' );
-		modelDispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'linkHref', 'bar.html', null );
+			modelElement.removeAttribute( 'linkHref' );
+			modelDispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'linkHref', 'bar.html', null );
 
-		expected = '<div><blockquote>foo<strong>bar</strong></blockquote></div>';
-		expect( viewToString( viewRoot ) ).to.equal( expected );
-	} );
+			expected = '<div><blockquote>foo<strong>bar</strong></blockquote></div>';
+			expect( viewToString( viewRoot ) ).to.equal( expected );
+		} );
 
-	it( 'should convert view blockquote with a element to model', () => {
-		let viewElement = new ViewContainerElement(
-			'blockquote',
-			null,
-			[
-				new ViewText( 'foo' ),
-				new ViewAttributeElement(
-					'a',
-					{
-						href: 'foo.html',
-						title: 'Foo source'
-					},
-					new ViewText( 'see source' )
-				)
-			]
-		);
-
-		let modelElement = viewDispatcher.convert( viewElement );
-		modelRoot.appendChildren( modelElement );
-
-		expect( modelToString( modelElement ) ).to.equal( '<quote linkHref="foo.html" linkTitle="Foo source">foo</quote>' );
+		it( 'should convert view blockquote with a element to model', () => {
+			let viewElement = new ViewContainerElement(
+				'blockquote',
+				null,
+				[
+					new ViewText( 'foo' ),
+					new ViewAttributeElement(
+						'a',
+						{
+							href: 'foo.html',
+							title: 'Foo source'
+						},
+						new ViewText( 'see source' )
+					)
+				]
+			);
+
+			let modelElement = viewDispatcher.convert( viewElement );
+			modelRoot.appendChildren( modelElement );
+
+			expect( modelToString( modelElement ) ).to.equal( '<quote linkHref="foo.html" linkTitle="Foo source">foo</quote>' );
+		} );
 	} );
-} );
 
-// Default view converter for tables that will convert table structure into paragraphs if tables are not supported.
-// TRs are supposed to become paragraphs and TDs content should be separated using space.
-it( 'default table view to model converter', () => {
-	viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
-		if ( consumable.consume( data.input, { name: true, attribute: 'href' } ) ) {
-			if ( !data.output ) {
-				data.output = conversionApi.convertChildren( data.input, consumable );
-			}
+	// Default view converter for tables that will convert table structure into paragraphs if tables are not supported.
+	// TRs are supposed to become paragraphs and TDs content should be separated using space.
+	it( 'default table view to model converter', () => {
+		viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
+			if ( consumable.consume( data.input, { name: true, attribute: 'href' } ) ) {
+				if ( !data.output ) {
+					data.output = conversionApi.convertChildren( data.input, consumable );
+				}
 
-			for ( let child of data.output ) {
-				child.setAttribute( 'linkHref', data.input.getAttribute( 'href' ) );
+				for ( let child of data.output ) {
+					child.setAttribute( 'linkHref', data.input.getAttribute( 'href' ) );
+				}
 			}
-		}
-	} );
+		} );
 
-	viewDispatcher.on( 'element:tr', ( evt, data, consumable, conversionApi ) => {
-		if ( consumable.consume( data.input, { name: true } ) ) {
-			data.output = new ModelElement( 'paragraph' );
-			const children = conversionApi.convertChildren( data.input, consumable );
+		viewDispatcher.on( 'element:tr', ( evt, data, consumable, conversionApi ) => {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				data.output = new ModelElement( 'paragraph' );
+				const children = conversionApi.convertChildren( data.input, consumable );
 
-			for ( let i = 1; i < children.length; i++ ) {
-				if ( children[ i ] instanceof ModelText && children[ i - 1 ] instanceof ModelText ) {
-					children.splice( i, 0, new ModelText( ' ' ) );
-					i++;
+				for ( let i = 1; i < children.length; i++ ) {
+					if ( children[ i ] instanceof ModelText && children[ i - 1 ] instanceof ModelText ) {
+						children.splice( i, 0, new ModelText( ' ' ) );
+						i++;
+					}
 				}
-			}
 
-			data.output.appendChildren( children );
-		}
-	} );
+				data.output.appendChildren( children );
+			}
+		} );
 
-	viewDispatcher.on( 'element:table', ( evt, data, consumable, conversionApi ) => {
-		if ( consumable.consume( data.input, { name: true } ) ) {
-			data.output = conversionApi.convertChildren( data.input, consumable );
-		}
-	} );
+		viewDispatcher.on( 'element:table', ( evt, data, consumable, conversionApi ) => {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				data.output = conversionApi.convertChildren( data.input, consumable );
+			}
+		} );
 
-	viewDispatcher.on( 'element:td', ( evt, data, consumable, conversionApi ) => {
-		if ( consumable.consume( data.input, { name: true } ) ) {
-			data.output = conversionApi.convertChildren( data.input, consumable );
-		}
-	} );
+		viewDispatcher.on( 'element:td', ( evt, data, consumable, conversionApi ) => {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				data.output = conversionApi.convertChildren( data.input, consumable );
+			}
+		} );
 
-	let viewTable = new ViewContainerElement( 'table', null, [
-		new ViewContainerElement( 'tr', null, [
-			new ViewContainerElement( 'td', null, new ViewText( 'foo' ) ),
-			new ViewContainerElement( 'td', null, new ViewAttributeElement( 'a', { href: 'bar.html' }, new ViewText( 'bar' ) ) )
-		] ),
-		new ViewContainerElement( 'tr', null, [
-			new ViewContainerElement( 'td' ),
-			new ViewContainerElement( 'td', null, new ViewText( 'abc' ) )
-		] )
-	] );
-
-	let model = viewDispatcher.convert( viewTable );
-	let modelFragment = new ModelDocumentFragment( model );
-
-	expect( modelToString( modelFragment ) )
-		.to.equal( '<paragraph>foo <$text linkHref="bar.html">bar</$text></paragraph><paragraph>abc</paragraph>' );
-} );
+		let viewTable = new ViewContainerElement( 'table', null, [
+			new ViewContainerElement( 'tr', null, [
+				new ViewContainerElement( 'td', null, new ViewText( 'foo' ) ),
+				new ViewContainerElement( 'td', null, new ViewAttributeElement( 'a', { href: 'bar.html' }, new ViewText( 'bar' ) ) )
+			] ),
+			new ViewContainerElement( 'tr', null, [
+				new ViewContainerElement( 'td' ),
+				new ViewContainerElement( 'td', null, new ViewText( 'abc' ) )
+			] )
+		] );
 
-// Model converter that converts any non-converted elements and attributes into view elements and attributes.
-// View converter that converts any non-converted elements and attributes into model elements and attributes.
-describe( 'universal converter', () => {
-	beforeEach( () => {
-		// "Universal" converters
-		modelDispatcher.on( 'insert', insertElement( ( data ) => new ViewContainerElement( data.item.name ) ), { priority: 'lowest' } );
-		modelDispatcher.on( 'addAttribute', setAttribute(), { priority: 'lowest' } );
-		modelDispatcher.on( 'changeAttribute', setAttribute(), { priority: 'lowest' } );
-		modelDispatcher.on( 'removeAttribute', removeAttribute(), { priority: 'lowest' } );
+		let model = viewDispatcher.convert( viewTable );
+		let modelFragment = new ModelDocumentFragment( model );
 
-		viewDispatcher.on( 'element', ( evt, data, consumable, conversionApi ) => {
-			if ( consumable.consume( data.input, { name: true } ) ) {
-				data.output = new ModelElement( data.input.name );
+		expect( modelToString( modelFragment ) )
+			.to.equal( '<paragraph>foo <$text linkHref="bar.html">bar</$text></paragraph><paragraph>abc</paragraph>' );
+	} );
 
-				for ( let key of data.input.getAttributeKeys() ) {
-					if ( consumable.consume( data.input, { attribute: key } ) ) {
-						data.output.setAttribute( key, data.input.getAttribute( key ) );
+	// Model converter that converts any non-converted elements and attributes into view elements and attributes.
+	// View converter that converts any non-converted elements and attributes into model elements and attributes.
+	describe( 'universal converter', () => {
+		beforeEach( () => {
+			// "Universal" converters
+			modelDispatcher.on( 'insert', insertElement( ( data ) => new ViewContainerElement( data.item.name ) ), { priority: 'lowest' } );
+			modelDispatcher.on( 'addAttribute', setAttribute(), { priority: 'lowest' } );
+			modelDispatcher.on( 'changeAttribute', setAttribute(), { priority: 'lowest' } );
+			modelDispatcher.on( 'removeAttribute', removeAttribute(), { priority: 'lowest' } );
+
+			viewDispatcher.on( 'element', ( evt, data, consumable, conversionApi ) => {
+				if ( consumable.consume( data.input, { name: true } ) ) {
+					data.output = new ModelElement( data.input.name );
+
+					for ( let key of data.input.getAttributeKeys() ) {
+						if ( consumable.consume( data.input, { attribute: key } ) ) {
+							data.output.setAttribute( key, data.input.getAttribute( key ) );
+						}
 					}
+
+					data.output.appendChildren( conversionApi.convertChildren( data.input, consumable ) );
 				}
+			}, { priority: 'lowest' } );
 
-				data.output.appendChildren( conversionApi.convertChildren( data.input, consumable ) );
-			}
-		}, { priority: 'lowest' } );
+			// "Real" converters -- added with higher priority. Should overwrite the "universal" converters.
+			modelDispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
+			modelDispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'changeAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewAttributeElement( 'strong' ) ) );
 
-		// "Real" converters -- added with higher priority. Should overwrite the "universal" converters.
-		modelDispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
-		modelDispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
-		modelDispatcher.on( 'changeAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
-		modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewAttributeElement( 'strong' ) ) );
+			viewDispatcher.on( 'element:img', ( evt, data, consumable ) => {
+				if ( consumable.consume( data.input, { name: true } ) ) {
+					const modelImage = new ModelElement( 'image' );
 
-		viewDispatcher.on( 'element:img', ( evt, data, consumable ) => {
-			if ( consumable.consume( data.input, { name: true } ) ) {
-				const modelImage = new ModelElement( 'image' );
+					for ( let attributeKey of data.input.getAttributeKeys() ) {
+						modelImage.setAttribute( attributeKey, data.input.getAttribute( attributeKey ) );
+					}
 
-				for ( let attributeKey of data.input.getAttributeKeys() ) {
-					modelImage.setAttribute( attributeKey, data.input.getAttribute( attributeKey ) );
+					data.output = modelImage;
 				}
+			} );
+			viewDispatcher.on( 'element:strong', ( evt, data, consumable, conversionApi ) => {
+				if ( consumable.consume( data.input, { name: true } ) ) {
+					if ( !data.output ) {
+						data.output = conversionApi.convertChildren( data.input, consumable );
+					}
 
-				data.output = modelImage;
-			}
-		} );
-		viewDispatcher.on( 'element:strong', ( evt, data, consumable, conversionApi ) => {
-			if ( consumable.consume( data.input, { name: true } ) ) {
-				if ( !data.output ) {
-					data.output = conversionApi.convertChildren( data.input, consumable );
+					for ( let child of data.output ) {
+						child.setAttribute( 'bold', true );
+					}
 				}
+			} );
+		} );
 
-				for ( let child of data.output ) {
-					child.setAttribute( 'bold', true );
-				}
-			}
+		it( 'should convert model to view', () => {
+			let modelElement = new ModelElement( 'table', { cellpadding: 5, cellspacing: 5 }, [
+				new ModelElement( 'tr', null, [
+					new ModelElement( 'td', null, [
+						new ModelText( 'foo ' ),
+						new ModelText( 'abc', { bold: true } ),
+						new ModelText( ' bar' )
+					] ),
+					new ModelElement( 'td', null, [
+						new ModelElement( 'foo', { foo: 'bar' }, new ModelText( 'bar' ) )
+					] )
+				] )
+			] );
+
+			modelRoot.appendChildren( modelElement );
+			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div>' +
+					'<table cellpadding="5" cellspacing="5">' +
+						'<tr>' +
+							'<td>foo <strong>abc</strong> bar</td>' +
+							'<td><foo foo="bar">bar</foo></td>' +
+						'</tr>' +
+					'</table>' +
+				'</div>'
+			);
 		} );
-	} );
 
-	it( 'should convert model to view', () => {
-		let modelElement = new ModelElement( 'table', { cellpadding: 5, cellspacing: 5 }, [
-			new ModelElement( 'tr', null, [
-				new ModelElement( 'td', null, [
-					new ModelText( 'foo ' ),
-					new ModelText( 'abc', { bold: true } ),
-					new ModelText( ' bar' )
-				] ),
-				new ModelElement( 'td', null, [
-					new ModelElement( 'foo', { foo: 'bar' }, new ModelText( 'bar' ) )
+		it( 'should convert view to model', () => {
+			let viewElement = new ViewContainerElement( 'table', { cellpadding: 5, cellspacing: 5 }, [
+				new ViewContainerElement( 'tr', null, [
+					new ViewContainerElement( 'td', null, [
+						new ViewText( 'foo ' ),
+						new ViewAttributeElement( 'strong', null, new ViewText( 'abc' ) ),
+						new ViewText( ' bar' )
+					] ),
+					new ViewContainerElement( 'td', null, new ViewContainerElement( 'foo', { foo: 'bar' }, new ViewText( 'bar' ) ) )
 				] )
-			] )
-		] );
+			] );
 
-		modelRoot.appendChildren( modelElement );
-		modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			let modelElement = viewDispatcher.convert( viewElement );
+			modelRoot.appendChildren( modelElement );
 
-		expect( viewToString( viewRoot ) ).to.equal(
-			'<div>' +
+			expect( modelToString( modelElement ) ).to.equal(
 				'<table cellpadding="5" cellspacing="5">' +
 					'<tr>' +
-						'<td>foo <strong>abc</strong> bar</td>' +
+						'<td>foo <$text bold="true">abc</$text> bar</td>' +
 						'<td><foo foo="bar">bar</foo></td>' +
 					'</tr>' +
-				'</table>' +
-			'</div>'
-		);
-	} );
-
-	it( 'should convert view to model', () => {
-		let viewElement = new ViewContainerElement( 'table', { cellpadding: 5, cellspacing: 5 }, [
-			new ViewContainerElement( 'tr', null, [
-				new ViewContainerElement( 'td', null, [
-					new ViewText( 'foo ' ),
-					new ViewAttributeElement( 'strong', null, new ViewText( 'abc' ) ),
-					new ViewText( ' bar' )
-				] ),
-				new ViewContainerElement( 'td', null, new ViewContainerElement( 'foo', { foo: 'bar' }, new ViewText( 'bar' ) ) )
-			] )
-		] );
-
-		let modelElement = viewDispatcher.convert( viewElement );
-		modelRoot.appendChildren( modelElement );
-
-		expect( modelToString( modelElement ) ).to.equal(
-			'<table cellpadding="5" cellspacing="5">' +
-				'<tr>' +
-					'<td>foo <$text bold="true">abc</$text> bar</td>' +
-					'<td><foo foo="bar">bar</foo></td>' +
-				'</tr>' +
-			'</table>'
-		);
+				'</table>'
+			);
+		} );
 	} );
 } );

+ 1 - 0
packages/ckeditor5-engine/tests/conversion/buildviewconverter.js

@@ -80,6 +80,7 @@ describe( 'View converter builder', () => {
 		schema.allow( { name: '$inline', attributes: textAttributes, inside: '$root' } );
 		schema.allow( { name: 'image', attributes: [ 'src' ], inside: '$root' } );
 		schema.allow( { name: 'image', attributes: [ 'src' ], inside: '$block' } );
+		schema.allow( { name: '$text', inside: '$inline' } );
 		schema.allow( { name: '$text', attributes: textAttributes, inside: '$block' } );
 		schema.allow( { name: '$text', attributes: textAttributes, inside: '$root' } );
 		schema.allow( { name: 'paragraph', attributes: pAttributes, inside: '$root' } );

+ 426 - 419
packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js

@@ -34,493 +34,500 @@ import {
 import { stringify as stringifyView } from 'ckeditor5/engine/dev-utils/view.js';
 import { setData as setModelData } from 'ckeditor5/engine/dev-utils/model.js';
 
-let dispatcher, mapper;
-let modelDoc, modelRoot, modelSelection;
-let viewDoc, viewRoot, viewSelection;
+describe( 'model-selection-to-view-converters', () => {
+	let dispatcher, mapper;
+	let modelDoc, modelRoot, modelSelection;
+	let viewDoc, viewRoot, viewSelection;
 
-beforeEach( () => {
-	modelDoc = new ModelDocument();
-	modelRoot = modelDoc.createRoot();
-	modelSelection = modelDoc.selection;
-
-	modelDoc.schema.allow( { name: '$text', inside: '$root' } );
-
-	viewDoc = new ViewDocument();
-	viewRoot = viewDoc.createRoot( 'div' );
-	viewSelection = viewDoc.selection;
-
-	mapper = new Mapper();
-	mapper.bindElements( modelRoot, viewRoot );
-
-	dispatcher = new ModelConversionDispatcher( { mapper, viewSelection } );
-
-	dispatcher.on( 'insert:$text', insertText() );
-	dispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
-
-	// Default selection converters.
-	dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
-	dispatcher.on( 'selection', convertRangeSelection(), { priority: 'low' } );
-	dispatcher.on( 'selection', convertCollapsedSelection(), { priority: 'low' } );
-} );
-
-afterEach( () => {
-	viewDoc.destroy();
-} );
-
-describe( 'default converters', () => {
 	beforeEach( () => {
-		// Selection converters for selection attributes.
-		dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
-		dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
-	} );
-
-	describe( 'range selection', () => {
-		it( 'in same container', () => {
-			test(
-				[ 1, 4 ],
-				'foobar',
-				'f{oob}ar'
-			);
-		} );
-
-		it( 'in same container with unicode characters', () => {
-			test(
-				[ 2, 6 ],
-				'நிலைக்கு',
-				'நி{லைக்}கு'
-			);
-		} );
-
-		it( 'in same container, over attribute', () => {
-			test(
-				[ 1, 5 ],
-				'fo<$text bold="true">ob</$text>ar',
-				'f{o<strong>ob</strong>a}r'
-			);
-		} );
-
-		it( 'in same container, next to attribute', () => {
-			test(
-				[ 1, 2 ],
-				'fo<$text bold="true">ob</$text>ar',
-				'f{o}<strong>ob</strong>ar'
-			);
-		} );
-
-		it( 'in same attribute', () => {
-			test(
-				[ 2, 4 ],
-				'f<$text bold="true">ooba</$text>r',
-				'f<strong>o{ob}a</strong>r'
-			);
-		} );
-
-		it( 'in same attribute, selection same as attribute', () => {
-			test(
-				[ 2, 4 ],
-				'fo<$text bold="true">ob</$text>ar',
-				'fo{<strong>ob</strong>}ar'
-			);
-		} );
+		modelDoc = new ModelDocument();
+		modelRoot = modelDoc.createRoot();
+		modelSelection = modelDoc.selection;
 
-		it( 'starts in text node, ends in attribute #1', () => {
-			test(
-				[ 1, 3 ],
-				'fo<$text bold="true">ob</$text>ar',
-				'f{o<strong>o}b</strong>ar'
-			);
-		} );
+		modelDoc.schema.allow( { name: '$text', inside: '$root' } );
 
-		it( 'starts in text node, ends in attribute #2', () => {
-			test(
-				[ 1, 4 ],
-				'fo<$text bold="true">ob</$text>ar',
-				'f{o<strong>ob</strong>}ar'
-			);
-		} );
+		viewDoc = new ViewDocument();
+		viewRoot = viewDoc.createRoot( 'div' );
+		viewSelection = viewDoc.selection;
 
-		it( 'starts in attribute, ends in text node', () => {
-			test(
-				[ 3, 5 ],
-				'fo<$text bold="true">ob</$text>ar',
-				'fo<strong>o{b</strong>a}r'
-			);
-		} );
-
-		it( 'consumes consumable values properly', () => {
-			// Add callback that will fire before default ones.
-			// This should prevent default callback doing anything.
-			dispatcher.on( 'selection', ( evt, data, consumable ) => {
-				expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
-			}, { priority: 'high' } );
+		mapper = new Mapper();
+		mapper.bindElements( modelRoot, viewRoot );
 
-			// Similar test case as the first in this suite.
-			test(
-				[ 1, 4 ],
-				'foobar',
-				'foobar' // No selection in view.
-			);
-		} );
+		dispatcher = new ModelConversionDispatcher( { mapper, viewSelection } );
 
-		it( 'should convert backward selection', () => {
-			test(
-				[ 1, 3, 'backward' ],
-				'foobar',
-				'f{oo}bar'
-			);
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
 
-			expect( viewSelection.focus.offset ).to.equal( 1 );
-		} );
+		// Default selection converters.
+		dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
+		dispatcher.on( 'selection', convertRangeSelection(), { priority: 'low' } );
+		dispatcher.on( 'selection', convertCollapsedSelection(), { priority: 'low' } );
 	} );
 
-	describe( 'collapsed selection', () => {
-		it( 'in container', () => {
-			test(
-				[ 1, 1 ],
-				'foobar',
-				'f{}oobar'
-			);
-		} );
-
-		it( 'in attribute', () => {
-			test(
-				[ 3, 3 ],
-				'f<$text bold="true">ooba</$text>r',
-				'f<strong>oo{}ba</strong>r'
-			);
-		} );
+	afterEach( () => {
+		viewDoc.destroy();
+	} );
 
-		it( 'in container with extra attributes', () => {
-			test(
-				[ 1, 1 ],
-				'foobar',
-				'f<em>[]</em>oobar',
-				{ italic: true }
-			);
+	describe( 'default converters', () => {
+		beforeEach( () => {
+			// Selection converters for selection attributes.
+			dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
+			dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
 		} );
 
-		it( 'in attribute with extra attributes', () => {
-			test(
-				[ 3, 3 ],
-				'f<$text bold="true">ooba</$text>r',
-				'f<strong>oo</strong><em><strong>[]</strong></em><strong>ba</strong>r',
-				{ italic: true }
-			);
+		describe( 'range selection', () => {
+			it( 'in same container', () => {
+				test(
+					[ 1, 4 ],
+					'foobar',
+					'f{oob}ar'
+				);
+			} );
+
+			it( 'in same container with unicode characters', () => {
+				test(
+					[ 2, 6 ],
+					'நிலைக்கு',
+					'நி{லைக்}கு'
+				);
+			} );
+
+			it( 'in same container, over attribute', () => {
+				test(
+					[ 1, 5 ],
+					'fo<$text bold="true">ob</$text>ar',
+					'f{o<strong>ob</strong>a}r'
+				);
+			} );
+
+			it( 'in same container, next to attribute', () => {
+				test(
+					[ 1, 2 ],
+					'fo<$text bold="true">ob</$text>ar',
+					'f{o}<strong>ob</strong>ar'
+				);
+			} );
+
+			it( 'in same attribute', () => {
+				test(
+					[ 2, 4 ],
+					'f<$text bold="true">ooba</$text>r',
+					'f<strong>o{ob}a</strong>r'
+				);
+			} );
+
+			it( 'in same attribute, selection same as attribute', () => {
+				test(
+					[ 2, 4 ],
+					'fo<$text bold="true">ob</$text>ar',
+					'fo{<strong>ob</strong>}ar'
+				);
+			} );
+
+			it( 'starts in text node, ends in attribute #1', () => {
+				test(
+					[ 1, 3 ],
+					'fo<$text bold="true">ob</$text>ar',
+					'f{o<strong>o}b</strong>ar'
+				);
+			} );
+
+			it( 'starts in text node, ends in attribute #2', () => {
+				test(
+					[ 1, 4 ],
+					'fo<$text bold="true">ob</$text>ar',
+					'f{o<strong>ob</strong>}ar'
+				);
+			} );
+
+			it( 'starts in attribute, ends in text node', () => {
+				test(
+					[ 3, 5 ],
+					'fo<$text bold="true">ob</$text>ar',
+					'fo<strong>o{b</strong>a}r'
+				);
+			} );
+
+			it( 'consumes consumable values properly', () => {
+				// Add callback that will fire before default ones.
+				// This should prevent default callback doing anything.
+				dispatcher.on( 'selection', ( evt, data, consumable ) => {
+					expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
+				}, { priority: 'high' } );
+
+				// Similar test case as the first in this suite.
+				test(
+					[ 1, 4 ],
+					'foobar',
+					'foobar' // No selection in view.
+				);
+			} );
+
+			it( 'should convert backward selection', () => {
+				test(
+					[ 1, 3, 'backward' ],
+					'foobar',
+					'f{oo}bar'
+				);
+
+				expect( viewSelection.focus.offset ).to.equal( 1 );
+			} );
 		} );
 
-		it( 'consumes consumable values properly', () => {
-			// Add callbacks that will fire before default ones.
-			// This should prevent default callbacks doing anything.
-			dispatcher.on( 'selection', ( evt, data, consumable ) => {
-				expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
-			}, { priority: 'high' } );
-
-			dispatcher.on( 'selectionAttribute:bold', ( evt, data, consumable ) => {
-				expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
-			}, { priority: 'high' } );
-
-			// Similar test case as above
-			test(
-				[ 3, 3 ],
-				'f<$text bold="true">ooba</$text>r',
-				'f<strong>ooba</strong>r' // No selection in view.
-			);
+		describe( 'collapsed selection', () => {
+			it( 'in container', () => {
+				test(
+					[ 1, 1 ],
+					'foobar',
+					'f{}oobar'
+				);
+			} );
+
+			it( 'in attribute', () => {
+				test(
+					[ 3, 3 ],
+					'f<$text bold="true">ooba</$text>r',
+					'f<strong>oo{}ba</strong>r'
+				);
+			} );
+
+			it( 'in container with extra attributes', () => {
+				test(
+					[ 1, 1 ],
+					'foobar',
+					'f<em>[]</em>oobar',
+					{ italic: true }
+				);
+			} );
+
+			it( 'in attribute with extra attributes', () => {
+				test(
+					[ 3, 3 ],
+					'f<$text bold="true">ooba</$text>r',
+					'f<strong>oo</strong><em><strong>[]</strong></em><strong>ba</strong>r',
+					{ italic: true }
+				);
+			} );
+
+			it( 'consumes consumable values properly', () => {
+				// Add callbacks that will fire before default ones.
+				// This should prevent default callbacks doing anything.
+				dispatcher.on( 'selection', ( evt, data, consumable ) => {
+					expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
+				}, { priority: 'high' } );
+
+				dispatcher.on( 'selectionAttribute:bold', ( evt, data, consumable ) => {
+					expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
+				}, { priority: 'high' } );
+
+				// Similar test case as above
+				test(
+					[ 3, 3 ],
+					'f<$text bold="true">ooba</$text>r',
+					'f<strong>ooba</strong>r' // No selection in view.
+				);
+			} );
 		} );
 	} );
-} );
-
-describe( 'clean-up', () => {
-	describe( 'convertRangeSelection', () => {
-		it( 'should remove all ranges before adding new range', () => {
-			test(
-				[ 0, 2 ],
-				'foobar',
-				'{fo}obar'
-			);
 
-			test(
-				[ 3, 5 ],
-				'foobar',
-				'foo{ba}r'
-			);
-
-			expect( viewSelection.rangeCount ).to.equal( 1 );
+	describe( 'clean-up', () => {
+		describe( 'convertRangeSelection', () => {
+			it( 'should remove all ranges before adding new range', () => {
+				test(
+					[ 0, 2 ],
+					'foobar',
+					'{fo}obar'
+				);
+
+				test(
+					[ 3, 5 ],
+					'foobar',
+					'foo{ba}r'
+				);
+
+				expect( viewSelection.rangeCount ).to.equal( 1 );
+			} );
 		} );
-	} );
 
-	describe( 'convertCollapsedSelection', () => {
-		it( 'should remove all ranges before adding new range', () => {
-			test(
-				[ 2, 2 ],
-				'foobar',
-				'fo{}obar'
-			);
-
-			test(
-				[ 3, 3 ],
-				'foobar',
-				'foo{}bar'
-			);
-
-			expect( viewSelection.rangeCount ).to.equal( 1 );
+		describe( 'convertCollapsedSelection', () => {
+			it( 'should remove all ranges before adding new range', () => {
+				test(
+					[ 2, 2 ],
+					'foobar',
+					'fo{}obar'
+				);
+
+				test(
+					[ 3, 3 ],
+					'foobar',
+					'foo{}bar'
+				);
+
+				expect( viewSelection.rangeCount ).to.equal( 1 );
+			} );
 		} );
-	} );
 
-	describe( 'clearAttributes', () => {
-		it( 'should remove all ranges before adding new range', () => {
-			dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-			dispatcher.on( 'addAttribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
+		describe( 'clearAttributes', () => {
+			it( 'should remove all ranges before adding new range', () => {
+				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
+				dispatcher.on( 'addAttribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
 
-			test(
-				[ 3, 3 ],
-				'foobar',
-				'foo<b>[]</b>bar',
-				{ bold: 'true' }
-			);
+				test(
+					[ 3, 3 ],
+					'foobar',
+					'foo<b>[]</b>bar',
+					{ bold: 'true' }
+				);
 
-			const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
-			modelDoc.selection.setRanges( [ modelRange ] );
+				const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
+				modelDoc.selection.setRanges( [ modelRange ] );
 
-			dispatcher.convertSelection( modelDoc.selection );
+				dispatcher.convertSelection( modelDoc.selection );
 
-			expect( viewSelection.rangeCount ).to.equal( 1 );
+				expect( viewSelection.rangeCount ).to.equal( 1 );
 
-			const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
-			expect( viewString ).to.equal( '<div>f{}oobar</div>' );
-		} );
+				const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
+				expect( viewString ).to.equal( '<div>f{}oobar</div>' );
+			} );
 
-		it( 'should do nothing if the attribute element had been already removed', () => {
-			dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-			dispatcher.on( 'addAttribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
+			it( 'should do nothing if the attribute element had been already removed', () => {
+				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
+				dispatcher.on( 'addAttribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
 
-			test(
-				[ 3, 3 ],
-				'foobar',
-				'foo<b>[]</b>bar',
-				{ bold: 'true' }
-			);
+				test(
+					[ 3, 3 ],
+					'foobar',
+					'foo<b>[]</b>bar',
+					{ bold: 'true' }
+				);
 
-			// Remove <b></b> manually.
-			mergeAttributes( viewSelection.getFirstPosition() );
+				// Remove <b></b> manually.
+				mergeAttributes( viewSelection.getFirstPosition() );
 
-			const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
-			modelDoc.selection.setRanges( [ modelRange ] );
+				const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
+				modelDoc.selection.setRanges( [ modelRange ] );
 
-			dispatcher.convertSelection( modelDoc.selection );
+				dispatcher.convertSelection( modelDoc.selection );
 
-			expect( viewSelection.rangeCount ).to.equal( 1 );
+				expect( viewSelection.rangeCount ).to.equal( 1 );
 
-			const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
-			expect( viewString ).to.equal( '<div>f{}oobar</div>' );
+				const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
+				expect( viewString ).to.equal( '<div>f{}oobar</div>' );
+			} );
 		} );
-	} );
 
-	describe( 'clearFakeSelection', () => {
-		it( 'should clear fake selection', () => {
-			dispatcher.on( 'selection', clearFakeSelection() );
-			viewSelection.setFake( true );
+		describe( 'clearFakeSelection', () => {
+			it( 'should clear fake selection', () => {
+				dispatcher.on( 'selection', clearFakeSelection() );
+				viewSelection.setFake( true );
 
-			dispatcher.convertSelection( modelSelection );
+				dispatcher.convertSelection( modelSelection );
 
-			expect( viewSelection.isFake ).to.be.false;
+				expect( viewSelection.isFake ).to.be.false;
+			} );
 		} );
 	} );
-} );
 
-describe( 'using element creator for attributes conversion', () => {
-	beforeEach( () => {
-		function themeElementCreator( themeValue ) {
-			if ( themeValue == 'important' ) {
-				return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } );
-			} else if ( themeValue == 'gold' ) {
-				return new ViewAttributeElement( 'span', { style: 'color:yellow;' } );
+	describe( 'using element creator for attributes conversion', () => {
+		beforeEach( () => {
+			function themeElementCreator( themeValue ) {
+				if ( themeValue == 'important' ) {
+					return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } );
+				} else if ( themeValue == 'gold' ) {
+					return new ViewAttributeElement( 'span', { style: 'color:yellow;' } );
+				}
 			}
-		}
 
-		dispatcher.on( 'selectionAttribute:theme', convertSelectionAttribute( themeElementCreator ) );
-		dispatcher.on( 'addAttribute:theme', wrap( themeElementCreator ) );
+			dispatcher.on( 'selectionAttribute:theme', convertSelectionAttribute( themeElementCreator ) );
+			dispatcher.on( 'addAttribute:theme', wrap( themeElementCreator ) );
 
-		dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
-	} );
-
-	describe( 'range selection', () => {
-		it( 'in same container, over attribute', () => {
-			test(
-				[ 1, 5 ],
-				'fo<$text theme="gold">ob</$text>ar',
-				'f{o<span style="color:yellow;">ob</span>a}r'
-			);
-		} );
-
-		it( 'in same attribute', () => {
-			test(
-				[ 2, 4 ],
-				'f<$text theme="gold">ooba</$text>r',
-				'f<span style="color:yellow;">o{ob}a</span>r'
-			);
+			dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
 		} );
 
-		it( 'in same attribute, selection same as attribute', () => {
-			test(
-				[ 2, 4 ],
-				'fo<$text theme="important">ob</$text>ar',
-				'fo{<strong style="text-transform:uppercase;">ob</strong>}ar'
-			);
+		describe( 'range selection', () => {
+			it( 'in same container, over attribute', () => {
+				test(
+					[ 1, 5 ],
+					'fo<$text theme="gold">ob</$text>ar',
+					'f{o<span style="color:yellow;">ob</span>a}r'
+				);
+			} );
+
+			it( 'in same attribute', () => {
+				test(
+					[ 2, 4 ],
+					'f<$text theme="gold">ooba</$text>r',
+					'f<span style="color:yellow;">o{ob}a</span>r'
+				);
+			} );
+
+			it( 'in same attribute, selection same as attribute', () => {
+				test(
+					[ 2, 4 ],
+					'fo<$text theme="important">ob</$text>ar',
+					'fo{<strong style="text-transform:uppercase;">ob</strong>}ar'
+				);
+			} );
+
+			it( 'starts in attribute, ends in text node', () => {
+				test(
+					[ 3, 5 ],
+					'fo<$text theme="important">ob</$text>ar',
+					'fo<strong style="text-transform:uppercase;">o{b</strong>a}r'
+				);
+			} );
 		} );
 
-		it( 'starts in attribute, ends in text node', () => {
-			test(
-				[ 3, 5 ],
-				'fo<$text theme="important">ob</$text>ar',
-				'fo<strong style="text-transform:uppercase;">o{b</strong>a}r'
-			);
+		describe( 'collapsed selection', () => {
+			it( 'in attribute', () => {
+				test(
+					[ 3, 3 ],
+					'f<$text theme="gold">ooba</$text>r',
+					'f<span style="color:yellow;">oo{}ba</span>r'
+				);
+			} );
+
+			it( 'in container with theme attribute', () => {
+				test(
+					[ 1, 1 ],
+					'foobar',
+					'f<strong style="text-transform:uppercase;">[]</strong>oobar',
+					{ theme: 'important' }
+				);
+			} );
+
+			it( 'in theme attribute with extra attributes #1', () => {
+				test(
+					[ 3, 3 ],
+					'f<$text theme="gold">ooba</$text>r',
+					'f<span style="color:yellow;">oo</span>' +
+					'<em><span style="color:yellow;">[]</span></em>' +
+					'<span style="color:yellow;">ba</span>r',
+					{ italic: true }
+				);
+			} );
+
+			it( 'in theme attribute with extra attributes #2', () => {
+				// In contrary to test above, we don't have strong + span on the selection.
+				// This is because strong and span are both created by the same attribute.
+				// Since style="important" overwrites style="gold" on selection, we have only strong element.
+				// In example above, selection has both style and italic attribute.
+				test(
+					[ 3, 3 ],
+					'f<$text theme="gold">ooba</$text>r',
+					'f<span style="color:yellow;">oo</span>' +
+					'<strong style="text-transform:uppercase;">[]</strong>' +
+					'<span style="color:yellow;">ba</span>r',
+					{ theme: 'important' }
+				);
+			} );
 		} );
 	} );
 
-	describe( 'collapsed selection', () => {
-		it( 'in attribute', () => {
-			test(
-				[ 3, 3 ],
-				'f<$text theme="gold">ooba</$text>r',
-				'f<span style="color:yellow;">oo{}ba</span>r'
-			);
+	describe( 'table cell selection converter', () => {
+		beforeEach( () => {
+			modelDoc.schema.registerItem( 'table' );
+			modelDoc.schema.registerItem( 'tr' );
+			modelDoc.schema.registerItem( 'td' );
+
+			modelDoc.schema.allow( { name: 'table', inside: '$root' } );
+			modelDoc.schema.allow( { name: 'tr', inside: 'table' } );
+			modelDoc.schema.allow( { name: 'td', inside: 'tr' } );
+			modelDoc.schema.allow( { name: '$text', inside: 'td' } );
+
+			// "Universal" converter to convert table structure.
+			const tableConverter = insertElement( ( data ) => new ViewContainerElement( data.item.name ) );
+			dispatcher.on( 'insert:table', tableConverter );
+			dispatcher.on( 'insert:tr', tableConverter );
+			dispatcher.on( 'insert:td', tableConverter );
+
+			// Special converter for table cells.
+			dispatcher.on( 'selection', ( evt, data, consumable, conversionApi ) => {
+				const selection = data.selection;
+
+				if ( !consumable.test( selection, 'selection' ) || selection.isCollapsed ) {
+					return;
+				}
+
+				for ( let range of selection.getRanges() ) {
+					const node = range.start.nodeAfter;
+
+					if ( node == range.end.nodeBefore && node instanceof ModelElement && node.name == 'td' ) {
+						consumable.consume( selection, 'selection' );
+
+						let viewNode = conversionApi.mapper.toViewElement( node );
+						viewNode.addClass( 'selected' );
+					}
+				}
+			} );
 		} );
 
-		it( 'in container with theme attribute', () => {
+		it( 'should not be used to convert selection that is not on table cell', () => {
 			test(
-				[ 1, 1 ],
-				'foobar',
-				'f<strong style="text-transform:uppercase;">[]</strong>oobar',
-				{ theme: 'important' }
+				[ 1, 5 ],
+				'f{o<$text bold="true">ob</$text>a}r',
+				'f{o<strong>ob</strong>a}r'
 			);
 		} );
 
-		it( 'in theme attribute with extra attributes #1', () => {
+		it( 'should add a class to the selected table cell', () => {
 			test(
-				[ 3, 3 ],
-				'f<$text theme="gold">ooba</$text>r',
-				'f<span style="color:yellow;">oo</span>' +
-				'<em><span style="color:yellow;">[]</span></em>' +
-				'<span style="color:yellow;">ba</span>r',
-				{ italic: true }
+				// table tr#0 |td#0, table tr#0 td#0|
+				[ [ 0, 0, 0 ], [ 0, 0, 1 ] ],
+				'<table><tr><td>foo</td></tr><tr><td>bar</td></tr></table>',
+				'<table><tr><td class="selected">foo</td></tr><tr><td>bar</td></tr></table>'
 			);
 		} );
 
-		it( 'in theme attribute with extra attributes #2', () => {
-			// In contrary to test above, we don't have strong + span on the selection.
-			// This is because strong and span are both created by the same attribute.
-			// Since style="important" overwrites style="gold" on selection, we have only strong element.
-			// In example above, selection has both style and italic attribute.
+		it( 'should not be used if selection contains more than just a table cell', () => {
 			test(
-				[ 3, 3 ],
-				'f<$text theme="gold">ooba</$text>r',
-				'f<span style="color:yellow;">oo</span>' +
-				'<strong style="text-transform:uppercase;">[]</strong>' +
-				'<span style="color:yellow;">ba</span>r',
-				{ theme: 'important' }
+				// table tr td#1, table tr#2
+				[ [ 0, 0, 0, 1 ], [ 0, 0, 2 ] ],
+				'<table><tr><td>foo</td><td>bar</td></tr></table>',
+				'<table><tr><td>f{oo</td><td>bar</td>]</tr></table>'
 			);
 		} );
 	} );
-} );
-
-describe( 'table cell selection converter', () => {
-	beforeEach( () => {
-		modelDoc.schema.registerItem( 'table', '$block' );
-		modelDoc.schema.registerItem( 'tr', '$block' );
-		modelDoc.schema.registerItem( 'td', '$block' );
-
-		// "Universal" converter to convert table structure.
-		const tableConverter = insertElement( ( data ) => new ViewContainerElement( data.item.name ) );
-		dispatcher.on( 'insert:table', tableConverter );
-		dispatcher.on( 'insert:tr', tableConverter );
-		dispatcher.on( 'insert:td', tableConverter );
-
-		// Special converter for table cells.
-		dispatcher.on( 'selection', ( evt, data, consumable, conversionApi ) => {
-			const selection = data.selection;
-
-			if ( !consumable.test( selection, 'selection' ) || selection.isCollapsed ) {
-				return;
-			}
-
-			for ( let range of selection.getRanges() ) {
-				const node = range.start.nodeAfter;
-
-				if ( node == range.end.nodeBefore && node instanceof ModelElement && node.name == 'td' ) {
-					consumable.consume( selection, 'selection' );
 
-					let viewNode = conversionApi.mapper.toViewElement( node );
-					viewNode.addClass( 'selected' );
-				}
+	// Tests if the selection got correctly converted.
+	// Because `setData` might use selection converters itself to set the selection, we can't use it
+	// to set the selection (because then we would test converters using converters).
+	// Instead, the `test` function expects to be passed `selectionPaths` which is an array containing two numbers or two arrays,
+	// that are offsets or paths of selection positions in root element.
+	function test( selectionPaths, modelInput, expectedView, selectionAttributes = {} ) {
+		// Parse passed `modelInput` string and set it as current model.
+		setModelData( modelDoc, modelInput );
+
+		// Manually set selection ranges using passed `selectionPaths`.
+		const startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
+		const endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
+		const startPos = new ModelPosition( modelRoot, startPath );
+		const endPos = new ModelPosition( modelRoot, endPath );
+		const isBackward = selectionPaths[ 2 ] === 'backward';
+		modelSelection.setRanges( [ new ModelRange( startPos, endPos ) ], isBackward );
+
+		// Updated selection attributes according to model.
+		modelSelection._updateAttributes();
+
+		// And add or remove passed attributes.
+		for ( let key in selectionAttributes ) {
+			let value = selectionAttributes[ key ];
+
+			if ( value ) {
+				modelSelection.setAttribute( key, value );
+			} else {
+				modelSelection.removeAttribute( key );
 			}
-		} );
-	} );
-
-	it( 'should not be used to convert selection that is not on table cell', () => {
-		test(
-			[ 1, 5 ],
-			'f{o<$text bold="true">ob</$text>a}r',
-			'f{o<strong>ob</strong>a}r'
-		);
-	} );
-
-	it( 'should add a class to the selected table cell', () => {
-		test(
-			// table tr#0 |td#0, table tr#0 td#0|
-			[ [ 0, 0, 0 ], [ 0, 0, 1 ] ],
-			'<table><tr><td>foo</td></tr><tr><td>bar</td></tr></table>',
-			'<table><tr><td class="selected">foo</td></tr><tr><td>bar</td></tr></table>'
-		);
-	} );
-
-	it( 'should not be used if selection contains more than just a table cell', () => {
-		test(
-			// table tr td#1, table tr#2
-			[ [ 0, 0, 0, 1 ], [ 0, 0, 2 ] ],
-			'<table><tr><td>foo</td><td>bar</td></tr></table>',
-			'<table><tr><td>f{oo</td><td>bar</td>]</tr></table>'
-		);
-	} );
-} );
-
-// Tests if the selection got correctly converted.
-// Because `setData` might use selection converters itself to set the selection, we can't use it
-// to set the selection (because then we would test converters using converters).
-// Instead, the `test` function expects to be passed `selectionPaths` which is an array containing two numbers or two arrays,
-// that are offsets or paths of selection positions in root element.
-function test( selectionPaths, modelInput, expectedView, selectionAttributes = {} ) {
-	// Parse passed `modelInput` string and set it as current model.
-	setModelData( modelDoc, modelInput );
-
-	// Manually set selection ranges using passed `selectionPaths`.
-	const startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
-	const endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
-	const startPos = new ModelPosition( modelRoot, startPath );
-	const endPos = new ModelPosition( modelRoot, endPath );
-	const isBackward = selectionPaths[ 2 ] === 'backward';
-	modelSelection.setRanges( [ new ModelRange( startPos, endPos ) ], isBackward );
-
-	// Updated selection attributes according to model.
-	modelSelection._updateAttributes();
-
-	// And add or remove passed attributes.
-	for ( let key in selectionAttributes ) {
-		let value = selectionAttributes[ key ];
-
-		if ( value ) {
-			modelSelection.setAttribute( key, value );
-		} else {
-			modelSelection.removeAttribute( key );
 		}
-	}
 
-	// Remove view children since we do not want to convert deletion.
-	viewRoot.removeChildren( 0, viewRoot.childCount );
+		// Remove view children since we do not want to convert deletion.
+		viewRoot.removeChildren( 0, viewRoot.childCount );
 
-	// Convert model to view.
-	dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-	dispatcher.convertSelection( modelSelection );
+		// Convert model to view.
+		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+		dispatcher.convertSelection( modelSelection );
 
-	// Stringify view and check if it is same as expected.
-	expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal( '<div>' + expectedView + '</div>' );
-}
+		// Stringify view and check if it is same as expected.
+		expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal( '<div>' + expectedView + '</div>' );
+	}
+} );

+ 436 - 434
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -33,598 +33,600 @@ import {
 
 import { createRangeOnElementOnly } from 'tests/engine/model/_utils/utils.js';
 
-let dispatcher, modelDoc, modelRoot, mapper, viewRoot;
+describe( 'model-to-view-converters', () => {
+	let dispatcher, modelDoc, modelRoot, mapper, viewRoot;
 
-beforeEach( () => {
-	modelDoc = new ModelDocument();
-	modelRoot = modelDoc.createRoot();
-	viewRoot = new ViewContainerElement( 'div' );
+	beforeEach( () => {
+		modelDoc = new ModelDocument();
+		modelRoot = modelDoc.createRoot();
+		viewRoot = new ViewContainerElement( 'div' );
 
-	mapper = new Mapper();
-	mapper.bindElements( modelRoot, viewRoot );
+		mapper = new Mapper();
+		mapper.bindElements( modelRoot, viewRoot );
 
-	dispatcher = new ModelConversionDispatcher( { mapper } );
-} );
+		dispatcher = new ModelConversionDispatcher( { mapper } );
+	} );
 
-function viewAttributesToString( item ) {
-	let result = '';
+	function viewAttributesToString( item ) {
+		let result = '';
 
-	for ( let key of item.getAttributeKeys() ) {
-		let value = item.getAttribute( key );
+		for ( let key of item.getAttributeKeys() ) {
+			let value = item.getAttribute( key );
 
-		if ( value ) {
-			result += ' ' + key + '="' + value + '"';
+			if ( value ) {
+				result += ' ' + key + '="' + value + '"';
+			}
 		}
+
+		return result;
 	}
 
-	return result;
-}
+	function viewToString( item ) {
+		let result = '';
 
-function viewToString( item ) {
-	let result = '';
+		if ( item instanceof ViewText ) {
+			result = item.data;
+		} else {
+			// ViewElement or ViewDocumentFragment.
+			for ( let child of item.getChildren() ) {
+				result += viewToString( child );
+			}
 
-	if ( item instanceof ViewText ) {
-		result = item.data;
-	} else {
-		// ViewElement or ViewDocumentFragment.
-		for ( let child of item.getChildren() ) {
-			result += viewToString( child );
+			if ( item instanceof ViewElement ) {
+				result = '<' + item.name + viewAttributesToString( item ) + '>' + result + '</' + item.name + '>';
+			}
 		}
 
-		if ( item instanceof ViewElement ) {
-			result = '<' + item.name + viewAttributesToString( item ) + '>' + result + '</' + item.name + '>';
-		}
+		return result;
 	}
 
-	return result;
-}
-
-describe( 'insertText', () => {
-	it( 'should convert text insertion in model to view text', () => {
-		modelRoot.appendChildren( new ModelText( 'foobar' ) );
-		dispatcher.on( 'insert:$text', insertText() );
+	describe( 'insertText', () => {
+		it( 'should convert text insertion in model to view text', () => {
+			modelRoot.appendChildren( new ModelText( 'foobar' ) );
+			dispatcher.on( 'insert:$text', insertText() );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div>foobar</div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foobar</div>' );
+		} );
 
-	it( 'should support unicode', () => {
-		modelRoot.appendChildren( new ModelText( 'நிலைக்கு' ) );
-		dispatcher.on( 'insert:$text', insertText() );
+		it( 'should support unicode', () => {
+			modelRoot.appendChildren( new ModelText( 'நிலைக்கு' ) );
+			dispatcher.on( 'insert:$text', insertText() );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div>நிலைக்கு</div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div>நிலைக்கு</div>' );
+		} );
 
-	it( 'should be possible to override it', () => {
-		modelRoot.appendChildren( new ModelText( 'foobar' ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'insert:$text', ( evt, data, consumable ) => {
-			consumable.consume( data.item, 'insert' );
-		}, { priority: 'high' } );
+		it( 'should be possible to override it', () => {
+			modelRoot.appendChildren( new ModelText( 'foobar' ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'insert:$text', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'insert' );
+			}, { priority: 'high' } );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div></div>' );
+		} );
 	} );
-} );
 
-describe( 'insertElement', () => {
-	it( 'should convert element insertion in model to and map positions for future converting', () => {
-		const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
-		const viewElement = new ViewContainerElement( 'p' );
+	describe( 'insertElement', () => {
+		it( 'should convert element insertion in model to and map positions for future converting', () => {
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
+			const viewElement = new ViewContainerElement( 'p' );
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
-		dispatcher.on( 'insert:$text', insertText() );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
+			dispatcher.on( 'insert:$text', insertText() );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 
-	it( 'should take view element function generator as a parameter', () => {
-		const elementGenerator = ( data, consumable ) => {
-			if ( consumable.consume( data.item, 'addAttribute:nice' ) ) {
-				return new ViewContainerElement( 'div' );
-			} else {
-				return new ViewContainerElement( 'p' );
-			}
-		};
-		const niceP = new ModelElement( 'myParagraph', { nice: true }, new ModelText( 'foo' ) );
-		const badP = new ModelElement( 'myParagraph', null, new ModelText( 'bar' ) );
+		it( 'should take view element function generator as a parameter', () => {
+			const elementGenerator = ( data, consumable ) => {
+				if ( consumable.consume( data.item, 'addAttribute:nice' ) ) {
+					return new ViewContainerElement( 'div' );
+				} else {
+					return new ViewContainerElement( 'p' );
+				}
+			};
+			const niceP = new ModelElement( 'myParagraph', { nice: true }, new ModelText( 'foo' ) );
+			const badP = new ModelElement( 'myParagraph', null, new ModelText( 'bar' ) );
 
-		modelRoot.appendChildren( [ niceP, badP ] );
+			modelRoot.appendChildren( [ niceP, badP ] );
 
-		dispatcher.on( 'insert:myParagraph', insertElement( elementGenerator ) );
-		dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'insert:myParagraph', insertElement( elementGenerator ) );
+			dispatcher.on( 'insert:$text', insertText() );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><p>bar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><p>bar</p></div>' );
+		} );
 	} );
-} );
 
-describe( 'setAttribute/removeAttribute', () => {
-	it( 'should convert attribute insert/change/remove on a model node', () => {
-		const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
-		const viewElement = new ViewContainerElement( 'p' );
+	describe( 'setAttribute/removeAttribute', () => {
+		it( 'should convert attribute insert/change/remove on a model node', () => {
+			const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
+			const viewElement = new ViewContainerElement( 'p' );
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:class', setAttribute() );
-		dispatcher.on( 'changeAttribute:class', setAttribute() );
-		dispatcher.on( 'removeAttribute:class', removeAttribute() );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:class', setAttribute() );
+			dispatcher.on( 'changeAttribute:class', setAttribute() );
+			dispatcher.on( 'removeAttribute:class', removeAttribute() );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
 
-		modelElement.setAttribute( 'class', 'bar' );
-		dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'foo', 'bar' );
+			modelElement.setAttribute( 'class', 'bar' );
+			dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'foo', 'bar' );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="bar">foobar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="bar">foobar</p></div>' );
 
-		modelElement.removeAttribute( 'class' );
-		dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'bar', null );
+			modelElement.removeAttribute( 'class' );
+			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'bar', null );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 
-	it( 'should convert insert/change/remove with attribute generating function as a parameter', () => {
-		const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) );
-		const modelDiv = new ModelElement( 'div', { theme: 'nice' } );
+		it( 'should convert insert/change/remove with attribute generating function as a parameter', () => {
+			const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) );
+			const modelDiv = new ModelElement( 'div', { theme: 'nice' } );
 
-		const themeConverter = ( value, key, data ) => {
-			if ( data.item instanceof ModelElement && data.item.childCount > 0 ) {
-				value += ' ' + 'fix-content';
-			}
+			const themeConverter = ( value, key, data ) => {
+				if ( data.item instanceof ModelElement && data.item.childCount > 0 ) {
+					value += ' ' + 'fix-content';
+				}
 
-			return { key: 'class', value };
-		};
+				return { key: 'class', value };
+			};
 
-		modelRoot.appendChildren( [ modelParagraph, modelDiv ] );
-		dispatcher.on( 'insert:paragraph', insertElement( new ViewContainerElement( 'p' ) ) );
-		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:theme', setAttribute( themeConverter ) );
-		dispatcher.on( 'changeAttribute:theme', setAttribute( themeConverter ) );
-		dispatcher.on( 'removeAttribute:theme', removeAttribute( themeConverter ) );
+			modelRoot.appendChildren( [ modelParagraph, modelDiv ] );
+			dispatcher.on( 'insert:paragraph', insertElement( new ViewContainerElement( 'p' ) ) );
+			dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:theme', setAttribute( themeConverter ) );
+			dispatcher.on( 'changeAttribute:theme', setAttribute( themeConverter ) );
+			dispatcher.on( 'removeAttribute:theme', removeAttribute( themeConverter ) );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="nice fix-content">foobar</p><div class="nice"></div></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="nice fix-content">foobar</p><div class="nice"></div></div>' );
 
-		modelParagraph.setAttribute( 'theme', 'awesome' );
-		dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelParagraph ), 'theme', 'nice', 'awesome' );
+			modelParagraph.setAttribute( 'theme', 'awesome' );
+			dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelParagraph ), 'theme', 'nice', 'awesome' );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="awesome fix-content">foobar</p><div class="nice"></div></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="awesome fix-content">foobar</p><div class="nice"></div></div>' );
 
-		modelParagraph.removeAttribute( 'theme' );
-		dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelParagraph ), 'theme', 'awesome', null );
+			modelParagraph.removeAttribute( 'theme' );
+			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelParagraph ), 'theme', 'awesome', null );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p><div class="nice"></div></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p><div class="nice"></div></div>' );
+		} );
 
-	it( 'should be possible to override setAttribute', () => {
-		const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
-		const viewElement = new ViewContainerElement( 'p' );
+		it( 'should be possible to override setAttribute', () => {
+			const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
+			const viewElement = new ViewContainerElement( 'p' );
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:class', setAttribute() );
-		dispatcher.on( 'addAttribute:class', ( evt, data, consumable ) => {
-			consumable.consume( data.item, 'addAttribute:class' );
-		}, { priority: 'high' } );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:class', setAttribute() );
+			dispatcher.on( 'addAttribute:class', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'addAttribute:class' );
+			}, { priority: 'high' } );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		// No attribute set.
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-	} );
+			// No attribute set.
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 
-	it( 'should be possible to override removeAttribute', () => {
-		const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
-		const viewElement = new ViewContainerElement( 'p' );
+		it( 'should be possible to override removeAttribute', () => {
+			const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
+			const viewElement = new ViewContainerElement( 'p' );
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:class', setAttribute() );
-		dispatcher.on( 'removeAttribute:class', removeAttribute() );
-		dispatcher.on( 'removeAttribute:class', ( evt, data, consumable ) => {
-			consumable.consume( data.item, 'removeAttribute:class' );
-		}, { priority: 'high' } );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:class', setAttribute() );
+			dispatcher.on( 'removeAttribute:class', removeAttribute() );
+			dispatcher.on( 'removeAttribute:class', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'removeAttribute:class' );
+			}, { priority: 'high' } );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
 
-		modelElement.removeAttribute( 'class' );
-		dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'bar', null );
+			modelElement.removeAttribute( 'class' );
+			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'bar', null );
 
-		// Nothing changed.
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
+			// Nothing changed.
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
+		} );
 	} );
-} );
 
-describe( 'wrap/unwrap', () => {
-	it( 'should convert insert/change/remove of attribute in model into wrapping element in a view', () => {
-		const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
-		const viewP = new ViewContainerElement( 'p' );
-		const viewB = new ViewAttributeElement( 'b' );
+	describe( 'wrap/unwrap', () => {
+		it( 'should convert insert/change/remove of attribute in model into wrapping element in a view', () => {
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
+			const viewP = new ViewContainerElement( 'p' );
+			const viewB = new ViewAttributeElement( 'b' );
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
-		dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
+			dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
 
-		modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' );
+			modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' );
 
-		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 
-	it( 'should convert insert/remove of attribute in model with wrapping element generating function as a parameter', () => {
-		const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { style: 'bold' } ) );
-		const viewP = new ViewContainerElement( 'p' );
+		it( 'should convert insert/remove of attribute in model with wrapping element generating function as a parameter', () => {
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { style: 'bold' } ) );
+			const viewP = new ViewContainerElement( 'p' );
 
-		const elementGenerator = ( value ) => {
-			if ( value == 'bold' ) {
-				return new ViewAttributeElement( 'b' );
-			}
-		};
+			const elementGenerator = ( value ) => {
+				if ( value == 'bold' ) {
+					return new ViewAttributeElement( 'b' );
+				}
+			};
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:style', wrap( elementGenerator ) );
-		dispatcher.on( 'removeAttribute:style', unwrap( elementGenerator ) );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:style', wrap( elementGenerator ) );
+			dispatcher.on( 'removeAttribute:style', unwrap( elementGenerator ) );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
 
-		modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'style' );
+			modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'style' );
 
-		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'style', 'bold', null );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'style', 'bold', null );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 
-	it( 'should update range on re-wrapping attribute (#475)', () => {
-		const modelElement = new ModelElement( 'paragraph', null, [
-			new ModelText( 'x' ),
-			new ModelText( 'foo', { link: 'http://foo.com' } ),
-			new ModelText( 'x' )
-		] );
+		it( 'should update range on re-wrapping attribute (#475)', () => {
+			const modelElement = new ModelElement( 'paragraph', null, [
+				new ModelText( 'x' ),
+				new ModelText( 'foo', { link: 'http://foo.com' } ),
+				new ModelText( 'x' )
+			] );
 
-		const viewP = new ViewContainerElement( 'p' );
+			const viewP = new ViewContainerElement( 'p' );
 
-		const elementGenerator = ( href ) => new ViewAttributeElement( 'a', { href } );
+			const elementGenerator = ( href ) => new ViewAttributeElement( 'a', { href } );
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:link', wrap( elementGenerator ) );
-		dispatcher.on( 'changeAttribute:link', wrap( elementGenerator ) );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:link', wrap( elementGenerator ) );
+			dispatcher.on( 'changeAttribute:link', wrap( elementGenerator ) );
 
-		dispatcher.convertInsertion(
-			ModelRange.createIn( modelRoot )
-		);
+			dispatcher.convertInsertion(
+				ModelRange.createIn( modelRoot )
+			);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>x<a href="http://foo.com">foo</a>x</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>x<a href="http://foo.com">foo</a>x</p></div>' );
 
-		modelWriter.setAttribute( ModelRange.createIn( modelElement ), 'link', 'http://foobar.com' );
+			modelWriter.setAttribute( ModelRange.createIn( modelElement ), 'link', 'http://foobar.com' );
 
-		dispatcher.convertAttribute(
-			'changeAttribute',
-			ModelRange.createIn( modelElement ),
-			'link',
-			'http://foo.com',
-			'http://foobar.com'
-		);
+			dispatcher.convertAttribute(
+				'changeAttribute',
+				ModelRange.createIn( modelElement ),
+				'link',
+				'http://foo.com',
+				'http://foobar.com'
+			);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p><a href="http://foobar.com">xfoox</a></p></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p><a href="http://foobar.com">xfoox</a></p></div>' );
+		} );
 
-	it( 'should support unicode', () => {
-		const modelElement = new ModelElement( 'paragraph', null, [ 'நி', new ModelText( 'லைக்', { bold: true } ), 'கு' ] );
-		const viewP = new ViewContainerElement( 'p' );
-		const viewB = new ViewAttributeElement( 'b' );
+		it( 'should support unicode', () => {
+			const modelElement = new ModelElement( 'paragraph', null, [ 'நி', new ModelText( 'லைக்', { bold: true } ), 'கு' ] );
+			const viewP = new ViewContainerElement( 'p' );
+			const viewB = new ViewAttributeElement( 'b' );
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
-		dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
+			dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>நி<b>லைக்</b>கு</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>நி<b>லைக்</b>கு</p></div>' );
 
-		modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' );
+			modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' );
 
-		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>நிலைக்கு</p></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>நிலைக்கு</p></div>' );
+		} );
 
-	it( 'should be possible to override wrap', () => {
-		const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
-		const viewP = new ViewContainerElement( 'p' );
-		const viewB = new ViewAttributeElement( 'b' );
+		it( 'should be possible to override wrap', () => {
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
+			const viewP = new ViewContainerElement( 'p' );
+			const viewB = new ViewAttributeElement( 'b' );
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
-		dispatcher.on( 'addAttribute:bold', ( evt, data, consumable ) => {
-			consumable.consume( data.item, 'addAttribute:bold' );
-		}, { priority: 'high' } );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
+			dispatcher.on( 'addAttribute:bold', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'addAttribute:bold' );
+			}, { priority: 'high' } );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 
-	it( 'should be possible to override unwrap', () => {
-		const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
-		const viewP = new ViewContainerElement( 'p' );
-		const viewB = new ViewAttributeElement( 'b' );
+		it( 'should be possible to override unwrap', () => {
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
+			const viewP = new ViewContainerElement( 'p' );
+			const viewB = new ViewAttributeElement( 'b' );
 
-		modelRoot.appendChildren( modelElement );
-		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
-		dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
-		dispatcher.on( 'removeAttribute:bold', ( evt, data, consumable ) => {
-			consumable.consume( data.item, 'removeAttribute:bold' );
-		}, { priority: 'high' } );
+			modelRoot.appendChildren( modelElement );
+			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
+			dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
+			dispatcher.on( 'removeAttribute:bold', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'removeAttribute:bold' );
+			}, { priority: 'high' } );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
 
-		modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' );
+			modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' );
 
-		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null );
 
-		// Nothing changed.
-		expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
+			// Nothing changed.
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
+		} );
 	} );
-} );
 
-describe( 'move', () => {
-	it( 'should move items in view accordingly to changes in model', () => {
-		const modelDivA = new ModelElement( 'div', null, [
-			new ModelText( 'foo' ),
-			new ModelElement( 'image' ),
-			new ModelText( 'bar' )
-		] );
+	describe( 'move', () => {
+		it( 'should move items in view accordingly to changes in model', () => {
+			const modelDivA = new ModelElement( 'div', null, [
+				new ModelText( 'foo' ),
+				new ModelElement( 'image' ),
+				new ModelText( 'bar' )
+			] );
 
-		const modelDivB = new ModelElement( 'div', null, new ModelText( 'xxyy' ) );
+			const modelDivB = new ModelElement( 'div', null, new ModelText( 'xxyy' ) );
 
-		modelRoot.appendChildren( [ modelDivA, modelDivB ] );
-		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
-		dispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'move', move() );
+			modelRoot.appendChildren( [ modelDivA, modelDivB ] );
+			dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+			dispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'move', move() );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		const removedNodes = modelDivA.removeChildren( 0, 2 );
-		modelDivB.insertChildren( 0, removedNodes );
+			const removedNodes = modelDivA.removeChildren( 0, 2 );
+			modelDivB.insertChildren( 0, removedNodes );
 
-		dispatcher.convertMove(
-			ModelPosition.createFromParentAndOffset( modelDivA, 0 ),
-			ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 4 )
-		);
+			dispatcher.convertMove(
+				ModelPosition.createFromParentAndOffset( modelDivA, 0 ),
+				ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 4 )
+			);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div><div>foo<img></img>xxyy</div></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div><div>foo<img></img>xxyy</div></div>' );
+		} );
 
-	it( 'should not execute if value was already consumed', () => {
-		const modelDivA = new ModelElement( 'div', null, new ModelText( 'foo' ) );
-		const modelDivB = new ModelElement( 'div', null, new ModelText( 'xxyy' ) );
+		it( 'should not execute if value was already consumed', () => {
+			const modelDivA = new ModelElement( 'div', null, new ModelText( 'foo' ) );
+			const modelDivB = new ModelElement( 'div', null, new ModelText( 'xxyy' ) );
 
-		modelRoot.appendChildren( [ modelDivA, modelDivB ] );
-		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'move', move() );
-		dispatcher.on( 'move', ( evt, data, consumable ) => {
-			consumable.consume( data.item, 'move' );
-		}, { priority: 'high' } );
+			modelRoot.appendChildren( [ modelDivA, modelDivB ] );
+			dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'move', move() );
+			dispatcher.on( 'move', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'move' );
+			}, { priority: 'high' } );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><div>xxyy</div></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><div>xxyy</div></div>' );
 
-		const removedNodes = modelDivA.removeChildren( 0, 1 );
-		modelDivB.insertChildren( 0, removedNodes );
+			const removedNodes = modelDivA.removeChildren( 0, 1 );
+			modelDivB.insertChildren( 0, removedNodes );
 
-		dispatcher.convertMove(
-			ModelPosition.createFromParentAndOffset( modelDivA, 0 ),
-			ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 3 )
-		);
+			dispatcher.convertMove(
+				ModelPosition.createFromParentAndOffset( modelDivA, 0 ),
+				ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 3 )
+			);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><div>xxyy</div></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><div>xxyy</div></div>' );
+		} );
 
-	it( 'should support unicode', () => {
-		const modelDivA = new ModelElement( 'div', null, 'நிலைக்கு' );
-		const modelDivB = new ModelElement( 'div' );
+		it( 'should support unicode', () => {
+			const modelDivA = new ModelElement( 'div', null, 'நிலைக்கு' );
+			const modelDivB = new ModelElement( 'div' );
 
-		modelRoot.appendChildren( [ modelDivA, modelDivB ] );
-		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'move', move() );
+			modelRoot.appendChildren( [ modelDivA, modelDivB ] );
+			dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'move', move() );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		modelWriter.move(
-			ModelRange.createFromParentsAndOffsets( modelDivA, 2, modelDivA, 6 ),
-			ModelPosition.createAt( modelDivB, 'end' )
-		);
+			modelWriter.move(
+				ModelRange.createFromParentsAndOffsets( modelDivA, 2, modelDivA, 6 ),
+				ModelPosition.createAt( modelDivB, 'end' )
+			);
 
-		dispatcher.convertMove(
-			ModelPosition.createFromParentAndOffset( modelDivA, 2 ),
-			ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 4 )
-		);
+			dispatcher.convertMove(
+				ModelPosition.createFromParentAndOffset( modelDivA, 2 ),
+				ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 4 )
+			);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>நிகு</div><div>லைக்</div></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><div>நிகு</div><div>லைக்</div></div>' );
+		} );
 	} );
-} );
 
-describe( 'remove', () => {
-	it( 'should remove items from view accordingly to changes in model', () => {
-		const modelDiv = new ModelElement( 'div', null, [
-			new ModelText( 'foo' ),
-			new ModelElement( 'image' ),
-			new ModelText( 'bar' )
-		] );
+	describe( 'remove', () => {
+		it( 'should remove items from view accordingly to changes in model', () => {
+			const modelDiv = new ModelElement( 'div', null, [
+				new ModelText( 'foo' ),
+				new ModelElement( 'image' ),
+				new ModelText( 'bar' )
+			] );
 
-		modelRoot.appendChildren( modelDiv );
-		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
-		dispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'remove', remove() );
+			modelRoot.appendChildren( modelDiv );
+			dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+			dispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'remove', remove() );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		const removedNodes = modelDiv.removeChildren( 0, 2 );
-		modelDoc.graveyard.insertChildren( 0, removedNodes );
+			const removedNodes = modelDiv.removeChildren( 0, 2 );
+			modelDoc.graveyard.insertChildren( 0, removedNodes );
 
-		dispatcher.convertRemove(
-			ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
-			ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 4 )
-		);
+			dispatcher.convertRemove(
+				ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
+				ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 4 )
+			);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div></div>' );
+		} );
 
-	it( 'should not execute if value was already consumed', () => {
-		const modelDiv = new ModelElement( 'div', null, new ModelText( 'foo' ) );
+		it( 'should not execute if value was already consumed', () => {
+			const modelDiv = new ModelElement( 'div', null, new ModelText( 'foo' ) );
 
-		modelRoot.appendChildren( modelDiv );
-		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'remove', remove() );
-		dispatcher.on( 'remove', ( evt, data, consumable ) => {
-			consumable.consume( data.item, 'remove' );
-		}, { priority: 'high' } );
+			modelRoot.appendChildren( modelDiv );
+			dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'remove', remove() );
+			dispatcher.on( 'remove', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'remove' );
+			}, { priority: 'high' } );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
 
-		const removedNodes = modelDiv.removeChildren( 0, 1 );
-		modelDoc.graveyard.insertChildren( 0, removedNodes );
+			const removedNodes = modelDiv.removeChildren( 0, 1 );
+			modelDoc.graveyard.insertChildren( 0, removedNodes );
 
-		dispatcher.convertRemove(
-			ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
-			ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 3 )
-		);
+			dispatcher.convertRemove(
+				ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
+				ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 3 )
+			);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
-	} );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
+		} );
 
-	it( 'should support unicode', () => {
-		const modelDiv = new ModelElement( 'div', null, 'நிலைக்கு' );
+		it( 'should support unicode', () => {
+			const modelDiv = new ModelElement( 'div', null, 'நிலைக்கு' );
 
-		modelRoot.appendChildren( modelDiv );
-		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'remove', remove() );
+			modelRoot.appendChildren( modelDiv );
+			dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'remove', remove() );
 
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-		modelWriter.move(
-			ModelRange.createFromParentsAndOffsets( modelDiv, 0, modelDiv, 6 ),
-			ModelPosition.createAt( modelDoc.graveyard, 'end' )
-		);
+			modelWriter.move(
+				ModelRange.createFromParentsAndOffsets( modelDiv, 0, modelDiv, 6 ),
+				ModelPosition.createAt( modelDoc.graveyard, 'end' )
+			);
 
-		dispatcher.convertRemove(
-			ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
-			ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 6 )
-		);
+			dispatcher.convertRemove(
+				ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
+				ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 6 )
+			);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>கு</div></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><div>கு</div></div>' );
+		} );
 	} );
-} );
 
-describe( 'rename', () => {
-	const oldName = 'oldName';
-	const newName = 'newName';
+	describe( 'rename', () => {
+		const oldName = 'oldName';
+		const newName = 'newName';
 
-	let element, converters;
+		let element, converters;
 
-	beforeEach( () => {
-		converters = {
-			insertText: insertText(),
-			insert:	insertElement( ( data ) => new ViewContainerElement( data.item.name ) ),
-			move: move(),
-			remove: remove(),
-			rename: rename()
-		};
-
-		sinon.spy( converters, 'insert' );
-		sinon.spy( converters, 'move' );
-		sinon.spy( converters, 'remove' );
-
-		element = new ModelElement( oldName, null, new ModelText( 'foo' ) );
-		modelRoot.appendChildren( element );
-
-		dispatcher.on( 'insert:$text', converters.insertText );
-		dispatcher.on( 'insert', converters.insert );
-		dispatcher.on( 'move', converters.move );
-		dispatcher.on( 'remove', converters.remove );
-		dispatcher.on( 'rename', converters.rename );
-
-		dispatcher.convertInsertion( ModelRange.createOn( element ) );
-
-		element.name = newName;
-	} );
+		beforeEach( () => {
+			converters = {
+				insertText: insertText(),
+				insert:	insertElement( ( data ) => new ViewContainerElement( data.item.name ) ),
+				move: move(),
+				remove: remove(),
+				rename: rename()
+			};
 
-	afterEach( () => {
-		converters.insert.restore();
-		converters.move.restore();
-		converters.remove.restore();
-	} );
+			sinon.spy( converters, 'insert' );
+			sinon.spy( converters, 'move' );
+			sinon.spy( converters, 'remove' );
 
-	it( 'should enable default rename conversion, that uses already registered callbacks', () => {
-		const insertCallCount = converters.insert.callCount;
+			element = new ModelElement( oldName, null, new ModelText( 'foo' ) );
+			modelRoot.appendChildren( element );
 
-		expect( viewRoot.getChild( 0 ).name ).to.equal( 'oldName' );
-		dispatcher.convertRename( element, oldName );
+			dispatcher.on( 'insert:$text', converters.insertText );
+			dispatcher.on( 'insert', converters.insert );
+			dispatcher.on( 'move', converters.move );
+			dispatcher.on( 'remove', converters.remove );
+			dispatcher.on( 'rename', converters.rename );
 
-		expect( converters.insert.callCount - insertCallCount ).to.equal( 1 );
-		expect( converters.move.calledOnce ).to.be.true;
-		expect( converters.remove.calledOnce ).to.be.true;
+			dispatcher.convertInsertion( ModelRange.createOn( element ) );
 
-		expect( viewRoot.getChild( 0 ).name ).to.equal( 'newName' );
-		expect( viewRoot.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
-	} );
+			element.name = newName;
+		} );
+
+		afterEach( () => {
+			converters.insert.restore();
+			converters.move.restore();
+			converters.remove.restore();
+		} );
+
+		it( 'should enable default rename conversion, that uses already registered callbacks', () => {
+			const insertCallCount = converters.insert.callCount;
 
-	it( 'should not execute if converted value was already consumed', () => {
-		dispatcher.on( 'rename', ( evt, data, consumable ) => {
-			consumable.consume( data.element, 'rename' );
-		}, { priority: 'high' } );
+			expect( viewRoot.getChild( 0 ).name ).to.equal( 'oldName' );
+			dispatcher.convertRename( element, oldName );
 
-		dispatcher.on( 'rename', ( evt, data ) => {
-			expect( data.fakeElement ).to.be.undefined;
+			expect( converters.insert.callCount - insertCallCount ).to.equal( 1 );
+			expect( converters.move.calledOnce ).to.be.true;
+			expect( converters.remove.calledOnce ).to.be.true;
+
+			expect( viewRoot.getChild( 0 ).name ).to.equal( 'newName' );
+			expect( viewRoot.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
 		} );
 
-		dispatcher.convertRename( element, oldName );
+		it( 'should not execute if converted value was already consumed', () => {
+			dispatcher.on( 'rename', ( evt, data, consumable ) => {
+				consumable.consume( data.element, 'rename' );
+			}, { priority: 'high' } );
+
+			dispatcher.on( 'rename', ( evt, data ) => {
+				expect( data.fakeElement ).to.be.undefined;
+			} );
+
+			dispatcher.convertRename( element, oldName );
+		} );
 	} );
 } );

+ 89 - 87
packages/ckeditor5-engine/tests/conversion/view-to-model-converters.js

@@ -17,116 +17,118 @@ import ModelText from 'ckeditor5/engine/model/text.js';
 
 import { convertToModelFragment, convertText } from 'ckeditor5/engine/conversion/view-to-model-converters.js';
 
-let dispatcher, schema, objWithContext;
-
-beforeEach( () => {
-	schema = new ModelSchema();
-	schema.registerItem( 'paragraph', '$block' );
-	schema.allow( { name: '$text', inside: '$root' } );
-	objWithContext = { context: [ '$root' ] };
-	dispatcher = new ViewConversionDispatcher( { schema } );
-} );
+describe( 'view-to-model-converters', () => {
+	let dispatcher, schema, objWithContext;
+
+	beforeEach( () => {
+		schema = new ModelSchema();
+		schema.registerItem( 'paragraph', '$block' );
+		schema.allow( { name: '$text', inside: '$root' } );
+		objWithContext = { context: [ '$root' ] };
+		dispatcher = new ViewConversionDispatcher( { schema } );
+	} );
 
-describe( 'convertText', () => {
-	it( 'should return converter converting ViewText to ModelText', () => {
-		const viewText = new ViewText( 'foobar' );
+	describe( 'convertText', () => {
+		it( 'should return converter converting ViewText to ModelText', () => {
+			const viewText = new ViewText( 'foobar' );
 
-		dispatcher.on( 'text', convertText() );
+			dispatcher.on( 'text', convertText() );
 
-		const result = dispatcher.convert( viewText, objWithContext );
+			const result = dispatcher.convert( viewText, objWithContext );
 
-		expect( result ).to.be.instanceof( ModelText );
-		expect( result.data ).to.equal( 'foobar' );
-	} );
+			expect( result ).to.be.instanceof( ModelText );
+			expect( result.data ).to.equal( 'foobar' );
+		} );
 
-	it( 'should not convert already consumed texts', () => {
-		const viewText = new ViewText( 'foofuckbafuckr' );
+		it( 'should not convert already consumed texts', () => {
+			const viewText = new ViewText( 'foofuckbafuckr' );
 
-		// Default converter for elements. Returns just converted children. Added with lowest priority.
-		dispatcher.on( 'text', convertText(), { priority: 'lowest' } );
-		// Added with normal priority. Should make the above converter not fire.
-		dispatcher.on( 'text', ( evt, data, consumable ) => {
-			if ( consumable.consume( data.input ) ) {
-				data.output = new ModelText( data.input.data.replace( /fuck/gi, '****' ) );
-			}
-		} );
+			// Default converter for elements. Returns just converted children. Added with lowest priority.
+			dispatcher.on( 'text', convertText(), { priority: 'lowest' } );
+			// Added with normal priority. Should make the above converter not fire.
+			dispatcher.on( 'text', ( evt, data, consumable ) => {
+				if ( consumable.consume( data.input ) ) {
+					data.output = new ModelText( data.input.data.replace( /fuck/gi, '****' ) );
+				}
+			} );
 
-		const result = dispatcher.convert( viewText, objWithContext );
+			const result = dispatcher.convert( viewText, objWithContext );
 
-		expect( result ).to.be.instanceof( ModelText );
-		expect( result.data ).to.equal( 'foo****ba****r' );
-	} );
+			expect( result ).to.be.instanceof( ModelText );
+			expect( result.data ).to.equal( 'foo****ba****r' );
+		} );
 
-	it( 'should not convert text if it is wrong with schema', () => {
-		schema.disallow( { name: '$text', inside: '$root' } );
+		it( 'should not convert text if it is wrong with schema', () => {
+			schema.disallow( { name: '$text', inside: '$root' } );
 
-		const viewText = new ViewText( 'foobar' );
-		dispatcher.on( 'text', convertText() );
+			const viewText = new ViewText( 'foobar' );
+			dispatcher.on( 'text', convertText() );
 
-		let result = dispatcher.convert( viewText, objWithContext );
+			let result = dispatcher.convert( viewText, objWithContext );
 
-		expect( result ).to.be.null;
+			expect( result ).to.be.null;
 
-		result = dispatcher.convert( viewText, { context: [ '$block' ] } );
-		expect( result ).to.be.instanceof( ModelText );
-		expect( result.data ).to.equal( 'foobar' );
-	} );
+			result = dispatcher.convert( viewText, { context: [ '$block' ] } );
+			expect( result ).to.be.instanceof( ModelText );
+			expect( result.data ).to.equal( 'foobar' );
+		} );
 
-	it( 'should support unicode', () => {
-		const viewText = new ViewText( 'நிலைக்கு' );
+		it( 'should support unicode', () => {
+			const viewText = new ViewText( 'நிலைக்கு' );
 
-		dispatcher.on( 'text', convertText() );
+			dispatcher.on( 'text', convertText() );
 
-		const result = dispatcher.convert( viewText, objWithContext );
+			const result = dispatcher.convert( viewText, objWithContext );
 
-		expect( result ).to.be.instanceof( ModelText );
-		expect( result.data ).to.equal( 'நிலைக்கு' );
+			expect( result ).to.be.instanceof( ModelText );
+			expect( result.data ).to.equal( 'நிலைக்கு' );
+		} );
 	} );
-} );
 
-describe( 'convertToModelFragment', () => {
-	it( 'should return converter converting whole ViewDocumentFragment to ModelDocumentFragment', () => {
-		const viewFragment = new ViewDocumentFragment( [
-			new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ),
-			new ViewText( 'bar' )
-		] );
+	describe( 'convertToModelFragment', () => {
+		it( 'should return converter converting whole ViewDocumentFragment to ModelDocumentFragment', () => {
+			const viewFragment = new ViewDocumentFragment( [
+				new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ),
+				new ViewText( 'bar' )
+			] );
 
-		// To get any meaningful results we have to actually convert something.
-		dispatcher.on( 'text', convertText() );
-		// This way P element won't be converted per-se but will fire converting it's children.
-		dispatcher.on( 'element', convertToModelFragment() );
-		dispatcher.on( 'documentFragment', convertToModelFragment() );
+			// To get any meaningful results we have to actually convert something.
+			dispatcher.on( 'text', convertText() );
+			// This way P element won't be converted per-se but will fire converting it's children.
+			dispatcher.on( 'element', convertToModelFragment() );
+			dispatcher.on( 'documentFragment', convertToModelFragment() );
 
-		const result = dispatcher.convert( viewFragment, objWithContext );
+			const result = dispatcher.convert( viewFragment, objWithContext );
 
-		expect( result ).to.be.instanceof( ModelDocumentFragment );
-		expect( result.maxOffset ).to.equal( 6 );
-		expect( result.getChild( 0 ).data ).to.equal( 'foobar' );
-	} );
-
-	it( 'should not convert already consumed (converted) changes', () => {
-		const viewP = new ViewContainerElement( 'p', null, new ViewText( 'foo' ) );
-
-		// To get any meaningful results we have to actually convert something.
-		dispatcher.on( 'text', convertText() );
-		// Default converter for elements. Returns just converted children. Added with lowest priority.
-		dispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
-		// Added with normal priority. Should make the above converter not fire.
-		dispatcher.on( 'element:p', ( evt, data, consumable, conversionApi ) => {
-			if ( consumable.consume( data.input, { name: true } ) ) {
-				data.output = new ModelElement( 'paragraph' );
-
-				data.context.push( data.output );
-				data.output.appendChildren( conversionApi.convertChildren( data.input, consumable, data ) );
-				data.context.pop();
-			}
+			expect( result ).to.be.instanceof( ModelDocumentFragment );
+			expect( result.maxOffset ).to.equal( 6 );
+			expect( result.getChild( 0 ).data ).to.equal( 'foobar' );
 		} );
 
-		const result = dispatcher.convert( viewP, objWithContext );
-
-		expect( result ).to.be.instanceof( ModelElement );
-		expect( result.name ).to.equal( 'paragraph' );
-		expect( result.maxOffset ).to.equal( 3 );
-		expect( result.getChild( 0 ).data ).to.equal( 'foo' );
+		it( 'should not convert already consumed (converted) changes', () => {
+			const viewP = new ViewContainerElement( 'p', null, new ViewText( 'foo' ) );
+
+			// To get any meaningful results we have to actually convert something.
+			dispatcher.on( 'text', convertText() );
+			// Default converter for elements. Returns just converted children. Added with lowest priority.
+			dispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
+			// Added with normal priority. Should make the above converter not fire.
+			dispatcher.on( 'element:p', ( evt, data, consumable, conversionApi ) => {
+				if ( consumable.consume( data.input, { name: true } ) ) {
+					data.output = new ModelElement( 'paragraph' );
+
+					data.context.push( data.output );
+					data.output.appendChildren( conversionApi.convertChildren( data.input, consumable, data ) );
+					data.context.pop();
+				}
+			} );
+
+			const result = dispatcher.convert( viewP, objWithContext );
+
+			expect( result ).to.be.instanceof( ModelElement );
+			expect( result.name ).to.equal( 'paragraph' );
+			expect( result.maxOffset ).to.equal( 3 );
+			expect( result.getChild( 0 ).data ).to.equal( 'foo' );
+		} );
 	} );
 } );

+ 8 - 2
packages/ckeditor5-engine/tests/dev-utils/model.js

@@ -25,13 +25,19 @@ describe( 'model test utils', () => {
 		document.schema.registerItem( 'a', '$inline' );
 		document.schema.allow( { name: 'a', inside: '$root' } );
 		document.schema.allow( { name: 'a', inside: '$root', attributes: [ 'bar', 'car', 'foo' ] } );
+
 		document.schema.registerItem( 'b', '$inline' );
 		document.schema.allow( { name: 'b', inside: '$root' } );
 		document.schema.allow( { name: 'b', inside: '$root', attributes: [ 'barFoo', 'fooBar', 'x' ] } );
+
 		document.schema.registerItem( 'c', '$inline' );
 		document.schema.allow( { name: 'c', inside: '$root' } );
+
 		document.schema.registerItem( 'paragraph', '$block' );
 		document.schema.allow( { name: '$text', inside: '$root' } );
+		document.schema.allow( { name: '$text', inside: 'a' } );
+		document.schema.allow( { name: '$text', inside: 'b' } );
+		document.schema.allow( { name: 'c', inside: 'b' } );
 	} );
 
 	afterEach( () => {
@@ -480,7 +486,7 @@ describe( 'model test utils', () => {
 		it( 'throws when try to set element not registered in schema', () => {
 			expect( () => {
 				parse( '<xyz></xyz>', document.schema );
-			} ).to.throw( Error, `Element 'xyz' not allowed in context.` );
+			} ).to.throw( Error, `Element 'xyz' not allowed in context ["$root"].` );
 		} );
 
 		it( 'throws when try to set text directly to $root without registering it', () => {
@@ -488,7 +494,7 @@ describe( 'model test utils', () => {
 
 			expect( () => {
 				parse( 'text', doc.schema );
-			} ).to.throw( Error, `Element '$text' not allowed in context.` );
+			} ).to.throw( Error, `Element '$text' not allowed in context ["$root"].` );
 		} );
 
 		it( 'converts data in the specified context', () => {

+ 6 - 9
packages/ckeditor5-engine/tests/model/delta/attributedelta.js

@@ -16,21 +16,16 @@ import AttributeDelta from 'ckeditor5/engine/model/delta/attributedelta.js';
 import { RootAttributeDelta } from 'ckeditor5/engine/model/delta/attributedelta.js';
 import AttributeOperation from 'ckeditor5/engine/model/operation/attributeoperation.js';
 
-let doc, root;
-
-beforeEach( () => {
-	doc = new Document();
-	root = doc.createRoot();
-} );
-
 describe( 'Batch', () => {
-	let batch;
+	let batch, doc, root;
 
 	const correctDeltaMatcher = sinon.match( ( operation ) => {
 		return operation.delta && operation.delta.batch && operation.delta.batch == batch;
 	} );
 
 	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot();
 		batch = doc.batch();
 	} );
 
@@ -403,9 +398,11 @@ describe( 'Batch', () => {
 } );
 
 describe( 'AttributeDelta', () => {
-	let delta;
+	let doc, root, delta;
 
 	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot();
 		delta = new AttributeDelta();
 	} );
 

+ 352 - 281
packages/ckeditor5-engine/tests/model/schema/schema.js

@@ -15,388 +15,459 @@ import testUtils from 'tests/core/_utils/utils.js';
 
 testUtils.createSinonSandbox();
 
-let schema;
-
-beforeEach( () => {
-	schema = new Schema();
-} );
-
-describe( 'constructor()', () => {
-	it( 'should register base items: inline, block, root', () => {
-		testUtils.sinon.spy( Schema.prototype, 'registerItem' );
+describe( 'Schema', () => {
+	let schema;
 
+	beforeEach( () => {
 		schema = new Schema();
-
-		expect( schema.registerItem.calledWithExactly( '$root', null ) );
-		expect( schema.registerItem.calledWithExactly( '$block', null ) );
-		expect( schema.registerItem.calledWithExactly( '$inline', null ) );
 	} );
 
-	it( 'should allow block in root', () => {
-		expect( schema.check( { name: '$block', inside: [ '$root' ] } ) ).to.be.true;
-	} );
+	describe( 'constructor()', () => {
+		it( 'should register base items: inline, block, root', () => {
+			testUtils.sinon.spy( Schema.prototype, 'registerItem' );
 
-	it( 'should allow inline in block', () => {
-		expect( schema.check( { name: '$inline', inside: [ '$block' ] } ) ).to.be.true;
-	} );
+			schema = new Schema();
 
-	it( 'should create the objects set', () => {
-		expect( schema.objects ).to.be.instanceOf( Set );
-	} );
+			expect( schema.registerItem.calledWithExactly( '$root', null ) );
+			expect( schema.registerItem.calledWithExactly( '$block', null ) );
+			expect( schema.registerItem.calledWithExactly( '$inline', null ) );
+		} );
 
-	describe( '$clipboardHolder', () => {
-		it( 'should allow $block', () => {
-			expect( schema.check( { name: '$block', inside: [ '$clipboardHolder' ] } ) ).to.be.true;
+		it( 'should allow block in root', () => {
+			expect( schema.check( { name: '$block', inside: [ '$root' ] } ) ).to.be.true;
 		} );
 
-		it( 'should allow $inline', () => {
-			expect( schema.check( { name: '$inline', inside: [ '$clipboardHolder' ] } ) ).to.be.true;
+		it( 'should allow inline in block', () => {
+			expect( schema.check( { name: '$inline', inside: [ '$block' ] } ) ).to.be.true;
 		} );
 
-		it( 'should allow $text', () => {
-			expect( schema.check( { name: '$text', inside: [ '$clipboardHolder' ] } ) ).to.be.true;
+		it( 'should create the objects set', () => {
+			expect( schema.objects ).to.be.instanceOf( Set );
 		} );
-	} );
-} );
 
-describe( 'registerItem', () => {
-	it( 'should register in schema item under given name', () => {
-		schema.registerItem( 'new' );
+		describe( '$clipboardHolder', () => {
+			it( 'should allow $block', () => {
+				expect( schema.check( { name: '$block', inside: [ '$clipboardHolder' ] } ) ).to.be.true;
+			} );
+
+			it( 'should allow $inline', () => {
+				expect( schema.check( { name: '$inline', inside: [ '$clipboardHolder' ] } ) ).to.be.true;
+			} );
 
-		expect( schema.hasItem( 'new' ) ).to.be.true;
+			it( 'should allow $text', () => {
+				expect( schema.check( { name: '$text', inside: [ '$clipboardHolder' ] } ) ).to.be.true;
+			} );
+		} );
 	} );
 
-	it( 'should build correct base chains', () => {
-		schema.registerItem( 'first' );
-		schema.registerItem( 'secondA', 'first' );
-		schema.registerItem( 'secondB', 'first' );
-		schema.registerItem( 'third', 'secondA' );
+	describe( 'registerItem', () => {
+		it( 'should register in schema item under given name', () => {
+			schema.registerItem( 'new' );
 
-		expect( schema._extensionChains.get( 'first' ) ).to.deep.equal( [ 'first' ] );
-		expect( schema._extensionChains.get( 'secondA' ) ).to.deep.equal( [ 'first', 'secondA' ] );
-		expect( schema._extensionChains.get( 'secondB' ) ).to.deep.equal( [ 'first', 'secondB' ] );
-		expect( schema._extensionChains.get( 'third' ) ).to.deep.equal( [ 'first', 'secondA', 'third' ] );
-	} );
+			expect( schema.hasItem( 'new' ) ).to.be.true;
+		} );
 
-	it( 'should make registered item inherit allows from base item', () => {
-		schema.registerItem( 'image', '$inline' );
+		it( 'should build correct base chains', () => {
+			schema.registerItem( 'first' );
+			schema.registerItem( 'secondA', 'first' );
+			schema.registerItem( 'secondB', 'first' );
+			schema.registerItem( 'third', 'secondA' );
 
-		expect( schema.check( { name: 'image', inside: [ '$block' ] } ) ).to.be.true;
-	} );
+			expect( schema._extensionChains.get( 'first' ) ).to.deep.equal( [ 'first' ] );
+			expect( schema._extensionChains.get( 'secondA' ) ).to.deep.equal( [ 'first', 'secondA' ] );
+			expect( schema._extensionChains.get( 'secondB' ) ).to.deep.equal( [ 'first', 'secondB' ] );
+			expect( schema._extensionChains.get( 'third' ) ).to.deep.equal( [ 'first', 'secondA', 'third' ] );
+		} );
+
+		it( 'should make registered item inherit allows from base item', () => {
+			schema.registerItem( 'image', '$inline' );
 
-	it( 'should throw if item with given name has already been registered in schema', () => {
-		schema.registerItem( 'new' );
+			expect( schema.check( { name: 'image', inside: [ '$block' ] } ) ).to.be.true;
+		} );
 
-		expect( () => {
+		it( 'should throw if item with given name has already been registered in schema', () => {
 			schema.registerItem( 'new' );
-		} ).to.throw( CKEditorError, /model-schema-item-exists/ );
-	} );
 
-	it( 'should throw if base item has not been registered in schema', () => {
-		expect( () => {
-			schema.registerItem( 'new', 'old' );
-		} ).to.throw( CKEditorError, /model-schema-no-item/ );
-	} );
-} );
+			expect( () => {
+				schema.registerItem( 'new' );
+			} ).to.throw( CKEditorError, /model-schema-item-exists/ );
+		} );
 
-describe( 'hasItem', () => {
-	it( 'should return true if given item name has been registered in schema', () => {
-		expect( schema.hasItem( '$block' ) ).to.be.true;
+		it( 'should throw if base item has not been registered in schema', () => {
+			expect( () => {
+				schema.registerItem( 'new', 'old' );
+			} ).to.throw( CKEditorError, /model-schema-no-item/ );
+		} );
 	} );
 
-	it( 'should return false if given item name has not been registered in schema', () => {
-		expect( schema.hasItem( 'new' ) ).to.be.false;
+	describe( 'hasItem', () => {
+		it( 'should return true if given item name has been registered in schema', () => {
+			expect( schema.hasItem( '$block' ) ).to.be.true;
+		} );
+
+		it( 'should return false if given item name has not been registered in schema', () => {
+			expect( schema.hasItem( 'new' ) ).to.be.false;
+		} );
 	} );
-} );
 
-describe( '_getItem', () => {
-	it( 'should return SchemaItem registered under given name', () => {
-		schema.registerItem( 'new' );
+	describe( '_getItem', () => {
+		it( 'should return SchemaItem registered under given name', () => {
+			schema.registerItem( 'new' );
 
-		let item = schema._getItem( 'new' );
+			let item = schema._getItem( 'new' );
 
-		expect( item ).to.be.instanceof( SchemaItem );
-	} );
+			expect( item ).to.be.instanceof( SchemaItem );
+		} );
 
-	it( 'should throw if there is no item registered under given name', () => {
-		expect( () => {
-			schema._getItem( 'new' );
-		} ).to.throw( CKEditorError, /model-schema-no-item/ );
+		it( 'should throw if there is no item registered under given name', () => {
+			expect( () => {
+				schema._getItem( 'new' );
+			} ).to.throw( CKEditorError, /model-schema-no-item/ );
+		} );
 	} );
-} );
 
-describe( 'allow', () => {
-	it( 'should add passed query to allowed in schema', () => {
-		schema.registerItem( 'p', '$block' );
-		schema.registerItem( 'div', '$block' );
+	describe( 'allow', () => {
+		it( 'should add passed query to allowed in schema', () => {
+			schema.registerItem( 'p', '$block' );
+			schema.registerItem( 'div', '$block' );
 
-		expect( schema.check( { name: 'p', inside: [ 'div' ] } ) ).to.be.false;
+			expect( schema.check( { name: 'p', inside: [ 'div' ] } ) ).to.be.false;
 
-		schema.allow( { name: 'p', inside: 'div' } );
+			schema.allow( { name: 'p', inside: 'div' } );
 
-		expect( schema.check( { name: 'p', inside: [ 'div' ] } ) ).to.be.true;
+			expect( schema.check( { name: 'p', inside: [ 'div' ] } ) ).to.be.true;
+		} );
 	} );
-} );
 
-describe( 'disallow', () => {
-	it( 'should add passed query to disallowed in schema', () => {
-		schema.registerItem( 'p', '$block' );
-		schema.registerItem( 'div', '$block' );
+	describe( 'disallow', () => {
+		it( 'should add passed query to disallowed in schema', () => {
+			schema.registerItem( 'p', '$block' );
+			schema.registerItem( 'div', '$block' );
 
-		schema.allow( { name: '$block', attributes: 'bold', inside: 'div' } );
+			schema.allow( { name: '$block', attributes: 'bold', inside: 'div' } );
 
-		expect( schema.check( { name: 'p', attributes: 'bold', inside: [ 'div' ] } ) ).to.be.true;
+			expect( schema.check( { name: 'p', attributes: 'bold', inside: [ 'div' ] } ) ).to.be.true;
 
-		schema.disallow( { name: 'p', attributes: 'bold', inside: 'div' } );
+			schema.disallow( { name: 'p', attributes: 'bold', inside: 'div' } );
 
-		expect( schema.check( { name: 'p', attributes: 'bold', inside: [ 'div' ] } ) ).to.be.false;
+			expect( schema.check( { name: 'p', attributes: 'bold', inside: [ 'div' ] } ) ).to.be.false;
+		} );
 	} );
-} );
 
-describe( 'check', () => {
-	describe( 'string or array of strings as inside', () => {
-		it( 'should return false if given element is not registered in schema', () => {
-			expect( schema.check( { name: 'new', inside: [ 'div', 'header' ] } ) ).to.be.false;
+	describe( 'check', () => {
+		describe( 'string or array of strings as inside', () => {
+			it( 'should return false if given element is not registered in schema', () => {
+				expect( schema.check( { name: 'new', inside: [ 'div', 'header' ] } ) ).to.be.false;
+			} );
+
+			it( 'should handle path given as string', () => {
+				expect( schema.check( { name: '$inline', inside: '$block $block $block' } ) ).to.be.true;
+			} );
+
+			it( 'should handle attributes', () => {
+				schema.registerItem( 'p', '$block' );
+				schema.allow( { name: 'p', inside: '$block' } );
+
+				expect( schema.check( { name: 'p', inside: [ '$block' ] } ) ).to.be.true;
+				expect( schema.check( { name: 'p', inside: [ '$block' ], attributes: 'bold' } ) ).to.be.false;
+			} );
+
+			it( 'should support required attributes', () => {
+				schema.registerItem( 'a', '$inline' );
+				schema.requireAttributes( 'a', [ 'name' ] );
+				schema.requireAttributes( 'a', [ 'href' ] );
+				schema.allow( { name: 'a', inside: '$block', attributes: [ 'name', 'href', 'title', 'target' ] } );
+
+				// Even though a is allowed in $block thanks to inheriting from $inline, we require href or name attribute.
+				expect( schema.check( { name: 'a', inside: '$block' } ) ).to.be.false;
+
+				// Even though a with title is allowed, we have to meet at least on required attributes set.
+				expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'title' ] } ) ).to.be.false;
+
+				expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'name' ] } ) ).to.be.true;
+				expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'href' ] } ) ).to.be.true;
+				expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'name', 'href' ] } ) ).to.be.true;
+				expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'name', 'title', 'target' ] } ) ).to.be.true;
+			} );
+
+			it( 'should not require attributes from parent schema items', () => {
+				schema.registerItem( 'parent' );
+				schema.registerItem( 'child', 'parent' );
+				schema.allow( { name: 'parent', inside: '$block' } );
+				schema.requireAttributes( 'parent', [ 'required' ] );
+
+				// Even though we require "required" attribute on parent, the requirement should not be inherited.
+				expect( schema.check( { name: 'child', inside: '$block' } ) ).to.be.true;
+			} );
+
+			it( 'should support multiple attributes', () => {
+				// Let's take example case, where image item has to have a pair of "alt" and "src" attributes.
+				// Then it could have other attribute which is allowed on inline elements, i.e. "bold".
+				schema.registerItem( 'img', '$inline' );
+				schema.requireAttributes( 'img', [ 'alt', 'src' ] );
+				schema.allow( { name: '$inline', inside: '$block', attributes: 'bold' } );
+				schema.allow( { name: 'img', inside: '$block', attributes: [ 'alt', 'src' ] } );
+
+				// Image without any attributes is not allowed.
+				expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt' ] } ) ).to.be.false;
+
+				// Image can't have just alt or src.
+				expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt' ] } ) ).to.be.false;
+				expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'src' ] } ) ).to.be.false;
+
+				expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt', 'src' ] } ) ).to.be.true;
+
+				// Because of inherting from $inline, image can have bold
+				expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt', 'src', 'bold' ] } ) ).to.be.true;
+				// But it can't have only bold without alt or/and src.
+				expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt', 'bold' ] } ) ).to.be.false;
+				expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'src', 'bold' ] } ) ).to.be.false;
+				expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'bold' ] } ) ).to.be.false;
+
+				// Even if image has src and alt, it can't have attributes that weren't allowed
+				expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt', 'src', 'attr' ] } ) ).to.be.false;
+			} );
+
+			it( 'should omit path elements that are added to schema', () => {
+				expect( schema.check( { name: '$inline', inside: '$block new $block' } ) ).to.be.true;
+			} );
 		} );
 
-		it( 'should handle path given as string', () => {
-			expect( schema.check( { name: '$inline', inside: '$block $block $block' } ) ).to.be.true;
-		} );
+		describe( 'array of elements as inside', () => {
+			beforeEach( () => {
+				schema.registerItem( 'div', '$block' );
+				schema.registerItem( 'header', '$block' );
+				schema.registerItem( 'p', '$block' );
+				schema.registerItem( 'img', '$inline' );
 
-		it( 'should handle attributes', () => {
-			schema.registerItem( 'p', '$block' );
-			schema.allow( { name: 'p', inside: '$block' } );
+				schema.allow( { name: '$block', inside: 'div' } );
+				schema.allow( { name: '$inline', attributes: 'bold', inside: '$block' } );
 
-			expect( schema.check( { name: 'p', inside: [ '$block' ] } ) ).to.be.true;
-			expect( schema.check( { name: 'p', inside: [ '$block' ], attributes: 'bold' } ) ).to.be.false;
-		} );
+				schema.disallow( { name: '$inline', attributes: 'bold', inside: 'header' } );
+			} );
 
-		it( 'should support required attributes', () => {
-			schema.registerItem( 'a', '$inline' );
-			schema.requireAttributes( 'a', [ 'name' ] );
-			schema.requireAttributes( 'a', [ 'href' ] );
-			schema.allow( { name: 'a', inside: '$block', attributes: [ 'name', 'href', 'title', 'target' ] } );
+			it( 'should return true if given element is allowed by schema at given position', () => {
+				// P is block and block is allowed in DIV.
+				expect( schema.check( { name: 'p', inside: [ new Element( 'div' ) ] } ) ).to.be.true;
 
-			// Even though a is allowed in $block thanks to inheriting from $inline, we require href or name attribute.
-			expect( schema.check( { name: 'a', inside: '$block' } ) ).to.be.false;
+				// IMG is inline and inline is allowed in block.
+				expect( schema.check( { name: 'img', inside: [ new Element( 'div' ) ] } ) ).to.be.true;
+				expect( schema.check( { name: 'img', inside: [ new Element( 'p' ) ] } ) ).to.be.true;
 
-			// Even though a with title is allowed, we have to meet at least on required attributes set.
-			expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'title' ] } ) ).to.be.false;
+				// Inline is allowed in any block and is allowed with attribute bold.
+				expect( schema.check( { name: 'img', inside: [ new Element( 'div' ) ], attributes: [ 'bold' ] } ) ).to.be.true;
+				expect( schema.check( { name: 'img', inside: [ new Element( 'p' ) ], attributes: [ 'bold' ] } ) ).to.be.true;
 
-			expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'name' ] } ) ).to.be.true;
-			expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'href' ] } ) ).to.be.true;
-			expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'name', 'href' ] } ) ).to.be.true;
-			expect( schema.check( { name: 'a', inside: '$block', attributes: [ 'name', 'title', 'target' ] } ) ).to.be.true;
-		} );
+				// Inline is allowed in header which is allowed in DIV.
+				expect( schema.check( { name: 'header', inside: [ new Element( 'div' ) ] } ) ).to.be.true;
+				expect( schema.check( { name: 'img', inside: [ new Element( 'header' ) ] } ) ).to.be.true;
+				expect( schema.check( { name: 'img', inside: [ new Element( 'div' ), new Element( 'header' ) ] } ) ).to.be.true;
+			} );
+
+			it( 'should return false if given element is not allowed by schema at given position', () => {
+				// P with attribute is not allowed.
+				expect( schema.check( { name: 'p', inside: [ new Element( 'div' ) ], attributes: 'bold' } ) ).to.be.false;
 
-		it( 'should not require attributes from parent schema items', () => {
-			schema.registerItem( 'parent' );
-			schema.registerItem( 'child', 'parent' );
-			schema.allow( { name: 'parent', inside: '$block' } );
-			schema.requireAttributes( 'parent', [ 'required' ] );
+				// Bold text is not allowed in header
+				expect( schema.check( { name: '$text', inside: [ new Element( 'header' ) ], attributes: 'bold' } ) ).to.be.false;
+			} );
 
-			// Even though we require "required" attribute on parent, the requirement should not be inherited.
-			expect( schema.check( { name: 'child', inside: '$block' } ) ).to.be.true;
+			it( 'should return false if given element is not registered in schema', () => {
+				expect( schema.check( { name: 'new', inside: [ new Element( 'div' ) ] } ) ).to.be.false;
+			} );
 		} );
 
-		it( 'should support multiple attributes', () => {
-			// Let's take example case, where image item has to have a pair of "alt" and "src" attributes.
-			// Then it could have other attribute which is allowed on inline elements, i.e. "bold".
-			schema.registerItem( 'img', '$inline' );
-			schema.requireAttributes( 'img', [ 'alt', 'src' ] );
-			schema.allow( { name: '$inline', inside: '$block', attributes: 'bold' } );
-			schema.allow( { name: 'img', inside: '$block', attributes: [ 'alt', 'src' ] } );
+		describe( 'position as inside', () => {
+			let doc, root;
 
-			// Image without any attributes is not allowed.
-			expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt' ] } ) ).to.be.false;
+			beforeEach( () => {
+				doc = new Document();
+				root = doc.createRoot( 'div' );
 
-			// Image can't have just alt or src.
-			expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt' ] } ) ).to.be.false;
-			expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'src' ] } ) ).to.be.false;
+				root.insertChildren( 0, [
+					new Element( 'div' ),
+					new Element( 'header' ),
+					new Element( 'p' )
+				] );
 
-			expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt', 'src' ] } ) ).to.be.true;
+				schema.registerItem( 'div', '$block' );
+				schema.registerItem( 'header', '$block' );
+				schema.registerItem( 'p', '$block' );
 
-			// Because of inherting from $inline, image can have bold
-			expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt', 'src', 'bold' ] } ) ).to.be.true;
-			// But it can't have only bold without alt or/and src.
-			expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt', 'bold' ] } ) ).to.be.false;
-			expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'src', 'bold' ] } ) ).to.be.false;
-			expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'bold' ] } ) ).to.be.false;
+				schema.allow( { name: '$block', inside: 'div' } );
+				schema.allow( { name: '$inline', attributes: 'bold', inside: '$block' } );
 
-			// Even if image has src and alt, it can't have attributes that weren't allowed
-			expect( schema.check( { name: 'img', inside: '$block', attributes: [ 'alt', 'src', 'attr' ] } ) ).to.be.false;
-		} );
+				schema.disallow( { name: '$inline', attributes: 'bold', inside: 'header' } );
+			} );
 
-		it( 'should omit path elements that are added to schema', () => {
-			expect( schema.check( { name: '$inline', inside: '$block new $block' } ) ).to.be.true;
-		} );
-	} );
+			it( 'should return true if given element is allowed by schema at given position', () => {
+				// Block should be allowed in root.
+				expect( schema.check( { name: '$block', inside: new Position( root, [ 0 ] ) } ) ).to.be.true;
 
-	describe( 'array of elements as inside', () => {
-		beforeEach( () => {
-			schema.registerItem( 'div', '$block' );
-			schema.registerItem( 'header', '$block' );
-			schema.registerItem( 'p', '$block' );
-			schema.registerItem( 'img', '$inline' );
+				// P is block and block should be allowed in root.
+				expect( schema.check( { name: 'p', inside: new Position( root, [ 0 ] ) } ) ).to.be.true;
 
-			schema.allow( { name: '$block', inside: 'div' } );
-			schema.allow( { name: '$inline', attributes: 'bold', inside: '$block' } );
+				// P is allowed in DIV by the set rule.
+				expect( schema.check( { name: 'p', inside: new Position( root, [ 0, 0 ] ) } ) ).to.be.true;
 
-			schema.disallow( { name: '$inline', attributes: 'bold', inside: 'header' } );
-		} );
+				// Inline is allowed in any block and is allowed with attribute bold.
+				// We do not check if it is allowed in header, because it is disallowed by the set rule.
+				expect( schema.check( { name: '$inline', inside: new Position( root, [ 0, 0 ] ) } ) ).to.be.true;
+				expect( schema.check( { name: '$inline', inside: new Position( root, [ 2, 0 ] ) } ) ).to.be.true;
+				expect( schema.check( { name: '$inline', inside: new Position( root, [ 0, 0 ] ), attributes: 'bold' } ) ).to.be.true;
+				expect( schema.check( { name: '$inline', inside: new Position( root, [ 2, 0 ] ), attributes: 'bold' } ) ).to.be.true;
 
-		it( 'should return true if given element is allowed by schema at given position', () => {
-			// P is block and block is allowed in DIV.
-			expect( schema.check( { name: 'p', inside: [ new Element( 'div' ) ] } ) ).to.be.true;
+				// Header is allowed in DIV.
+				expect( schema.check( { name: 'header', inside: new Position( root, [ 0, 0 ] ) } ) ).to.be.true;
 
-			// IMG is inline and inline is allowed in block.
-			expect( schema.check( { name: 'img', inside: [ new Element( 'div' ) ] } ) ).to.be.true;
-			expect( schema.check( { name: 'img', inside: [ new Element( 'p' ) ] } ) ).to.be.true;
+				// Inline is allowed in block and root is DIV, which is block.
+				expect( schema.check( { name: '$inline', inside: new Position( root, [ 0 ] ) } ) ).to.be.true;
+			} );
 
-			// Inline is allowed in any block and is allowed with attribute bold.
-			expect( schema.check( { name: 'img', inside: [ new Element( 'div' ) ], attributes: [ 'bold' ] } ) ).to.be.true;
-			expect( schema.check( { name: 'img', inside: [ new Element( 'p' ) ], attributes: [ 'bold' ] } ) ).to.be.true;
+			it( 'should return false if given element is not allowed by schema at given position', () => {
+				// P with attribute is not allowed anywhere.
+				expect( schema.check( { name: 'p', inside: new Position( root, [ 0 ] ), attributes: 'bold' } ) ).to.be.false;
+				expect( schema.check( { name: 'p', inside: new Position( root, [ 0, 0 ] ), attributes: 'bold' } ) ).to.be.false;
 
-			// Inline is allowed in header which is allowed in DIV.
-			expect( schema.check( { name: 'header', inside: [ new Element( 'div' ) ] } ) ).to.be.true;
-			expect( schema.check( { name: 'img', inside: [ new Element( 'header' ) ] } ) ).to.be.true;
-			expect( schema.check( { name: 'img', inside: [ new Element( 'div' ), new Element( 'header' ) ] } ) ).to.be.true;
-		} );
-
-		it( 'should return false if given element is not allowed by schema at given position', () => {
-			// P with attribute is not allowed.
-			expect( schema.check( { name: 'p', inside: [ new Element( 'div' ) ], attributes: 'bold' } ) ).to.be.false;
+				// Bold text is not allowed in header
+				expect( schema.check( { name: '$text', inside: new Position( root, [ 1, 0 ] ), attributes: 'bold' } ) ).to.be.false;
+			} );
 
-			// Bold text is not allowed in header
-			expect( schema.check( { name: '$text', inside: [ new Element( 'header' ) ], attributes: 'bold' } ) ).to.be.false;
+			it( 'should return false if given element is not registered in schema', () => {
+				expect( schema.check( { name: 'new', inside: new Position( root, [ 0 ] ) } ) ).to.be.false;
+			} );
 		} );
 
-		it( 'should return false if given element is not registered in schema', () => {
-			expect( schema.check( { name: 'new', inside: [ new Element( 'div' ) ] } ) ).to.be.false;
-		} );
-	} );
+		describe( 'bug #732', () => {
+			// Ticket case.
+			it( 'should return false if given element is allowed in the root but not deeper', () => {
+				schema.registerItem( 'paragraph', '$block' );
 
-	describe( 'position as inside', () => {
-		let doc, root;
+				expect( schema.check( { name: 'paragraph', inside: [ '$root', 'paragraph' ] } ) ).to.be.false;
+			} );
 
-		beforeEach( () => {
-			doc = new Document();
-			root = doc.createRoot( 'div' );
+			// Two additional, real life cases accompanying the ticket case.
+			it( 'should return true if checking whether text is allowed in $root > paragraph', () => {
+				schema.registerItem( 'paragraph', '$block' );
 
-			root.insertChildren( 0, [
-				new Element( 'div' ),
-				new Element( 'header' ),
-				new Element( 'p' )
-			] );
+				expect( schema.check( { name: '$text', inside: [ '$root', 'paragraph' ] } ) ).to.be.true;
+			} );
 
-			schema.registerItem( 'div', '$block' );
-			schema.registerItem( 'header', '$block' );
-			schema.registerItem( 'p', '$block' );
+			it( 'should return true if checking whether text is allowed in paragraph', () => {
+				schema.registerItem( 'paragraph', '$block' );
 
-			schema.allow( { name: '$block', inside: 'div' } );
-			schema.allow( { name: '$inline', attributes: 'bold', inside: '$block' } );
+				expect( schema.check( { name: '$text', inside: [ 'paragraph' ] } ) ).to.be.true;
+			} );
 
-			schema.disallow( { name: '$inline', attributes: 'bold', inside: 'header' } );
-		} );
+			// Veryfing the matching algorithm.
+			// The right ends of the element to check and "inside" paths must match.
+			describe( 'right ends of paths must match', () => {
+				beforeEach( () => {
+					schema.registerItem( 'a' );
+					schema.registerItem( 'b' );
+					schema.registerItem( 'c' );
+					schema.registerItem( 'd' );
+					schema.registerItem( 'e' );
 
-		it( 'should return true if given element is allowed by schema at given position', () => {
-			// Block should be allowed in root.
-			expect( schema.check( { name: '$block', inside: new Position( root, [ 0 ] ) } ) ).to.be.true;
+					schema.allow( { name: 'a', inside: [ 'b', 'c', 'd' ] } );
+					schema.allow( { name: 'e', inside: [ 'a' ] } );
+				} );
 
-			// P is block and block should be allowed in root.
-			expect( schema.check( { name: 'p', inside: new Position( root, [ 0 ] ) } ) ).to.be.true;
+				// Simple chains created by a single allow() call.
 
-			// P is allowed in DIV by the set rule.
-			expect( schema.check( { name: 'p', inside: new Position( root, [ 0, 0 ] ) } ) ).to.be.true;
+				it( 'a inside b, c', () => {
+					expect( schema.check( { name: 'a', inside: [ 'b', 'c' ] } ) ).to.be.false;
+				} );
 
-			// Inline is allowed in any block and is allowed with attribute bold.
-			// We do not check if it is allowed in header, because it is disallowed by the set rule.
-			expect( schema.check( { name: '$inline', inside: new Position( root, [ 0, 0 ] ) } ) ).to.be.true;
-			expect( schema.check( { name: '$inline', inside: new Position( root, [ 2, 0 ] ) } ) ).to.be.true;
-			expect( schema.check( { name: '$inline', inside: new Position( root, [ 0, 0 ] ), attributes: 'bold' } ) ).to.be.true;
-			expect( schema.check( { name: '$inline', inside: new Position( root, [ 2, 0 ] ), attributes: 'bold' } ) ).to.be.true;
+				it( 'a inside b', () => {
+					expect( schema.check( { name: 'a', inside: [ 'b' ] } ) ).to.be.false;
+				} );
 
-			// Header is allowed in DIV.
-			expect( schema.check( { name: 'header', inside: new Position( root, [ 0, 0 ] ) } ) ).to.be.true;
+				it( 'a inside b, c, d', () => {
+					expect( schema.check( { name: 'a', inside: [ 'b', 'c', 'd' ] } ) ).to.be.true;
+				} );
 
-			// Inline is allowed in block and root is DIV, which is block.
-			expect( schema.check( { name: '$inline', inside: new Position( root, [ 0 ] ) } ) ).to.be.true;
-		} );
+				it( 'a inside c, d', () => {
+					expect( schema.check( { name: 'a', inside: [ 'c', 'd' ] } ) ).to.be.true;
+				} );
 
-		it( 'should return false if given element is not allowed by schema at given position', () => {
-			// P with attribute is not allowed anywhere.
-			expect( schema.check( { name: 'p', inside: new Position( root, [ 0 ] ), attributes: 'bold' } ) ).to.be.false;
-			expect( schema.check( { name: 'p', inside: new Position( root, [ 0, 0 ] ), attributes: 'bold' } ) ).to.be.false;
+				it( 'a inside d', () => {
+					expect( schema.check( { name: 'a', inside: [ 'd' ] } ) ).to.be.true;
+				} );
 
-			// Bold text is not allowed in header
-			expect( schema.check( { name: '$text', inside: new Position( root, [ 1, 0 ] ), attributes: 'bold' } ) ).to.be.false;
-		} );
+				// "Allowed in" chains created by two separate allow() calls (`e inside a` and `a inside b,c,d`).
+
+				it( 'e inside a, d', () => {
+					expect( schema.check( { name: 'e', inside: [ 'd', 'a' ] } ) ).to.be.true;
+				} );
 
-		it( 'should return false if given element is not registered in schema', () => {
-			expect( schema.check( { name: 'new', inside: new Position( root, [ 0 ] ) } ) ).to.be.false;
+				it( 'e inside b, c, d', () => {
+					expect( schema.check( { name: 'e', inside: [ 'b', 'c', 'd' ] } ) ).to.be.false;
+				} );
+			} );
 		} );
 	} );
-} );
 
-describe( 'itemExtends', () => {
-	it( 'should return true if given item extends another given item', () => {
-		schema.registerItem( 'div', '$block' );
-		schema.registerItem( 'myDiv', 'div' );
+	describe( 'itemExtends', () => {
+		it( 'should return true if given item extends another given item', () => {
+			schema.registerItem( 'div', '$block' );
+			schema.registerItem( 'myDiv', 'div' );
 
-		expect( schema.itemExtends( 'div', '$block' ) ).to.be.true;
-		expect( schema.itemExtends( 'myDiv', 'div' ) ).to.be.true;
-		expect( schema.itemExtends( 'myDiv', '$block' ) ).to.be.true;
-	} );
+			expect( schema.itemExtends( 'div', '$block' ) ).to.be.true;
+			expect( schema.itemExtends( 'myDiv', 'div' ) ).to.be.true;
+			expect( schema.itemExtends( 'myDiv', '$block' ) ).to.be.true;
+		} );
 
-	it( 'should return false if given item does not extend another given item', () => {
-		schema.registerItem( 'div' );
-		schema.registerItem( 'myDiv', 'div' );
+		it( 'should return false if given item does not extend another given item', () => {
+			schema.registerItem( 'div' );
+			schema.registerItem( 'myDiv', 'div' );
 
-		expect( schema.itemExtends( 'div', '$block' ) ).to.be.false;
-		expect( schema.itemExtends( 'div', 'myDiv' ) ).to.be.false;
-	} );
+			expect( schema.itemExtends( 'div', '$block' ) ).to.be.false;
+			expect( schema.itemExtends( 'div', 'myDiv' ) ).to.be.false;
+		} );
 
-	it( 'should throw if one or both given items are not registered in schema', () => {
-		expect( () => {
-			schema.itemExtends( 'foo', '$block' );
-		} ).to.throw( CKEditorError, /model-schema-no-item/ );
+		it( 'should throw if one or both given items are not registered in schema', () => {
+			expect( () => {
+				schema.itemExtends( 'foo', '$block' );
+			} ).to.throw( CKEditorError, /model-schema-no-item/ );
 
-		expect( () => {
-			schema.itemExtends( '$block', 'foo' );
-		} ).to.throw( CKEditorError, /model-schema-no-item/ );
+			expect( () => {
+				schema.itemExtends( '$block', 'foo' );
+			} ).to.throw( CKEditorError, /model-schema-no-item/ );
+		} );
 	} );
-} );
 
-describe( '_normalizeQueryPath', () => {
-	it( 'should normalize string with spaces to an array of strings', () => {
-		expect( Schema._normalizeQueryPath( '$root div strong' ) ).to.deep.equal( [ '$root', 'div', 'strong' ] );
-	} );
+	describe( '_normalizeQueryPath', () => {
+		it( 'should normalize string with spaces to an array of strings', () => {
+			expect( Schema._normalizeQueryPath( '$root div strong' ) ).to.deep.equal( [ '$root', 'div', 'strong' ] );
+		} );
 
-	it( 'should normalize model position to an array of strings', () => {
-		let doc = new Document();
-		let root = doc.createRoot();
+		it( 'should normalize model position to an array of strings', () => {
+			let doc = new Document();
+			let root = doc.createRoot();
+
+			root.insertChildren( 0, [
+				new Element( 'div', null, [
+					new Element( 'header' )
+				] )
+			] );
 
-		root.insertChildren( 0, [
-			new Element( 'div', null, [
-				new Element( 'header' )
-			] )
-		] );
+			let position = new Position( root, [ 0, 0, 0 ] );
 
-		let position = new Position( root, [ 0, 0, 0 ] );
+			expect( Schema._normalizeQueryPath( position ) ).to.deep.equal( [ '$root', 'div', 'header' ] );
+		} );
 
-		expect( Schema._normalizeQueryPath( position ) ).to.deep.equal( [ '$root', 'div', 'header' ] );
-	} );
+		it( 'should normalize array with strings and model elements to an array of strings and drop unrecognized parts', () => {
+			let input = [
+				'$root',
+				[ 'div' ],
+				new Element( 'div' ),
+				null,
+				new Element( 'p' ),
+				'strong'
+			];
 
-	it( 'should normalize array with strings and model elements to an array of strings and drop unrecognized parts', () => {
-		let input = [
-			'$root',
-			[ 'div' ],
-			new Element( 'div' ),
-			null,
-			new Element( 'p' ),
-			'strong'
-		];
-
-		expect( Schema._normalizeQueryPath( input ) ).to.deep.equal( [ '$root', 'div', 'p', 'strong' ] );
+			expect( Schema._normalizeQueryPath( input ) ).to.deep.equal( [ '$root', 'div', 'p', 'strong' ] );
+		} );
 	} );
 } );

+ 103 - 103
packages/ckeditor5-engine/tests/model/schema/schemaitem.js

@@ -10,145 +10,145 @@ import { SchemaItem as SchemaItem } from 'ckeditor5/engine/model/schema.js';
 
 let schema, item;
 
-beforeEach( () => {
-	schema = new Schema();
-
-	schema.registerItem( 'p', '$block' );
-	schema.registerItem( 'header', '$block' );
-	schema.registerItem( 'div', '$block' );
-	schema.registerItem( 'html', '$block' );
-	schema.registerItem( 'span', '$inline' );
-	schema.registerItem( 'image', '$inline' );
-
-	item = new SchemaItem( schema );
-} );
+describe( 'SchemaItem', () => {
+	beforeEach( () => {
+		schema = new Schema();
+
+		schema.registerItem( 'p', '$block' );
+		schema.registerItem( 'header', '$block' );
+		schema.registerItem( 'div', '$block' );
+		schema.registerItem( 'html', '$block' );
+		schema.registerItem( 'span', '$inline' );
+		schema.registerItem( 'image', '$inline' );
+
+		item = new SchemaItem( schema );
+	} );
 
-describe( 'constructor()', () => {
-	it( 'should create empty schema item', () => {
-		let item = new SchemaItem( schema );
+	describe( 'constructor()', () => {
+		it( 'should create empty schema item', () => {
+			let item = new SchemaItem( schema );
 
-		expect( item._disallowed ).to.deep.equal( [] );
-		expect( item._allowed ).to.deep.equal( [] );
+			expect( item._disallowed ).to.deep.equal( [] );
+			expect( item._allowed ).to.deep.equal( [] );
+		} );
 	} );
-} );
 
-describe( 'allow', () => {
-	it( 'should add paths to the item as copies of passed array', () => {
-		let path1 = [ 'div', 'header' ];
-		let path2 = [ 'p' ];
+	describe( 'allow', () => {
+		it( 'should add paths to the item as copies of passed array', () => {
+			let path1 = [ 'div', 'header' ];
+			let path2 = [ 'p' ];
 
-		item.allow( path1 );
-		item.allow( path2 );
+			item.allow( path1 );
+			item.allow( path2 );
 
-		let paths = item._getPaths( 'allow' );
+			let paths = item._getPaths( 'allow' );
 
-		expect( paths.length ).to.equal( 2 );
+			expect( paths.length ).to.equal( 2 );
 
-		expect( paths[ 0 ] ).not.to.equal( path1 );
-		expect( paths[ 1 ] ).not.to.equal( path2 );
+			expect( paths[ 0 ] ).not.to.equal( path1 );
+			expect( paths[ 1 ] ).not.to.equal( path2 );
 
-		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
-		expect( paths[ 1 ] ).to.deep.equal( [ 'p' ] );
-	} );
+			expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+			expect( paths[ 1 ] ).to.deep.equal( [ 'p' ] );
+		} );
 
-	it( 'should group paths by attribute', () => {
-		item.allow( [ 'p' ], 'bold' );
-		item.allow( [ 'div' ] );
-		item.allow( [ 'header' ], 'bold' );
+		it( 'should group paths by attribute', () => {
+			item.allow( [ 'p' ], 'bold' );
+			item.allow( [ 'div' ] );
+			item.allow( [ 'header' ], 'bold' );
 
-		let pathsWithNoAttribute = item._getPaths( 'allow' );
-		let pathsWithBoldAttribute = item._getPaths( 'allow', 'bold' );
+			let pathsWithNoAttribute = item._getPaths( 'allow' );
+			let pathsWithBoldAttribute = item._getPaths( 'allow', 'bold' );
 
-		expect( pathsWithNoAttribute.length ).to.equal( 1 );
-		expect( pathsWithNoAttribute[ 0 ] ).to.deep.equal( [ 'div' ] );
+			expect( pathsWithNoAttribute.length ).to.equal( 1 );
+			expect( pathsWithNoAttribute[ 0 ] ).to.deep.equal( [ 'div' ] );
 
-		expect( pathsWithBoldAttribute.length ).to.equal( 2 );
-		expect( pathsWithBoldAttribute[ 0 ] ).to.deep.equal( [ 'p' ] );
-		expect( pathsWithBoldAttribute[ 1 ] ).to.deep.equal( [ 'header' ] );
+			expect( pathsWithBoldAttribute.length ).to.equal( 2 );
+			expect( pathsWithBoldAttribute[ 0 ] ).to.deep.equal( [ 'p' ] );
+			expect( pathsWithBoldAttribute[ 1 ] ).to.deep.equal( [ 'header' ] );
+		} );
 	} );
-} );
 
-describe( 'disallow', () => {
-	it( 'should add paths to the item as copies of passed array', () => {
-		let path1 = [ 'div', 'header' ];
-		let path2 = [ 'p' ];
+	describe( 'disallow', () => {
+		it( 'should add paths to the item as copies of passed array', () => {
+			let path1 = [ 'div', 'header' ];
+			let path2 = [ 'p' ];
 
-		item.disallow( path1 );
-		item.disallow( path2 );
+			item.disallow( path1 );
+			item.disallow( path2 );
 
-		let paths = item._getPaths( 'disallow' );
+			let paths = item._getPaths( 'disallow' );
 
-		expect( paths.length ).to.equal( 2 );
+			expect( paths.length ).to.equal( 2 );
 
-		expect( paths[ 0 ] ).not.to.equal( path1 );
-		expect( paths[ 1 ] ).not.to.equal( path2 );
+			expect( paths[ 0 ] ).not.to.equal( path1 );
+			expect( paths[ 1 ] ).not.to.equal( path2 );
 
-		expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
-		expect( paths[ 1 ] ).to.deep.equal( [ 'p' ] );
-	} );
+			expect( paths[ 0 ] ).to.deep.equal( [ 'div', 'header' ] );
+			expect( paths[ 1 ] ).to.deep.equal( [ 'p' ] );
+		} );
 
-	it( 'should group paths by attribute', () => {
-		item.disallow( [ 'p' ], 'bold' );
-		item.disallow( [ 'div' ] );
-		item.disallow( [ 'header' ], 'bold' );
+		it( 'should group paths by attribute', () => {
+			item.disallow( [ 'p' ], 'bold' );
+			item.disallow( [ 'div' ] );
+			item.disallow( [ 'header' ], 'bold' );
 
-		let pathsWithNoAttribute = item._getPaths( 'disallow' );
-		let pathsWithBoldAttribute = item._getPaths( 'disallow', 'bold' );
+			let pathsWithNoAttribute = item._getPaths( 'disallow' );
+			let pathsWithBoldAttribute = item._getPaths( 'disallow', 'bold' );
 
-		expect( pathsWithNoAttribute.length ).to.equal( 1 );
-		expect( pathsWithNoAttribute[ 0 ] ).to.deep.equal( [ 'div' ] );
+			expect( pathsWithNoAttribute.length ).to.equal( 1 );
+			expect( pathsWithNoAttribute[ 0 ] ).to.deep.equal( [ 'div' ] );
 
-		expect( pathsWithBoldAttribute.length ).to.equal( 2 );
-		expect( pathsWithBoldAttribute[ 0 ] ).to.deep.equal( [ 'p' ] );
-		expect( pathsWithBoldAttribute[ 1 ] ).to.deep.equal( [ 'header' ] );
+			expect( pathsWithBoldAttribute.length ).to.equal( 2 );
+			expect( pathsWithBoldAttribute[ 0 ] ).to.deep.equal( [ 'p' ] );
+			expect( pathsWithBoldAttribute[ 1 ] ).to.deep.equal( [ 'header' ] );
+		} );
 	} );
-} );
 
-describe( '_hasMatchingPath', () => {
-	it( 'should return true if there is at least one allowed path that matches query path', () => {
-		item.allow( [ 'div' , 'header' ] );
-		item.allow( [ 'image' ] );
+	describe( '_hasMatchingPath', () => {
+		it( 'should return true if there is at least one allowed path that matches query path', () => {
+			item.allow( [ 'div' , 'header' ] );
+			item.allow( [ 'image' ] );
 
-		expect( item._hasMatchingPath( 'allow', [ 'div', 'header' ] ) ).to.be.true;
-		expect( item._hasMatchingPath( 'allow', [ 'html', 'div', 'header' ] ) ).to.be.true;
-		expect( item._hasMatchingPath( 'allow', [ 'div', 'header', 'span' ] ) ).to.be.true;
-		expect( item._hasMatchingPath( 'allow', [ 'html', 'div', 'p', 'header', 'span' ] ) ).to.be.true;
-	} );
+			expect( item._hasMatchingPath( 'allow', [ 'div', 'header' ] ) ).to.be.true;
+			expect( item._hasMatchingPath( 'allow', [ 'html', 'div', 'header' ] ) ).to.be.true;
+		} );
 
-	it( 'should return false if there are no allowed paths that match query path', () => {
-		item.allow( [ 'div', 'p' ] );
+		it( 'should return false if there are no allowed paths that match query path', () => {
+			item.allow( [ 'div', 'p' ] );
 
-		expect( item._hasMatchingPath( 'allow', [ 'p' ] ) ).to.be.false;
-		expect( item._hasMatchingPath( 'allow', [ 'div' ] ) ).to.be.false;
-		expect( item._hasMatchingPath( 'allow', [ 'p', 'div' ] ) ).to.be.false;
-	} );
+			expect( item._hasMatchingPath( 'allow', [ 'div' ] ) ).to.be.false;
+			expect( item._hasMatchingPath( 'allow', [ 'p', 'div' ] ) ).to.be.false;
+			expect( item._hasMatchingPath( 'allow', [ 'div', 'p', 'span' ] ) ).to.be.false;
+		} );
 
-	it( 'should return true if there is at least one disallowed path that matches query path', () => {
-		item.allow( [ 'div', 'header' ] );
-		item.disallow( [ 'p', 'header' ] );
+		it( 'should return true if there is at least one disallowed path that matches query path', () => {
+			item.allow( [ 'div', 'header' ] );
+			item.disallow( [ 'p', 'header' ] );
 
-		expect( item._hasMatchingPath( 'disallow', [ 'html', 'div', 'p', 'header', 'span' ] ) ).to.be.true;
-	} );
+			expect( item._hasMatchingPath( 'disallow', [ 'html', 'div', 'p', 'header' ] ) ).to.be.true;
+		} );
 
-	it( 'should use only paths that are registered for given attribute', () => {
-		item.allow( [ 'div', 'p' ] );
-		item.allow( [ 'div' ], 'bold' );
-		item.allow( [ 'header' ] );
-		item.disallow( [ 'header' ], 'bold' );
+		it( 'should use only paths that are registered for given attribute', () => {
+			item.allow( [ 'div', 'p' ] );
+			item.allow( [ 'div' ], 'bold' );
+			item.allow( [ 'header' ] );
+			item.disallow( [ 'header' ], 'bold' );
 
-		expect( item._hasMatchingPath( 'allow', [ 'html', 'div', 'p' ]  ) ).to.be.true;
-		expect( item._hasMatchingPath( 'allow', [ 'html', 'div' ] ) ).to.be.false;
-		expect( item._hasMatchingPath( 'allow', [ 'html', 'div' ], 'bold' ) ).to.be.true;
+			expect( item._hasMatchingPath( 'allow', [ 'html', 'div', 'p' ]  ) ).to.be.true;
+			expect( item._hasMatchingPath( 'allow', [ 'html', 'div' ] ) ).to.be.false;
+			expect( item._hasMatchingPath( 'allow', [ 'html', 'div' ], 'bold' ) ).to.be.true;
 
-		expect( item._hasMatchingPath( 'disallow', [ 'html', 'div', 'header' ] ) ).to.be.false;
-		expect( item._hasMatchingPath( 'disallow', [ 'html', 'div', 'p', 'header', 'span' ], 'bold' ) ).to.be.true;
+			expect( item._hasMatchingPath( 'disallow', [ 'html', 'div', 'header' ] ) ).to.be.false;
+			expect( item._hasMatchingPath( 'disallow', [ 'html', 'div', 'p', 'header' ], 'bold' ) ).to.be.true;
+		} );
 	} );
-} );
 
-describe( 'toJSON', () => {
-	it( 'should create proper JSON string', () => {
-		let parsedItem = JSON.parse( JSON.stringify( item ) );
+	describe( 'toJSON', () => {
+		it( 'should create proper JSON string', () => {
+			let parsedItem = JSON.parse( JSON.stringify( item ) );
 
-		expect( parsedItem._schema ).to.equal( '[model.Schema]' );
+			expect( parsedItem._schema ).to.equal( '[model.Schema]' );
+		} );
 	} );
 } );