Răsfoiți Sursa

Merge branch 'master' into t/389

Piotrek Koszuliński 9 ani în urmă
părinte
comite
539e4e25fd
38 a modificat fișierele cu 3723 adăugiri și 3263 ștergeri
  1. 0 1
      packages/ckeditor5-engine/.jshintrc
  2. 0 1
      packages/ckeditor5-engine/dev/.jshintrc
  3. 277 0
      packages/ckeditor5-engine/src/treecontroller/model-converter-builder.js
  4. 20 11
      packages/ckeditor5-engine/src/treecontroller/model-to-view-converters.js
  5. 409 0
      packages/ckeditor5-engine/src/treecontroller/view-converter-builder.js
  6. 11 4
      packages/ckeditor5-engine/src/treecontroller/view-to-model-converters.js
  7. 9 7
      packages/ckeditor5-engine/src/treecontroller/viewconversiondispatcher.js
  8. 11 0
      packages/ckeditor5-engine/src/treemodel/batch.js
  9. 63 0
      packages/ckeditor5-engine/src/treemodel/characterproxy.js
  10. 4 9
      packages/ckeditor5-engine/src/treemodel/nodelist.js
  11. 5 0
      packages/ckeditor5-engine/src/treemodel/schema.js
  12. 18 0
      packages/ckeditor5-engine/src/treeview/documentfragment.js
  13. 13 0
      packages/ckeditor5-engine/src/treeview/matcher.js
  14. 50 1
      packages/ckeditor5-engine/src/treeview/writer.js
  15. 0 1
      packages/ckeditor5-engine/tests/.jshintrc
  16. 437 0
      packages/ckeditor5-engine/tests/_utils-tests/view.js
  17. 846 0
      packages/ckeditor5-engine/tests/_utils/view.js
  18. 2 1
      packages/ckeditor5-engine/tests/treecontroller/advanced-converters.js
  19. 309 0
      packages/ckeditor5-engine/tests/treecontroller/model-converter-builder.js
  20. 4 7
      packages/ckeditor5-engine/tests/treecontroller/model-to-view-converters.js
  21. 0 66
      packages/ckeditor5-engine/tests/treecontroller/modelconversiondispatcher.js
  22. 368 0
      packages/ckeditor5-engine/tests/treecontroller/view-converter-builder.js
  23. 30 7
      packages/ckeditor5-engine/tests/treecontroller/view-to-model-converters.js
  24. 25 8
      packages/ckeditor5-engine/tests/treemodel/batch.js
  25. 93 4
      packages/ckeditor5-engine/tests/treemodel/characterproxy.js
  26. 1 1
      packages/ckeditor5-engine/tests/treemodel/textproxy.js
  27. 29 0
      packages/ckeditor5-engine/tests/treeview/documentfragment.js
  28. 32 0
      packages/ckeditor5-engine/tests/treeview/matcher.js
  29. 0 161
      packages/ckeditor5-engine/tests/treeview/writer/_utils/utils.js
  30. 60 264
      packages/ckeditor5-engine/tests/treeview/writer/breakattributes.js
  31. 41 179
      packages/ckeditor5-engine/tests/treeview/writer/breakrange.js
  32. 99 321
      packages/ckeditor5-engine/tests/treeview/writer/insert.js
  33. 56 226
      packages/ckeditor5-engine/tests/treeview/writer/mergeattributes.js
  34. 35 226
      packages/ckeditor5-engine/tests/treeview/writer/remove.js
  35. 164 814
      packages/ckeditor5-engine/tests/treeview/writer/unwrap.js
  36. 135 663
      packages/ckeditor5-engine/tests/treeview/writer/wrap.js
  37. 59 258
      packages/ckeditor5-engine/tests/treeview/writer/wrapposition.js
  38. 8 22
      packages/ckeditor5-engine/tests/treeview/writer/writer.js

+ 0 - 1
packages/ckeditor5-engine/.jshintrc

@@ -3,7 +3,6 @@
 	"esnext": true,
 
 	"immed": true,
-	"latedef": "nofunc",
 	"loopfunc": true,
 	"noarg": true,
 	"nonbsp": true,

+ 0 - 1
packages/ckeditor5-engine/dev/.jshintrc

@@ -3,7 +3,6 @@
 	"esnext": true,
 
 	"immed": true,
-	"latedef": "nofunc",
 	"loopfunc": true,
 	"noarg": true,
 	"nonbsp": true,

+ 277 - 0
packages/ckeditor5-engine/src/treecontroller/model-converter-builder.js

@@ -0,0 +1,277 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import {
+	insertElement,
+	setAttribute,
+	removeAttribute,
+	wrap,
+	unwrap
+} from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
+
+import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+
+/**
+ * Provides chainable, high-level API to easily build basic model-to-view converters that are appended to given
+ * dispatchers. In many cases, this is the API that should be used to specify how abstract model elements and
+ * attributes should be represented in the view (and then later in DOM). Instances of this class are created by
+ * {@link engine.treeController.BuildModelConverterFor}.
+ *
+ * If you need more complex converters, see {@link engine.treeController.ModelConversionDispatcher},
+ * {@link engine.treeController.modelToView}, {@link engine.treeController.ModelConsumable}, {@link engine.treeController.Mapper}.
+ *
+ * Using this API it is possible to create three kinds of converters:
+ *
+ * 1. Model element to view element converter. This is a converter that takes the model element and represents it
+ * in the view.
+ *
+ *		BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+ *		BuildModelConverterFor( dispatcher ).fromElement( 'image' ).toElement( 'img' );
+ *
+ * 2. Model attribute to view attribute converter. This is a converter that operates on model element attributes
+ * and converts them to view element attributes. It is suitable for elements like `image` (`src`, `title` attributes).
+ *
+ *		BuildModelConverterFor( dispatcher ).fromElement( 'image' ).toElement( 'img' );
+ *		BuildModelConverterFor( dispatcher ).fromAttribute( 'src' ).toAttribute();
+ *
+ * 3. Model attribute to view element converter. This is a converter that takes model attributes and represents them
+ * as view elements. Those view elements are wrapping view elements are node that correspond to model elements and
+ * nodes which had converter attribute. It is suitable for attributes like `bold`, where `bold` attribute set on model
+ * text nodes is converter to `strong` view element.
+ *
+ *		BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' );
+ *
+ * It is possible to provide various different parameters for {@link engine.treeController.ModelConverterBuilder#toElement}
+ * and {@link engine.treeController.ModelConverterBuilder#toAttribute} methods. See their descriptions to learn more.
+ *
+ * It is also possible to {@link engine.treeController.ModelConverterBuilder#withPriority change default priority}
+ * of created converters to decide which converter should be fired earlier and which later. This is useful if you provide
+ * a general converter but want to provide different converter for a specific-case (i.e. given model element is converted
+ * always to given view element, but if it has given attribute it is converter to other view element). For this,
+ * use {@link engine.treeController.ModelConverterBuilder#withPriority withPriority} right after `from...` method.
+ *
+ * Note that `to...` methods are "terminators", which means that should be the last one used in building converter.
+ *
+ * You can use {@link engine.treeController.ViewConverterBuilder} to create "opposite" converters - from view to model.
+ *
+ * @memberOf engine.treeController
+ */
+class ModelConverterBuilder {
+	/**
+	 * Creates `ModelConverterBuilder` with given `dispatchers` registered to it.
+	 *
+	 * @param {Array.<engine.treeController.ModelConversionDispatcher>} dispatchers Dispatchers to which converters will
+	 * be attached.
+	 */
+	constructor( dispatchers ) {
+		/**
+		 * Dispatchers to which converters will be attached.
+		 *
+		 * @type {Array.<engine.treeController.ModelConversionDispatcher>}
+		 * @private
+		 */
+		this._dispatchers = dispatchers;
+
+		/**
+		 * Contains data about registered "from" query.
+		 *
+		 * @type {Object}
+		 * @private
+		 */
+		this._from = null;
+	}
+
+	/**
+	 * Registers what model element should be converted.
+	 *
+	 * @chainable
+	 * @param {String} elementName Name of element to convert.
+	 * @returns {engine.treeController.ModelConverterBuilder}
+	 */
+	fromElement( elementName ) {
+		this._from = {
+			type: 'element',
+			name: elementName,
+			priority: null
+		};
+
+		return this;
+	}
+
+	/**
+	 * Registers what model attribute should be converted.
+	 *
+	 * @chainable
+	 * @param {String} key Key of attribute to convert.
+	 * @returns {engine.treeController.ModelConverterBuilder}
+	 */
+	fromAttribute( key ) {
+		this._from = {
+			type: 'attribute',
+			key: key,
+			priority: null
+		};
+
+		return this;
+	}
+
+	/**
+	 * Changes default priority for built converter. The lower the number, the earlier converter will be fired.
+	 * Default priority is `10`.
+	 *
+	 * **Note:** Keep in mind that event priority, that is set by this modifier, is used for attribute priority
+	 * when {@link engine.treeView.Writer} is used. This changes how model attributes converter to view elements are
+	 * ordered, i.e.: `<strong><em>foo</em></strong>` vs `<em><strong>foo</strong></em>`. Using priority you can also
+	 * prevent node merging, i.e.: `<span class="bold"><span class="theme">foo</span><span>` vs `<span class="bold theme">foo</span>`.
+	 * If you want to prevent merging, just set different priority for both converters.
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).withPriority( 2 ).toElement( 'strong' );
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'italic' ).withPriority( 3 ).toElement( 'em' );
+	 *
+	 * @chainable
+	 * @param {Number} priority Converter priority.
+	 * @returns {engine.treeController.ModelConverterBuilder}
+	 */
+	withPriority( priority ) {
+		this._from.priority = priority;
+
+		return this;
+	}
+
+	/**
+	 * Registers what view element will be created by converter.
+	 *
+	 * Method accepts various ways of providing how the view element will be created. You can pass view element name as
+	 * `string`, view element instance which will be cloned and used, or creator function which returns view element that
+	 * will be used. Keep in mind that when you view element instance or creator function, it has to be/return a
+	 * proper type of view element: {@link engine.treeView.ViewContainerElement ViewContainerElement} if you convert
+	 * from element or {@link engine.treeView.ViewAttributeElement ViewAttributeElement} if you convert from attribute.
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromElement( 'image' ).toElement( new ViewContainerElement( 'img' ) );
+	 *
+	 *		BuildModelConverterFor( dispatcher )
+	 *			.fromElement( 'header' )
+	 *			.toElement( ( data ) => new ViewContainerElement( 'h' + data.item.getAttribute( 'level' ) ) );
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).toElement( new ViewAttributeElement( 'strong' ) );
+	 *
+	 * Creator function will be passed different values depending whether conversion is from element or from attribute:
+	 *
+	 * * from element: dispatcher's {@link engine.treeController.ModelConversionDispatcher#event:insert insert event} parameters
+	 * will be passed
+	 * * from attribute: first parameter is attribute value, then the rest of parameters are dispatcher's
+	 * {@link engine.treeController.ModelConversionDispatcher#event:changeAttribute changeAttribute event} parameters.
+	 *
+	 * This method creates the converter and adds it as a callback to a proper
+	 * {@link engine.treeController.ModelConversionDispatcher conversion dispatcher} event.
+	 *
+	 * @param {String|engine.treeView.ViewElement|Function} element Element created by converter.
+	 */
+	toElement( element ) {
+		const priority = this._from.priority === null ? 10 : this._from.priority;
+
+		for ( let dispatcher of this._dispatchers ) {
+			if ( this._from.type == 'element' ) {
+				// From model element to view element -> insert element.
+				element = typeof element == 'string' ? new ViewContainerElement( element ) : element;
+
+				dispatcher.on( 'insert:' + this._from.name, insertElement( element ), null, priority );
+			} else {
+				// From model attribute to view element -> wrap and unwrap.
+				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
+
+				dispatcher.on( 'addAttribute:' + this._from.key, wrap( element ), null, priority );
+				dispatcher.on( 'changeAttribute:' + this._from.key, wrap( element ), null, priority );
+				dispatcher.on( 'removeAttribute:' + this._from.key, unwrap( element ), null, priority );
+			}
+		}
+	}
+
+	/**
+	 * Registers what view attribute will be created by converter. Keep in mind, that only model attribute to
+	 * view attribute conversion is supported.
+	 *
+	 * Method accepts various ways of providing how the view attribute will be created:
+	 *
+	 * * for no passed parameter, attribute key and value will be converted 1-to-1 to view attribute,
+	 * * if you pass one `string`, it will be used as new attribute key while attribute value will be copied,
+	 * * if you pass two `string`s, first one will be used as new attribute key and second one as new attribute value,
+	 * * if you pass a function, it is expected to return an object with `key` and `value` properties representing attribute key and value.
+	 * This function will be passed model attribute value and model attribute key as first two parameters and then
+	 * all dispatcher's {engine.treeController.ModelConversionDispatcher#event:changeAttribute changeAttribute event} parameters.
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'class' ).toAttribute( '' );
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'linkTitle' ).toAttribute( 'title' );
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'highlighted' ).toAttribute( 'style', 'background:yellow' );
+	 *
+	 *		BuildModelConverterFor( dispatcher )
+	 *			.fromAttribute( 'theme' )
+	 *			.toAttribute( ( value ) => ( { key: 'class', value: value + '-theme' } ) );
+	 *
+	 * This method creates the converter and adds it as a callback to a proper
+	 * {@link engine.treeController.ModelConversionDispatcher conversion dispatcher} event.
+	 *
+	 * @param {String|Function} [keyOrCreator] Attribute key or a creator function.
+	 * @param {*} [value] Attribute value.
+	 */
+	toAttribute( keyOrCreator, value ) {
+		if ( this._from.type == 'element' ) {
+			// Converting from model element to view attribute is unsupported.
+			return;
+		}
+
+		let attributeCreator;
+
+		if ( !keyOrCreator ) {
+			// If `keyOrCreator` is not set, we assume default behavior which is 1:1 attribute re-write.
+			// This is also a default behavior for `setAttribute` converter when no attribute creator is passed.
+			attributeCreator = undefined;
+		} else if ( typeof keyOrCreator == 'string' ) {
+			// `keyOrCreator` is an attribute key.
+
+			if ( value ) {
+				// If value is set, create "dumb" creator that always returns the same object.
+				attributeCreator = function() {
+					return { key: keyOrCreator, value: value };
+				};
+			} else {
+				// If value is not set, take it from the passed parameter.
+				attributeCreator = function( value ) {
+					return { key: keyOrCreator, value: value };
+				};
+			}
+		} else {
+			// `keyOrCreator` is an attribute creator function.
+			attributeCreator = keyOrCreator;
+		}
+
+		for ( let dispatcher of this._dispatchers ) {
+			dispatcher.on( 'addAttribute:' + this._from.key, setAttribute( attributeCreator ), null, this._from.priority || 10 );
+			dispatcher.on( 'changeAttribute:' + this._from.key, setAttribute( attributeCreator ), null, this._from.priority || 10 );
+			dispatcher.on( 'removeAttribute:' + this._from.key, removeAttribute( attributeCreator ), null, this._from.priority || 10 );
+		}
+	}
+}
+
+/**
+ * Entry point for model-to-view converters builder. This chainable API makes it easy to create basic, most common
+ * model-to-view converters and attach them to provided dispatchers. The method returns an instance of
+ * {@link engine.treeController.ModelConverterBuilder}.
+ *
+ * @external engine.treeController.BuildModelConverterFor
+ * @memberOf engine.treeController
+ * @param {...engine.treeController.ModelConversionDispatcher} dispatchers One or more dispatchers which
+ * the built converter will be attached to.
+ */
+export default function BuildModelConverterFor( ...dispatchers ) {
+	return new ModelConverterBuilder( dispatchers );
+}

+ 20 - 11
packages/ckeditor5-engine/src/treecontroller/model-to-view-converters.js

@@ -22,7 +22,7 @@ import ViewText from '../treeview/text.js';
  * Function factory, creates a converter that converts node insertion changes from the model to the view.
  * The view element that will be added to the view depends on passed parameter. If {@link engine.treeView.Element} was passed,
  * it will be cloned and the copy will be inserted. If `Function` is provided, it is passed all the parameters of the
- * {@link engine.treeController.ModelConversionDispatcher.insert dispatcher's insert event}. It's expected that the
+ * dispatcher's {@link engine.treeController.ModelConversionDispatcher#event:insert insert event}. It's expected that the
  * function returns a {@link engine.treeView.Element}. The result of the function will be inserted to the view.
  *
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
@@ -122,10 +122,10 @@ export function insertText() {
  * @returns {Function} Set/change attribute converter.
  */
 export function setAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( data ) => ( { key: data.attributeKey, value: data.attributeNewValue } ) );
+	attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
 
 	return ( evt, data, consumable, conversionApi ) => {
-		const { key, value } = attributeCreator( data, consumable, conversionApi );
+		const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
 
 		consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
 		conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
@@ -164,15 +164,15 @@ export function setAttribute( attributeCreator ) {
  * @function engine.treeController.modelToView.removeAttribute
  * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
  * represents attribute key and attribute value to be removed from {@link engine.treeView.Element view element}. The function
- * is passed all the parameters of the {@link engine.treeController.ModelConversionDispatcher.addAttribute}
- * or {@link engine.treeController.ModelConversionDispatcher.changeAttribute} event.
+ * is passed all the parameters of the {@link engine.treeController.ModelConversionDispatcher#event:addAttribute addAttribute event}
+ * or {@link engine.treeController.ModelConversionDispatcher#event:changeAttribute changeAttribute event}.
  * @returns {Function} Remove attribute converter.
  */
 export function removeAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( data ) => ( { key: data.attributeKey } ) );
+	attributeCreator = attributeCreator || ( ( value, key ) => ( { key } ) );
 
 	return ( evt, data, consumable, conversionApi ) => {
-		const { key } = attributeCreator( data, consumable, conversionApi );
+		const { key } = attributeCreator( data.attributeOldValue, data.attributeKey, data, consumable, conversionApi );
 
 		consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
 		conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
@@ -194,7 +194,7 @@ export function removeAttribute( attributeCreator ) {
  *
  * The wrapping node depends on passed parameter. If {@link engine.treeView.Element} was passed, it will be cloned and
  * the copy will become the wrapping element. If `Function` is provided, it is passed all the parameters of the
- * {@link engine.treeController.ModelConversionDispatcher.setAttribute event}. It's expected that the
+ * {@link engine.treeController.ModelConversionDispatcher#event:setAttribute setAttribute event}. It's expected that the
  * function returns a {@link engine.treeView.Element}. The result of the function will be the wrapping element.
  *
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
@@ -218,6 +218,14 @@ export function wrap( elementCreator ) {
 			elementCreator.clone( true ) :
 			elementCreator( data.attributeNewValue, data, consumable, conversionApi );
 
+		// If this is a change event (because old value is not empty) and the creator is a function (so
+		// it may create different view elements basing on attribute value) we have to create
+		// view element basing on old value and unwrap it before wrapping with a newly created view element.
+		if ( data.attributeOldValue !== null && !( elementCreator instanceof ViewElement ) ) {
+			const oldViewElement = elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+			conversionApi.writer.unwrap( viewRange, oldViewElement, evt.priority );
+		}
+
 		conversionApi.writer.wrap( viewRange, viewElement, evt.priority );
 
 		evt.stop();
@@ -231,9 +239,10 @@ export function wrap( elementCreator ) {
  *
  * The view element type that will be unwrapped depends on passed parameter.
  * If {@link engine.treeView.Element} was passed, it will be used to look for similar element in the view for unwrapping. If `Function`
- * is provided, it is passed all the parameters of the {@link engine.treeController.ModelConversionDispatcher.setAttribute event}.
- * It's expected that the function returns a {@link engine.treeView.Element}. The result of the function will be used to
- * look for similar element in the view for unwrapping.
+ * is provided, it is passed all the parameters of the
+ * {@link engine.treeController.ModelConversionDispatcher#event:setAttribute setAttribute event}. It's expected that the
+ * function returns a {@link engine.treeView.Element}. The result of the function will be used to look for similar element
+ * in the view for unwrapping.
  *
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
  * {@link engine.treeController.ModelConversionDispatcher}) and bind model and view elements.

+ 409 - 0
packages/ckeditor5-engine/src/treecontroller/view-converter-builder.js

@@ -0,0 +1,409 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Matcher from '../treeview/matcher.js';
+import ModelElement from '../treemodel/element.js';
+import utils from '../../utils/utils';
+
+/**
+ * Provides chainable, high-level API to easily build basic view-to-model converters that are appended to given
+ * dispatchers. View-to-model converters are used when external data is added to the editor, i.e. when a user pastes
+ * HTML content to the editor. Then, converters are used to translate this structure, possibly removing unknown/incorrect
+ * nodes, and add it to the model. Also multiple, different elements might be translated into the same thing in the
+ * model, i.e. `<b>` and `<strong>` elements might be converted to `bold` attribute (even though `bold` attribute will
+ * be then converted only to `<strong>` tag). Instances of this class are created by {@link engine.treeController.BuildViewConverterFor}.
+ *
+ * If you need more complex converters, see {@link engine.treeController.ViewConversionDispatcher},
+ * {@link engine.treeController.viewToModel}, {@link engine.treeController.ViewConsumable}.
+ *
+ * Using this API it is possible to create various kind of converters:
+ *
+ * 1. View element to model element:
+ *
+ *		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+ *
+ * 2. View element to model attribute:
+ *
+ *		BuildViewConverterFor( dispatcher ).fromElement( 'b' ).fromElement( 'strong' ).toAttributes( { bold: true } );
+ *
+ * 3. View attribute to model attribute:
+ *
+ *		BuildViewConverterFor( dispatcher ).fromAttribute( 'style', { 'font-weight': 'bold' } ).toAttributes( { bold: true } );
+ *		BuildViewConverterFor( dispatcher )
+ *			.fromAttribute( 'class' )
+ *			.toAttributes( ( viewElement ) => ( { class: viewElement.getAttribute( 'class' ) } ) );
+ *
+ * 4. View elements and attributes to model attribute:
+ *
+ *		BuildViewConverterFor( dispatcher )
+ *			.fromElement( 'b' ).fromElement( 'strong' ).fromAttribute( 'style', { 'font-weight': 'bold' } )
+ *			.toAttributes( { bold: true } );
+ *
+ * 5. View {@link engine.treeView.Matcher view element matcher instance} or {@link engine.treeView.Matcher#add matcher pattern}
+ * to model element or attribute:
+ *
+ *		const matcher = new ViewMatcher();
+ *		matcher.add( 'div', { class: 'quote' } );
+ *		BuildViewConverterFor( dispatcher ).from( matcher ).toElement( 'quote' );
+ *
+ *		BuildViewConverterFor( dispatcher ).from( { name: 'span', class: 'bold' } ).toAttributes( { bold: true } );
+ *
+ * Note, that converters built using `ViewConverterBuilder` automatically check {@link engine.treeModel.Schema schema}
+ * if created model structure is valid. If given conversion would be invalid according to schema, it is ignored.
+ *
+ * It is possible to provide creator functions as parameters for {@link engine.treeController.ViewConverterBuilder#toElement}
+ * and {@link engine.treeController.ViewConverterBuilder#toAttributes} methods. See their descriptions to learn more.
+ *
+ * By default, converter will {@link engine.treeController.ViewConsumable#consume consume} every value specified in
+ * given `from...` query, i.e. `.from( { name: 'span', class: 'bold' } )` will make converter consume both `span` name
+ * and `bold` class. It is possible to change this behavior using {@link engine.treeController.ViewConverterBuilder#consuming consuming}
+ * modifier. The modifier alters the last `fromXXX` query used before it. To learn more about consuming values,
+ * see {@link engine.treeController.ViewConsumable}.
+ *
+ * It is also possible to {@link engine.treeController.ViewConverterBuilder#withPriority change default priority}
+ * of created converters to decide which converter should be fired earlier and which later. This is useful if you provide
+ * a general converter but want to provide different converter for a specific-case (i.e. given view element is converted
+ * always to given model element, but if it has given class it is converter to other model element). For this,
+ * use {@link engine.treeController.ViewConverterBuilder#withPriority withPriority} modifier. The modifier alters
+ * the last `from...` query used before it.
+ *
+ * Note that `to...` methods are "terminators", which means that should be the last one used in building converter.
+ *
+ * You can use {@link engine.treeController.ModelConverterBuilder} to create "opposite" converters - from model to view.
+ *
+ * @memberOf engine.treeController
+ */
+class ViewConverterBuilder {
+	/**
+	 * Creates `ViewConverterBuilder` with given `dispatchers` registered to it.
+	 *
+	 * @param {Array.<engine.treeController.ViewConversionDispatcher>} dispatchers Dispatchers to which converters will
+	 * be attached.
+	 */
+	constructor( dispatchers ) {
+		/**
+		 * Dispatchers to which converters will be attached.
+		 *
+		 * @type {Array.<engine.treeController.ViewConversionDispatcher>}
+		 * @private
+		 */
+		this._dispatchers = dispatchers;
+
+		/**
+		 * Stores "from" queries.
+		 *
+		 * @type {Array}
+		 * @private
+		 */
+		this._from = [];
+	}
+
+	/**
+	 * Registers what view element should be converted.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+	 *
+	 * @chainable
+	 * @param {String} elementName View element name.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	fromElement( elementName ) {
+		return this.from( { name: elementName } );
+	}
+
+	/**
+	 * Registers what view attribute should be converted.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromAttribute( 'style', { 'font-weight': 'bold' } ).toAttributes( { bold: true } );
+	 *
+	 * @chainable
+	 * @param {String|RegExp} key View attribute key.
+	 * @param {String|RegExp} [value] View attribute value.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	fromAttribute( key, value = /.*/ ) {
+		let pattern = {};
+		pattern[ key ] = value;
+
+		return this.from( pattern );
+	}
+
+	/**
+	 * Registers what view pattern should be converted. The method accepts either {@link engine.treeView.Matcher view matcher}
+	 * or view matcher pattern.
+	 *
+	 *		const matcher = new ViewMatcher();
+	 *		matcher.add( 'div', { class: 'quote' } );
+	 *		BuildViewConverterFor( dispatcher ).from( matcher ).toElement( 'quote' );
+	 *
+	 *		BuildViewConverterFor( dispatcher ).from( { name: 'span', class: 'bold' } ).toAttributes( { bold: true } );
+	 *
+	 * @chainable
+	 * @param {Object|engine.treeView.Matcher} matcher View matcher or view matcher pattern.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	from( matcher ) {
+		if ( !( matcher instanceof Matcher ) ) {
+			matcher = new Matcher( matcher );
+		}
+
+		this._from.push( {
+			matcher: matcher,
+			consume: false,
+			priority: null
+		} );
+
+		return this;
+	}
+
+	/**
+	 * Modifies which consumable values will be {@link engine.treeController.ViewConsumable#consume consumed} by built converter.
+	 * It modifies the last `from...` query. Can be used after each `from...` query in given chain. Useful for providing
+	 * more specific matches.
+	 *
+	 *		// This converter will only handle class bold conversion (to proper attribute) but span element
+	 *		// conversion will have to be done in separate converter.
+	 *		// Without consuming modifier, the converter would consume both class and name, so a converter for
+	 *		// span element would not be fired.
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.from( { name: 'span', class: 'bold' } ).consuming( { class: 'bold' } )
+	 *			.toAttribute( { bold: true } );
+	 *
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.fromElement( 'img' ).consuming( { name: true, attributes: [ 'src', 'title' ] } )
+	 *			.toElement( ( viewElement ) => new ModelElement( 'image', { src: viewElement.getAttribute( 'src' ),
+	 *																		title: viewElement.getAttribute( 'title' ) } );
+	 *
+	 * **Note:** All and only values from passed object has to be consumable on converted view element. This means that
+	 * using `consuming` method, you can either make looser conversion conditions (like in first example) or tighter
+	 * conversion conditions (like in second example). So, the view element, to be converter, has to match query of
+	 * `from...` method and then have to have enough consumable values to consume.
+	 *
+	 * @see engine.treeController.ViewConsumable
+	 * @chainable
+	 * @param {Object} consume Values to consume.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	consuming( consume ) {
+		let lastFrom = this._from[ this._from.length - 1 ];
+		lastFrom.consume = consume;
+
+		return this;
+	}
+
+	/**
+	 * Changes default priority for built converter. It modifies the last `from...` query. Can be used after each
+	 * `from...` query in given chain. Useful for overwriting converters. The lower the number, the earlier converter will be fired.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+	 *		// Register converter with proper priority, otherwise "p" element would get consumed by first
+	 *		// converter and the second converter would not be fired.
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.from( { name: 'p', class: 'custom' } ).withPriority( 9 )
+	 *			.toElement( 'customParagraph' );
+	 *
+	 * **Note:** `ViewConverterBuilder` takes care so all `toElement` conversions takes place before all `toAttributes`
+	 * conversions. This is done by setting default `toElement` priority to `10` and `toAttributes` priority to `1000`.
+	 * It is recommended to set converter priority for `toElement` conversions below `500` and `toAttributes` priority
+	 * above `500`. It is important that model elements are created before attributes, otherwise attributes would
+	 * not be applied or other errors may occur.
+	 *
+	 * @chainable
+	 * @param {Number} priority Converter priority.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	withPriority( priority ) {
+		let lastFrom = this._from[ this._from.length - 1 ];
+		lastFrom.priority = priority;
+
+		return this;
+	}
+
+	/**
+	 * Registers what model element will be created by converter.
+	 *
+	 * Method accepts two ways of providing what kind of model element will be created. You can pass model element
+	 * name as a `string` or a function that will return model element instance. If you provide creator function,
+	 * it will be passed converted view element as first and only parameter.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.fromElement( 'img' )
+	 *			.toElement( ( viewElement ) => new ModelElement( 'image', { src: viewElement.getAttribute( 'src' ) } );
+	 *
+	 * @param {String|Function} element Model element name or model element creator function.
+	 */
+	toElement( element ) {
+		const eventCallbackGen = function( from ) {
+			return ( evt, data, consumable, conversionApi ) => {
+				// There is one callback for all patterns in the matcher.
+				// This will be usually just one pattern but we support matchers with many patterns too.
+				let matchAll = from.matcher.matchAll( data.input );
+
+				// If there is no match, this callback should not do anything.
+				if ( !matchAll ) {
+					return;
+				}
+
+				// Now, for every match between matcher and actual element, we will try to consume the match.
+				for ( let match of matchAll ) {
+					// Create model element basing on creator function or element name.
+					const modelElement = element instanceof Function ? element( data.input ) : new ModelElement( element );
+
+					// Check whether generated structure is okay with `Schema`.
+					// TODO: Make it more sane after .getAttributeKeys() is available for ModelElement.
+					const keys = Array.from( modelElement.getAttributes() ).map( ( attribute ) => attribute[ 0 ] );
+
+					if ( !conversionApi.schema.check( { name: modelElement.name, attributes: keys, inside: data.context } ) ) {
+						continue;
+					}
+
+					// Try to consume appropriate values from consumable values list.
+					if ( !consumable.consume( data.input, from.consume || match.match ) ) {
+						continue;
+					}
+
+					// If everything is fine, we are ready to start the conversion.
+					// Add newly created `modelElement` to the parents stack.
+					data.context.push( modelElement );
+
+					// Convert children of converted view element and append them to `modelElement`.
+					modelElement.appendChildren( conversionApi.convertChildren( data.input, consumable, data ) );
+
+					// Remove created `modelElement` from the parents stack.
+					data.context.pop();
+
+					// Add `modelElement` as a result.
+					data.output = modelElement;
+
+					// Prevent multiple conversion if there are other correct matches.
+					break;
+				}
+			};
+		};
+
+		this._setCallback( eventCallbackGen, 10 );
+	}
+
+	/**
+	 * Registers what model attribute will be created by converter.
+	 *
+	 * Method accepts two ways of providing what kind of model attribute will be created. You can either pass two strings
+	 * representing attribute key and attribute value or a function that returns an object with `key` and `value` properties.
+	 * If you provide creator function, it will be passed converted view element as first and only parameter.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromAttribute( 'style', { 'font-weight': 'bold' } ).toAttribute( 'bold', true );
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.fromAttribute( 'class' )
+	 *			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+	 *
+	 * @param {String|Function} keyOrCreator Attribute key or a creator function.
+	 * @param {String} [value] Attribute value. Required if `keyOrCreator` is a `string`. Ignored otherwise.
+	 */
+	toAttribute( keyOrCreator, value ) {
+		const eventCallbackGen = function( from ) {
+			return ( evt, data, consumable, conversionApi ) => {
+				// There is one callback for all patterns in the matcher.
+				// This will be usually just one pattern but we support matchers with many patterns too.
+				let matchAll = from.matcher.matchAll( data.input );
+
+				// If there is no match, this callback should not do anything.
+				if ( !matchAll ) {
+					return;
+				}
+
+				// Now, for every match between matcher and actual element, we will try to consume the match.
+				for ( let match of matchAll ) {
+					// Try to consume appropriate values from consumable values list.
+					if ( !consumable.consume( data.input, from.consume || match.match ) ) {
+						continue;
+					}
+
+					// Since we are converting to attribute we need an output on which we will set the attribute.
+					// If the output is not created yet, we will create it.
+					if ( !data.output ) {
+						data.output = conversionApi.convertChildren( data.input, consumable, data );
+					}
+
+					// Use attribute creator function, if provided.
+					let attribute = keyOrCreator instanceof Function ? keyOrCreator( data.input ) : { key: keyOrCreator, value: value };
+
+					// Set attribute on current `output`. `Schema` is checked inside this helper function.
+					setAttributeOn( data.output, attribute, data, conversionApi );
+
+					// Prevent multiple conversion if there are other correct matches.
+					break;
+				}
+			};
+		};
+
+		this._setCallback( eventCallbackGen, 1000 );
+	}
+
+	/**
+	 * Helper function that uses given callback generator to created callback function and sets it on registered dispatchers.
+	 *
+	 * @param eventCallbackGen
+	 * @param defaultPriority
+	 * @private
+	 */
+	_setCallback( eventCallbackGen, defaultPriority ) {
+		// We will add separate event callback for each registered `from` entry.
+		for ( let from of this._from ) {
+			// We have to figure out event name basing on matcher's patterns.
+			// If there is exactly one pattern and it has `name` property we will used that name.
+			const matcherElementName = from.matcher.getElementName();
+			const eventName = matcherElementName ? 'element:' + matcherElementName : 'element';
+			const eventCallback = eventCallbackGen( from );
+
+			const priority = from.priority === null ? defaultPriority : from.priority;
+
+			// Add event to each registered dispatcher.
+			for ( let dispatcher of this._dispatchers ) {
+				dispatcher.on( eventName, eventCallback, null, priority );
+			}
+		}
+	}
+}
+
+// Helper function that sets given attributes on given `engine.treeModel.Item` or `engine.treeModel.DocumentFragment`.
+function setAttributeOn( toChange, attribute, data, conversionApi ) {
+	if ( utils.isIterable( toChange ) ) {
+		for ( let node of toChange ) {
+			setAttributeOn( node, attribute, data, conversionApi );
+		}
+
+		return;
+	}
+
+	// TODO: Make it more sane after .getAttributeKeys() is available for ModelElement.
+	const keys = Array.from( toChange.getAttributes() ).map( ( attribute ) => attribute[ 0 ] ).concat( attribute.key );
+
+	const schemaQuery = {
+		name: toChange.name || '$text',
+		attributes: keys,
+		inside: data.context
+	};
+
+	if ( conversionApi.schema.check( schemaQuery ) ) {
+		toChange.setAttribute( attribute.key, attribute.value );
+	}
+}
+
+/**
+ * Entry point for view-to-model converters builder. This chainable API makes it easy to create basic, most common
+ * view-to-model converters and attach them to provided dispatchers. The method returns an instance of
+ * {@link engine.treeController.ViewConverterBuilder}.
+ *
+ * @external engine.treeController.BuildViewConverterFor
+ * @memberOf engine.treeController
+ * @param {...engine.treeController.ViewConversionDispatcher} dispatchers One or more dispatchers to which
+ * the built converter will be attached.
+ */
+export default function BuildViewConverterFor( ...dispatchers ) {
+	return new ViewConverterBuilder( dispatchers );
+}

+ 11 - 4
packages/ckeditor5-engine/src/treecontroller/view-to-model-converters.js

@@ -35,7 +35,7 @@ export function convertToModelFragment() {
 	return ( evt, data, consumable, conversionApi ) => {
 		// Second argument in `consumable.test` is discarded for ViewDocumentFragment but is needed for ViewElement.
 		if ( !data.output && consumable.test( data.input, { name: true } ) ) {
-			const convertedChildren = conversionApi.convertChildren( data.input, consumable, { context: data.context } );
+			const convertedChildren = conversionApi.convertChildren( data.input, consumable, data );
 
 			data.output = new ModelDocumentFragment( convertedChildren );
 		}
@@ -50,9 +50,16 @@ export function convertToModelFragment() {
  * @returns {Function} {@link engine.treeView.Text View text} converter.
  */
 export function convertText() {
-	return ( evt, data, consumable ) => {
-		if ( consumable.consume( data.input ) ) {
-			data.output = new ModelText( data.input.data );
+	return ( evt, data, consumable, conversionApi ) => {
+		const schemaQuery = {
+			name: '$text',
+			inside: data.context
+		};
+
+		if ( conversionApi.schema.check( schemaQuery ) ) {
+			if ( consumable.consume( data.input ) ) {
+				data.output = new ModelText( data.input.data );
+			}
 		}
 	};
 }

+ 9 - 7
packages/ckeditor5-engine/src/treecontroller/viewconversiondispatcher.js

@@ -47,11 +47,12 @@ import extend from '../../utils/lib/lodash/extend.js';
  *				inside: data.context
  *			};
  *
- *			if ( conversionApi.schema.checkQuery( schemaQuery ) ) {
+ *			if ( conversionApi.schema.check( schemaQuery ) ) {
  *				if ( !consumable.consume( data.input, { name: true } ) ) {
  *					// Before converting this paragraph's children we have to update their context by this paragraph.
- *					const context = data.context.concat( paragraph );
- *					const children = conversionApi.convertChildren( data.input, consumable, { context } );
+ *					data.context.push( paragraph );
+ *					const children = conversionApi.convertChildren( data.input, consumable, data );
+ *					data.context.pop();
  *					paragraph.appendChildren( children );
  *					data.output = paragraph;
  *				}
@@ -63,7 +64,7 @@ import extend from '../../utils/lib/lodash/extend.js';
  *			if ( consumable.consume( data.input, { name: true, attributes: [ 'href' ] } ) ) {
  *				// <a> element is inline and is represented by an attribute in the model.
  *				// This is why we are not updating `context` property.
- *				data.output = conversionApi.convertChildren( data.input, consumable, { context: data.context } );
+ *				data.output = conversionApi.convertChildren( data.input, consumable, data );
  *
  *				for ( let item of Range.createFrom( data.output ) ) {
  *					const schemaQuery = {
@@ -80,8 +81,9 @@ import extend from '../../utils/lib/lodash/extend.js';
  *		} );
  *
  *		// Fire conversion.
- *		// At the beginning, the context is empty because given `viewDocumentFragment` has no parent.
- *		viewDispatcher.convert( viewDocumentFragment, { context: [] } );
+ *		// Always take care where the converted model structure will be appended to. If this `viewDocumentFragment`
+ *		// is going to be appended directly to a '$root' element, use that in `context`.
+ *		viewDispatcher.convert( viewDocumentFragment, { context: [ '$root' ] } );
  *
  * Before each conversion process, `ViewConversionDispatcher` fires {@link engine.treeController.ViewConversionDispatcher.viewCleanup}
  * event which can be used to prepare tree view for conversion.
@@ -140,7 +142,7 @@ export default class ViewConversionDispatcher {
 	 * @see engine.treeController.ViewConversionApi#convertItem
 	 */
 	_convertItem( input, consumable, additionalData = {} ) {
-		const data = extend( additionalData, {
+		const data = extend( {}, additionalData, {
 			input: input,
 			output: null
 		} );

+ 11 - 0
packages/ckeditor5-engine/src/treemodel/batch.js

@@ -67,6 +67,17 @@ export default class Batch {
 
 		return delta;
 	}
+
+	/**
+	 * Gets an iterable collection of operations.
+	 *
+	 * @returns {Iterable.<engine.treeModel.operation.Operation}
+	 */
+	*getOperations() {
+		for ( let delta of this.deltas ) {
+			yield* delta.operations;
+		}
+	}
 }
 
 /**

+ 63 - 0
packages/ckeditor5-engine/src/treemodel/characterproxy.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import Node from './node.js';
+import utils from '../../utils/utils.js';
 
 /**
  * A proxy object representing one character stored in the tree data model. It looks and behaves like
@@ -68,4 +69,66 @@ export default class CharacterProxy extends Node {
 		 */
 		this._index = index;
 	}
+
+	/**
+	 * Sets attribute on the text fragment. If attribute with the same key already is set, it overwrites its values.
+	 *
+	 * **Note:** Changing attributes of text fragment affects document state. This TextProxy instance properties
+	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextProxy instances.
+	 *
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		let index = this.getIndex();
+
+		this.parent._children.setAttribute( index, 1, key, value );
+		this._attrs.set( key, value );
+	}
+
+	/**
+	 * Removes all attributes from the character proxy and sets given attributes.
+	 *
+	 * **Note:** Changing attributes of character proxy affects document state. This `CharacterProxy` instance properties
+	 * will be refreshed, but other instances of `CharacterProxy` and `TextProxy` may get invalidated.
+	 * It is highly unrecommended to store references to `CharacterProxy` instances.
+	 *
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set. See
+	 * {@link engine.treeModel.CharacterProxy#getAttributes}.
+	 */
+	setAttributesTo( attrs ) {
+		let attrsMap = utils.toMap( attrs );
+
+		this.clearAttributes();
+
+		for ( let attr of attrsMap ) {
+			this.setAttribute( attr[ 0 ], attr[ 1 ] );
+		}
+	}
+
+	/**
+	 * Removes an attribute with given key from the character proxy.
+	 *
+	 * **Note:** Changing attributes of character proxy affects document state. This `CharacterProxy` instance properties
+	 * will be refreshed, but other instances of `CharacterProxy` and `TextProxy` may get invalidated.
+	 * It is highly unrecommended to store references to `CharacterProxy` instances.
+	 *
+	 * @param {String} key Key of attribute to remove.
+	 */
+	removeAttribute( key ) {
+		this.setAttribute( key, null );
+	}
+
+	/**
+	 * Removes all attributes from the character proxy.
+	 *
+	 * **Note:** Changing attributes of character proxy affects document state. This `CharacterProxy` instance properties
+	 * will be refreshed, but other instances of `CharacterProxy` and `TextProxy` may get invalidated.
+	 * It is highly unrecommended to store references to `CharacterProxy` instances.
+	 */
+	clearAttributes() {
+		for ( let attr of this.getAttributes() ) {
+			this.removeAttribute( attr[ 0 ] );
+		}
+	}
 }

+ 4 - 9
packages/ckeditor5-engine/src/treemodel/nodelist.js

@@ -189,15 +189,10 @@ export default class NodeList {
 	/**
 	 * Node list iterator.
 	 */
-	[ Symbol.iterator ]() {
-		let i = 0;
-
-		return {
-			next: () => ( {
-				done: i == this.length,
-				value: this.get( i++ )
-			} )
-		};
+	*[ Symbol.iterator ]() {
+		for ( let i = 0; i < this.length; i++ ) {
+			yield this.get( i );
+		}
 	}
 
 	/**

+ 5 - 0
packages/ckeditor5-engine/src/treemodel/schema.js

@@ -361,6 +361,11 @@ export default class Schema {
 		// If attributes property is a string or undefined, wrap it in an array for easier processing.
 		if ( !isArray( query.attributes ) ) {
 			query.attributes = [ query.attributes ];
+		} else if ( query.attributes.length === 0 ) {
+			// To simplify algorithms, when a SchemaItem path is added "without" attribute, it is added with
+			// attribute equal to undefined. This means that algorithms can work the same way for specified attributes
+			// and no-atrtibutes, but we have to fill empty array with "fake" undefined value for algorithms reasons.
+			query.attributes.push( undefined );
 		}
 
 		// Normalize the path to an array of strings.

+ 18 - 0
packages/ckeditor5-engine/src/treeview/documentfragment.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import utils from '../../utils/utils.js';
+import EmitterMixin from '../../utils/emittermixin.js';
 
 /**
  * DocumentFragment class.
@@ -98,6 +99,7 @@ export default class DocumentFragment {
 	 * @returns {Number} Number of inserted nodes.
 	 */
 	insertChildren( index, nodes ) {
+		this._fireChange( 'CHILDREN', this );
 		let count = 0;
 
 		if ( !utils.isIterable( nodes ) ) {
@@ -123,10 +125,26 @@ export default class DocumentFragment {
 	 * @returns {Array.<engine.treeView.Node>} The array of removed nodes.
 	 */
 	removeChildren( index, howMany = 1 ) {
+		this._fireChange( 'CHILDREN', this );
+
 		for ( let i = index; i < index + howMany; i++ ) {
 			this._children[ i ].parent = null;
 		}
 
 		return this._children.splice( index, howMany );
 	}
+
+	/**
+	 * Fires `change` event with given type of the change.
+	 *
+	 * @private
+	 * @param {engine.treeView.ChangeType} type Type of the change.
+	 * @param {engine.treeView.Node} node Changed node.
+	 * @fires engine.treeView.Node#change
+	 */
+	_fireChange( type, node ) {
+		this.fire( 'change', type, node );
+	}
 }
+
+utils.mix( DocumentFragment, EmitterMixin );

+ 13 - 0
packages/ckeditor5-engine/src/treeview/matcher.js

@@ -195,6 +195,19 @@ export default class Matcher {
 
 		return results.length > 0 ? results : null;
 	}
+
+	/**
+	 * Returns the name of the element to match if there is exactly one pattern added to the matcher instance
+	 * and it matches element name defined by `string` (not `RegExp`). Otherwise, returns `null`.
+	 *
+	 * @returns {String|null} Element name trying to match.
+	 */
+	getElementName() {
+		return this._patterns.length == 1 && this._patterns[ 0 ].name && !( this._patterns[ 0 ].name instanceof RegExp ) ?
+			this._patterns[ 0 ].name :
+			null;
+	}
+
 }
 
 // Returns match information if {@link engine.treeView.Element element} is matching provided pattern.

+ 50 - 1
packages/ckeditor5-engine/src/treeview/writer.js

@@ -12,6 +12,7 @@ import Text from './text.js';
 import Range from './range.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 import DocumentFragment from './documentfragment.js';
+import utils from '../../utils/utils.js';
 
 /**
  * Tree View Writer class.
@@ -219,11 +220,23 @@ import DocumentFragment from './documentfragment.js';
 	 * Insert node or nodes at specified position. Takes care about breaking attributes before insertion
 	 * and merging them afterwards.
 	 *
+	 * Throws {@link utils.CKEditorError CKEditorError} `treeview-writer-insert-invalid-node` when nodes to insert
+	 * contains instances that are not {@link engine.treeView.Text Texts},
+	 * {@link engine.treeView.AttributeElement AttributeElements} or
+	 * {@link engine.treeView.ContainerElement ContainerElements}.
+	 *
 	 * @param {engine.treeView.Position} position Insertion position.
-	 * @param {engine.treeView.Node|Iterable.<engine.treeView.Node>} nodes Node or nodes to insert.
+	 * @param {engine.treeView.Text|engine.treeView.AttributeElement|engine.treeView.ContainerElement
+	 * |Iterable.<engine.treeView.Text|engine.treeView.AttributeElement|engine.treeView.ContainerElement>} nodes Node or
+	 * nodes to insert.
 	 * @returns {engine.treeView.Range} Range around inserted nodes.
 	 */
 	insert( position, nodes ) {
+		nodes = utils.isIterable( nodes ) ? [ ...nodes ] : [ nodes ];
+
+		// Check if nodes to insert are instances of AttributeElements, ContainerElements or Text.
+		validateNodesToInsert( nodes );
+
 		const container = this.getParentContainer( position );
 		const insertionPosition = this.breakAttributes( position );
 
@@ -239,6 +252,7 @@ import DocumentFragment from './documentfragment.js';
 			if ( !start.isEqual( insertionPosition ) ) {
 				endPosition.offset--;
 			}
+
 			const end = this.mergeAttributes( endPosition );
 
 			return new Range( start, end );
@@ -646,6 +660,7 @@ function wrapChildren( writer, parent, startOffset, endOffset, attribute ) {
 //		<p>{foo}|</p>  ->  <p>{foo|}</p>
 //		<p>|{foo}</p>  ->  <p>{|foo}</p>
 //
+// @private
 // @param {engine.treeView.Position} position
 // @returns {engine.treeView.Position} Position located inside text node or same position if there is no text nodes
 // before or after position location.
@@ -671,6 +686,7 @@ function movePositionToTextNode( position ) {
 //		<p>{|foobar}</p> -> <p>|{foobar}</p>
 //		<p>{foobar|}</p> -> <p>{foobar}|</p>
 //
+// @private
 // @param {engine.treeView.Position} position Position that need to be placed inside text node.
 // @returns {engine.treeView.Position} New position after breaking text node.
 function breakTextNode( position ) {
@@ -697,6 +713,7 @@ function breakTextNode( position ) {
 
 // Merges two text nodes into first node. Removes second node and returns merge position.
 //
+// @private
 // @param {engine.treeView.Text} t1 First text node to merge. Data from second text node will be moved at the end of
 // this text node.
 // @param {engine.treeView.Text} t2 Second text node to merge. This node will be removed after merging.
@@ -715,6 +732,7 @@ function mergeTextNodes( t1, t2 ) {
 // When merging is possible - all attributes, styles and classes are moved from wrapper element to element being
 // wrapped.
 //
+// @private
 // @param {engine.treeView.AttributeElement} wrapper Wrapper AttributeElement.
 // @param {engine.treeView.AttributeElement} toWrap AttributeElement to wrap using wrapper element.
 // @returns {Boolean} Returns `true` if elements are merged.
@@ -775,6 +793,7 @@ function wrapAttributeElement( wrapper, toWrap ) {
 // Unwraps {@link engine.treeView.AttributeElement AttributeElement} from another by removing corresponding attributes,
 // classes and styles. All attributes, classes and styles from wrapper should be present inside element being unwrapped.
 //
+// @private
 // @param {engine.treeView.AttributeElement} wrapper Wrapper AttributeElement.
 // @param {engine.treeView.AttributeElement} toUnwrap AttributeElement to unwrap using wrapper element.
 // @returns {Boolean} Returns `true` if elements are unwrapped.
@@ -833,9 +852,39 @@ function unwrapAttributeElement( wrapper, toUnwrap ) {
 // (`start` and `end` positions are located inside same {@link engine.treeView.AttributeElement AttributeElement}),
 // starts on 0 offset and ends after last child node.
 //
+// @private
 // @param {engine.treeView.Range} Range
 // @returns {Boolean}
 function rangeSpansOnAllChildren( range ) {
 	return range.start.parent == range.end.parent && range.start.parent instanceof AttributeElement &&
 		range.start.offset === 0 && range.end.offset === range.start.parent.getChildCount();
+}
+
+// Checks if provided nodes are valid to insert by writer. Checks if each node is an instance of
+// {@link engine.treeView.Text Text} or {@link engine.treeView.AttributeElement AttributeElement} or
+// {@link engine.treeView.ContainerElement ContainerElement}.
+//
+// Throws {@link utils.CKEditorError CKEditorError} `treeview-writer-insert-invalid-node` when nodes to insert
+// contains instances that are not {@link engine.treeView.Text Texts},
+// {@link engine.treeView.AttributeElement AttributeElements} or
+// {@link engine.treeView.ContainerElement ContainerElements}.
+//
+// @private
+// @param Iterable.<engine.treeView.Text|engine.treeView.AttributeElement|engine.treeView.ContainerElement> nodes
+function validateNodesToInsert( nodes ) {
+	for ( let node of nodes ) {
+		if ( !( node instanceof Text || node instanceof AttributeElement || node instanceof ContainerElement ) ) {
+			/**
+			 * Inserted nodes should be instance of {@link engine.treeView.AttributeElement AttributeElement},
+			 * {@link engine.treeView.ContainerElement ContainerElement} or {@link engine.treeView.Text Text}.
+			 *
+			 * @error treeview-writer-insert-invalid-node
+			 */
+			throw new CKEditorError( 'treeview-writer-insert-invalid-node' );
+		}
+
+		if ( !( node instanceof Text ) ) {
+			validateNodesToInsert( node.getChildren() );
+		}
+	}
 }

+ 0 - 1
packages/ckeditor5-engine/tests/.jshintrc

@@ -4,7 +4,6 @@
 
 	"expr": true,
 	"immed": true,
-	"latedef": "nofunc",
 	"loopfunc": true,
 	"noarg": true,
 	"nonbsp": true,

+ 437 - 0
packages/ckeditor5-engine/tests/_utils-tests/view.js

@@ -0,0 +1,437 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import { stringify, parse } from '/tests/engine/_utils/view.js';
+import DocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
+import Position from '/ckeditor5/engine/treeview/position.js';
+import Element from '/ckeditor5/engine/treeview/element.js';
+import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+import Text from '/ckeditor5/engine/treeview/text.js';
+import Selection from '/ckeditor5/engine/treeview/selection.js';
+import Range from '/ckeditor5/engine/treeview/range.js';
+
+describe( 'view test utils', () => {
+	describe( 'stringify', () => {
+		it( 'should write text', () => {
+			const text = new Text( 'foobar' );
+			expect( stringify( text ) ).to.equal( 'foobar' );
+		} );
+
+		it( 'should write elements and texts', () => {
+			const text = new Text( 'foobar' );
+			const b = new Element( 'b', null, text );
+			const p = new Element( 'p', null, b );
+
+			expect( stringify( p ) ).to.equal( '<p><b>foobar</b></p>' );
+		} );
+
+		it( 'should write elements with attributes', () => {
+			const text = new Text( 'foobar' );
+			const b = new Element( 'b', {
+				foo: 'bar'
+			}, text );
+			const p = new Element( 'p', {
+				baz: 'qux',
+				bar: 'taz',
+				class: 'short wide'
+			}, b );
+
+			expect( stringify( p ) ).to.equal( '<p class="short wide" baz="qux" bar="taz"><b foo="bar">foobar</b></p>' );
+		} );
+
+		it( 'should write selection ranges inside elements', () => {
+			const text1 = new Text( 'foobar' );
+			const text2 = new Text( 'bazqux' );
+			const b1 = new Element( 'b', null, text1 );
+			const b2 = new Element( 'b', null, text2 );
+			const p = new Element( 'p', null, [ b1, b2 ] );
+			const range = Range.createFromParentsAndOffsets( p, 1, p, 2 );
+			const selection = new Selection();
+			selection.addRange( range );
+			expect( stringify( p, selection ) ).to.equal( '<p><b>foobar</b>[<b>bazqux</b>]</p>' );
+		} );
+
+		it( 'should write collapsed selection ranges inside elements', () => {
+			const text = new Text( 'foobar' );
+			const p = new Element( 'p', null, text );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 0 );
+			const selection = new Selection();
+			selection.addRange( range );
+			expect( stringify( p, selection ) ).to.equal( '<p>[]foobar</p>' );
+		} );
+
+		it( 'should write selection ranges inside text', () => {
+			const text1 = new Text( 'foobar' );
+			const text2 = new Text( 'bazqux' );
+			const b1 = new Element( 'b', null, text1 );
+			const b2 = new Element( 'b', null, text2 );
+			const p = new Element( 'p', null, [ b1, b2 ] );
+			const range = Range.createFromParentsAndOffsets( text1, 1, text1, 5 );
+			const selection = new Selection();
+			selection.addRange( range );
+			expect( stringify( p, selection ) ).to.equal( '<p><b>f{ooba}r</b><b>bazqux</b></p>' );
+		} );
+
+		it( 'should write collapsed selection ranges inside texts', () => {
+			const text = new Text( 'foobar' );
+			const p = new Element( 'p', null, text );
+			const range = Range.createFromParentsAndOffsets( text, 0, text, 0 );
+			const selection = new Selection();
+			selection.addRange( range );
+			expect( stringify( p, selection ) ).to.equal( '<p>{}foobar</p>' );
+		} );
+
+		it( 'should write ranges that start inside text end ends between elements', () => {
+			const text1 = new Text( 'foobar' );
+			const text2 = new Text( 'bazqux' );
+			const b1 = new Element( 'b', null, text1 );
+			const b2 = new Element( 'b', null, text2 );
+			const p = new Element( 'p', null, [ b1, b2 ] );
+			const range = Range.createFromParentsAndOffsets( p, 0, text2, 5 );
+			const selection = new Selection();
+			selection.addRange( range );
+			expect( stringify( p, selection ) ).to.equal( '<p>[<b>foobar</b><b>bazqu}x</b></p>' );
+		} );
+
+		it( 'should write elements types as namespaces when needed', () => {
+			const text = new Text( 'foobar' );
+			const b = new AttributeElement( 'b', null, text );
+			const p = new ContainerElement( 'p', null, b );
+
+			expect( stringify( p, null, { showType: true } ) )
+				.to.equal( '<container:p><attribute:b>foobar</attribute:b></container:p>' );
+		} );
+
+		it( 'should not write element type when type is not specified', () => {
+			const p = new Element( 'p' );
+			expect( stringify( p, null, { showType: true } ) ).to.equal( '<p></p>' );
+		} );
+
+		it( 'should write elements priorities when needed', () => {
+			const text = new Text( 'foobar' );
+			const b = new AttributeElement( 'b', null, text );
+			const p = new ContainerElement( 'p', null, b );
+
+			expect( stringify( p, null, { showPriority: true } ) )
+				.to.equal( '<p><b:10>foobar</b:10></p>' );
+		} );
+
+		it( 'should parse DocumentFragment as root', () => {
+			const text1 = new Text( 'foobar' );
+			const text2 = new Text( 'bazqux' );
+			const b1 = new Element( 'b', null, text1 );
+			const b2 = new Element( 'b', null, text2 );
+			const fragment = new DocumentFragment( [ b1, b2 ] );
+			expect( stringify( fragment, null ) ).to.equal( '<b>foobar</b><b>bazqux</b>' );
+		} );
+
+		it( 'should not write ranges outside elements - end position outside element', () => {
+			const text = new Text( 'foobar' );
+			const b = new Element( 'b', null, text );
+			const p = new Element( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 5 );
+
+			expect( stringify( p, range ) ).to.equal( '<p>[<b>foobar</b></p>' );
+		} );
+
+		it( 'should not write ranges outside elements - start position outside element', () => {
+			const text = new Text( 'foobar' );
+			const b = new Element( 'b', null, text );
+			const p = new Element( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( p, -1, p, 1 );
+
+			expect( stringify( p, range ) ).to.equal( '<p><b>foobar</b>]</p>' );
+		} );
+
+		it( 'should not write ranges outside elements - end position outside text', () => {
+			const text = new Text( 'foobar' );
+			const b = new Element( 'b', null, text );
+			const p = new Element( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( text, 0, text, 7 );
+
+			expect( stringify( p, range ) ).to.equal( '<p><b>{foobar</b></p>' );
+		} );
+
+		it( 'should not write ranges outside elements - start position outside text', () => {
+			const text = new Text( 'foobar' );
+			const b = new Element( 'b', null, text );
+			const p = new Element( 'p', null, b );
+			const range = Range.createFromParentsAndOffsets( text, -1, text, 2 );
+
+			expect( stringify( p, range ) ).to.equal( '<p><b>fo}obar</b></p>' );
+		} );
+
+		it( 'should write multiple ranges from selection #1', () => {
+			const text1 = new Text( 'foobar' );
+			const text2 = new Text( 'bazqux' );
+			const b1 = new Element( 'b', null, text1 );
+			const b2 = new Element( 'b', null, text2 );
+			const p = new Element( 'p', null, [ b1, b2 ] );
+			const range1 = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+			const range2 = Range.createFromParentsAndOffsets( p, 1, p, 1 );
+			const selection = new Selection();
+			selection.setRanges( [ range2, range1 ] );
+
+			expect( stringify( p, selection ) ).to.equal( '<p>[<b>foobar</b>][]<b>bazqux</b></p>' );
+		} );
+
+		it( 'should write multiple ranges from selection #2', () => {
+			const text1 = new Text( 'foobar' );
+			const text2 = new Text( 'bazqux' );
+			const b = new Element( 'b', null, text1 );
+			const p = new Element( 'p', null, [ b, text2 ] );
+			const range1 = Range.createFromParentsAndOffsets( p, 0, p, 1 );
+			const range2 = Range.createFromParentsAndOffsets( text2, 0, text2, 3 );
+			const range3 = Range.createFromParentsAndOffsets( text2, 3, text2, 4 );
+			const range4 = Range.createFromParentsAndOffsets( p, 1, p, 1 );
+			const selection = new Selection();
+			selection.setRanges( [ range1, range2, range3, range4 ] );
+
+			expect( stringify( p, selection ) ).to.equal( '<p>[<b>foobar</b>][]{baz}{q}ux</p>' );
+		} );
+
+		it( 'should use Position instance instead of Selection', () => {
+			const text = new Text( 'foobar' );
+			const position = new Position( text, 3 );
+			const string = stringify( text, position );
+			expect( string ).to.equal( 'foo{}bar' );
+		} );
+
+		it( 'should use Range instance instead of Selection', () => {
+			const text = new Text( 'foobar' );
+			const range = Range.createFromParentsAndOffsets( text, 3, text, 4 );
+			const string = stringify( text, range );
+			expect( string ).to.equal( 'foo{b}ar' );
+		} );
+	} );
+
+	describe( 'parse', () => {
+		it( 'should parse text', () => {
+			const text = parse( 'foobar' );
+			expect( text ).to.be.instanceOf( Text );
+			expect( text.data ).to.equal( 'foobar' );
+		} );
+
+		it( 'should parse elements and texts', () => {
+			const view = parse( '<b>foobar</b>' );
+			const element = new Element( 'b' );
+
+			expect( view ).to.be.instanceof( Element );
+			expect( view.isSimilar( element ) ).to.be.true;
+			expect( view.getChildCount() ).to.equal( 1 );
+			const text = view.getChild( 0 );
+			expect( text ).to.be.instanceof( Text );
+			expect( text.data ).to.equal( 'foobar' );
+		} );
+
+		it( 'should parse element attributes', () => {
+			const view = parse( '<b name="foo" title="bar" class="foo bar" style="color:red;"></b>' );
+			const element = new Element( 'b', { name: 'foo', title: 'bar', class: 'foo bar', style: 'color:red;' } );
+
+			expect( view ).to.be.instanceof( Element );
+			expect( view.isSimilar( element ) ).to.be.true;
+			expect( view.getChildCount() ).to.equal( 0 );
+		} );
+
+		it( 'should parse element type', () => {
+			const view1 = parse( '<attribute:b></attribute:b>' );
+			const attribute = new AttributeElement( 'b' );
+			const view2 = parse( '<container:p></container:p>' );
+			const container = new ContainerElement( 'p' );
+
+			expect( view1 ).to.be.instanceof( AttributeElement );
+			expect( view1.isSimilar( attribute ) ).to.be.true;
+			expect( view2 ).to.be.instanceof( ContainerElement );
+			expect( view2.isSimilar( container ) ).to.be.true;
+		} );
+
+		it( 'should parse element priority', () => {
+			const parsed1 = parse( '<b:12></b:12>' );
+			const attribute1 = new AttributeElement( 'b' );
+			attribute1.priority = 12;
+			const parsed2 = parse( '<attribute:b:44></attribute:b:44>' );
+			const attribute2 = new AttributeElement( 'b' );
+			attribute2.priority = 44;
+
+			parsed1.isSimilar( attribute1 );
+			expect( parsed1.isSimilar( attribute1 ) ).to.be.true;
+			expect( parsed2.isSimilar( attribute2 ) ).to.be.true;
+		} );
+
+		it( 'should create DocumentFragment when multiple elements on root', () => {
+			const view = parse( '<b></b><i></i>' );
+			expect( view ).to.be.instanceOf( DocumentFragment );
+			expect( view.getChildCount() ).to.equal( 2 );
+			expect( view.getChild( 0 ).isSimilar( new Element( 'b' ) ) ).to.be.true;
+			expect( view.getChild( 1 ).isSimilar( new Element( 'i' ) ) ).to.be.true;
+		} );
+
+		it( 'should paste nested elements and texts', () => {
+			const parsed = parse( '<container:p>foo<b:12>bar<i:25>qux</i:25></b:12></container:p>' );
+			expect( parsed.isSimilar( new ContainerElement( 'p' ) ) ).to.be.true;
+			expect( parsed.getChildCount() ).to.equal( 2 );
+			expect( parsed.getChild( 0 ) ).to.be.instanceof( Text ).and.have.property( 'data' ).that.equal( 'foo' );
+			const b = parsed.getChild( 1 );
+			expect( b ).to.be.instanceof( AttributeElement );
+			expect( b.priority ).to.equal( 12 );
+			expect( b.getChildCount() ).to.equal( 2 );
+			expect( b.getChild( 0 ) ).to.be.instanceof( Text ).and.have.property( 'data' ).that.equal( 'bar' );
+			const i = b.getChild( 1 );
+			expect( i ).to.be.instanceof( AttributeElement );
+			expect( i.priority ).to.equal( 25 );
+			expect( i.getChild( 0 ) ).to.be.instanceof( Text ).and.have.property( 'data' ).that.equal( 'qux' );
+		} );
+
+		it( 'should parse selection range inside text', () => {
+			const { view, selection } = parse( 'f{oo}b{}ar' );
+			expect( view ).to.be.instanceof( Text );
+			expect( view.data ).to.equal( 'foobar' );
+			expect( selection.rangeCount ).to.equal( 2 );
+			const ranges = [ ...selection.getRanges() ];
+
+			expect( ranges[ 0 ].isEqual( Range.createFromParentsAndOffsets( view, 1, view, 3 ) ) ).to.be.true;
+			expect( ranges[ 1 ].isEqual( Range.createFromParentsAndOffsets( view, 4, view, 4 ) ) ).to.be.true;
+		} );
+
+		it( 'should parse selection range between elements', () => {
+			const { view, selection } = parse( '<p>[<b>foobar]</b>[]</p>' );
+			expect( view ).to.be.instanceof( Element );
+			expect( view.getChildCount() ).to.equal( 1 );
+			const b = view.getChild( 0 );
+			expect( b ).to.be.instanceof( Element );
+			expect( b.name ).to.equal( 'b' );
+			expect( b.getChildCount() ).to.equal( 1 );
+			const text = b.getChild( 0 );
+			expect( text ).to.be.instanceof( Text );
+			expect( text.data ).to.equal( 'foobar' );
+			expect( selection.rangeCount ).to.equal( 2 );
+			const ranges = [ ...selection.getRanges() ];
+			expect( ranges[ 0 ].isEqual( Range.createFromParentsAndOffsets( view, 0, b, 1 ) ) ).to.be.true;
+			expect( ranges[ 1 ].isEqual( Range.createFromParentsAndOffsets( view, 1, view, 1 ) ) ).to.be.true;
+		} );
+
+		it( 'should parse ranges #1', () => {
+			const { view, selection } = parse( '<container:p>foo{bar]</container:p>' );
+			expect( view.isSimilar( new ContainerElement( 'p' ) ) ).to.be.true;
+			expect( view.getChildCount() ).to.equal( 1 );
+			const text = view.getChild( 0 );
+			expect( text ).to.be.instanceof( Text );
+			expect( text.data ).to.equal( 'foobar' );
+			expect( selection.rangeCount ).to.equal( 1 );
+			expect( selection.getFirstRange().isEqual( Range.createFromParentsAndOffsets( text, 3, view, 1 ) ) ).to.be.true;
+		} );
+
+		it( 'should parse ranges #2', () => {
+			const { view, selection } = parse( '<attribute:b>[foob}ar<i>{baz</i>]</attribute:b>' );
+			expect( view.isSimilar( new AttributeElement( 'b' ) ) ).to.be.true;
+			expect( view.getChildCount() ).to.equal( 2 );
+			const text1 = view.getChild( 0 );
+			expect( text1 ).to.be.instanceof( Text );
+			expect( text1.data ).to.equal( 'foobar' );
+			const i = view.getChild( 1 );
+			expect( i.isSimilar( new Element( 'i' ) ) ).to.be.true;
+			expect( i.getChildCount() ).to.equal( 1 );
+			const text2 = i.getChild( 0 );
+			expect( text2 ).to.be.instanceof( Text );
+			expect( text2.data ).to.equal( 'baz' );
+			expect( selection.rangeCount ).to.equal( 2 );
+			const ranges = [ ...selection.getRanges() ];
+			expect( ranges[ 0 ].isEqual( Range.createFromParentsAndOffsets( view, 0, text1, 4 ) ) ).to.be.true;
+			expect( ranges[ 1 ].isEqual( Range.createFromParentsAndOffsets( text2, 0, view, 2 ) ) ).to.be.true;
+		} );
+
+		it( 'should use ranges order when provided', () => {
+			const { view, selection } = parse( '{f}oo{b}arb{a}z', { order: [ 3, 1, 2 ] } );
+			expect( selection.rangeCount ).to.equal( 3 );
+			const ranges = [ ...selection.getRanges() ];
+			expect( ranges[ 0 ].isEqual( Range.createFromParentsAndOffsets( view, 3, view, 4 ) ) ).to.be.true;
+			expect( ranges[ 1 ].isEqual( Range.createFromParentsAndOffsets( view, 7, view, 8 ) ) ).to.be.true;
+			expect( ranges[ 2 ].isEqual( Range.createFromParentsAndOffsets( view, 0, view, 1 ) ) ).to.be.true;
+			expect( selection.anchor.isEqual( ranges[ 2 ].start ) ).to.be.true;
+			expect( selection.focus.isEqual( ranges[ 2 ].end ) ).to.be.true;
+		} );
+
+		it( 'should set last range backward if needed', () => {
+			const { view, selection } = parse( '{f}oo{b}arb{a}z', { order: [ 3, 1, 2 ], lastRangeBackward: true } );
+			expect( selection.rangeCount ).to.equal( 3 );
+			const ranges = [ ...selection.getRanges() ];
+			expect( ranges[ 0 ].isEqual( Range.createFromParentsAndOffsets( view, 3, view, 4 ) ) ).to.be.true;
+			expect( ranges[ 1 ].isEqual( Range.createFromParentsAndOffsets( view, 7, view, 8 ) ) ).to.be.true;
+			expect( ranges[ 2 ].isEqual( Range.createFromParentsAndOffsets( view, 0, view, 1 ) ) ).to.be.true;
+			expect( selection.anchor.isEqual( ranges[ 2 ].end ) ).to.be.true;
+			expect( selection.focus.isEqual( ranges[ 2 ].start ) ).to.be.true;
+		} );
+
+		it( 'should return DocumentFragment if range is around single element', () => {
+			const { view, selection } = parse( '[<b>foobar</b>]' );
+			expect( view ).to.be.instanceOf( DocumentFragment );
+			expect( selection.rangeCount ).to.equal( 1 );
+			expect( selection.getFirstRange().isEqual( Range.createFromParentsAndOffsets( view, 0, view, 1 ) ) ).to.be.true;
+		} );
+
+		it( 'should throw when ranges order does not include all ranges', () => {
+			expect( () => {
+				parse( '{}foobar{}', { order: [ 1 ] } );
+			} ).to.throw( Error );
+		} );
+
+		it( 'should throw when ranges order is invalid', () => {
+			expect( () => {
+				parse( '{}foobar{}', { order: [ 1, 4 ] } );
+			} ).to.throw( Error );
+		} );
+
+		it( 'should throw when element range delimiter is inside text node', () => {
+			expect( () => {
+				parse( 'foo[bar' );
+			} ).to.throw( Error );
+		} );
+
+		it( 'should throw when text range delimiter is inside empty text node', () => {
+			expect( () => {
+				parse( '<b>foo</b>}' );
+			} ).to.throw( Error );
+		} );
+
+		it( 'should throw when end of range is found before start', () => {
+			expect( () => {
+				parse( 'fo}obar' );
+			} ).to.throw( Error );
+		} );
+
+		it( 'should throw when intersecting ranges found', () => {
+			expect( () => {
+				parse( '[fo{o}bar]' );
+			} ).to.throw( Error );
+		} );
+
+		it( 'should throw when opened ranges are left', () => {
+			expect( () => {
+				parse( 'fo{obar' );
+			} ).to.throw( Error );
+		} );
+
+		it( 'should throw when wrong tag name is provided #1', () => {
+			expect( () => {
+				parse( '<b:bar></b:bar>' );
+			} ).to.throw( Error );
+		} );
+
+		it( 'should throw when wrong tag name is provided #2', () => {
+			expect( () => {
+				parse( '<container:b:bar></container:b:bar>' );
+			} ).to.throw( Error );
+		} );
+
+		it( 'should throw when wrong tag name is provided #3', () => {
+			expect( () => {
+				parse( '<container:b:10:test></container:b:10:test>' );
+			} ).to.throw( Error );
+		} );
+	} );
+} );

+ 846 - 0
packages/ckeditor5-engine/tests/_utils/view.js

@@ -0,0 +1,846 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ViewDocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
+import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
+import ViewElement from '/ckeditor5/engine/treeview/element.js';
+import Selection from '/ckeditor5/engine/treeview/selection.js';
+import Range from '/ckeditor5/engine/treeview/range.js';
+import Position from '/ckeditor5/engine/treeview/position.js';
+import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+import ViewText from '/ckeditor5/engine/treeview/text.js';
+
+const DomDocumentFragment = window.DocumentFragment;
+const DomElement = window.Element;
+
+const ELEMENT_RANGE_START_TOKEN = '[';
+const ELEMENT_RANGE_END_TOKEN = ']';
+const TEXT_RANGE_START_TOKEN = '{';
+const TEXT_RANGE_END_TOKEN = '}';
+
+/**
+ * Converts view elements to its HTML-like string representation.
+ * Root element can be provided as {@link engine.treeView.Text Text}:
+ *
+ *		const text = new Text( 'foobar' );
+ *		stringify( text ); // 'foobar'
+ *
+ * or as {@link engine.treeView.Element Element}:
+ *
+ *		const element = new Element( 'p', null, new Text( 'foobar' ) );
+ *		stringify( element ); // '<p>foobar</p>'
+ *
+ * or as {@link engine.treeView.DocumentFragment DocumentFragment}:
+ *
+ *		const text = new Text( 'foobar' );
+ *		const b = new Element( 'b', { name: 'test' }, text );
+ *		const p = new Element( 'p', { style: 'color:red;' } );
+ *		const fragment = new DocumentFragment( [ p, b ] );
+ *
+ *		stringify( fragment ); // '<p style="color:red;"></p><b name="test">foobar</b>'
+ *
+ * Additionally {@link engine.treeView.Selection Selection} instance can be provided, then ranges from that selection
+ * will be included in output data.
+ * If range position is placed inside element node, it will be represented with `[` and `]`:
+ *
+ *		const text = new Text( 'foobar' );
+ *		const b = new Element( 'b', null, text );
+ *		const p = new Element( 'p', null, b );
+ *		const selection = new Selection();
+ *		selection.addRange( Range.createFromParentsAndOffsets( p, 0, p, 1 ) );
+ *
+ *		stringify( p, selection ); // '<p>[<b>foobar</b>]</p>'
+ *
+ * If range is placed inside text node, it will be represented with `{` and `}`:
+ *
+ *		const text = new Text( 'foobar' );
+ *		const b = new Element( 'b', null, text );
+ *		const p = new Element( 'p', null, b );
+ *		const selection = new Selection();
+ *		selection.addRange( Range.createFromParentsAndOffsets( text, 1, text, 5 ) );
+ *
+ *		stringify( p, selection ); // '<p><b>f{ooba}r</b></p>'
+ *
+ * Multiple ranges are supported:
+ *
+ *		const text = new Text( 'foobar' );
+ *		const selection = new Selection();
+ *		selection.addRange( Range.createFromParentsAndOffsets( text, 0, text, 1 ) );
+ *		selection.addRange( Range.createFromParentsAndOffsets( text, 3, text, 5 ) );
+ *
+ *		stringify( text, selection ); // '{f}oo{ba}r'
+ *
+ * Instead of {@link engine.treeView.Selection Selection} instance {@link engine.treeView.Range Range} or
+ * {@link engine.treeView.Position Position} instance can be provided. If Range instance is provided - it will be
+ * converted to selection containing this range. If Position instance is provided - it will be converted to selection
+ * containing one range collapsed at this position.
+ *
+ *		const text = new Text( 'foobar' );
+ *		const range = Range.createFromParentsAndOffsets( text, 0, text, 1 );
+ *		const position = new Position( text, 3 );
+ *
+ *		stringify( text, range ); // '{f}oobar'
+ *		stringify( text, position ); // 'foo{}bar'
+ *
+ * Additional options object can be provided.
+ * If `options.showType` is set to `true`, element's types will be
+ * presented for {@link engine.treeView.AttributeElement AttributeElements} and {@link engine.treeView.ContainerElement
+ * ContainerElements}:
+ *
+ *		const attribute = new AttributeElement( 'b' );
+ *		const container = new ContainerElement( 'p' );
+ *		getData( attribute, null, { showType: true } ); // '<attribute:b></attribute:b>'
+ *		getData( container, null, { showType: true } ); // '<container:p></container:p>'
+ *
+ * If `options.showPriority` is set to `true`, priority will be displayed for all
+ * {@link engine.treeView.AttributeElement AttributeElements}.
+ *
+ *		const attribute = new AttributeElement( 'b' );
+ *		attribute.priority = 20;
+ *		getData( attribute, null, { showPriority: true } ); // <b:20></b:20>
+ *
+ * @param {engine.treeView.Text|engine.treeView.Element|engine.treeView.DocumentFragment} node Node to stringify.
+ * @param {engine.treeView.Selection|engine.treeView.Position|engine.treeView.Range} [selectionOrPositionOrRange = null ]
+ * Selection instance which ranges will be included in returned string data. If Range instance is provided - it will be
+ * converted to selection containing this range. If Position instance is provided - it will be converted to selection
+ * containing one range collapsed at this position.
+ * @param {Object} [options] Object with additional options.
+ * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed (`<container:p>`
+ * instead of `<p>` and `<attribute:b>` instead of `<b>`).
+ * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed
+ * (`<span:12>`, `<b:10>`).
+ * @returns {String} HTML-like string representing the view.
+ */
+export function stringify( node, selectionOrPositionOrRange = null, options = {} ) {
+	let selection;
+
+	if ( selectionOrPositionOrRange instanceof Position ) {
+		selection = new Selection();
+		selection.addRange( new Range( selectionOrPositionOrRange, selectionOrPositionOrRange ) );
+	} else if ( selectionOrPositionOrRange instanceof Range ) {
+		selection = new Selection( );
+		selection.addRange( selectionOrPositionOrRange );
+	} else {
+		selection = selectionOrPositionOrRange;
+	}
+
+	const viewStringify = new ViewStringify( node, selection, options );
+
+	return viewStringify.stringify();
+}
+
+/**
+ * Parses HTML-like string and returns view tree nodes.
+ * Simple string will be converted to {@link engine.treeView.Text Text} node:
+ *
+ *		parse( 'foobar' ); // Returns instance of Text.
+ *
+ * {@link engine.treeView.Element Elements} will be parsed with attributes an children:
+ *
+ *		parse( '<b name="baz">foobar</b>' ); // Returns instance of Element with `baz` attribute and text child node.
+ *
+ * Multiple nodes provided on root level will be converted to {@link engine.treeView.DocumentFragment DocumentFragment}:
+ *
+ *		parse( '<b>foo</b><i>bar</i>' ); // Returns DocumentFragment with two child elements.
+ *
+ * Method can parse multiple {@link engine.treeView.Range ranges} provided in string data and return
+ * {@link engine.treeView.Selection Selection} instance containing these ranges. Ranges placed inside
+ * {@link engine.treeView.Text Text} nodes should be marked using `{` and `}` brackets:
+ *
+ *		const { text, selection } = parse( 'f{ooba}r' );
+ *
+ * Ranges placed outside text nodes should be marked using `[` and `]` brackets:
+ *
+ *		const { root, selection } = parse( '<p>[<b>foobar</b>]</p>' );
+ *
+ * Sometimes there is a need for defining order of ranges inside created selection. This can be achieved by providing
+ * ranges order array as additional parameter:
+ *
+ *		const { root, selection } = parse( '{fo}ob{ar}{ba}z', { order: [ 2, 3, 1 ] } );
+ *
+ * In above example first range (`{fo}`) will be added to selection as second one, second range (`{ar}`) will be added
+ * as third and third range (`{ba}`) will be added as first one.
+ *
+ * If selection's last range should be added as backward one (so the {@link engine.treeView.Selection#anchor selection
+ * anchor} is represented by `end` position and {@link engine.treeView.Selection#focus selection focus} is
+ * represented by `start` position) use `lastRangeBackward` flag:
+ *
+ *		const { root, selection } = parse( `{foo}bar{baz}`, { lastRangeBackward: true } );
+ *
+ * @param {String} data HTML-like string to be parsed.
+ * @param {Object} options
+ * @param {Array.<Number>} [options.order] Array with order of parsed ranges added to returned
+ * {@link engine.treeView.Selection Selection} instance. Each element should represent desired position of each range in
+ * selection instance. For example: `[2, 3, 1]` means that first range will be placed as second, second as third and third as first.
+ * @param {Boolean} [options.lastRangeBackward=false] If set to true last range will be added as backward to the returned
+ * {@link engine.treeView.Selection Selection} instance.
+ * @returns {engine.treeView.Text|engine.treeView.Element|engine.treeView.DocumentFragment|Object} Returns parsed view node
+ * or object with two fields `view` and `selection` when selection ranges were included in data to parse.
+ */
+export function parse( data, options = { } ) {
+	options.order = options.order || [];
+	const viewParser = new ViewParser();
+	const rangeParser = new RangeParser();
+
+	const view = viewParser.parse( data );
+	const ranges = rangeParser.parse( view, options.order );
+
+	// When ranges are present - return object containing view, and selection.
+	if ( ranges.length ) {
+		const selection = new Selection();
+		selection.setRanges( ranges, !!options.lastRangeBackward );
+
+		return {
+			view: view,
+			selection: selection
+		};
+	}
+
+	return view;
+}
+
+/**
+ * Private helper class used for converting ranges represented as text inside view {@link engine.treeView.Text Text nodes}.
+ *
+ * @private
+ */
+class RangeParser {
+	/**
+	 * Parses the view, and returns ranges represented inside {@link engine.treeView.Text Text nodes}.
+	 * Method will remove all occurrences of `{`, `}`, `[` and `]` from found text nodes. If text node is empty after
+	 * the process - it will be removed too.
+	 *
+	 * @param {engine.treeView.Node} node Starting node.
+	 * @param {Array.<Number>} order Ranges order. Each element should represent desired position of the range after
+	 * sorting. For example: `[2, 3, 1]` means that first range will be placed as second, second as third and third as first.
+	 * @returns {Array.<engine.treeView.Range>} Array with ranges found.
+	 */
+	parse( node, order ) {
+		this._positions = [];
+
+		// Remove all range brackets from view nodes and save their positions.
+		this._getPositions( node );
+
+		// Create ranges using gathered positions.
+		let ranges = this._createRanges();
+
+		// Sort ranges if needed.
+		if ( order.length ) {
+			if ( order.length != ranges.length ) {
+				throw new Error(
+					`Parse error - there are ${ ranges.length} ranges found, but ranges order array contains ${ order.length } elements.`
+				);
+			}
+
+			ranges = this._sortRanges( ranges,  order );
+		}
+
+		return ranges;
+	}
+
+	/**
+	 * Gathers positions of brackets inside view tree starting from provided node. Method will remove all occurrences of
+	 * `{`, `}`, `[` and `]` from found text nodes. If text node is empty after the process - it will be removed
+	 * too.
+	 *
+	 * @private
+	 * @param {engine.treeView.Node} node Staring node.
+	 */
+	_getPositions( node ) {
+		if ( node instanceof ViewDocumentFragment || node instanceof ViewElement ) {
+			// Copy elements into the array, when nodes will be removed from parent node this array will still have all the
+			// items needed for iteration.
+			const children = [ ...node.getChildren() ];
+
+			for ( let child of children ) {
+				this._getPositions( child );
+			}
+		}
+
+		if ( node instanceof ViewText ) {
+			const regexp = new RegExp(
+				`[ ${ TEXT_RANGE_START_TOKEN }${ TEXT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_START_TOKEN } ]`,
+				'g'
+			);
+			let text = node.data;
+			let match;
+			let offset = 0;
+			const brackets = [];
+
+			// Remove brackets from text and store info about offset inside text node.
+			while ( ( match = regexp.exec( text ) ) ) {
+				const index = match.index;
+				const bracket = match[ 0 ];
+
+				brackets.push( {
+					bracket: bracket,
+					textOffset: index - offset
+				} );
+
+				offset++;
+			}
+			text = text.replace( regexp, '' );
+			node.data = text;
+			const index = node.getIndex();
+			const parent = node.parent;
+
+			// Remove empty text nodes.
+			if ( !text ) {
+				node.remove();
+			}
+
+			for ( let item of brackets ) {
+				// Non-empty text node.
+				if ( text ) {
+					if ( item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) {
+						// Store information about text range delimiter.
+						this._positions.push( {
+							bracket: item.bracket,
+							position: new Position( node, item.textOffset )
+						} );
+					} else {
+						// Check if element range delimiter is not placed inside text node.
+						if ( item.textOffset !== 0 && item.textOffset !== text.length ) {
+							throw new Error( `Parse error - range delimiter '${ item.bracket }' is placed inside text node.` );
+						}
+
+						// If bracket is placed at the end of the text node - it should be positioned after it.
+						const offset = ( item.textOffset === 0 ? index : index + 1 );
+
+						// Store information about element range delimiter.
+						this._positions.push( {
+							bracket: item.bracket,
+							position: new Position( parent, offset )
+						} );
+					}
+				} else {
+					if ( item.bracket == TEXT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) {
+						throw new Error( `Parse error - text range delimiter '${ item.bracket }' is placed inside empty text node. ` );
+					}
+
+					// Store information about element range delimiter.
+					this._positions.push( {
+						bracket: item.bracket,
+						position: new Position( parent, index )
+					} );
+				}
+			}
+		}
+	}
+
+	/**
+	 * Sort ranges in given order. Ranges order should be an array, each element should represent desired position
+	 * of the range after sorting.
+	 * For example: `[2, 3, 1]` means that first range will be placed as second, second as third and third as first.
+	 *
+	 * @private
+	 * @param {Array.<engine.treeView.Range>} ranges Ranges to sort.
+	 * @param {Array.<Number>} rangesOrder Array with new ranges order.
+	 * @returns {Array} Sorted ranges array.
+	 */
+	_sortRanges( ranges, rangesOrder ) {
+		const sortedRanges = [];
+		let index = 0;
+
+		for ( let newPosition of rangesOrder ) {
+			if ( ranges[ newPosition - 1 ] === undefined ) {
+				throw new Error( 'Parse error - provided ranges order is invalid.' );
+			}
+
+			sortedRanges[ newPosition - 1 ] = ranges[ index ];
+			index++;
+		}
+
+		return sortedRanges;
+	}
+
+	/**
+	 * Uses all found bracket positions to create ranges from them.
+	 *
+	 * @private
+	 * @returns {Array.<engine.treeView.Range}
+	 */
+	_createRanges() {
+		const ranges = [];
+		let range = null;
+
+		for ( let item of this._positions ) {
+			// When end of range is found without opening.
+			if ( !range && ( item.bracket == ELEMENT_RANGE_END_TOKEN || item.bracket == TEXT_RANGE_END_TOKEN ) ) {
+				throw new Error( `Parse error - end of range was found '${ item.bracket }' but range was not started before.` );
+			}
+
+			// When second start of range is found when one is already opened - selection does not allow intersecting
+			// ranges.
+			if ( range && ( item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN ) ) {
+				throw new Error( `Parse error - start of range was found '${ item.bracket }' but one range is already started.` );
+			}
+
+			if ( item.bracket == ELEMENT_RANGE_START_TOKEN || item.bracket == TEXT_RANGE_START_TOKEN ) {
+				range = new Range( item.position, item.position );
+			} else {
+				range.end = item.position;
+				ranges.push( range );
+				range = null;
+			}
+		}
+
+		// Check if all ranges have proper ending.
+		if ( range !== null ) {
+			throw new Error( 'Parse error - range was started but no end delimiter was found.' );
+		}
+
+		return ranges;
+	}
+}
+
+/**
+ * Private helper class used to convert given HTML-like string to view tree.
+ *
+ * @private
+ */
+class ViewParser {
+	/**
+	 * Parses HTML-like string to view tree elements.
+	 *
+	 * @param {String} data
+	 * @returns {engine.treeView.Node|engine.treeView.DocumentFragment}
+	 */
+	parse( data ) {
+		const htmlProcessor = new HtmlDataProcessor();
+
+		// Convert HTML string to DOM.
+		const domRoot = htmlProcessor.toDom( data );
+
+		// Convert DOM to View.
+		return this._walkDom( domRoot );
+	}
+
+	/**
+	 * Walks through DOM elements and converts them to tree view elements.
+	 *
+	 * @private
+	 * @param {Node} domNode
+	 * @returns {engine.treeView.Node|engine.treeView.DocumentFragment}
+	 */
+	_walkDom( domNode ) {
+		const isDomElement = domNode instanceof DomElement;
+
+		if ( isDomElement || domNode instanceof DomDocumentFragment ) {
+			const children = domNode.childNodes;
+			const length = children.length;
+
+			// If there is only one element inside DOM DocumentFragment - use it as root.
+			if ( !isDomElement && length == 1 ) {
+				return this._walkDom( domNode.childNodes[ 0 ] );
+			}
+
+			let viewElement;
+
+			if ( isDomElement ) {
+				viewElement = this._convertElement( domNode );
+			} else {
+				viewElement = new ViewDocumentFragment();
+			}
+
+			for ( let i = 0; i < children.length; i++ ) {
+				const child = children[ i ];
+				viewElement.appendChildren( this._walkDom( child ) );
+			}
+
+			return viewElement;
+		}
+
+		return new ViewText( domNode.textContent );
+	}
+
+	/**
+	 * Converts DOM Element to {engine.treeView.Element view Element}.
+	 *
+	 * @private
+	 * @param {Element} domElement DOM element to convert.
+	 * @returns {engine.treeView.Element|engine.treeView.AttributeElement|engine.treeView.ContainerElement} Tree view
+	 * element converted from DOM element.
+	 */
+	_convertElement( domElement ) {
+		const info = this._convertElementName( domElement );
+		let viewElement;
+
+		if ( info.type == 'attribute' ) {
+			viewElement = new AttributeElement( info.name );
+
+			if ( info.priority !== null ) {
+				viewElement.priority = info.priority;
+			}
+		} else if ( info.type == 'container' ) {
+			viewElement = new ContainerElement( info.name );
+		} else {
+			viewElement = new ViewElement( info.name );
+		}
+
+		const attributes = domElement.attributes;
+		const attributesCount = attributes.length;
+
+		for ( let i = 0; i < attributesCount; i++ ) {
+			const attribute = attributes[ i ];
+			viewElement.setAttribute( attribute.name, attribute.value );
+		}
+
+		return viewElement;
+	}
+
+	/**
+	 * Converts DOM element tag name to information needed for creating {@link engine.treeView.Element view Element} instance.
+	 * Name can be provided in couple formats: as a simple element's name (`div`), as a type and name (`container:div`,
+	 * `attribute:span`), as a name and priority (`span:12`) and as a type, priority, name trio (`attribute:span:12`);
+	 *
+	 * @private
+	 * @param {Element} element DOM Element which tag name should be converted.
+	 * @returns {Object} info Object with parsed information.
+	 * @returns {String} info.name Parsed name of the element.
+	 * @returns {String|null} info.type Parsed type of the element, can be `attribute` or `container`.
+	 * @returns {Number|null} info.priority Parsed priority of the element.
+	 */
+	_convertElementName( element ) {
+		const parts = element.tagName.toLowerCase().split( ':' );
+
+		if ( parts.length == 1 ) {
+			return {
+				name: parts[ 0 ],
+				type: null,
+				priority: null
+			};
+		}
+
+		if ( parts.length == 2 ) {
+			// Check if type and name: container:div.
+			const type = this._convertType( parts[ 0 ] );
+
+			if ( type ) {
+				return {
+					name: parts[ 1 ],
+					type: type,
+					priority: null
+				};
+			}
+
+			// Check if name and priority: span:10.
+			const priority = this._convertPriority( parts[ 1 ] );
+
+			if ( priority !== null ) {
+				return {
+					name: parts[ 0 ],
+					type: 'attribute',
+					priority: priority
+				};
+			}
+
+			throw new Error( `Parse error - cannot parse element's tag name: ${ element.tagName.toLowerCase() }.` );
+		}
+
+		// Check if name is in format type:name:priority.
+		if ( parts.length === 3 ) {
+			const type = this._convertType( parts[ 0 ] );
+			const priority = this._convertPriority( parts[ 2 ] );
+
+			if ( type && priority !== null ) {
+				return {
+					name: parts[ 1 ],
+					type: type,
+					priority: priority
+				};
+			}
+		}
+
+		throw new Error( `Parse error - cannot parse element's tag name: ${ element.tagName.toLowerCase() }.` );
+	}
+
+	/**
+	 * Checks if element's type is allowed. Returns `attribute`, `container` or `null`.
+	 *
+	 * @private
+	 * @param {String} type
+	 * @returns {String|null}
+	 */
+	_convertType( type ) {
+		if ( type == 'container' || type == 'attribute' ) {
+			return type;
+		}
+
+		return null;
+	}
+
+	/**
+	 * Checks if given priority is allowed. Returns null if priority cannot be converted.
+	 *
+	 * @private
+	 * @param {String} priorityString
+	 * @returns {Number|Null}
+	 */
+	_convertPriority( priorityString ) {
+		const priority = parseInt( priorityString, 10 );
+
+		if ( !isNaN( priority ) ) {
+			return priority;
+		}
+
+		return null;
+	}
+
+}
+
+/**
+ * Private helper class used for converting view tree to string.
+ *
+ * @private
+ */
+class ViewStringify {
+	/**
+	 * Creates ViewStringify instance.
+	 *
+	 * @param root
+	 * @param {engine.treeView.Selection} [selection=null] Selection which ranges should be also converted to string.
+	 * @param {Object} [options] Options object.
+	 * @param {Boolean} [options.showType=false] When set to `true` type of elements will be printed ( `<container:p>`
+	 * instead of `<p>` and `<attribute:b>` instead of `<b>`.
+	 * @param {Boolean} [options.showPriority=false] When set to `true` AttributeElement's priority will be printed.
+	 */
+	constructor( root, selection = null, options = {} ) {
+		this.root = root;
+		this.selection = selection;
+		this.ranges = [];
+
+		if ( this.selection ) {
+			this.ranges = [ ...selection.getRanges() ];
+		}
+
+		this.showType = !!options.showType;
+		this.showPriority = !!options.showPriority;
+	}
+
+	/**
+	 * Converts view to string.
+	 *
+	 * @returns {String} String representation of the view elements.
+	 */
+	stringify() {
+		let result = '';
+		this._walkView( this.root, ( chunk ) => {
+			result += chunk;
+		} );
+
+		return result;
+	}
+
+	/**
+	 * Executes simple walker that iterates over all elements in the view tree starting from root element.
+	 * Calls `callback` with parsed chunks of string data.
+	 *
+	 * @private
+	 * @param {engine.treeView.DocumentFragment|engine.treeView.Element|engine.treeView.Text} root
+	 * @param {Function} callback
+	 */
+	_walkView( root, callback ) {
+		const isElement = root instanceof ViewElement;
+
+		if ( isElement || root instanceof ViewDocumentFragment ) {
+			if ( isElement ) {
+				callback( this._stringifyElementOpen( root ) );
+			}
+
+			let offset = 0;
+			callback( this._stringifyElementRanges( root, offset ) );
+
+			for ( let child of root.getChildren() ) {
+				this._walkView( child, callback );
+				offset++;
+				callback( this._stringifyElementRanges( root, offset ) );
+			}
+
+			if ( isElement ) {
+				callback( this._stringifyElementClose( root ) );
+			}
+		}
+
+		if ( root instanceof ViewText ) {
+			callback( this._stringifyTextRanges( root ) );
+		}
+	}
+
+	/**
+	 * Checks if given {@link engine.treeView.Element Element} has {@link engine.treeView.Range#start range start} or
+	 * {@link engine.treeView.Range#start range end} placed at given offset and returns its string representation.
+	 *
+	 * @private
+	 * @param {engine.treeView.Element} element
+	 * @param {Number} offset
+	 */
+	_stringifyElementRanges( element, offset ) {
+		let start = '';
+		let end = '';
+		let collapsed = '';
+
+		for ( let range of this.ranges ) {
+			if ( range.start.parent == element && range.start.offset === offset ) {
+				if ( range.isCollapsed ) {
+					collapsed += ELEMENT_RANGE_START_TOKEN + ELEMENT_RANGE_END_TOKEN;
+				} else {
+					start += ELEMENT_RANGE_START_TOKEN;
+				}
+			}
+
+			if ( range.end.parent === element && range.end.offset === offset && !range.isCollapsed ) {
+				end += ELEMENT_RANGE_END_TOKEN;
+			}
+		}
+
+		return end + collapsed + start;
+	}
+
+	/**
+	 * Checks if given {@link engine.treeView.Element Text node} has {@link engine.treeView.Range#start range start} or
+	 * {@link engine.treeView.Range#start range end} placed somewhere inside. Returns string representation of text
+	 * with range delimiters placed inside.
+	 *
+	 * @private
+	 * @param {engine.treeView.Text} node
+	 */
+	_stringifyTextRanges( node ) {
+		const length = node.data.length;
+		let result = node.data.split( '' );
+
+		// Add one more element for ranges ending after last character in text.
+		result[ length ] = '';
+
+		// Represent each letter as object with information about opening/closing ranges at each offset.
+		result = result.map( ( letter ) => {
+			return {
+				letter: letter,
+				start: '',
+				end: '',
+				collapsed: ''
+			};
+		}  );
+
+		for ( let range of this.ranges ) {
+			const start = range.start;
+			const end = range.end;
+
+			if ( start.parent == node && start.offset >= 0 && start.offset <= length ) {
+				if ( range.isCollapsed ) {
+					result[ end.offset ].collapsed += TEXT_RANGE_START_TOKEN + TEXT_RANGE_END_TOKEN;
+				} else {
+					result[ start.offset ].start += TEXT_RANGE_START_TOKEN;
+				}
+			}
+
+			if ( end.parent == node && end.offset >= 0 && end.offset <= length && !range.isCollapsed  ) {
+				result[ end.offset ].end += TEXT_RANGE_END_TOKEN;
+			}
+		}
+
+		return result.map( item => item.end + item.collapsed + item.start + item.letter ).join( '' );
+	}
+
+	/**
+	 * Converts passed {@link engine.treeView.Element Element} to opening tag.
+	 * Depending on current configuration opening tag can be simple (`<a>`), contain type prefix (`<container:p>` or
+	 * `<attribute:a>`), contain priority information ( `<attribute:a priority=20>` ). Element's attributes also
+	 * will be included (`<a href="http://ckeditor.com" name="foobar">`).
+	 *
+	 * @private
+	 * @param {engine.treeView.Element} element
+	 * @returns {String}
+	 */
+	_stringifyElementOpen( element ) {
+		const priority = this._stringifyElementPriority( element );
+		const type = this._stringifyElementType( element );
+		const name = [ type, element.name, priority ].filter( i=> i !== '' ).join( ':' );
+		const attributes = this._stringifyElementAttributes( element );
+		const parts = [ name, attributes ];
+
+		return `<${ parts.filter( i => i !== '' ).join( ' ' ) }>`;
+	}
+
+	/**
+	 * Converts passed {@link engine.treeView.Element Element} to closing tag.
+	 * Depending on current configuration opening tag can be simple (`</a>`) or contain type prefix (`</container:p>` or
+	 * `</attribute:a>`).
+	 *
+	 * @private
+	 * @param {engine.treeView.Element} element
+	 * @returns {String}
+	 */
+	_stringifyElementClose( element ) {
+		const priority = this._stringifyElementPriority( element );
+		const type = this._stringifyElementType( element );
+		const name = [ type, element.name, priority ].filter( i=> i !== '' ).join( ':' );
+
+		return `</${ name }>`;
+	}
+
+	/**
+	 * Converts passed {@link engine.treeView.Element Element's} type to its string representation
+	 * Returns 'attribute' for {@link engine.treeView.AttributeElement AttributeElements} and
+	 * 'container' for {@link engine.treeView.ContainerElement ContainerElements}. Returns empty string when current
+	 * configuration is preventing showing elements' types.
+	 *
+	 * @private
+	 * @param {engine.treeView.Element} element
+	 * @returns {String}
+	 */
+	_stringifyElementType( element ) {
+		if ( this.showType ) {
+			if ( element instanceof AttributeElement ) {
+				return 'attribute';
+			}
+
+			if ( element instanceof ContainerElement ) {
+				return 'container';
+			}
+		}
+
+		return '';
+	}
+
+	/**
+	 * Converts passed {@link engine.treeView.Element Element} to its priority representation.
+	 * Priority string representation will be returned when passed element is an instance of
+	 * {@link engine.treeView.AttributeElement AttributeElement} and current configuration allow to show priority.
+	 * Otherwise returns empty string.
+	 *
+	 * @private
+	 * @param {engine.treeView.Element} element
+	 * @returns {String}
+	 */
+	_stringifyElementPriority( element ) {
+		if ( this.showPriority && element instanceof AttributeElement ) {
+			return element.priority;
+		}
+
+		return '';
+	}
+
+	/**
+	 * Converts passed {@link engine.treeView.Element Element} attributes to their string representation.
+	 * If element has no attributes - empty string is returned.
+	 *
+	 * @private
+	 * @param {engine.treeView.Element} element
+	 * @returns {String}
+	 */
+	_stringifyElementAttributes( element ) {
+		const attributes = [];
+
+		for ( let attribute of element.getAttributeKeys() ) {
+			attributes.push( `${ attribute }="${ element.getAttribute( attribute ) }"` );
+		}
+
+		return attributes.join( ' ' );
+	}
+}

+ 2 - 1
packages/ckeditor5-engine/tests/treecontroller/advanced-converters.js

@@ -54,7 +54,8 @@ beforeEach( () => {
 	writer = new ViewWriter();
 
 	modelDispatcher = new ModelConversionDispatcher( { mapper, writer } );
-	viewDispatcher = new ViewConversionDispatcher();
+	// 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() );

+ 309 - 0
packages/ckeditor5-engine/tests/treecontroller/model-converter-builder.js

@@ -0,0 +1,309 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+import BuildModelConverterFor from '/ckeditor5/engine/treecontroller/model-converter-builder.js';
+
+import ModelDocument from '/ckeditor5/engine/treemodel/document.js';
+import ModelElement from '/ckeditor5/engine/treemodel/element.js';
+import ModelText from '/ckeditor5/engine/treemodel/text.js';
+import ModelRange from '/ckeditor5/engine/treemodel/range.js';
+
+import ViewElement from '/ckeditor5/engine/treeview/element.js';
+import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ViewText from '/ckeditor5/engine/treeview/text.js';
+import ViewWriter from '/ckeditor5/engine/treeview/writer.js';
+
+import Mapper from '/ckeditor5/engine/treecontroller/mapper.js';
+import ModelConversionDispatcher from '/ckeditor5/engine/treecontroller/modelconversiondispatcher.js';
+
+import {
+	insertText,
+	move,
+	remove
+} from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
+
+function viewAttributesToString( item ) {
+	let result = '';
+
+	for ( let key of item.getAttributeKeys() ) {
+		let value = item.getAttribute( key );
+
+		if ( value ) {
+			result += ' ' + key + '="' + value + '"';
+		}
+	}
+
+	return 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 ViewElement ) {
+			result = '<' + item.name + viewAttributesToString( item ) + '>' + result + '</' + item.name + '>';
+		}
+	}
+
+	return result;
+}
+
+describe( 'Model converter builder', () => {
+	let dispatcher, modelDoc, modelRoot, viewRoot, mapper, writer;
+
+	beforeEach( () => {
+		modelDoc = new ModelDocument();
+		modelRoot = modelDoc.createRoot( 'root', 'root' );
+
+		viewRoot = new ViewContainerElement( 'div' );
+
+		mapper = new Mapper();
+		mapper.bindElements( modelRoot, viewRoot );
+
+		writer = new ViewWriter();
+
+		dispatcher = new ModelConversionDispatcher( { writer, mapper } );
+
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'move', move() );
+		dispatcher.on( 'remove', remove() );
+	} );
+
+	describe( 'model element to view element conversion', () => {
+		it( 'using passed view element name', () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+
+			let modelElement = new ModelElement( 'paragraph', null, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed view element', () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'image' ).toElement( new ViewContainerElement( 'img' ) );
+
+			let modelElement = new ModelElement( 'image' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><img></img></div>' );
+		} );
+
+		it( 'using passed creator function', () => {
+			BuildModelConverterFor( dispatcher )
+				.fromElement( 'header' )
+				.toElement( ( data ) => new ViewContainerElement( 'h' + data.item.getAttribute( 'level' ) ) );
+
+			let modelElement = new ModelElement( 'header', { level: 2 }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><h2>foobar</h2></div>' );
+		} );
+	} );
+
+	describe( 'model attribute to view element conversion', () => {
+		beforeEach( () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+		} );
+
+		it( 'using passed view element name', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' );
+
+			let modelElement = new ModelText( 'foo', { bold: true } );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
+
+			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
+				value.item.removeAttribute( 'bold' );
+			}
+
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'bold', true, null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
+		} );
+
+		it( 'using passed view element', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).toElement( new ViewAttributeElement( 'strong' ) );
+
+			let modelElement = new ModelText( 'foo', { bold: true } );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
+
+			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
+				value.item.removeAttribute( 'bold' );
+			}
+
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'bold', true, null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
+		} );
+
+		it( 'using passed creator function', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'italic' ).toElement( ( value ) => new ViewAttributeElement( value ) );
+
+			let modelElement = new ModelText( 'foo', { italic: 'em' } );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
+
+			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
+				value.item.setAttribute( 'italic', 'i' );
+			}
+
+			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createFromElement( modelRoot ), 'italic', 'em', 'i' );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><i>foo</i></div>' );
+
+			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
+				value.item.removeAttribute();
+			}
+
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'italic', 'i', null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
+		} );
+	} );
+
+	describe( 'model attribute to view attribute conversion', () => {
+		beforeEach( () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+		} );
+
+		it( 'using default 1-to-1 conversion', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'class' ).toAttribute();
+
+			let modelElement = new ModelElement( 'paragraph', { class: 'myClass' }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="myClass">foobar</p></div>' );
+
+			modelElement.setAttribute( 'class', 'newClass' );
+			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'class', 'myClass', 'newClass' );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="newClass">foobar</p></div>' );
+
+			modelElement.removeAttribute( 'class' );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'class', 'newClass', null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed attribute key', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'theme' ).toAttribute( 'class' );
+
+			let modelElement = new ModelElement( 'paragraph', { theme: 'abc' }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="abc">foobar</p></div>' );
+
+			modelElement.setAttribute( 'theme', 'xyz' );
+			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'theme', 'abc', 'xyz' );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="xyz">foobar</p></div>' );
+
+			modelElement.removeAttribute( 'theme' );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'theme', 'xyz', null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed attribute key and value', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'highlighted' ).toAttribute( 'style', 'background:yellow' );
+
+			let modelElement = new ModelElement( 'paragraph', { 'highlighted': true }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p style="background:yellow;">foobar</p></div>' );
+
+			modelElement.removeAttribute( 'highlighted' );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'highlighted', true, null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed attribute creator function', () => {
+			BuildModelConverterFor( dispatcher )
+				.fromAttribute( 'theme' )
+				.toAttribute( ( value ) => ( { key: 'class', value: value + '-theme' } ) );
+
+			let modelElement = new ModelElement( 'paragraph', { theme: 'nice' }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="nice-theme">foobar</p></div>' );
+
+			modelElement.setAttribute( 'theme', 'good' );
+			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'theme', 'nice', 'good' );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="good-theme">foobar</p></div>' );
+
+			modelElement.removeAttribute( 'theme' );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'theme', 'good', null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+	} );
+
+	describe( 'withPriority', () => {
+		it( 'should change default converters priority', () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'custom' ).toElement( 'custom' );
+			BuildModelConverterFor( dispatcher ).fromElement( 'custom' ).withPriority( 0 ).toElement( 'other' );
+
+			let modelElement = new ModelElement( 'custom', null, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><other>foobar</other></div>' );
+		} );
+	} );
+
+	it( 'should do nothing on model element to view attribute conversion', () => {
+		BuildModelConverterFor( dispatcher ).fromElement( 'div' ).toElement( 'div' );
+		// Should do nothing:
+		BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true );
+		// If above would do something this one would not be fired:
+		BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+
+		let modelElement = new ModelElement( 'div', null, new ModelElement( 'paragraph', null, 'foobar' ) );
+		modelRoot.appendChildren( modelElement );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div><p>foobar</p></div></div>' );
+	} );
+} );

+ 4 - 7
packages/ckeditor5-engine/tests/treecontroller/model-to-view-converters.js

@@ -158,15 +158,12 @@ describe( 'setAttribute/removeAttribute', () => {
 		const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, 'foobar' );
 		const modelDiv = new ModelElement( 'div', { theme: 'nice' } );
 
-		const themeConverter = ( data ) => {
-			const key = 'class';
-			let value = data.attributeNewValue;
-
-			if ( value && data.item instanceof ModelElement && data.item.getChildCount() > 0 ) {
+		const themeConverter = ( value, key, data ) => {
+			if ( data.item instanceof ModelElement && data.item.getChildCount() > 0 ) {
 				value += ' ' + 'fix-content';
 			}
 
-			return { key, value };
+			return { key: 'class', value };
 		};
 
 		modelRoot.appendChildren( [ modelParagraph, modelDiv ] );
@@ -194,7 +191,7 @@ describe( 'setAttribute/removeAttribute', () => {
 } );
 
 describe( 'wrap/unwrap', () => {
-	it( 'should convert insert/remove of attribute in model into wrapping element in a view', () => {
+	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' );

+ 0 - 66
packages/ckeditor5-engine/tests/treecontroller/modelconversiondispatcher.js

@@ -39,10 +39,6 @@ describe( 'ModelConversionDispatcher', () => {
 
 		let image, imagePos;
 
-		//const flatRangeMatcher = ( parent, start, end ) => sinon.match(
-		//	( matched ) => ModelRange.createFromParentsAndOffsets( parent, start, parent, end ).isEqual( matched )
-		//);
-
 		beforeEach( () => {
 			image = new ModelElement( 'image' );
 			root.appendChildren( [ image, 'foobar' ] );
@@ -59,8 +55,6 @@ describe( 'ModelConversionDispatcher', () => {
 			const cbInsertImage = sinon.spy();
 			const cbAddAttribute = sinon.spy();
 
-			//sinon.spy( dispatcher, 'convertInsert' );
-
 			dispatcher.on( 'insert:$text', cbInsertText );
 			dispatcher.on( 'insert:image', cbInsertImage );
 			dispatcher.on( 'addAttribute:key:$text', cbAddAttribute );
@@ -71,8 +65,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbInsertText.called ).to.be.true;
 			expect( cbAddAttribute.called ).to.be.true;
 			expect( cbInsertImage.called ).to.be.false;
-
-			//expect( dispatcher.convertInsert.calledWith( flatRangeMatcher( root, 0, 3 ) ) ).to.be.true;
 		} );
 
 		it( 'should fire insert and addAttribute callbacks for reinsertion changes', () => {
@@ -89,8 +81,6 @@ describe( 'ModelConversionDispatcher', () => {
 			const cbInsertImage = sinon.spy();
 			const cbAddAttribute = sinon.spy();
 
-			//sinon.spy( dispatcher, 'convertInsert' );
-
 			dispatcher.on( 'insert:$text', cbInsertText );
 			dispatcher.on( 'insert:image', cbInsertImage );
 			dispatcher.on( 'addAttribute:key:image', cbAddAttribute );
@@ -100,8 +90,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbInsertImage.called ).to.be.true;
 			expect( cbAddAttribute.called ).to.be.true;
 			expect( cbInsertText.called ).to.be.false;
-
-			//expect( dispatcher.convertInsert.calledWith( flatRangeMatcher( root, 0, 1 ) ) ).to.be.true;
 		} );
 
 		it( 'should fire move callback for move changes', () => {
@@ -109,18 +97,9 @@ describe( 'ModelConversionDispatcher', () => {
 
 			dispatcher.on( 'move', cbMove );
 
-			//sinon.spy( dispatcher, 'convertMove' );
-
 			doc.batch().move( image, imagePos.getShiftedBy( 3 ) );
 
 			expect( cbMove.called );
-
-			//expect(
-			//	dispatcher.convertMove.calledWith(
-			//		sinon.match( ( position ) => imagePos.isEqual( position ) ),
-			//		flatRangeMatcher( root, 3, 4 )
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should fire remove callback for remove changes', () => {
@@ -128,18 +107,9 @@ describe( 'ModelConversionDispatcher', () => {
 
 			dispatcher.on( 'remove', cbRemove );
 
-			//sinon.spy( dispatcher, 'convertRemove' );
-
 			doc.batch().remove( image );
 
 			expect( cbRemove.called );
-
-			//expect(
-			//	dispatcher.convertRemove.calledWith(
-			//		sinon.match( ( position ) => imagePos.isEqual( position ) ),
-			//		flatRangeMatcher( doc.graveyard, 0, 1 )
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should fire addAttribute callbacks for add attribute change', () => {
@@ -149,8 +119,6 @@ describe( 'ModelConversionDispatcher', () => {
 			dispatcher.on( 'addAttribute:key:$text', cbAddText );
 			dispatcher.on( 'addAttribute:key:image', cbAddImage );
 
-			//sinon.spy( dispatcher, 'convertAttribute' );
-
 			doc.batch().setAttr( 'key', 'value', image );
 
 			// Callback for adding attribute on text not called.
@@ -162,16 +130,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbAddText.calledOnce ).to.be.true;
 			// Callback for adding attribute on image not called this time.
 			expect( cbAddImage.calledOnce ).to.be.true;
-
-			//expect(
-			//	dispatcher.convertAttribute.calledWith(
-			//		'addAttribute',
-			//		flatRangeMatcher( root, 3, 4 ),
-			//		'key',
-			//		null,
-			//		'value'
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should fire changeAttribute callbacks for change attribute change', () => {
@@ -181,8 +139,6 @@ describe( 'ModelConversionDispatcher', () => {
 			dispatcher.on( 'changeAttribute:key:$text', cbChangeText );
 			dispatcher.on( 'changeAttribute:key:image', cbChangeImage );
 
-			//sinon.spy( dispatcher, 'convertAttribute' );
-
 			doc.batch().setAttr( 'key', 'value', image ).setAttr( 'key', 'newValue', image );
 
 			// Callback for adding attribute on text not called.
@@ -195,16 +151,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbChangeText.calledOnce ).to.be.true;
 			// Callback for adding attribute on image not called this time.
 			expect( cbChangeImage.calledOnce ).to.be.true;
-
-			//expect(
-			//	dispatcher.convertAttribute.calledWith(
-			//		'changeAttribute',
-			//		flatRangeMatcher( root, 3, 4 ),
-			//		'key',
-			//		'value',
-			//		'newValue'
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should fire removeAttribute callbacks for remove attribute change', () => {
@@ -214,8 +160,6 @@ describe( 'ModelConversionDispatcher', () => {
 			dispatcher.on( 'removeAttribute:key:$text', cbRemoveText );
 			dispatcher.on( 'removeAttribute:key:image', cbRemoveImage );
 
-			//sinon.spy( dispatcher, 'convertAttribute' );
-
 			doc.batch().setAttr( 'key', 'value', image ).removeAttr( 'key', image );
 
 			// Callback for adding attribute on text not called.
@@ -228,16 +172,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbRemoveText.calledOnce ).to.be.true;
 			// Callback for adding attribute on image not called this time.
 			expect( cbRemoveImage.calledOnce ).to.be.true;
-
-			//expect(
-			//	dispatcher.convertAttribute.calledWith(
-			//		'removeAttribute',
-			//		flatRangeMatcher( root, 3, 4 ),
-			//		'key',
-			//		'value',
-			//		null
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should not fire any event if not recognized event type was passed', () => {

+ 368 - 0
packages/ckeditor5-engine/tests/treecontroller/view-converter-builder.js

@@ -0,0 +1,368 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+import BuildViewConverterFor from '/ckeditor5/engine/treecontroller/view-converter-builder.js';
+
+import ModelSchema from '/ckeditor5/engine/treemodel/schema.js';
+import ModelDocument from '/ckeditor5/engine/treemodel/document.js';
+import ModelElement from '/ckeditor5/engine/treemodel/element.js';
+import ModelTextProxy from '/ckeditor5/engine/treemodel/textproxy.js';
+import ModelRange from '/ckeditor5/engine/treemodel/range.js';
+import ModelWalker from '/ckeditor5/engine/treemodel/treewalker.js';
+
+import ViewDocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
+import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ViewText from '/ckeditor5/engine/treeview/text.js';
+import ViewMatcher from '/ckeditor5/engine/treeview/matcher.js';
+
+import ViewConversionDispatcher from '/ckeditor5/engine/treecontroller/viewconversiondispatcher.js';
+
+import { convertToModelFragment, convertText } from '/ckeditor5/engine/treecontroller/view-to-model-converters.js';
+
+function modelAttributesToString( item ) {
+	let result = '';
+
+	for ( let attr of item.getAttributes() ) {
+		result += ' ' + attr[ 0 ] + '="' + attr[ 1 ] + '"';
+	}
+
+	return result;
+}
+
+function modelToString( item ) {
+	let result = '';
+
+	if ( item instanceof ModelTextProxy ) {
+		let attributes = modelAttributesToString( item );
+
+		result = attributes ? '<$text' + attributes + '>' + item.text + '</$text>' : item.text;
+	} else {
+		let walker = new ModelWalker( { boundaries: ModelRange.createFromElement( item ), shallow: true } );
+
+		for ( let value of walker ) {
+			result += modelToString( value.item );
+		}
+
+		if ( item instanceof ModelElement ) {
+			let attributes = modelAttributesToString( item );
+
+			result = '<' + item.name + attributes + '>' + result + '</' + item.name + '>';
+		}
+	}
+
+	return result;
+}
+
+const textAttributes = [ undefined, 'linkHref', 'linkTitle', 'bold', 'italic', 'style' ];
+const pAttributes = [ undefined, 'class', 'important', 'theme', 'decorated', 'size' ];
+
+describe( 'View converter builder', () => {
+	let dispatcher, modelDoc, modelRoot, schema, objWithContext;
+
+	beforeEach( () => {
+		// `additionalData` parameter for `.convert` calls.
+		objWithContext = { context: [ '$root' ] };
+
+		schema = new ModelSchema();
+
+		schema.registerItem( 'paragraph', '$block' );
+		schema.registerItem( 'div', '$block' );
+		schema.registerItem( 'customP', 'paragraph' );
+		schema.registerItem( 'image', '$inline' );
+		schema.registerItem( 'span', '$inline' );
+		schema.registerItem( 'MEGATRON', '$inline' ); // Yes, folks, we are building MEGATRON.
+		schema.registerItem( 'abcd', '$inline' );
+		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', attributes: textAttributes, inside: '$block' } );
+		schema.allow( { name: '$text', attributes: textAttributes, inside: '$root' } );
+		schema.allow( { name: 'paragraph', attributes: pAttributes, inside: '$root' } );
+		schema.allow( { name: 'span', attributes: [ 'transformer' ], inside: '$root' } );
+		schema.allow( { name: 'div', attributes: [ 'class' ], inside: '$root' } );
+
+		dispatcher = new ViewConversionDispatcher( { schema } );
+		dispatcher.on( 'text', convertText() );
+
+		modelDoc = new ModelDocument();
+		modelRoot = modelDoc.createRoot( 'root', '$root' );
+	} );
+
+	it( 'should convert from view element to model element', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		const result = dispatcher.convert( new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
+	} );
+
+	it( 'should convert from view element to model element using creator function', () => {
+		BuildViewConverterFor( dispatcher )
+			.fromElement( 'img' )
+			.toElement( ( viewElement ) => new ModelElement( 'image', { src: viewElement.getAttribute( 'src' ) } ) );
+
+		const result = dispatcher.convert( new ViewContainerElement( 'img', { src: 'foo.jpg' } ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<image src="foo.jpg"></image>' );
+	} );
+
+	it( 'should convert from view element to model attribute', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'strong' ).toAttribute( 'bold', true );
+
+		const result = dispatcher.convert( new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		// Have to check root because result is a ModelText.
+		expect( modelToString( modelRoot ) ).to.equal( '<$root><$text bold="true">foo</$text></$root>' );
+	} );
+
+	it( 'should convert from view element to model attributes using creator function', () => {
+		BuildViewConverterFor( dispatcher )
+			.fromElement( 'a' )
+			.toAttribute( ( viewElement ) => ( { key: 'linkHref', value: viewElement.getAttribute( 'href' ) } ) );
+
+		const result = dispatcher.convert( new ViewAttributeElement( 'a', { href: 'foo.html' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		// Have to check root because result is a ModelText.
+		expect( modelToString( modelRoot ) ).to.equal( '<$root><$text linkHref="foo.html">foo</$text></$root>' );
+	} );
+
+	it( 'should convert from view attribute to model attribute', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		BuildViewConverterFor( dispatcher )
+			.fromAttribute( 'class' )
+			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+
+		const result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
+	} );
+
+	it( 'should convert from view attribute and key to model attribute', () => {
+		dispatcher.on( 'documentFragment', convertToModelFragment() );
+
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+		BuildViewConverterFor( dispatcher ).fromAttribute( 'class', 'important' ).toAttribute( 'important', true );
+		BuildViewConverterFor( dispatcher ).fromAttribute( 'class', 'theme-nice' ).toAttribute( 'theme', 'nice' );
+
+		const viewStructure = new ViewDocumentFragment( [
+			new ViewContainerElement( 'p', { class: 'important' }, new ViewText( 'foo' ) ),
+			new ViewContainerElement( 'p', { class: 'important theme-nice' }, new ViewText( 'bar' ) )
+		] );
+
+		const result = dispatcher.convert( viewStructure, objWithContext );
+
+		expect( modelToString( result ) )
+			.to.equal( '<paragraph important="true">foo</paragraph><paragraph important="true" theme="nice">bar</paragraph>' );
+	} );
+
+	it( 'should convert from multiple view entities to model attribute', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		BuildViewConverterFor( dispatcher )
+			.fromElement( 'strong' )
+			.fromElement( 'b' )
+			.fromAttribute( 'class', 'bold' )
+			.fromAttribute( 'style', { 'font-weight': 'bold' } )
+			.toAttribute( 'bold', true );
+
+		const viewElement = new ViewContainerElement( 'p', null, [
+			new ViewAttributeElement( 'strong', null, new ViewText( 'aaa' ) ),
+			new ViewAttributeElement( 'b', null, new ViewText( 'bbb' ) ),
+			new ViewContainerElement( 'span', { class: 'bold' }, new ViewText( 'ccc' ) ),
+			new ViewContainerElement( 'span', { style: 'font-weight:bold; font-size:20px' }, new ViewText( 'ddd' ) )
+		] );
+
+		const result = dispatcher.convert( viewElement, objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<paragraph><$text bold="true">aaabbbcccddd</$text></paragraph>' );
+	} );
+
+	it( 'should convert from pattern to model element', () => {
+		BuildViewConverterFor( dispatcher ).from(
+			{ name: 'span', class: 'megatron', attribute: { head: 'megatron', body: 'megatron', legs: 'megatron' } }
+		).toElement( 'MEGATRON' );
+
+		// Adding callbacks later so they are called later. MEGATRON callback is more important.
+		BuildViewConverterFor( dispatcher ).fromElement( 'span' ).toElement( 'span' );
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		let result;
+
+		// Not quite megatron.
+		result = dispatcher.convert( new ViewContainerElement( 'span', { class: 'megatron' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<span>foo</span>' );
+
+		// Almost a megatron. Missing a head.
+		result = dispatcher.convert(
+			new ViewContainerElement( 'span', { class: 'megatron', body: 'megatron', legs: 'megatron' }, new ViewText( 'foo' ) ),
+			objWithContext
+		);
+
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<span>foo</span>' );
+
+		// This would be a megatron but is a paragraph.
+		result = dispatcher.convert(
+			new ViewContainerElement(
+				'p',
+				{ class: 'megatron', body: 'megatron', legs: 'megatron', head: 'megatron' },
+				new ViewText( 'foo' )
+			),
+			objWithContext
+		);
+
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
+
+		// At last we have a megatron!
+		result = dispatcher.convert(
+			new ViewContainerElement(
+				'span',
+				{ class: 'megatron', body: 'megatron', legs: 'megatron', head: 'megatron' },
+				new ViewText( 'foo' )
+			),
+			objWithContext
+		);
+
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<MEGATRON>foo</MEGATRON>' );
+	} );
+
+	it( 'should convert from pattern to model attribute', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'span' ).toElement( 'span' );
+
+		// This time without name so default span converter will convert children.
+		BuildViewConverterFor( dispatcher )
+			.from( { class: 'megatron', attribute: { head: 'megatron', body: 'megatron', legs: 'megatron' } } )
+			.toAttribute( 'transformer', 'megatron' );
+
+		let viewElement = new ViewContainerElement(
+			'span',
+			{ class: 'megatron', body: 'megatron', legs: 'megatron', head: 'megatron' },
+			new ViewText( 'foo' )
+		);
+
+		let result = dispatcher.convert( viewElement, objWithContext );
+
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<span transformer="megatron">foo</span>' );
+	} );
+
+	it( 'should set different priorities for `toElement` and `toAttribute` conversion', () => {
+		BuildViewConverterFor( dispatcher )
+			.fromAttribute( 'class' )
+			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		let result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		// Element converter was fired first even though attribute converter was added first.
+		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
+	} );
+
+	it( 'should overwrite default priorities for converters', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+		BuildViewConverterFor( dispatcher )
+			.fromAttribute( 'class' )
+			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+
+		let result;
+
+		result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
+
+		BuildViewConverterFor( dispatcher )
+			.from( { name: 'p', class: 'myClass' } ).withPriority( -1 ) // Default for `toElement` is 0.
+			.toElement( 'customP' );
+
+		result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<customP>foo</customP>' );
+	} );
+
+	it( 'should overwrite default consumed values', () => {
+		// Converter (1).
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		// Converter (2).
+		BuildViewConverterFor( dispatcher )
+			.from( { name: 'p', class: 'decorated' } ).consuming( { class: 'decorated' } )
+			.toAttribute( 'decorated', true );
+
+		// Converter (3).
+		BuildViewConverterFor( dispatcher )
+			.fromAttribute( 'class', 'small' ).consuming( { class: 'small' } )
+			.toAttribute( 'size', 'small' );
+
+		const viewElement = new ViewContainerElement( 'p', { class: 'decorated small' }, new ViewText( 'foo' ) );
+
+		const result = dispatcher.convert( viewElement, objWithContext );
+		modelRoot.appendChildren( result );
+
+		// P element and it's children got converted by the converter (1) and the converter (1) got fired
+		// because P name was not consumed in converter (2). Converter (3) could consume class="small" because
+		// only class="decorated" was consumed in converter (2).
+		expect( modelToString( result ) ).to.equal( '<paragraph decorated="true" size="small">foo</paragraph>' );
+	} );
+
+	it( 'should convert from matcher instance to model', () => {
+		// Universal class converter, synonymous to .fromAttribute( 'class' ).
+		BuildViewConverterFor( dispatcher )
+			.from( new ViewMatcher( { class: /.*/ } ) )
+			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+
+		// Universal element converter.
+		BuildViewConverterFor( dispatcher )
+			.from( new ViewMatcher( { name: /.*/ } ) )
+			.toElement( ( viewElement ) => new ModelElement( viewElement.name ) );
+
+		let viewStructure = new ViewContainerElement( 'div', { class: 'myClass' }, [
+			new ViewContainerElement( 'abcd', null, new ViewText( 'foo' ) )
+		] );
+
+		let result = dispatcher.convert( viewStructure, objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<div class="myClass"><abcd>foo</abcd></div>' );
+	} );
+
+	it( 'should filter out structure that is wrong with schema', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'strong' ).toAttribute( 'bold', true );
+		BuildViewConverterFor( dispatcher ).fromElement( 'div' ).toElement( 'div' );
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		schema.disallow( { name: '$text', attributes: 'bold', inside: 'paragraph' } );
+		schema.disallow( { name: 'div', inside: '$root' } );
+
+		dispatcher.on( 'element', convertToModelFragment() );
+
+		let viewElement = new ViewContainerElement( 'div', null,
+			new ViewContainerElement( 'p', null,
+				new ViewAttributeElement( 'strong', null,
+					new ViewText( 'foo' )
+				)
+			)
+		);
+
+		let result = dispatcher.convert( viewElement, objWithContext );
+
+		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
+	} );
+} );

+ 30 - 7
packages/ckeditor5-engine/tests/treecontroller/view-to-model-converters.js

@@ -12,16 +12,21 @@ import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js
 import ViewDocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
 import ViewText from '/ckeditor5/engine/treeview/text.js';
 
+import ModelSchema from '/ckeditor5/engine/treemodel/schema.js';
 import ModelDocumentFragment from '/ckeditor5/engine/treemodel/documentfragment.js';
 import ModelElement from '/ckeditor5/engine/treemodel/element.js';
 import ModelText from '/ckeditor5/engine/treemodel/text.js';
 
 import { convertToModelFragment, convertText } from '/ckeditor5/engine/treecontroller/view-to-model-converters.js';
 
-let dispatcher;
+let dispatcher, schema, objWithContext;
 
 beforeEach( () => {
-	dispatcher = new ViewConversionDispatcher();
+	schema = new ModelSchema();
+	schema.registerItem( 'paragraph', '$block' );
+	schema.allow( { name: '$text', inside: '$root' } );
+	objWithContext = { context: [ '$root' ] };
+	dispatcher = new ViewConversionDispatcher( { schema } );
 } );
 
 describe( 'convertText', () => {
@@ -30,7 +35,7 @@ describe( 'convertText', () => {
 
 		dispatcher.on( 'text', convertText() );
 
-		const result = dispatcher.convert( viewText );
+		const result = dispatcher.convert( viewText, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelText );
 		expect( result.text ).to.equal( 'foobar' );
@@ -48,11 +53,26 @@ describe( 'convertText', () => {
 			}
 		} );
 
-		const result = dispatcher.convert( viewText );
+		const result = dispatcher.convert( viewText, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelText );
 		expect( result.text ).to.equal( 'foo****ba****r' );
 	} );
+
+	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() );
+
+		let result = dispatcher.convert( viewText, objWithContext );
+
+		expect( result ).to.be.null;
+
+		result = dispatcher.convert( viewText, { context: [ '$block' ] } );
+		expect( result ).to.be.instanceof( ModelText );
+		expect( result.text ).to.equal( 'foobar' );
+	} );
 } );
 
 describe( 'convertToModelFragment', () => {
@@ -68,7 +88,7 @@ describe( 'convertToModelFragment', () => {
 		dispatcher.on( 'element', convertToModelFragment() );
 		dispatcher.on( 'documentFragment', convertToModelFragment() );
 
-		const result = dispatcher.convert( viewFragment );
+		const result = dispatcher.convert( viewFragment, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelDocumentFragment );
 		expect( result.getChildCount() ).to.equal( 6 );
@@ -91,11 +111,14 @@ describe( 'convertToModelFragment', () => {
 		dispatcher.on( 'element:p', ( evt, data, consumable, conversionApi ) => {
 			if ( consumable.consume( data.input, { name: true } ) ) {
 				data.output = new ModelElement( 'paragraph' );
-				data.output.appendChildren( conversionApi.convertChildren( data.input, consumable ) );
+
+				data.context.push( data.output );
+				data.output.appendChildren( conversionApi.convertChildren( data.input, consumable, data ) );
+				data.context.pop();
 			}
 		} );
 
-		const result = dispatcher.convert( viewP );
+		const result = dispatcher.convert( viewP, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelElement );
 		expect( result.name ).to.equal( 'paragraph' );

+ 25 - 8
packages/ckeditor5-engine/tests/treemodel/batch.js

@@ -7,21 +7,15 @@
 
 'use strict';
 
-/* jshint unused: false */
-import deltas from '/ckeditor5/engine/treemodel/delta/basic-deltas.js';
+import deltas from '/ckeditor5/engine/treemodel/delta/basic-deltas.js'; // jshint ignore:line
 
 import Document from '/ckeditor5/engine/treemodel/document.js';
 import Batch from '/ckeditor5/engine/treemodel/batch.js';
 import { register } from '/ckeditor5/engine/treemodel/batch.js';
 import Delta from '/ckeditor5/engine/treemodel/delta/delta.js';
+import Operation from '/ckeditor5/engine/treemodel/operation/operation.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
-class TestDelta extends Delta {
-	constructor( batch ) {
-		super( batch, [] );
-	}
-}
-
 describe( 'Batch', () => {
 	it( 'should have registered basic methods', () => {
 		const batch = new Batch( new Document() );
@@ -69,4 +63,27 @@ describe( 'Batch', () => {
 			expect( batch.deltas[ 1 ] ).to.equal( deltaB );
 		} );
 	} );
+
+	describe( 'getOperations', () => {
+		it( 'should return collection of operations from all deltas', () => {
+			const doc = new Document();
+			const batch = new Batch( doc );
+			const deltaA = new Delta();
+			const deltaB = new Delta();
+			const ops = [
+				new Operation( doc.version ),
+				new Operation( doc.version + 1 ),
+				new Operation( doc.version + 2 )
+			];
+
+			batch.addDelta( deltaA );
+			deltaA.addOperation( ops[ 0 ] );
+			batch.addDelta( deltaB );
+			deltaA.addOperation( ops[ 1 ] );
+			deltaA.addOperation( ops[ 2 ] );
+
+			expect( Array.from( batch.getOperations() ) ).to.deep.equal( ops );
+			expect( batch.getOperations() ).to.have.property( 'next' );
+		} );
+	} );
 } );

+ 93 - 4
packages/ckeditor5-engine/tests/treemodel/characterproxy.js

@@ -15,12 +15,9 @@ import utils from '/ckeditor5/utils/utils.js';
 describe( 'CharacterProxy', () => {
 	let text, element, char;
 
-	before( () => {
+	beforeEach( () => {
 		text = new Text( 'abc', { foo: true } );
 		element = new Element( 'div', [], [ new Element( 'p' ), text, new Element( 'p' ) ] );
-	} );
-
-	beforeEach( () => {
 		char = element.getChild( 2 );
 	} );
 
@@ -43,4 +40,96 @@ describe( 'CharacterProxy', () => {
 	it( 'should return correct index in parent node', () => {
 		expect( char.getIndex() ).to.equal( 2 );
 	} );
+
+	describe( 'attributes interface', () => {
+		describe( 'hasAttribute', () => {
+			it( 'should return true if text fragment has attribute with given key', () => {
+				expect( char.hasAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should return false if text fragment does not have attribute with given key', () => {
+				expect( char.hasAttribute( 'abc' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'getAttribute', () => {
+			it( 'should return attribute with given key if text fragment has given attribute', () => {
+				expect( char.getAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should return undefined if text fragment does not have given attribute', () => {
+				expect( char.getAttribute( 'bar' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'getAttributes', () => {
+			it( 'should return an iterator that iterates over all attributes set on the text fragment', () => {
+				let attrs = Array.from( char.getAttributes() );
+
+				expect( attrs ).to.deep.equal( [ [ 'foo', true ] ] );
+			} );
+		} );
+
+		describe( 'setAttribute', () => {
+			it( 'should set attribute on given character', () => {
+				char.setAttribute( 'abc', 'xyz' );
+
+				expect( element.getChild( 0 ).getAttribute( 'abc' ) ).to.be.undefined;
+				expect( element.getChild( 1 ).getAttribute( 'abc' ) ).to.be.undefined;
+				expect( element.getChild( 2 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+				expect( element.getChild( 3 ).getAttribute( 'abc' ) ).to.be.undefined;
+				expect( element.getChild( 4 ).getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+
+			it( 'should remove attribute when passed attribute value is null', () => {
+				char.setAttribute( 'foo', null );
+
+				expect( element.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 1 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 3 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
+			} );
+
+			it( 'should correctly split and merge characters', () => {
+				char.setAttribute( 'abc', 'xyz' );
+				char.nextSibling.setAttribute( 'abc', 'xyz' );
+
+				expect( element._children._nodes.length ).to.equal( 4 );
+				expect( element._children._nodes[ 1 ].text ).to.equal( 'a' );
+				expect( element._children._nodes[ 2 ].text ).to.equal( 'bc' );
+			} );
+		} );
+
+		describe( 'setAttributesTo', () => {
+			it( 'should remove all attributes from character and set given ones', () => {
+				char.setAttributesTo( { abc: 'xyz' } );
+
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 2 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+			} );
+		} );
+
+		describe( 'removeAttribute', () => {
+			it( 'should remove given attribute from character', () => {
+				char.removeAttribute( 'foo' );
+
+				expect( element.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 1 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 3 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'clearAttributes', () => {
+			it( 'should remove all attributes from text fragment', () => {
+				char.setAttribute( 'abc', 'xyz' );
+				char.clearAttributes();
+
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 2 ).hasAttribute( 'abc' ) ).to.be.false;
+			} );
+		} );
+	} );
 } );

+ 1 - 1
packages/ckeditor5-engine/tests/treemodel/textproxy.js

@@ -81,7 +81,7 @@ describe( 'TextProxy', () => {
 				expect( textFragment.getAttribute( 'foo' ) ).to.equal( 'bar' );
 			} );
 
-			it( 'should return null if text fragment does not have given attribute', () => {
+			it( 'should return undefined if text fragment does not have given attribute', () => {
 				expect( textFragment.getAttribute( 'bar' ) ).to.be.undefined;
 			} );
 		} );

+ 29 - 0
packages/ckeditor5-engine/tests/treeview/documentfragment.js

@@ -88,6 +88,24 @@ describe( 'DocumentFragment', () => {
 				expect( count2 ).to.equal( 1 );
 				expect( count3 ).to.equal( 1 );
 			} );
+
+			it( 'should fire change event when inserting', ( done ) => {
+				fragment.once( 'change', ( event, type ) => {
+					expect( type ).to.equal( 'CHILDREN' );
+					done();
+				} );
+
+				fragment.insertChildren( 0, el1 );
+			} );
+
+			it( 'should fire change event when appending', ( done ) => {
+				fragment.once( 'change', ( event, type ) => {
+					expect( type ).to.equal( 'CHILDREN' );
+					done();
+				} );
+
+				fragment.appendChildren( el1 );
+			} );
 		} );
 
 		describe( 'getChildIndex', () => {
@@ -154,6 +172,17 @@ describe( 'DocumentFragment', () => {
 				expect( removed.length ).to.equal( 1 );
 				expect( removed[ 0 ] ).to.have.property( 'name' ).that.equals( 'el2' );
 			} );
+
+			it( 'should fire change event', ( done ) => {
+				fragment.appendChildren( el1 );
+
+				fragment.once( 'change', ( event, type ) => {
+					expect( type ).to.equal( 'CHILDREN' );
+					done();
+				} );
+
+				fragment.removeChildren( 0 );
+			} );
 		} );
 	} );
 

+ 32 - 0
packages/ckeditor5-engine/tests/treeview/matcher.js

@@ -374,4 +374,36 @@ describe( 'Matcher', () => {
 			expect( matcher.matchAll( el3 ) ).to.be.null;
 		} );
 	} );
+
+	describe( 'getElementName', () => {
+		it( 'should return null if there are no patterns in the matcher instance', () => {
+			const matcher = new Matcher();
+
+			expect( matcher.getElementName() ).to.be.null;
+		} );
+
+		it( 'should return null if pattern has no name property', () => {
+			const matcher = new Matcher( { class: 'foo' } );
+
+			expect( matcher.getElementName() ).to.be.null;
+		} );
+
+		it( 'should return null if pattern has name property specified as RegExp', () => {
+			const matcher = new Matcher( { name: /foo.*/ } );
+
+			expect( matcher.getElementName() ).to.be.null;
+		} );
+
+		it( 'should return element name if matcher has one patter with name property specified as string', () => {
+			const matcher = new Matcher( { name: 'div' } );
+
+			expect( matcher.getElementName() ).to.equal( 'div' );
+		} );
+
+		it( 'should return null if matcher has more than one pattern', () => {
+			const matcher = new Matcher( { name: 'div' }, { class: 'foo' } );
+
+			expect( matcher.getElementName() ).to.be.null;
+		} );
+	} );
 } );

+ 0 - 161
packages/ckeditor5-engine/tests/treeview/writer/_utils/utils.js

@@ -1,161 +0,0 @@
-/**
- * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-'use strict';
-import Range from '/ckeditor5/engine/treeview/range.js';
-import Position from '/ckeditor5/engine/treeview/position.js';
-
-const utils = {
-	/**
-	 * Helper function that is used to create treeView elements from description object.
-	 *
-	 * @param {engine.treeView.Writer} writer Writer instance. Used to set priorities.
-	 * @param {Object} description Description object.
-	 * @param {engine.treeView.Range} [range] Optional parameter, used in recurrent calls.
-	 * @param {engine.treeView.Position} [position] Optional parameter, used in recurrent calls.
-	 * @returns {Object} Returns object with `node`, `range`, `position` fields, containing created node and, optionally
-	 * range and position if description object contain information about them.
-	 */
-	create( writer, description, range, position ) {
-		const node = new description.instanceOf();
-
-		if ( !range ) {
-			range = Range.createFromParentsAndOffsets( node, 0, node, 0 );
-		}
-
-		if ( !position ) {
-			position = new Position( node, 0 );
-		}
-
-		if ( description.name ) {
-			node.name = description.name;
-		}
-
-		if ( description.data ) {
-			node.data = description.data;
-		}
-
-		if ( description.attributes ) {
-			Object.keys( description.attributes ).forEach( ( key ) => {
-				node.setAttribute( key, description.attributes[ key ] );
-			} );
-		}
-
-		if ( description.priority !== undefined ) {
-			node.priority = description.priority;
-		}
-
-		if ( description.rangeStart !== undefined ) {
-			range.start.parent = node;
-			range.start.offset = description.rangeStart;
-		}
-
-		if ( description.rangeEnd !== undefined ) {
-			range.end.parent = node;
-			range.end.offset = description.rangeEnd;
-		}
-
-		if ( description.position !== undefined ) {
-			position.parent = node;
-			position.offset = description.position;
-		}
-
-		if ( description.children ) {
-			description.children.forEach( ( desc, index ) => {
-				const created = utils.create( writer, desc, range, position );
-				node.insertChildren( index, created.node );
-			} );
-		}
-
-		return { node, range, position };
-	},
-
-	/**
-	 * Helper function that is used to test output of writer methods by providing declarative description of the
-	 * expected output.
-	 * Examples:
-	 * 		test element: `<p>fo[o<b>ba]r</b></p>`
-	 * 		description: {
-	 * 			name: 'p',
-	 * 	    	instanceOf: Element,
-	 * 	    	children:[
-	 * 	    		{
-	 * 	    			instanceOf: Text,
-	 * 	    			data: 'foo',
-	 * 	    			rangeStart: 2
- 	 * 	    		},
-	 *				{
-	 *					name: 'b'
-	 *					instanceOf: Element
-	 *					priority: 1,
-	 *					children: [
-	 *						{ instanceOf: Text, data: 'bar', rangeEnd: 2 }
-	 *					]
-	 *				}
-	 * 	    	]
-	 * 		}
-	 *
-	 *
-	 * @param {engine.treeView.Writer} writer Writer instance. Used to test priority.
-	 * @param {engine.treeView.Range|engine.treeView.Position } location Range instance or Position instance.
-	 * Treated as Range when when `rangeStart`, `rangeEnd` is used, treated as Position when `position` is used.
-	 * @param {engine.treeView.Node} node Element to check.
-	 * @param {Object} description Object describing expected element and its children.
-	 */
-	test( writer, location, node, description ) {
-		// If no root node provided - iterate over node array.
-		if ( description instanceof Array && node instanceof Array ) {
-			node.forEach( ( n, i ) => {
-				utils.test( writer, location, n, description[ i ] );
-			} );
-		}
-
-		if ( description.instanceOf ) {
-			expect( node ).to.be.instanceof( description.instanceOf );
-		}
-
-		if ( description.name ) {
-			expect( description.name ).to.equal( node.name  );
-		}
-
-		if ( description.data ) {
-			expect( description.data ).to.equal( node.data );
-		}
-
-		if ( description.priority !== undefined ) {
-			expect( description.priority ).to.equal( node.priority );
-		}
-
-		if ( description.rangeStart !== undefined ) {
-			expect( node ).to.equal( location.start.parent );
-			expect( description.rangeStart ).to.equal( location.start.offset );
-		}
-
-		if ( description.rangeEnd !== undefined ) {
-			expect( node ).to.equal( location.end.parent );
-			expect( description.rangeEnd ).to.equal( location.end.offset );
-		}
-
-		if ( description.attributes ) {
-			Object.keys( description.attributes ).forEach( ( key ) => {
-				expect( description.attributes[ key ] ).to.equal( node.getAttribute( key ) );
-			} );
-		}
-
-		if ( description.position !== undefined ) {
-			expect( node ).to.equal( location.parent );
-			expect( description.position ).to.equal( location.offset );
-		}
-
-		if ( description.children ) {
-			expect( description.children.length ).to.equal( node.getChildCount() );
-			description.children.forEach( ( desc, index ) => {
-				utils.test( writer, location, node.getChild( index ), desc );
-			} );
-		}
-	}
-};
-
-export default utils;

+ 60 - 264
packages/ckeditor5-engine/tests/treeview/writer/breakattributes.js

@@ -9,294 +9,90 @@
 
 import Writer from '/ckeditor5/engine/treeview/writer.js';
 import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
-import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
 import Text from '/ckeditor5/engine/treeview/text.js';
-import utils from '/tests/engine/treeview/writer/_utils/utils.js';
+import Position from '/ckeditor5/engine/treeview/position.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
 
 describe( 'Writer', () => {
-	const create = utils.create;
-	const test = utils.test;
 	let writer;
 
+	/**
+	 * Executes test using `parse` and `stringify` utils functions. Uses range delimiters `[]{}` to create and
+	 * test break position.
+	 *
+	 * @param {String} input
+	 * @param {String} expected
+	 */
+	function test( input, expected ) {
+		const { view, selection } = parse( input );
+		const newPosition = writer.breakAttributes( selection.getFirstPosition() );
+		expect( stringify( view, newPosition, { showType: true, showPriority: true } ) ).to.equal( expected );
+	}
+
 	beforeEach( () => {
 		writer = new Writer();
 	} );
 
 	describe( 'breakAttributes', () => {
-		// <p>{|foobar}</p> -> <p>|{foobar}</p>
-		it( '<p>{|foobar}</p>', () => {
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 0 }
-				]
-			} );
-
-			const newPosition = writer.breakAttributes( created.position );
-
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 0,
-				children: [
-					{ instanceOf: Text, data: 'foobar' }
-				]
-			} );
+		it( 'should move position from begin of text node to the element', () => {
+			test( '<container:p>{}foobar</container:p>', '<container:p>[]foobar</container:p>' );
 		} );
 
-		it( '<p>foo|bar</p>', () => {
-			// <p>{foo|bar}</p> -> <p>{foo}|{bar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 3 }
-				]
-			} );
+		it( 'should split text node', () => {
+			const text = new Text( 'foobar' );
+			const container = new ContainerElement( 'p', null, text );
+			const position = new Position( text, 3 );
 
-			const newPosition = writer.breakAttributes( created.position );
+			const newPosition = writer.breakAttributes( position );
 
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{ instanceOf: Text, data: 'bar' }
-				]
-			} );
+			expect( container.getChildCount() ).to.equal( 2 );
+			expect( container.getChild( 0 ) ).to.be.instanceOf( Text ).and.have.property( 'data' ).that.equal( 'foo' );
+			expect( container.getChild( 1 ) ).to.be.instanceOf( Text ).and.have.property( 'data' ).that.equal( 'bar' );
+			expect( newPosition.isEqual( new Position( container, 1 ) ) ).to.be.true;
 		} );
 
-		it( '<p>{foobar|}</p>', () => {
-			// <p>{foobar|}</p> -> <p>{foobar}|</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 6 }
-				]
-			} );
-
-			const newPosition = writer.breakAttributes( created.position );
-
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{ instanceOf: Text, data: 'foobar' }
-				]
-			} );
+		it( 'should move position from end of text node to the element', () => {
+			test( '<container:p>foobar{}</container:p>', '<container:p>foobar[]</container:p>' );
 		} );
 
-		it( '<p><b>{foo|bar}</b></p>', () => {
-			// <p><b>{foo|bar}</b></p> -> <p><b>{foo}</b>|<b>{bar}</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobar', position: 3 }
-						]
-					}
-				]
-			} );
-
-			const newPosition = writer.breakAttributes( created.position );
-
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					}
-				]
-			} );
+		it( 'should split attribute element', () => {
+			test(
+				'<container:p><attribute:b:1>foo{}bar</attribute:b:1></container:p>',
+				'<container:p><attribute:b:1>foo</attribute:b:1>[]<attribute:b:1>bar</attribute:b:1></container:p>'
+			);
 		} );
 
-		it( '<p><b><u>{|foobar}</u></b></p>', () => {
-			// <p><b><u>{|foobar}</u></b></p> -> <p>|<b><u>{foobar}</u></b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'foobar', position: 0 }
-								]
-							}
-						]
-					}
-				]
-			} );
-
-			const newPosition = writer.breakAttributes( created.position );
-
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 0,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'foobar' }
-								]
-							}
-						]
-					}
-				]
-			} );
+		it( 'should move from beginning of the nested text node to the container', () => {
+			test(
+				'<container:p><attribute:b:1><attribute:u:1>{}foobar</attribute:u:1></attribute:b:1></container:p>',
+				'<container:p>[]<attribute:b:1><attribute:u:1>foobar</attribute:u:1></attribute:b:1></container:p>'
+			);
 		} );
 
-		// <p><b><u>{foo|ba}r</u></b></p> -> <p><b><u>{foo}</u></b>|<b></u>{bar}</u></b></p>
-		it( '<p><b><u>{foo|bar}</u></b></p>', () => {
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'foobar', position: 3 }
-								]
-							}
-						]
-					}
-				]
-			} );
-
-			const newPosition = writer.breakAttributes( created.position );
-
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'foo' }
-								]
-							}
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'bar' }
-								]
-							}
-						]
-					}
-				]
-			} );
+		it( 'should split nested attributes', () => {
+			test(
+				'<container:p><attribute:b:1><attribute:u:1>foo{}bar</attribute:u:1></attribute:b:1></container:p>',
+				'<container:p>' +
+					'<attribute:b:1>' +
+						'<attribute:u:1>' +
+							'foo' +
+						'</attribute:u:1>' +
+					'</attribute:b:1>' +
+					'[]' +
+					'<attribute:b:1>' +
+						'<attribute:u:1>' +
+							'bar' +
+						'</attribute:u:1>' +
+					'</attribute:b:1>' +
+				'</container:p>'
+			);
 		} );
 
-		it( '<p><b><u>{foobar|}</u></b></p>', () => {
-			// <p><b><u>{foobar|}</u></b></p> -> <p><b><u>{foobar}</u></b>|</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'foobar', position: 6 }
-								]
-							}
-						]
-					}
-				]
-			} );
-
-			const newPosition = writer.breakAttributes( created.position );
-
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'foobar' }
-								]
-							}
-						]
-					}
-				]
-			} );
+		it( 'should move from end of the nested text node to the container', () => {
+			test(
+				'<container:p><attribute:b:1><attribute:u:1>foobar{}</attribute:u:1></attribute:b:1></container:p>',
+				'<container:p><attribute:b:1><attribute:u:1>foobar</attribute:u:1></attribute:b:1>[]</container:p>'
+			);
 		} );
 	} );
 } );

+ 41 - 179
packages/ckeditor5-engine/tests/treeview/writer/breakrange.js

@@ -9,16 +9,24 @@
 
 import Writer from '/ckeditor5/engine/treeview/writer.js';
 import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
-import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
 import Range from '/ckeditor5/engine/treeview/range.js';
-import Text from '/ckeditor5/engine/treeview/text.js';
-import utils from '/tests/engine/treeview/writer/_utils/utils.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
 
 describe( 'Writer', () => {
-	const create = utils.create;
-	const test = utils.test;
 	let writer;
 
+	/**
+	 * Executes test using `parse` and `stringify` utils functions.
+	 *
+	 * @param {String} input
+	 * @param {String} expected
+	 */
+	function test( input, expected ) {
+		const { view, selection } = parse( input );
+		const newRange = writer.breakRange( selection.getFirstRange() );
+		expect( stringify( view, newRange, { showType: true, showPriority: true } ) ).to.equal( expected );
+	}
+
 	beforeEach( () => {
 		writer = new Writer();
 	} );
@@ -34,198 +42,52 @@ describe( 'Writer', () => {
 		} );
 
 		it( 'should break at collapsed range and return collapsed one', () => {
-			// <p>{foo[]bar}</p> -> <p>{foo}[]{bar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', rangeStart: 3, rangeEnd: 3 }
-				]
-			} );
-
-			const newRange = writer.breakRange( created.range );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{ instanceOf: Text, data: 'bar' }
-				]
-			} );
+			test(
+				'<container:p>foo{}bar</container:p>',
+				'<container:p>foo[]bar</container:p>'
+			);
 		} );
 
 		it( 'should break inside text node #1', () => {
-			// <p>{foo[bar]baz}</p> -> <p>{foo}[{bar}]{baz}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 3, rangeEnd: 6 }
-				]
-			} );
-
-			const newRange = writer.breakRange( created.range );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 2,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{ instanceOf: Text, data: 'bar' },
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
+			test(
+				'<container:p>foo{bar}baz</container:p>',
+				'<container:p>foo[bar]baz</container:p>'
+			);
 		} );
 
 		it( 'should break inside text node #2', () => {
-			// <p>{foo[barbaz]}</p> -> <p>{foo}[{barbaz}]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 3, rangeEnd: 9 }
-				]
-			} );
-
-			const newRange = writer.breakRange( created.range );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 2,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{ instanceOf: Text, data: 'barbaz' }
-				]
-			} );
+			test(
+				'<container:p>foo{barbaz}</container:p>',
+				'<container:p>foo[barbaz]</container:p>'
+			);
 		} );
 
 		it( 'should break inside text node #3', () => {
-			// <p>{foo[barbaz}]</p> -> <p>{foo}[{barbaz}]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 3 }
-				]
-			} );
-
-			const newRange = writer.breakRange( created.range );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 2,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{ instanceOf: Text, data: 'barbaz' }
-				]
-			} );
+			test(
+				'<container:p>foo{barbaz]</container:p>',
+				'<container:p>foo[barbaz]</container:p>'
+			);
 		} );
 
 		it( 'should break inside text node #4', () => {
-			// <p>{[foo]barbaz}</p> -> <p>[{foo}]{barbaz]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 0, rangeEnd: 3 }
-				]
-			} );
-
-			const newRange = writer.breakRange( created.range );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{ instanceOf: Text, data: 'barbaz' }
-				]
-			} );
+			test(
+				'<container:p>{foo}barbaz</container:p>',
+				'<container:p>[foo]barbaz</container:p>'
+			);
 		} );
 
 		it( 'should break inside text node #5', () => {
-			// <p>[{foo]barbaz}</p> -> <p>[{foo}]{barbaz]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				children: [
-					{ instanceOf: Text, data: 'foobarbaz', rangeEnd: 3 }
-				]
-			} );
-
-			const newRange = writer.breakRange( created.range );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{ instanceOf: Text, data: 'barbaz' }
-				]
-			} );
+			test(
+				'<container:p>[foo}barbaz</container:p>',
+				'<container:p>[foo]barbaz</container:p>'
+			);
 		} );
 
 		it( 'should break placed inside different nodes', () => {
-			// <p>{foo[bar}<b>{baz]qux}</b></p>
-			// <p>{foo}[{bar}<b>{baz}</b>]<b>qux</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', rangeStart: 3 },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bazqux', rangeEnd: 3 }
-						]
-					}
-				]
-			} );
-
-			const newRange = writer.breakRange( created.range );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 3,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{ instanceOf: Text, data: 'bar' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'baz' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'qux' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>foo{bar<attribute:b:1>baz}qux</attribute:b:1></container:p>',
+				'<container:p>foo[bar<attribute:b:1>baz</attribute:b:1>]<attribute:b:1>qux</attribute:b:1></container:p>'
+			);
 		} );
 	} );
 } );

+ 99 - 321
packages/ckeditor5-engine/tests/treeview/writer/insert.js

@@ -9,365 +9,143 @@
 
 import Writer from '/ckeditor5/engine/treeview/writer.js';
 import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
-import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import Element from '/ckeditor5/engine/treeview/element.js';
 import Position from '/ckeditor5/engine/treeview/position.js';
-import Text from '/ckeditor5/engine/treeview/text.js';
-import utils from '/tests/engine/treeview/writer/_utils/utils.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
 
 describe( 'Writer', () => {
-	const create = utils.create;
-	const test = utils.test;
 	let writer;
 
+	/**
+	 * Executes test using `parse` and `stringify` utils functions.
+	 *
+	 * @param {String} input
+	 * @param {Array.<String>} nodesToInsert
+	 * @param {String} expected
+	 */
+	function test( input, nodesToInsert, expected ) {
+		nodesToInsert = nodesToInsert.map( node => parse( node ) );
+		const { view, selection } = parse( input );
+		const newRange = writer.insert( selection.getFirstPosition(), nodesToInsert );
+		expect( stringify( view, newRange, { showType: true, showPriority: true } ) ).to.equal( expected );
+	}
+
 	beforeEach( () => {
 		writer = new Writer();
 	} );
 
 	describe( 'insert', () => {
 		it( 'should return collapsed range in insertion position when using empty array', () => {
-			// <p>{foo|bar}</p> -> <p>{foo[]bar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 3 }
-				]
-			} );
-
-			const newRange = writer.insert( created.position, [] );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', rangeStart: 3, rangeEnd: 3 }
-				]
-			} );
+			test(
+				'<container:p>foo{}bar</container:p>',
+				[],
+				'<container:p>foo{}bar</container:p>'
+			);
 		} );
 
 		it( 'should insert text into another text node #1', () => {
-			// <p>{foo|bar}</p> insert {baz}
-			// <p>{foo[baz]bar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 3 }
-				]
-			} );
-
-			const newRange = writer.insert( created.position, new Text( 'baz' ) );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobazbar', rangeStart: 3, rangeEnd: 6 }
-				]
-			} );
+			test(
+				'<container:p>foo{}bar</container:p>',
+				[ 'baz' ],
+				'<container:p>foo{baz}bar</container:p>'
+			);
 		} );
 
 		it( 'should insert text into another text node #2', () => {
-			// <p>{foobar|}</p> insert {baz}
-			// <p>{foobar[baz}]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 6 }
-				]
-			} );
-
-			const newRange = writer.insert( created.position, new Text( 'baz' ) );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 6 }
-				]
-			} );
+			test(
+				'<container:p>foobar{}</container:p>',
+				[ 'baz' ],
+				'<container:p>foobar{baz]</container:p>'
+			);
 		} );
 
 		it( 'should insert text into another text node #3', () => {
-			// <p>{|foobar}</p> insert {baz}
-			// <p>[{baz]foobar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 0 }
-				]
-			} );
-
-			const newRange = writer.insert( created.position, new Text( 'baz' ) );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				children: [
-					{ instanceOf: Text, data: 'bazfoobar', rangeEnd: 3 }
-				]
-			} );
+			test(
+				'<container:p>{}foobar</container:p>',
+				[ 'baz' ],
+				'<container:p>[baz}foobar</container:p>'
+			);
 		} );
 
 		it( 'should break attributes when inserting into text node', () => {
-			// <p>{foo|bar}</p> insert <b>{baz}</b>
-			// <p>{foo}[<b>baz</b>]{bar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 3 }
-				]
-			} );
-			const toInsert = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'b',
-				priority: 1,
-				children: [
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
-
-			const newRange = writer.insert( created.position, toInsert.node );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 2,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{
-						instanceOf: ContainerElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'baz' }
-						]
-					},
-					{ instanceOf: Text, data: 'bar' }
-				]
-			} );
+			test(
+				'<container:p>foo{}bar</container:p>',
+				[ '<attribute:b:1>baz</attribute:b:1>' ],
+				'<container:p>foo[<attribute:b:1>baz</attribute:b:1>]bar</container:p>'
+			);
 		} );
 
-		it( 'should merge text ndoes', () => {
-			// <p>|{foobar}</p> insert {baz}
-			// <p>[{baz]foobar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 0,
-				children: [
-					{ instanceOf: Text, data: 'foobar' }
-				]
-			} );
-
-			const newRange = writer.insert( created.position, new Text( 'baz' ) );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				children: [
-					{ instanceOf: Text, data: 'bazfoobar', rangeEnd: 3 }
-				]
-			} );
+		it( 'should merge text nodes', () => {
+			test(
+				'<container:p>[]foobar</container:p>',
+				[ 'baz' ],
+				'<container:p>[baz}foobar</container:p>'
+			);
 		} );
 
 		it( 'should merge same attribute nodes', () => {
-			// <p><b>{foo|bar}</b></p> insert <b>{baz}</b>
-			// <p><b>{foo[baz]bar}</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobar', position: 3 }
-						]
-					}
-				]
-			} );
-			const toInsert = create( writer, {
-				instanceOf: AttributeElement,
-				name: 'b',
-				priority: 1,
-				children: [
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
-
-			const newRange = writer.insert( created.position, toInsert.node );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobazbar', rangeStart: 3, rangeEnd: 6 }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p><attribute:b:1>foo{}bar</attribute:b:1></container:p>',
+				[ '<attribute:b:1>baz</attribute:b:1>' ],
+				'<container:p><attribute:b:1>foo{baz}bar</attribute:b:1></container:p>'
+			);
 		} );
 
 		it( 'should not merge different attributes', () => {
-			// <p><b>{foo|bar}</b></p> insert <b>{baz}</b> ( different priority )
-			// <p><b>{foo}</b>[<b>{baz}</b>]<b>{bar}</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobar', position: 3 }
-						]
-					}
-				]
-			} );
-			const toInsert = create( writer, {
-				instanceOf: AttributeElement,
-				name: 'b',
-				priority: 2,
-				children: [
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
-
-			const newRange = writer.insert( created.position, toInsert.node );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 2,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 2,
-						children: [
-							{ instanceOf: Text, data: 'baz' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p><attribute:b:1>foo{}bar</attribute:b:1></container:p>',
+				[ '<attribute:b:2>baz</attribute:b:2>' ],
+				'<container:p>' +
+					'<attribute:b:1>' +
+						'foo' +
+					'</attribute:b:1>' +
+					'[' +
+					'<attribute:b:2>' +
+						'baz' +
+					'</attribute:b:2>' +
+					']' +
+					'<attribute:b:1>' +
+						'bar' +
+					'</attribute:b:1>' +
+				'</container:p>'
+			);
 		} );
 
 		it( 'should allow to insert multiple nodes', () => {
-			// <p>|</p> insert <b>{foo}</b>{bar}
-			// <p>[<b>{foo}</b>{bar}]</p>
-			const root = new ContainerElement( 'p' );
-			const toInsert = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'fake',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{ instanceOf: Text, data: 'bar' }
-				]
-			} ).node.getChildren();
-			const position = new Position( root, 0 );
-
-			const newRange = writer.insert( position, toInsert );
-			test( writer, newRange, root, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 2,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{ instanceOf: Text, data: 'bar' }
-				]
-			} );
+			test(
+				'<container:p>[]</container:p>',
+				[ '<attribute:b:1>foo</attribute:b:1>', 'bar' ],
+				'<container:p>[<attribute:b:1>foo</attribute:b:1>bar]</container:p>'
+			);
 		} );
 
 		it( 'should merge after inserting multiple nodes', () => {
-			// <p><b>{qux}</b>|{baz}</p> insert <b>{foo}</b>{bar}
-			// <p><b>{qux[foo}</b>{bar]baz}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'qux' }
-						]
-					},
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
-			const toInsert = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'fake',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{ instanceOf: Text, data: 'bar' }
-				]
-			} ).node.getChildren();
+			test(
+				'<container:p><attribute:b:1>qux</attribute:b:1>[]baz</container:p>',
+				[ '<attribute:b:1>foo</attribute:b:1>', 'bar' ],
+				'<container:p><attribute:b:1>qux{foo</attribute:b:1>bar}baz</container:p>'
+			);
+		} );
+
+		it( 'should throw when inserting Element', () => {
+			const element = new Element( 'b' );
+			const container = new ContainerElement( 'p' );
+			const position = new Position( container, 0 );
+			expect( () => {
+				writer.insert( position, element );
+			} ).to.throw( CKEditorError, 'treeview-writer-insert-invalid-node' );
+		} );
+
+		it( 'should throw when Element is inserted as child node', () => {
+			const element = new Element( 'b' );
+			const root = new ContainerElement( 'p', null, element );
+			const container = new ContainerElement( 'p' );
+			const position = new Position( container, 0 );
 
-			const newRange = writer.insert( created.position, toInsert );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'quxfoo', rangeStart: 3 }
-						]
-					},
-					{ instanceOf: Text, data: 'barbaz', rangeEnd: 3 }
-				]
-			} );
+			expect( () => {
+				writer.insert( position, root );
+			} ).to.throw( CKEditorError, 'treeview-writer-insert-invalid-node' );
 		} );
 	} );
 } );

+ 56 - 226
packages/ckeditor5-engine/tests/treeview/writer/mergeattributes.js

@@ -9,270 +9,100 @@
 
 import Writer from '/ckeditor5/engine/treeview/writer.js';
 import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
-import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
 import Text from '/ckeditor5/engine/treeview/text.js';
-import utils from '/tests/engine/treeview/writer/_utils/utils.js';
+import Position from '/ckeditor5/engine/treeview/position.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
 
 describe( 'Writer', () => {
-	const create = utils.create;
-	const test = utils.test;
 	let writer;
 
+	/**
+	 * Executes test using `parse` and `stringify` utils functions. Uses range delimiters `[]{}` to create and
+	 * test merge position.
+	 *
+	 * @param {String} input
+	 * @param {String} expected
+	 */
+	function test( input, expected ) {
+		const { view, selection } = parse( input );
+		const newPosition = writer.mergeAttributes( selection.getFirstPosition() );
+		expect( stringify( view, newPosition, { showType: true, showPriority: true } ) ).to.equal( expected );
+	}
+
 	beforeEach( () => {
 		writer = new Writer();
 	} );
 
 	describe( 'mergeAttributes', () => {
 		it( 'should not merge if inside text node', () => {
-			// <p>{fo|obar}</p>
-			const description = {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 2 }
-				]
-			};
-			const created = create( writer, description );
-			const newPosition = writer.mergeAttributes( created.position );
-			test( writer, newPosition, created.node, description );
+			test( '<container:p>fo{}bar</container:p>', '<container:p>fo{}bar</container:p>' );
 		} );
 
 		it( 'should not merge if between containers', () => {
-			// <div><p>{foo}</p>|<p>{bar}</p></div>
-			const description = {
-				instanceOf: ContainerElement,
-				name: 'div',
-				position: 1,
-				children: [
-					{
-						instanceOf: ContainerElement,
-						name: 'p',
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{
-						instanceOf: ContainerElement,
-						name: 'p',
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					}
-				]
-			};
-			const created = create( writer, description );
-			const newPosition = writer.mergeAttributes( created.position );
-			test( writer, newPosition, created.node, description );
+			test(
+				'<container:div><container:p>foo</container:p>[]<container:p>bar</container:p></container:div>',
+				'<container:div><container:p>foo</container:p>[]<container:p>bar</container:p></container:div>'
+			);
 		} );
 
 		it( 'should return same position when inside empty container', () => {
-			// <p>|</p>
-			const description = { instanceOf: ContainerElement, name: 'p', position: 0 };
-			const created = create( writer, description );
-			const newPosition = writer.mergeAttributes( created.position );
-			test( writer, newPosition, created.node, description );
+			test(
+				'<container:p>[]</container:p>',
+				'<container:p>[]</container:p>'
+			);
 		} );
 
 		it( 'should not merge when position is placed at the beginning of the container', () => {
-			// <p>|<b></b></p>
-			const description = {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 0,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1
-					}
-				]
-			};
-			const created = create( writer, description );
-			const newPosition = writer.mergeAttributes( created.position );
-			test( writer, newPosition, created.node, description );
+			test(
+				'<container:p>[]<attribute:b:1></attribute:b:1></container:p>',
+				'<container:p>[]<attribute:b:1></attribute:b:1></container:p>'
+			);
 		} );
 
 		it( 'should not merge when position is placed at the end of the container', () => {
-			// <p><b></b>|</p>
-			const description = {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1
-					}
-				]
-			};
-			const created = create( writer, description );
-			const newPosition = writer.mergeAttributes( created.position );
-			test( writer, newPosition, created.node, description );
+			test(
+				'<container:p><attribute:b:1></attribute:b:1>[]</container:p>',
+				'<container:p><attribute:b:1></attribute:b:1>[]</container:p>'
+			);
 		} );
 
 		it( 'should merge when placed between two text nodes', () => {
-			// <p>{foo}|{bar}</p> -> <p>{foo|bar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{ instanceOf: Text, data: 'bar' }
-				]
-			} );
-
-			const newPosition = writer.mergeAttributes( created.position );
-
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', position: 3 }
-				]
-			} );
+			// <p>foobar</p> -> <p>foo|bar</p>
+			const t1 = new Text( 'foo' );
+			const t2 = new Text( 'bar' );
+			const p = new ContainerElement( 'p', null, [ t1, t2 ] );
+			const position = new Position( p, 1 );
+
+			const newPosition = writer.mergeAttributes( position );
+			expect( stringify( p, newPosition ) ).to.equal( '<p>foo{}bar</p>' );
 		} );
 
 		it( 'should merge when placed between similar attribute nodes', () => {
-			// <p><b foo="bar"></b>|<b foo="bar"></b></p> -> <p><b foo="bar">|</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						attributes: { foo: 'bar' }
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						attributes: { foo: 'bar' }
-					}
-				]
-			} );
-
-			const newPosition = writer.mergeAttributes( created.position );
-
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						position: 0,
-						attributes: { foo: 'bar' }
-					}
-				]
-			} );
+			test(
+				'<container:p><attribute:b:1 foo="bar"></attribute:b:1>[]<attribute:b:1 foo="bar"></attribute:b:1></container:p>',
+				'<container:p><attribute:b:1 foo="bar">[]</attribute:b:1></container:p>'
+			);
 		} );
 
 		it( 'should not merge when placed between non-similar attribute nodes', () => {
-			// <p><b foo="bar"></b>|<b foo="baz"></b></p> ->
-			// <p><b foo="bar"></b>|<b foo="baz"></b></p>
-			const description = {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						attributes: { foo: 'bar' }
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						attributes: { foo: 'baz' }
-					}
-				]
-			};
-			const created = create( writer, description );
-			const newPosition = writer.mergeAttributes( created.position );
-			test( writer, newPosition, created.node, description );
+			test(
+				'<container:p><attribute:b:1 foo="bar"></attribute:b:1>[]<attribute:b:1 foo="baz"></attribute:b:1></container:p>',
+				'<container:p><attribute:b:1 foo="bar"></attribute:b:1>[]<attribute:b:1 foo="baz"></attribute:b:1></container:p>'
+			);
 		} );
 
 		it( 'should not merge when placed between similar attribute nodes with different priority', () => {
-			// <p><b foo="bar"></b>|<b foo="bar"></b></p> -> <p><b foo="bar"></b>|<b foo="bar"></b></p>
-			const description =  {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						attributes: { foo: 'bar' }
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 2,
-						attributes: { foo: 'bar' }
-					}
-				]
-			};
-			const created = create( writer, description );
-			const newPosition = writer.mergeAttributes( created.position );
-			test( writer, newPosition, created.node, description );
+			test(
+				'<container:p><attribute:b:1 foo="bar"></attribute:b:1>[]<attribute:b:2 foo="bar"></attribute:b:2></container:p>',
+				'<container:p><attribute:b:1 foo="bar"></attribute:b:1>[]<attribute:b:2 foo="bar"></attribute:b:2></container:p>'
+			);
 		} );
 
 		it( 'should merge attribute nodes and their contents if possible', () => {
-			// <p><b foo="bar">{foo}</b>|<b foo="bar">{bar}</b></p>
-			// <p><b foo="bar">{foo}|{bar}</b></p>
-			// <p><b foo="bar">{foo|bar}</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						attributes: { foo: 'bar' },
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						attributes: { foo: 'bar' },
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					}
-				]
-			} );
-
-			const newPosition = writer.mergeAttributes( created.position );
-
-			test( writer, newPosition, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						attributes: { foo: 'bar' },
-						children: [
-							{ instanceOf: Text, data: 'foobar', position: 3 }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p><attribute:b:1 foo="bar">foo</attribute:b:1>[]<attribute:b:1 foo="bar">bar</attribute:b:1></container:p>',
+				'<container:p><attribute:b:1 foo="bar">foo{}bar</attribute:b:1></container:p>'
+			);
 		} );
 	} );
 } );

+ 35 - 226
packages/ckeditor5-engine/tests/treeview/writer/remove.js

@@ -9,17 +9,29 @@
 
 import Writer from '/ckeditor5/engine/treeview/writer.js';
 import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
-import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
 import Range from '/ckeditor5/engine/treeview/range.js';
-import Text from '/ckeditor5/engine/treeview/text.js';
 import DocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
-import utils from '/tests/engine/treeview/writer/_utils/utils.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
 
 describe( 'Writer', () => {
-	const create = utils.create;
-	const test = utils.test;
 	let writer;
 
+	/**
+	 * Executes test using `parse` and `stringify` utils functions. Uses range delimiters `[]{}` to create and
+	 * test ranges.
+	 *
+	 * @param {String} input
+	 * @param {String} expectedResult
+	 * @param {String} expectedRemoved
+	 */
+	function test( input, expectedResult, expectedRemoved ) {
+		const { view, selection } = parse( input );
+		const range = selection.getFirstRange();
+		const removed = writer.remove( range );
+		expect( stringify( view, range, { showType: true, showPriority: true } ) ).to.equal( expectedResult );
+		expect( stringify( removed, null, { showType: true, showPriority: true } ) ).to.equal( expectedRemoved );
+	}
+
 	beforeEach( () => {
 		writer = new Writer();
 	} );
@@ -45,242 +57,39 @@ describe( 'Writer', () => {
 		} );
 
 		it( 'should remove single text node', () => {
-			// <p>[{foobar}]</p> -> <p>|</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foobar' }
-				]
-			} );
-
-			const removed = writer.remove( created.range );
-			test( writer, created.range.start, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 0,
-				children: []
-			} );
-
-			// Test removed nodes.
-			test( writer, null, Array.from( removed.getChildren() ), [
-				{ instanceOf: Text, data: 'foobar' }
-			] );
+			test( '<container:p>[foobar]</container:p>', '<container:p>[]</container:p>', 'foobar' );
 		} );
 
 		it( 'should not leave empty text nodes', () => {
-			// <p>{[foobar]}</p> -> <p>|</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', rangeStart: 0, rangeEnd: 6 }
-				]
-			} );
-
-			const removed = writer.remove( created.range );
-			test( writer, created.range.start, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 0,
-				children: []
-			} );
-
-			// Test removed nodes.
-			test( writer, null, removed, [
-				{ instanceOf: Text, data: 'foobar' }
-			] );
+			test( '<container:p>{foobar}</container:p>', '<container:p>[]</container:p>', 'foobar' );
 		} );
 
 		it( 'should remove part of the text node', () => {
-			// <p>{f[oob]ar}</p> -> <p>{f|ar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', rangeStart: 1, rangeEnd: 4 }
-				]
-			} );
-
-			const removed = writer.remove( created.range );
-			test( writer, created.range.start, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'far', position: 1 }
-				]
-			} );
-
-			// Test removed nodes.
-			test( writer, null, removed, [
-				{ instanceOf: Text, data: 'oob' }
-			] );
+			test( '<container:p>f{oob}ar</container:p>', '<container:p>f{}ar</container:p>', 'oob' );
 		} );
 
 		it( 'should remove parts of nodes', () => {
-			// <p>{f[oo}<b>{ba]r}</b></p> -> <p>{f}|<b>r</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foo', rangeStart: 1 },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar', rangeEnd: 2 }
-						]
-					}
-				]
-			} );
-
-			const removed = writer.remove( created.range );
-			test( writer, created.range.start, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				position: 1,
-				children: [
-					{ instanceOf: Text, data: 'f' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'r' }
-						]
-					}
-				]
-			} );
-
-			// Test removed nodes.
-			test( writer, null, removed, [
-				{ instanceOf: Text, data: 'oo' },
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					priority: 1,
-					children: [
-						{ instanceOf: Text, data: 'ba' }
-					]
-				}
-			] );
+			test(
+				'<container:p>f{oo<attribute:b:10>ba}r</attribute:b:10></container:p>',
+				'<container:p>f[]<attribute:b:10>r</attribute:b:10></container:p>',
+				'oo<attribute:b:10>ba</attribute:b:10>'
+			);
 		} );
 
 		it( 'should merge after removing #1', () => {
-			// <p><b>foo</b>[{bar}]<b>bazqux</b></p> -> <p><b>foo|bazqux</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 2,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{ instanceOf: Text, data: 'bar' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bazqux' }
-						]
-					}
-				]
-			} );
-
-			const removed = writer.remove( created.range );
-			test( writer, created.range.start, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobazqux', position: 3 }
-						]
-					}
-				]
-			} );
-
-			// Test removed nodes.
-			test( writer, null, removed, [
-				{ instanceOf: Text, data: 'bar' }
-			] );
+			test(
+				'<container:p><attribute:b:1>foo</attribute:b:1>[bar]<attribute:b:1>bazqux</attribute:b:1></container:p>',
+				'<container:p><attribute:b:1>foo{}bazqux</attribute:b:1></container:p>',
+				'bar'
+			);
 		} );
 
 		it( 'should merge after removing #2', () => {
-			// <p><b>fo[o</b>{bar}<b>ba]zqux</b></p> -> <p><b>fo|zqux</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foo', rangeStart: 2 }
-						]
-					},
-					{ instanceOf: Text, data: 'bar' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bazqux', rangeEnd: 2 }
-						]
-					}
-				]
-			} );
-
-			const removed = writer.remove( created.range );
-			test( writer, created.range.start, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'fozqux', position: 2 }
-						]
-					}
-				]
-			} );
-
-			// Test removed nodes.
-			test( writer, null, removed, [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					priority: 1,
-					children: [
-						{ instanceOf: Text, data: 'o' }
-					]
-				},
-				{ instanceOf: Text, data: 'bar' },
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					priority: 1,
-					children: [
-						{ instanceOf: Text, data: 'ba' }
-					]
-				}
-			] );
+			test(
+				'<container:p><attribute:b:1>fo{o</attribute:b:1>bar<attribute:b:1>ba}zqux</attribute:b:1></container:p>',
+				'<container:p><attribute:b:1>fo{}zqux</attribute:b:1></container:p>',
+				'<attribute:b:1>o</attribute:b:1>bar<attribute:b:1>ba</attribute:b:1>'
+			);
 		} );
 	} );
 } );

+ 164 - 814
packages/ckeditor5-engine/tests/treeview/writer/unwrap.js

@@ -14,50 +14,44 @@ import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
 import Position from '/ckeditor5/engine/treeview/position.js';
 import Range from '/ckeditor5/engine/treeview/range.js';
 import Text from '/ckeditor5/engine/treeview/text.js';
-import utils from '/tests/engine/treeview/writer/_utils/utils.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
 
 describe( 'Writer', () => {
-	const create = utils.create;
-	const test = utils.test;
 	let writer;
 
+	/**
+	 * Executes test using `parse` and `stringify` utils functions.
+	 *
+	 * @param {String} input
+	 * @param {String} unwrapAttribute
+	 * @param {String} expected
+	 */
+	function test( input, unwrapAttribute, expected ) {
+		const { view, selection } = parse( input );
+		const newRange = writer.unwrap( selection.getFirstRange(), parse( unwrapAttribute ) );
+		expect( stringify( view, newRange, { showType: true, showPriority: true } ) ).to.equal( expected );
+	}
+
 	beforeEach( () => {
 		writer = new Writer();
 	} );
 
 	describe( 'unwrap', () => {
 		it( 'should do nothing on collapsed ranges', () => {
-			const description = {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foo', rangeStart: 1, rangeEnd: 1 }
-				]
-			};
-			const created = create( writer, description );
-			const newRange = writer.unwrap( created.range, new AttributeElement( 'b' ), 1 );
-			test( writer, newRange, created.node, description );
+			test(
+				'<container:p>f{}oo</container:p>',
+				'<attribute:b:10></attribute:b:10>',
+				'<container:p>f{}oo</container:p>'
+			);
 		} );
 
 		it( 'should do nothing on single text node', () => {
-			// <p>[{foobar}]</p>
-			const description = {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foobar' }
-				]
-			};
-
-			const created = create( writer, description );
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-			const newRange = writer.unwrap( created.range, b );
-
-			test( writer, newRange, created.node, description );
+			test(
+				'<container:p>[foobar]</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[foobar]</container:p>'
+			);
 		} );
 
 		it( 'should throw error when element is not instance of AttributeElement', () => {
@@ -88,841 +82,197 @@ describe( 'Writer', () => {
 		} );
 
 		it( 'should unwrap single node', () => {
-			// <p>[<b>{foobar}</b>]<p> -> <p>[{foobar}]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobar' }
-						]
-					}
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-			const newRange = writer.unwrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foobar' }
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1>foobar</attribute:b:1>]</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[foobar]</container:p>'
+			);
 		} );
 
 		it( 'should not unwrap attributes with different priorities #1', () => {
-			// <p>[<b>{foobar}</b>]<p> -> <p>[<b>{foobar}</b>]</p>
-			// Unwrapped with <b> but using different priority.
-			const description =  {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobar' }
-						]
-					}
-				]
-			};
-			const created = create( writer, description );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 2;
-			const newRange = writer.unwrap( created.range, b );
-
-			test( writer, newRange, created.node, description );
+			test(
+				'<container:p>[<attribute:b:1>foobar</attribute:b:1>]</container:p>',
+				'<attribute:b:2></attribute:b:2>',
+				'<container:p>[<attribute:b:1>foobar</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should not unwrap attributes with different priorities #2', () => {
-			// <p>[<b>{foo}</b><b>{bar}</b><b>{baz}</b>]<p> -> <p>[{foo}<b>bar</b>{baz}]</p>
-			// <b> around `bar` has different priority than others.
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 3,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 2,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 2,
-						children: [
-							{ instanceOf: Text, data: 'baz' }
-						]
-					}
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 2;
-			const newRange = writer.unwrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 3,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					},
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
+			test(
+				'<container:p>' +
+				'[' +
+					'<attribute:b:2>foo</attribute:b:2>' +
+					'<attribute:b:1>bar</attribute:b:1>' +
+					'<attribute:b:2>baz</attribute:b:2>' +
+				']' +
+				'</container:p>',
+				'<attribute:b:2></attribute:b:2>',
+				'<container:p>[foo<attribute:b:1>bar</attribute:b:1>baz]</container:p>'
+			);
 		} );
 
 		it( 'should unwrap part of the node', () => {
-			// <p>[{baz}<b>{foo]bar}</b><p> -> <p>[{bazfoo}]<b>{bar}</b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				children: [
-					{ instanceOf: Text, data: 'baz' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobar', rangeEnd: 3 }
-						]
-
-					}
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-
-			const newRange = writer.unwrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'bazfoo' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-
-					}
-				]
-			} );
+			test(
+				'<container:p>[baz<attribute:b:1>foo}bar</attribute:b:1>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[bazfoo]<attribute:b:1>bar</attribute:b:1></container:p>'
+			);
 		} );
 
 		it( 'should unwrap nested attributes', () => {
-			// <p>[<u><b>{foobar}</b></u>]</p> -> <p>[<u>{foobar}</u>]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'b',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'foobar' }
-								]
-							}
-						]
-					}
-				]
-			} );
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-
-			const newRange = writer.unwrap( created.range, b );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobar' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:u:1><attribute:b:1>foobar</attribute:b:1></attribute:u:1>]</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[<attribute:u:1>foobar</attribute:u:1>]</container:p>'
+			);
 		} );
 
 		it( 'should merge unwrapped nodes #1', () => {
-			// <p>{foo}[<b>{bar}</b>]{bom}</p> -> <p>{foo[bar]bom}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 2,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					},
-					{ instanceOf: Text, data: 'bom' }
-				]
-			} );
-
-			const b =  new AttributeElement( 'b' );
-			b.priority = 1;
-			const newRange = writer.unwrap( created.range, b );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: Text,
-						data: 'foobarbom',
-						rangeStart: 3,
-						rangeEnd: 6
-					}
-				]
-			} );
+			test(
+				'<container:p>foo[<attribute:b:1>bar</attribute:b:1>]baz</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>foo{bar}baz</container:p>'
+			);
 		} );
 
 		it( 'should merge unwrapped nodes #2', () => {
-			// <p>{foo}<u>{bar}</u>[<b><u>{bazqux}</u></b>]</p> -> <p>{foo}<u>{bar[bazqux}</u>]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 2,
-				rangeEnd: 3,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'bazqux' }
-								]
-							}
-						]
-					}
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-			const newRange = writer.unwrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeEnd: 2,
-				children: [
-					{
-						instanceOf: Text,
-						data: 'foo'
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'barbazqux', rangeStart: 3 }
-						]
-					}
-				]
-			} );
+			const input = '<container:p>' +
+			'foo' +
+				'<attribute:u:1>bar</attribute:u:1>' +
+				'[' +
+				'<attribute:b:1>' +
+					'<attribute:u:1>bazqux</attribute:u:1>' +
+				'</attribute:b:1>' +
+				']' +
+			'</container:p>';
+			const attribute = '<attribute:b:1></attribute:b:1>';
+			const result = '<container:p>foo<attribute:u:1>bar{bazqux</attribute:u:1>]</container:p>';
+
+			test( input, attribute, result );
 		} );
 
 		it( 'should merge unwrapped nodes #3', () => {
-			// <p>{foo}<u>{bar}</u>[<b><u>{baz]qux}</u></b></p> -> <p>{foo}<u>{bar[baz}</u>]<b><u>{qux}</u></b></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 2,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'bazqux', rangeEnd: 3 }
-								]
-							}
-						]
-					}
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-			const newRange = writer.unwrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeEnd: 2,
-				children: [
-					{
-						instanceOf: Text,
-						data: 'foo'
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'barbaz', rangeStart: 3 }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'qux' }
-								]
-							}
-						]
-					}
-				]
-			} );
+			const input = '<container:p>' +
+				'foo' +
+				'<attribute:u:1>bar</attribute:u:1>' +
+				'[' +
+				'<attribute:b:1>' +
+					'<attribute:u:1>baz}qux</attribute:u:1>' +
+				'</attribute:b:1>' +
+			'</container:p>';
+			const attribute = '<attribute:b:1></attribute:b:1>';
+			const result = '<container:p>' +
+				'foo' +
+				'<attribute:u:1>bar{baz</attribute:u:1>]' +
+				'<attribute:b:1>' +
+					'<attribute:u:1>qux</attribute:u:1>' +
+				'</attribute:b:1>' +
+			'</container:p>';
+
+			test( input, attribute, result );
 		} );
 
 		it( 'should merge unwrapped nodes #4', () => {
-			// <p>{foo}<u>{bar}</u>[<b><u>{baz}</u></b>]<u>qux</u></p> -> <p>{foo}<u>{bar[baz]qux}</u></p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 2,
-				rangeEnd: 3,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'baz' }
-								]
-							}
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'qux' }
-						]
-					}
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-			const newRange = writer.unwrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{
-						instanceOf: Text,
-						data: 'foo'
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'barbazqux', rangeStart: 3, rangeEnd: 6 }
-						]
-					}
-				]
-			} );
+			const input = '<container:p>' +
+				'foo' +
+				'<attribute:u:1>bar</attribute:u:1>' +
+				'[' +
+				'<attribute:b:1>' +
+					'<attribute:u:1>baz</attribute:u:1>' +
+				'</attribute:b:1>' +
+				']' +
+				'<attribute:u:1>qux</attribute:u:1>' +
+			'</container:p>';
+			const attribute = '<attribute:b:1></attribute:b:1>';
+			const result = '<container:p>' +
+				'foo' +
+				'<attribute:u:1>bar{baz}qux</attribute:u:1>' +
+			'</container:p>';
+
+			test( input, attribute, result );
 		} );
 
 		it( 'should merge unwrapped nodes #5', () => {
-			// <p>[<b><u>{foo}</u></b><b><u>{bar}</u></b><b><u>{baz}</u></b>]</p> -> <p>[<u>{foobarbaz}</u>]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 3,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'foo' }
-								]
-							}
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'bar' }
-								]
-							}
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'u',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'baz' }
-								]
-							}
-						]
-					}
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-			const newRange = writer.unwrap( created.range, b );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobarbaz' }
-						]
-					}
-				]
-			} );
+			const input = '<container:p>' +
+				'[' +
+				'<attribute:b:1><attribute:u:1>foo</attribute:u:1></attribute:b:1>' +
+				'<attribute:b:1><attribute:u:1>bar</attribute:u:1></attribute:b:1>' +
+				'<attribute:b:1><attribute:u:1>baz</attribute:u:1></attribute:b:1>' +
+				']' +
+			'</container:p>';
+			const attribute = '<attribute:b:1></attribute:b:1>';
+			const result = '<container:p>[<attribute:u:1>foobarbaz</attribute:u:1>]</container:p>';
+
+			test( input, attribute, result );
 		} );
 
 		it( 'should unwrap mixed ranges #1', () => {
-			// <p>[<u><b>{foo}]</b></u></p> -> <p>[<u>{foo}</u>]</p
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'b',
-								priority: 1,
-								rangeEnd: 1,
-								children: [
-									{ instanceOf: Text, data: 'foo' }
-								]
-							}
-						]
-					}
-				]
-			} );
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-			const newRange = writer.unwrap( created.range, b );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					}
-				]
-			} );
+			const input = '<container:p>' +
+				'[' +
+				'<attribute:u:1>' +
+					'<attribute:b:1>foo]</attribute:b:1>' +
+				'</attribute:u:1>' +
+			'</container:p>';
+			const attribute = '<attribute:b:1></attribute:b:1>';
+			const result = '<container:p>[<attribute:u:1>foo</attribute:u:1>]</container:p>';
+
+			test( input, attribute, result );
 		} );
 
 		it( 'should unwrap mixed ranges #2', () => {
-			// <p>[<u><b>{foo]}</b></u></p> -> <p>[<u>{foo}</u>]</p
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'b',
-								priority: 1,
-								children: [
-									{ instanceOf: Text, data: 'foo', rangeEnd: 3 }
-								]
-							}
-						]
-					}
-				]
-			} );
-			const b = new AttributeElement( 'b' );
-			b.priority = 1;
-			const newRange = writer.unwrap( created.range, b );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:u:1><attribute:b:1>foo}</attribute:b:1></attribute:u></container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[<attribute:u:1>foo</attribute:u:1>]</container:p>'
+			);
 		} );
 
 		it( 'should unwrap single element by removing matching attributes', () => {
-			// <p>[<b foo="bar" baz="qux" >test</b>]</p>
-			// unwrap using <b baz="qux"></b>
-			// <p>[<b foo="bar">test</b>]</p>
-			const text = new Text( 'test' );
-			const b = new AttributeElement( 'b', {
-				foo: 'bar',
-				baz: 'qux'
-			}, text );
-			const p = new ContainerElement( 'p', null, b );
-			const wrapper = new AttributeElement( 'b', {
-				baz: 'qux'
-			} );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-
-			const newRange = writer.unwrap( range, wrapper );
-			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
-			expect( b.hasAttribute( 'baz' ) ).to.be.false;
-			expect( b.parent ).to.equal( p );
-			test( writer, newRange, p, {
-				instanceof: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceof: AttributeElement,
-						name: 'b',
-						children: [
-							{ instanceof: Text, data: 'test' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 foo="bar" baz="qux">test</attribute:b:1>]</container:p>',
+				'<attribute:b:1 baz="qux"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 foo="bar">test</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should not unwrap single element when attributes are different', () => {
-			// <p>[<b foo="bar" baz="qux">test</b>]</p>
-			// unwrap using <b baz="qux" test="true"></b>
-			// <p>[<b foo="bar" baz="qux">test</b>]</p>
-			const text = new Text( 'test' );
-			const b = new AttributeElement( 'b', {
-				foo: 'bar',
-				baz: 'qux'
-			}, text );
-			const p = new ContainerElement( 'p', null, b );
-			const wrapper = new AttributeElement( 'b', {
-				baz: 'qux',
-				test: 'true'
-			} );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-
-			const newRange = writer.unwrap( range, wrapper );
-			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
-			expect( b.getAttribute( 'baz' ) ).to.equal( 'qux' );
-			expect( b.parent ).to.equal( p );
-			test( writer, newRange, p, {
-				instanceof: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceof: AttributeElement,
-						name: 'b',
-						children: [
-							{ instanceof: Text, data: 'test' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 foo="bar" baz="qux">test</attribute:b:1>]</container:p>',
+				'<attribute:b:1 baz="qux" test="true"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 foo="bar" baz="qux">test</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should unwrap single element by removing matching classes', () => {
-			// <p>[<b class="foo bar baz">test</b>]</p>
-			// unwrap using <b class="baz foo"></b>
-			// <p>[<b class="bar">test</b>]</p>
-			const text = new Text( 'test' );
-			const b = new AttributeElement( 'b', {
-				class: 'foo bar baz'
-			}, text );
-			const p = new ContainerElement( 'p', null, b );
-			const wrapper = new AttributeElement( 'b', {
-				class: 'baz foo'
-			} );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-
-			const newRange = writer.unwrap( range, wrapper );
-			expect( b.hasClass( 'bar' ) ).to.be.true;
-			expect( b.hasClass( 'foo' ) ).to.be.false;
-			expect( b.hasClass( 'baz' ) ).to.be.false;
-			expect( b.parent ).to.equal( p );
-			test( writer, newRange, p, {
-				instanceof: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceof: AttributeElement,
-						name: 'b',
-						children: [
-							{ instanceof: Text, data: 'test' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 class="foo bar baz">test</attribute:b:1>]</container:p>',
+				'<attribute:b:1 class="baz foo"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 class="bar">test</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should not unwrap single element when classes are different', () => {
-			// <p>[<b class="foo bar baz">test</b>]</p>
-			// unwrap using <b class="baz foo qux"></b>
-			// <p>[<b class="foo bar baz">test</b>]</p>
-			const text = new Text( 'test' );
-			const b = new AttributeElement( 'b', {
-				class: 'foo bar baz'
-			}, text );
-			const p = new ContainerElement( 'p', null, b );
-			const wrapper = new AttributeElement( 'b', {
-				class: 'baz foo qux'
-			} );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-
-			const newRange = writer.unwrap( range, wrapper );
-			expect( b.hasClass( 'bar' ) ).to.be.true;
-			expect( b.hasClass( 'foo' ) ).to.be.true;
-			expect( b.hasClass( 'baz' ) ).to.be.true;
-			expect( b.parent ).to.equal( p );
-			test( writer, newRange, p, {
-				instanceof: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceof: AttributeElement,
-						name: 'b',
-						children: [
-							{ instanceof: Text, data: 'test' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 class="foo bar baz">test</attribute:b:1>]</container:p>',
+				'<attribute:b:1 class="baz foo qux"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 class="foo bar baz">test</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should unwrap single element by removing matching styles', () => {
-			// <p>[<b style="color:red; position:absolute; top:10px;">test</b>]</p>
-			// unwrap using <b style="position: absolute;"></b>
-			// <p>[<b style="color:red; top:10px;">test</b>]</p>
-			const text = new Text( 'test' );
-			const b = new AttributeElement( 'b', {
-				style: 'color:red; position:absolute; top:10px;'
-			}, text );
-			const p = new ContainerElement( 'p', null, b );
-			const wrapper = new AttributeElement( 'b', {
-				style: 'position: absolute;'
-			} );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-
-			const newRange = writer.unwrap( range, wrapper );
-			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
-			expect( b.getStyle( 'top' ) ).to.equal( '10px' );
-			expect( b.hasStyle( 'position' ) ).to.be.false;
-			expect( b.parent ).to.equal( p );
-			test( writer, newRange, p, {
-				instanceof: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceof: AttributeElement,
-						name: 'b',
-						children: [
-							{ instanceof: Text, data: 'test' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 style="color:red;position:absolute;top:10px;">test</attribute:b:1>]</container:p>',
+				'<attribute:b:1 style="position: absolute;"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 style="color:red;top:10px;">test</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should not unwrap single element when styles are different', () => {
-			// <p>[<b style="color:red; position:absolute; top:10px;">test</b>]</p>
-			// unwrap using <b style="position: relative;"></b>
-			// <p>[<b style="color:red; position:absolute; top:10px;">test</b>]</p>
-			const text = new Text( 'test' );
-			const b = new AttributeElement( 'b', {
-				style: 'color:red; position:absolute; top:10px;'
-			}, text );
-			const p = new ContainerElement( 'p', null, b );
-			const wrapper = new AttributeElement( 'b', {
-				style: 'position: relative;'
-			} );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-
-			const newRange = writer.unwrap( range, wrapper );
-			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
-			expect( b.getStyle( 'top' ) ).to.equal( '10px' );
-			expect( b.getStyle( 'position' ) ).to.equal( 'absolute' );
-			expect( b.parent ).to.equal( p );
-			test( writer, newRange, p, {
-				instanceof: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceof: AttributeElement,
-						name: 'b',
-						children: [
-							{ instanceof: Text, data: 'test' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 style="color:red;position:absolute;top:10px;">test</attribute:b:1>]</container:p>',
+				'<attribute:b:1 style="position: relative;"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 style="color:red;position:absolute;top:10px;">test</attribute:b:1>]</container:p>'
+			);
 		} );
 	} );
 } );

+ 135 - 663
packages/ckeditor5-engine/tests/treeview/writer/wrap.js

@@ -11,69 +11,47 @@ import Writer from '/ckeditor5/engine/treeview/writer.js';
 import Element from '/ckeditor5/engine/treeview/element.js';
 import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
 import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
-import { DEFAULT_PRIORITY } from '/ckeditor5/engine/treeview/attributeelement.js';
 import Position from '/ckeditor5/engine/treeview/position.js';
 import Range from '/ckeditor5/engine/treeview/range.js';
 import Text from '/ckeditor5/engine/treeview/text.js';
-import utils from '/tests/engine/treeview/writer/_utils/utils.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
 
 describe( 'Writer', () => {
-	const create = utils.create;
-	const test = utils.test;
 	let writer;
 
+	/**
+	 * Executes test using `parse` and `stringify` utils functions.
+	 *
+	 * @param {String} input
+	 * @param {String} unwrapAttribute
+	 * @param {String} expected
+	 */
+	function test( input, unwrapAttribute, expected ) {
+		const { view, selection } = parse( input );
+		const newRange = writer.wrap( selection.getFirstRange(), parse( unwrapAttribute ) );
+		expect( stringify( view, newRange, { showType: true, showPriority: true } ) ).to.equal( expected );
+	}
+
 	beforeEach( () => {
 		writer = new Writer();
 	} );
 
 	describe( 'wrap', () => {
 		it( 'should do nothing on collapsed ranges', () => {
-			const description = {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foo', rangeStart: 1, rangeEnd: 1 }
-				]
-			};
-			const created = create( writer, description );
-			const newRange = writer.wrap( created.range, new AttributeElement( 'b' ) );
-			test( writer, newRange, created.node, description );
+			test(
+				'<container:p>f{}oo</container:p>',
+				'<attribute:b></attribute:b>',
+				'<container:p>f{}oo</container:p>'
+			);
 		} );
 
 		it( 'wraps single text node', () => {
-			// <p>[{foobar}]</p>
-			// wrap <b>
-			// <p>[<b>{foobar}<b>]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foobar' }
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			const newRange = writer.wrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foobar' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[foobar]</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[<attribute:b:1>foobar</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should throw error when element is not instance of AttributeElement', () => {
@@ -104,671 +82,165 @@ describe( 'Writer', () => {
 		} );
 
 		it( 'wraps part of a single text node #1', () => {
-			// <p>[{foo]bar}</p>
-			// wrap with <b>
-			// <p>[<b>{foo}</b>]{bar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				children: [
-					{ instanceOf: Text, data: 'foobar', rangeEnd: 3 }
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			const newRange = writer.wrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{ instanceOf: Text, data: 'bar' }
-				]
-			} );
+			test(
+				'<container:p>[foo}bar</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[<attribute:b:1>foo</attribute:b:1>]bar</container:p>'
+			);
 		} );
 
 		it( 'wraps part of a single text node #2', () => {
-			// <p>{[foo]bar}</p>
-			// wrap with <b>
-			// <p>[<b>{foo}</b>]{bar}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', rangeStart: 0, rangeEnd: 3 }
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			const newRange = writer.wrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{ instanceOf: Text, data: 'bar' }
-				]
-			} );
+			test(
+				'<container:p>{foo}bar</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[<attribute:b:1>foo</attribute:b:1>]bar</container:p>'
+			);
 		} );
 
 		it( 'wraps part of a single text node #3', () => {
-			// <p>{foo[bar]}</p>
-			// wrap with <b>
-			// <p>{foo}[<b>{bar}</b>]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				children: [
-					{ instanceOf: Text, data: 'foobar', rangeStart: 3, rangeEnd: 6 }
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			const newRange = writer.wrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 2,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>foo{bar}</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>foo[<attribute:b:1>bar</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should not wrap inside nested containers', () => {
-			// <div>[{foobar}<p>{baz}</p>]</div>
-			// wrap with <b>
-			// <div>[<b>{foobar}</b><p>{baz}</p>]</div>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'div',
-				rangeStart: 0,
-				rangeEnd: 2,
-				children: [
-					{ instanceOf: Text, data: 'foobar' },
-					{
-						instanceOf: ContainerElement,
-						name: 'p',
-						children: [
-							{ instanceOf: Text, data: 'baz' }
-						]
-					}
-				]
-			} );
-
-			const newRange = writer.wrap( created.range, new AttributeElement( 'b' ) );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'div',
-				rangeStart: 0,
-				rangeEnd: 2,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foobar' }
-						]
-					},
-					{
-						instanceOf: ContainerElement,
-						name: 'p',
-						children: [
-							{ instanceOf: Text, data: 'baz' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:div>[foobar<container:p>baz</container:p>]</container:div>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:div>[<attribute:b:1>foobar</attribute:b:1><container:p>baz</container:p>]</container:div>'
+			);
 		} );
 
 		it( 'wraps according to priorities', () => {
-			// <p>[<u>{foobar}</u>]</p>
-			// wrap with <b> that has higher priority than <u>
-			// <p>[<u><b>{foobar}</b></u>]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'foobar' }
-						]
-					}
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 2;
-			const newRange = writer.wrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'u',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'b',
-								priority: 2,
-								children: [
-									{ instanceOf: Text, data: 'foobar' }
-								]
-							}
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:u:1>foobar</attribute:u:1>]</container:p>',
+				'<attribute:b:2></attribute:b:2>',
+				'<container:p>[<attribute:u:1><attribute:b:2>foobar</attribute:b:2></attribute:u:1>]</container:p>'
+			);
 		} );
 
 		it( 'merges wrapped nodes #1', () => {
-			// <p>[<b>{foo}</b>{bar}<b>{baz}</b>]</p>
-			// wrap with <b>
-			// <p>[<b>{foobarbaz}</b>]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 3,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{ instanceOf: Text, data: 'bar' },
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'baz' }
-						]
-					}
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			const newRange = writer.wrap( created.range, b );
-
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foobarbaz' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1>foo</attribute:b:1>bar<attribute:b:1>baz</attribute:b:1>]</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[<attribute:b:1>foobarbaz</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'merges wrapped nodes #2', () => {
-			// <p><b>{foo}</b>[{bar]baz}</p>
-			// wrap with <b>
-			// <p><b>{foo[bar}</b>]{baz}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{ instanceOf: Text, data: 'barbaz', rangeEnd: 3 }
-				]
-			} );
-
-			const newRange = writer.wrap( created.range, new AttributeElement( 'b' ) );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foobar', rangeStart: 3 }
-						]
-					},
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
+			test(
+				'<container:p><attribute:b:1>foo</attribute:b:1>[bar}baz</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p><attribute:b:1>foo{bar</attribute:b:1>]baz</container:p>'
+			);
 		} );
 
 		it( 'merges wrapped nodes #3', () => {
-			// <p><b>{foobar}</b>[{baz}]</p>
-			// wrap with <b>
-			// <p><b>{foobar[baz}</b>]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 1,
-				rangeEnd: 2,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foobar' }
-						]
-					},
-					{ instanceOf: Text, data: 'baz', rangeEnd: 3 }
-				]
-			} );
-
-			const newRange = writer.wrap( created.range, new AttributeElement( 'b' ) );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foobarbaz', rangeStart: 6 }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p><attribute:b:1>foobar</attribute:b:1>[baz]</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p><attribute:b:1>foobar{baz</attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'merges wrapped nodes #4', () => {
-			// <p>[{foo}<i>{bar}</i>]{baz}</p>
-			// wrap with <b>
-			// <p>[<b>{foo}<i>{bar}</i></b>]{baz}</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 2,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{
-						instanceOf: AttributeElement,
-						name: 'i',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					},
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
-
-			const newRange = writer.wrap( created.range, new AttributeElement( 'b' ) );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: DEFAULT_PRIORITY,
-						children: [
-							{ instanceOf: Text, data: 'foo' },
-							{
-								instanceOf: AttributeElement,
-								name: 'i',
-								priority: DEFAULT_PRIORITY,
-								children: [
-									{ instanceOf: Text, data: 'bar' }
-								]
-							}
-						]
-					},
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
+			test(
+				'<container:p>[foo<attribute:i:1>bar</attribute:i:1>]baz</container:p>',
+				'<attribute:b:1></attribute:b:1>',
+				'<container:p>[<attribute:b:1>foo<attribute:i:1>bar</attribute:i:1></attribute:b:1>]baz</container:p>'
+			);
 		} );
 
 		it( 'merges wrapped nodes #5', () => {
-			// <p>[{foo}<i>{bar}</i>{baz}]</p>
-			// wrap with <b>, that has higher priority than <i>
-			// <p>[<b>{foo}</b><i><b>{bar}</b></i><b>{baz}</b>]</p>
-			const created = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 3,
-				children: [
-					{ instanceOf: Text, data: 'foo' },
-					{
-						instanceOf: AttributeElement,
-						name: 'i',
-						priority: 1,
-						children: [
-							{ instanceOf: Text, data: 'bar' }
-						]
-					},
-					{ instanceOf: Text, data: 'baz' }
-				]
-			} );
-
-			const b = new AttributeElement( 'b' );
-			b.priority = 2;
-			const newRange = writer.wrap( created.range, b );
-			test( writer, newRange, created.node, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 3,
-				children: [
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 2,
-						children: [
-							{ instanceOf: Text, data: 'foo' }
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'i',
-						priority: 1,
-						children: [
-							{
-								instanceOf: AttributeElement,
-								name: 'b',
-								priority: 2,
-								children: [
-									{ instanceOf: Text, data: 'bar' }
-								]
-							}
-						]
-					},
-					{
-						instanceOf: AttributeElement,
-						name: 'b',
-						priority: 2,
-						children: [
-							{ instanceOf: Text, data: 'baz' }
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p>[foo<attribute:i:1>bar</attribute:i:1>baz]</container:p>',
+				'<attribute:b:2></attribute:b:2>',
+				'<container:p>' +
+				'[' +
+					'<attribute:b:2>foo</attribute:b:2>' +
+					'<attribute:i:1>' +
+						'<attribute:b:2>bar</attribute:b:2>' +
+					'</attribute:i:1>' +
+					'<attribute:b:2>baz</attribute:b:2>' +
+				']' +
+				'</container:p>'
+			);
 		} );
 
 		it( 'should wrap single element by merging attributes', () => {
-			// <p>[<b foo="bar" one="two"></b>]</p>
-			// wrap with <b baz="qux" one="two"></b>
-			// <p>[<b foo="bar" one="two" baz="qux"></b>]</p>
-			const b = new AttributeElement( 'b', {
-				foo: 'bar',
-				one: 'two'
-			} );
-			const p = new ContainerElement( 'p', null, b );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-			const wrapper = new AttributeElement( 'b', {
-				baz: 'qux',
-				one: 'two'
-			} );
-
-			const newRange = writer.wrap( range, wrapper );
-			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
-			expect( b.getAttribute( 'baz' ) ).to.equal( 'qux' );
-			expect( b.getAttribute( 'one' ) ).to.equal( 'two' );
-
-			test( writer, newRange, p, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: AttributeElement, name: 'b', children: [] }
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 foo="bar" one="two"></attribute:b:1>]</container:p>',
+				'<attribute:b:1 baz="qux" one="two"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 foo="bar" one="two" baz="qux"></attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should not merge attributes when they differ', () => {
-			// <p>[<b foo="bar" ></b>]</p>
-			// wrap with <b foo="baz"></b>
-			// <p>[<b foo="baz"><b foo="bar"></b></b>]</p>
-			const b = new AttributeElement( 'b', {
-				foo: 'bar'
-			} );
-			const p = new ContainerElement( 'p', null, b );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-			const wrapper = new AttributeElement( 'b', {
-				foo: 'baz'
-			} );
-
-			const newRange = writer.wrap( range, wrapper );
-			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
-			expect( b.parent.isSimilar( wrapper ) ).to.be.true;
-			expect( b.parent.getAttribute( 'foo' ) ).to.equal( 'baz' );
-
-			test( writer, newRange, p, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: AttributeElement, name: 'b', children: [
-						{ instanceOf: AttributeElement, name: 'b', children: [] }
-					] }
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 foo="bar"></attribute:b:1>]</container:p>',
+				'<attribute:b:1 foo="baz"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 foo="baz"><attribute:b:1 foo="bar"></attribute:b:1></attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should wrap single element by merging classes', () => {
-			// <p>[<b class="foo bar baz" ></b>]</p>
-			// wrap with <b class="foo bar qux jax"></b>
-			// <p>[<b class="foo bar baz qux jax"></b>]</p>
-			const b = new AttributeElement( 'b', {
-				class: 'foo bar baz'
-			} );
-			const p = new ContainerElement( 'p', null, b );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-			const wrapper = new AttributeElement( 'b', {
-				class: 'foo bar qux jax'
-			} );
-
-			const newRange = writer.wrap( range, wrapper );
-			expect( b.hasClass( 'foo', 'bar', 'baz', 'qux', 'jax' ) ).to.be.true;
-			test( writer, newRange, p, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: AttributeElement, name: 'b', children: [] }
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 class="foo bar baz"></attribute:b:1>]</container:p>',
+				'<attribute:b:1 class="foo bar qux jax"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 class="foo bar baz qux jax"></attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should wrap single element by merging styles', () => {
-			// <p>[<b style="color:red; position: absolute;"></b>]</p>
-			// wrap with <b style="color:red; top: 20px;"></b>
-			// <p>[<b class="color:red; position: absolute; top:20px;"></b>]</p>
-			const b = new AttributeElement( 'b', {
-				style: 'color: red; position: absolute;'
-			} );
-			const p = new ContainerElement( 'p', null, b );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-			const wrapper = new AttributeElement( 'b', {
-				style: 'color:red; top: 20px;'
-			} );
-
-			const newRange = writer.wrap( range, wrapper );
-			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
-			expect( b.getStyle( 'position' ) ).to.equal( 'absolute' );
-			expect( b.getStyle( 'top' ) ).to.equal( '20px' );
-
-			test( writer, newRange, p, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: AttributeElement, name: 'b', children: [] }
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 style="color:red; position: absolute;"></attribute:b:1>]</container:p>',
+				'<attribute:b:1 style="color:red; top: 20px;"></attribute:b:1>',
+				'<container:p>[<attribute:b:1 style="color:red;position:absolute;top:20px;"></attribute:b:1>]</container:p>'
+			);
 		} );
 
 		it( 'should not merge styles when they differ', () => {
-			// <p>[<b style="color:red;"></b>]</p>
-			// wrap with <b style="color:black;"></b>
-			// <p>[<b style="color:black;"><b style="color:red;"></b></b>]</p>
-			const b = new AttributeElement( 'b', {
-				style: 'color:red'
-			} );
-			const p = new ContainerElement( 'p', null, b );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-			const wrapper = new AttributeElement( 'b', {
-				style: 'color:black'
-			} );
-
-			const newRange = writer.wrap( range, wrapper );
-			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
-			expect( b.parent.isSimilar( wrapper ) ).to.be.true;
-			expect( b.parent.getStyle( 'color' ) ).to.equal( 'black' );
-
-			test( writer, newRange, p, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: AttributeElement, name: 'b', children: [
-						{ instanceOf: AttributeElement, name: 'b', children: [] }
-					] }
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:1 style="color:red;"></attribute:b:1>]</container:p>',
+				'<attribute:b:1 style="color:black;"></attribute:b:1>',
+				'<container:p>' +
+				'[' +
+					'<attribute:b:1 style="color:black;">' +
+						'<attribute:b:1 style="color:red;"></attribute:b:1>' +
+					'</attribute:b:1>' +
+				']' +
+				'</container:p>'
+			);
 		} );
 
 		it( 'should not merge single elements when they have different priority', () => {
-			// <p>[<b style="color:red;"></b>]</p>
-			// wrap with <b style="color:red;"></b> with different priority
-			// <p>[<b style="color:red;"><b style="color:red;"></b></b>]</p>
-			const b = new AttributeElement( 'b', {
-				style: 'color:red'
-			} );
-			const p = new ContainerElement( 'p', null, b );
-			const range = Range.createFromParentsAndOffsets( p, 0, p, 1 );
-			const wrapper = new AttributeElement( 'b', {
-				style: 'color:red'
-			} );
-			wrapper.priority = b.priority - 1;
-
-			const newRange = writer.wrap( range, wrapper );
-			expect( b.getStyle( 'color' ) ).to.equal( 'red' );
-			expect( b.parent.isSimilar( wrapper ) ).to.be.true;
-			expect( b.parent.getStyle( 'color' ) ).to.equal( 'red' );
-
-			test( writer, newRange, p, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: AttributeElement, name: 'b', children: [
-						{ instanceOf: AttributeElement, name: 'b', children: [] }
-					] }
-				]
-			} );
+			test(
+				'<container:p>[<attribute:b:2 style="color:red;"></attribute:b:2>]</container:p>',
+				'<attribute:b:1 style="color:red;"></attribute:b:1>',
+				'<container:p>' +
+				'[' +
+					'<attribute:b:1 style="color:red;">' +
+						'<attribute:b:2 style="color:red;"></attribute:b:2>' +
+					'</attribute:b:1>' +
+				']</container:p>'
+			);
 		} );
 
 		it( 'should be merged with outside element when wrapping all children', () => {
-			// <p><b foo="bar">[{foobar}<i>{baz}</i>]</b></p>
-			// wrap with <b baz="qux"></b>
-			// <p>[<b foo="bar" baz="qux">{foobar}</b>]</p>
-			const text1 = new Text( 'foobar' );
-			const text2 = new Text( 'baz' );
-			const i = new AttributeElement( 'i', null, text2 );
-			const b = new AttributeElement( 'b', { foo: 'bar' }, [ text1, i ] );
-			const p = new ContainerElement( 'p', null, [ b ] );
-			const wrapper = new AttributeElement( 'b', { baz: 'qux' } );
-			const range = Range.createFromParentsAndOffsets( b, 0, b, 2 );
-
-			const newRange = writer.wrap( range, wrapper );
-			expect( b.getAttribute( 'foo' ) ).to.equal( 'bar' );
-			expect( b.getAttribute( 'baz' ) ).to.equal( 'qux' );
-			test( writer, newRange, p, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{
-						instanceof: AttributeElement,
-						name: 'b',
-						children: [
-							{ instanceOf: Text, data: 'foobar' },
-							{
-								instanceOf: AttributeElement,
-								name: 'i',
-								children: [
-									{ instanceOf: Text, data: 'baz' }
-								]
-							}
-						]
-					}
-				]
-			} );
+			test(
+				'<container:p><attribute:b:1 foo="bar">[foobar<attribute:i:1>baz</attribute:i:1>]</attribute:b:1></container:p>',
+				'<attribute:b:1 baz="qux"></attribute:b:1>',
+				'<container:p>' +
+				'[' +
+					'<attribute:b:1 foo="bar" baz="qux">' +
+						'foobar' +
+						'<attribute:i:1>baz</attribute:i:1>' +
+					'</attribute:b:1>' +
+				']' +
+				'</container:p>'
+			);
 		} );
 	} );
 } );

+ 59 - 258
packages/ckeditor5-engine/tests/treeview/writer/wrapposition.js

@@ -11,17 +11,26 @@ import Writer from '/ckeditor5/engine/treeview/writer.js';
 import Text from '/ckeditor5/engine/treeview/text.js';
 import Element from '/ckeditor5/engine/treeview/element.js';
 import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
-import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
 import Position from '/ckeditor5/engine/treeview/position.js';
-import utils from '/tests/engine/treeview/writer/_utils/utils.js';
-import { DEFAULT_PRIORITY } from '/ckeditor5/engine/treeview/attributeelement.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
 
 describe( 'wrapPosition', () => {
-	const create = utils.create;
-	const test = utils.test;
 	let writer;
 
+	/**
+	 * Executes test using `parse` and `stringify` utils functions.
+	 *
+	 * @param {String} input
+	 * @param {String} unwrapAttribute
+	 * @param {String} expected
+	 */
+	function test( input, unwrapAttribute, expected ) {
+		const { view, selection } = parse( input );
+		const newPosition = writer.wrapPosition( selection.getFirstPosition(), parse( unwrapAttribute ) );
+		expect( stringify( view, newPosition, { showType: true, showPriority: true } ) ).to.equal( expected );
+	}
+
 	beforeEach( () => {
 		writer = new Writer();
 	} );
@@ -37,278 +46,70 @@ describe( 'wrapPosition', () => {
 	} );
 
 	it( 'should wrap position at the beginning of text node', () => {
-		// <p>{|foobar}</p>
-		// wrap with <b>
-		// <p><b>|<b>{foobar}</p>
-		const description = {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{ instanceOf: Text, data: 'foobar', position: 0 }
-			]
-		};
-		const created = create( writer, description );
-		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
-		test( writer, newPosition, created.node, {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{ instanceOf: AttributeElement, name: 'b', position: 0 },
-				{ instanceOf: Text, data: 'foobar' }
-			]
-		} );
+		test(
+			'<container:p>{}foobar</container:p>',
+			'<attribute:b:1></attribute:b:1>',
+			'<container:p><attribute:b:1>[]</attribute:b:1>foobar</container:p>'
+		);
 	} );
 
 	it( 'should wrap position inside text node', () => {
-		// <p>{foo|bar}</p>
-		// wrap with <b>
-		// <p>{foo}<b>|</b>{bar}</p>
-		const description = {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{ instanceOf: Text, data: 'foobar', position: 3 }
-			]
-		};
-		const created = create( writer, description );
-		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
-		test( writer, newPosition, created.node, {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{ instanceOf: Text, data: 'foo' },
-				{ instanceOf: AttributeElement, name: 'b', position: 0 },
-				{ instanceOf: Text, data: 'bar' }
-			]
-		} );
+		test(
+			'<container:p>foo{}bar</container:p>',
+			'<attribute:b:1></attribute:b:1>',
+			'<container:p>foo<attribute:b:1>[]</attribute:b:1>bar</container:p>'
+		);
 	} );
 
 	it( 'should wrap position at the end of text node', () => {
-		// <p>{foobar|}</p>
-		// wrap with <b>
-		// <p>{foobar}<b>|</b></p>
-		const description = {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{ instanceOf: Text, data: 'foobar', position: 6 }
-			]
-		};
-		const created = create( writer, description );
-		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
-		test( writer, newPosition, created.node, {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{ instanceOf: Text, data: 'foobar' },
-				{ instanceOf: AttributeElement, name: 'b', position: 0 }
-			]
-		} );
+		test(
+			'<container:p>foobar{}</container:p>',
+			'<attribute:b:1></attribute:b:1>',
+			'<container:p>foobar<attribute:b:1>[]</attribute:b:1></container:p>'
+		);
 	} );
 
 	it( 'should merge with existing attributes #1', () => {
-		// <p><b>{foo}</b>|</p>
-		// wrap with <b>
-		// <p><b>{foo|}</b></p>
-		const description = {
-			instanceOf: ContainerElement,
-			name: 'p',
-			position: 1,
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					children: [
-						{ instanceOf: Text, data: 'foobar' }
-					]
-				}
-			]
-		};
-
-		const created = create( writer, description );
-		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
-
-		test( writer, newPosition, created.node, {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					priority: DEFAULT_PRIORITY,
-					children: [
-						{ instanceOf: Text, data: 'foobar', position: 6 }
-					]
-				}
-			]
-		} );
+		test(
+			'<container:p><attribute:b:1>foo</attribute:b:1>[]</container:p>',
+			'<attribute:b:1></attribute:b:1>',
+			'<container:p><attribute:b:1>foo{}</attribute:b:1></container:p>'
+		);
 	} );
 
 	it( 'should merge with existing attributes #2', () => {
-		// <p>|<b>{foo}</b></p>
-		// wrap with <b>
-		// <p><b>{|foo}</b></p>
-		const description = {
-			instanceOf: ContainerElement,
-			name: 'p',
-			position: 0,
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					children: [
-						{ instanceOf: Text, data: 'foobar' }
-					]
-				}
-			]
-		};
-
-		const created = create( writer, description );
-		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
-
-		test( writer, newPosition, created.node, {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					priority: DEFAULT_PRIORITY,
-					children: [
-						{ instanceOf: Text, data: 'foobar', position: 0 }
-					]
-				}
-			]
-		} );
+		test(
+			'<container:p>[]<attribute:b:1>foo</attribute:b:1></container:p>',
+			'<attribute:b:1></attribute:b:1>',
+			'<container:p><attribute:b:1>{}foo</attribute:b:1></container:p>'
+		);
 	} );
 
 	it( 'should wrap when inside nested attributes', () => {
-		// <p><b>{foo|bar}</b></p>
-		// wrap with <u>
-		// <p><b>{foo}</b><u><b>|</b></u><b>{bar}</b></p>
-		const description = {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					children: [
-						{ instanceOf: Text, data: 'foobar', position: 3 }
-					]
-				}
-			]
-		};
-
-		const created = create( writer, description );
-		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'u' ) );
-		test( writer, newPosition, created.node, {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					children: [
-						{ instanceOf: Text, data: 'foo' }
-					]
-				},
-				{
-					instanceOf: AttributeElement,
-					name: 'u',
-					children: [
-						{ instanceOf: AttributeElement, name: 'b', children: [] }
-					]
-				},
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					children: [
-						{ instanceOf: Text, data: 'bar' }
-					]
-				}
-			]
-		}  );
+		test(
+			'<container:p><attribute:b:1>foo{}bar</attribute:b:1></container:p>',
+			'<attribute:u:1></attribute:u:1>',
+			'<container:p>' +
+				'<attribute:b:1>foo</attribute:b:1>' +
+				'<attribute:u:1><attribute:b:1>[]</attribute:b:1></attribute:u:1>' +
+				'<attribute:b:1>bar</attribute:b:1>' +
+			'</container:p>'
+		);
 	} );
 
 	it( 'should merge when wrapping between same attribute', () => {
-		// <p><b>{foo}</b>|<b>{bar}</b></p>
-		// wrap with <b>
-		// <p><b>{foo|bar}</b></p>
-		const description = {
-			instanceOf: ContainerElement,
-			name: 'p',
-			position: 1,
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					children: [
-						{ instanceOf: Text, data: 'foo' }
-					]
-				},
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					children: [
-						{ instanceOf: Text, data: 'bar' }
-					]
-				}
-			]
-		};
-
-		const created = create( writer, description );
-		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
-
-		test( writer, newPosition, created.node, {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					children: [
-						{ instanceOf: Text, data: 'foobar', position: 3 }
-					]
-				}
-			]
-		}  );
+		test(
+			'<container:p><attribute:b:1>foo</attribute:b:1>[]<attribute:b:1>bar</attribute:b:1></container:p>',
+			'<attribute:b:1></attribute:b:1>',
+			'<container:p><attribute:b:1>foo{}bar</attribute:b:1></container:p>'
+		);
 	} );
 
-	it( 'should return same position when inside same attribute', () => {
-		// <p><b>{foobar}|</b></p>
-		// wrap with <b>
-		// <p><b>{foobar|}</b></p>
-		const description = {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					position: 1,
-					children: [
-						{ instanceOf: Text, data: 'foobar' }
-					]
-				}
-			]
-		};
-
-		const created = create( writer, description );
-		const newPosition = writer.wrapPosition( created.position, new AttributeElement( 'b' ) );
-
-		test( writer, newPosition, created.node, {
-			instanceOf: ContainerElement,
-			name: 'p',
-			children: [
-				{
-					instanceOf: AttributeElement,
-					name: 'b',
-					children: [
-						{ instanceOf: Text, data: 'foobar', position: 6 }
-					]
-				}
-			]
-		} );
+	it( 'should move position to text node if in same attribute', () => {
+		test(
+			'<container:p><attribute:b:1>foobar[]</attribute:b:1></container:p>',
+			'<attribute:b:1></attribute:b:1>',
+			'<container:p><attribute:b:1>foobar{}</attribute:b:1></container:p>'
+		);
 	} );
 } );

+ 8 - 22
packages/ckeditor5-engine/tests/treeview/writer/writer.js

@@ -12,10 +12,9 @@ import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
 import AttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
 import Text from '/ckeditor5/engine/treeview/text.js';
 import Position from '/ckeditor5/engine/treeview/position.js';
-import utils from '/tests/engine/treeview/writer/_utils/utils.js';
+import { parse } from '/tests/engine/_utils/view.js';
 
 describe( 'Writer', () => {
-	const create = utils.create;
 	let writer;
 
 	beforeEach( () => {
@@ -47,33 +46,20 @@ describe( 'Writer', () => {
 
 	describe( 'move', () => {
 		it( 'should move nodes using remove and insert methods', () => {
-			// <p>[{foobar}]</p>
-			// Move to <div>|</div>
-			// <div>[{foobar}]</div>
-			const source = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'p',
-				rangeStart: 0,
-				rangeEnd: 1,
-				children: [
-					{ instanceOf: Text, data: 'foobar' }
-				]
-			} );
-			const target = create( writer, {
-				instanceOf: ContainerElement,
-				name: 'div',
-				position: 0
-			} );
+			const { selection: sourceSelection } = parse( '<container:p>[foobar]</container:p>' );
+			const { selection: targetSelection } = parse( '<container:div>[]</container:div>' );
 
 			const removeSpy = sinon.spy( writer, 'remove' );
 			const insertSpy = sinon.spy( writer, 'insert' );
+			const sourceRange = sourceSelection.getFirstRange();
+			const targetPosition = targetSelection.getFirstPosition();
 
-			const newRange = writer.move( source.range, target.position );
+			const newRange = writer.move( sourceRange, targetPosition );
 
 			sinon.assert.calledOnce( removeSpy );
-			sinon.assert.calledWithExactly( removeSpy, source.range );
+			sinon.assert.calledWithExactly( removeSpy, sourceRange );
 			sinon.assert.calledOnce( insertSpy );
-			sinon.assert.calledWithExactly( insertSpy, target.position, removeSpy.firstCall.returnValue );
+			sinon.assert.calledWithExactly( insertSpy, targetPosition, removeSpy.firstCall.returnValue );
 			expect( newRange ).to.equal( insertSpy.firstCall.returnValue );
 		} );
 	} );