Explorar o código

Merge pull request #813 from ckeditor/t/808

Feature: Introduced debugging tools for the engine. Closes #808.
Szymon Kupś %!s(int64=9) %!d(string=hai) anos
pai
achega
fe85b95b70

+ 533 - 0
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -0,0 +1,533 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/dev-utils/enableenginedebug
+ */
+
+/* global console */
+
+import ModelPosition from '../model/position';
+import ModelRange from '../model/range';
+import ModelText from '../model/text';
+import ModelElement from '../model/element';
+import Operation from '../model/operation/operation';
+import AttributeOperation from '../model/operation/attributeoperation';
+import InsertOperation from '../model/operation/insertoperation';
+import MarkerOperation from '../model/operation/markeroperation';
+import MoveOperation from '../model/operation/moveoperation';
+import NoOperation from '../model/operation/nooperation';
+import RenameOperation from '../model/operation/renameoperation';
+import RootAttributeOperation from '../model/operation/rootattributeoperation';
+import Delta from '../model/delta/delta';
+import AttributeDelta from '../model/delta/attributedelta';
+import { RootAttributeDelta } from '../model/delta/attributedelta';
+import InsertDelta from '../model/delta/insertdelta';
+import MarkerDelta from '../model/delta/markerdelta';
+import MergeDelta from '../model/delta/mergedelta';
+import MoveDelta from '../model/delta/movedelta';
+import RenameDelta from '../model/delta/renamedelta';
+import SplitDelta from '../model/delta/splitdelta';
+import UnwrapDelta from '../model/delta/unwrapdelta';
+import WrapDelta from '../model/delta/wrapdelta';
+import ModelDocument from '../model/document';
+import ModelDocumentFragment from '../model/documentfragment';
+import ModelRootElement from '../model/rootelement';
+
+import ViewDocument from '../view/document';
+import ViewElement from '../view/element';
+import ViewText from '../view/text';
+import ViewDocumentFragment from '../view/documentfragment';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Editor from '@ckeditor/ckeditor5-core/src/editor/editor';
+
+const treeDump = Symbol( '_treeDump' );
+
+// Maximum number of stored states of model and view document.
+const maxTreeDumpLength = 20;
+
+// Specified whether debug tools were already enabled.
+let enabled = false;
+
+// Logging function used to log debug messages.
+let log = console.log;
+
+/**
+ * Enhances model classes with logging methods. Returns a plugin that should be loaded in the editor to
+ * enable debugging features.
+ *
+ * Every operation applied on {@link module:engine/model/document~Document model.Document} is logged.
+ *
+ * Following classes are expanded with `log` and meaningful `toString` methods:
+ * * {@link module:engine/model/position~Position model.Position},
+ * * {@link module:engine/model/range~Range model.Range},
+ * * {@link module:engine/model/text~Text model.Text},
+ * * {@link module:engine/model/element~Element model.Element},
+ * * {@link module:engine/model/rootelement~RootElement model.RootElement},
+ * * {@link module:engine/model/documentfragment~DocumentFragment model.DocumentFragment},
+ * * {@link module:engine/model/document~Document model.Document},
+ * * all {@link module:engine/model/operation/operation~Operation operations}
+ * * all {@link module:engine/model/delta/delta~Delta deltas},
+ * * {@link module:engine/view/element~Element view.Element},
+ * * {@link module:engine/view/documentfragment~DocumentFragment view.DocumentFragment},
+ * * {@link module:engine/view/document~Document view.Document}.
+ *
+ * Additionally, following logging utility methods are added:
+ * * {@link module:engine/model/text~Text model.Text} `logExtended`,
+ * * {@link module:engine/model/element~Element model.Element} `logExtended`,
+ * * {@link module:engine/model/element~Element model.Element} `logAll`,
+ * * {@link module:engine/model/delta/delta~Delta model.Delta} `logAll`.
+ *
+ * Additionally, following classes are expanded with `logTree` and `printTree` methods:
+ * * {@link module:engine/model/element~Element model.Element},
+ * * {@link module:engine/model/documentfragment~DocumentFragment model.DocumentFragment},
+ * * {@link module:engine/view/element~Element view.Element},
+ * * {@link module:engine/view/documentfragment~DocumentFragment view.DocumentFragment}.
+ *
+ * Finally, following methods are added to {@link module:core/editor/editor~Editor}: `logModel`, `logView`, `logDocuments`.
+ * All those methods take one parameter, which is a version of {@link module:engine/model/document~Document model document}
+ * for which model or view document state should be logged.
+ *
+ * @param {Function} [logger] Function used to log messages. By default messages are logged to console.
+ * @returns {module:engine/dev-utils/enableenginedebug~DebugPlugin} Plugin to be loaded in the editor.
+ */
+export default function enableEngineDebug( logger = console.log ) {
+	log = logger;
+
+	if ( !enabled ) {
+		enabled = true;
+
+		enableLoggingTools();
+		enableDocumentTools();
+	}
+
+	return DebugPlugin;
+}
+
+function enableLoggingTools() {
+	ModelPosition.prototype.toString = function() {
+		return `${ this.root } [ ${ this.path.join( ', ' ) } ]`;
+	};
+
+	ModelPosition.prototype.log = function() {
+		log( 'ModelPosition: ' + this );
+	};
+
+	ModelRange.prototype.toString = function() {
+		return `${ this.root } [ ${ this.start.path.join( ', ' ) } ] - [ ${ this.end.path.join( ', ' ) } ]`;
+	};
+
+	ModelRange.prototype.log = function() {
+		log( 'ModelRange: ' + this );
+	};
+
+	ModelText.prototype.toString = function() {
+		return `#${ this.data }`;
+	};
+
+	ModelText.prototype.logExtended = function() {
+		log( `ModelText: ${ this }, attrs: ${ mapString( this._attrs ) }` );
+	};
+
+	ModelText.prototype.log = function() {
+		log( 'ModelText: ' + this );
+	};
+
+	ModelElement.prototype.toString = function() {
+		return `<${ this.rootName || this.name }>`;
+	};
+
+	ModelElement.prototype.log = function() {
+		log( 'ModelElement: ' + this );
+	};
+
+	ModelElement.prototype.logExtended = function() {
+		log( `ModelElement: ${ this }, ${ this.childCount } children, attrs: ${ mapString( this._attrs ) }` );
+	};
+
+	ModelElement.prototype.logAll = function() {
+		log( '--------------------' );
+
+		this.logExtended();
+		log( 'List of children:' );
+
+		for ( let child of this.getChildren() ) {
+			child.log();
+		}
+	};
+
+	ModelElement.prototype.printTree = function( level = 0 ) {
+		let string = '';
+
+		string += '\t'.repeat( level ) + `<${ this.rootName || this.name }${ mapToTags( this.getAttributes() ) }>`;
+
+		for ( let child of this.getChildren() ) {
+			string += '\n';
+
+			if ( child instanceof ModelText ) {
+				const textAttrs = mapToTags( child._attrs );
+
+				string += '\t'.repeat( level + 1 );
+
+				if ( textAttrs !== '' ) {
+					string += `<$text${ textAttrs }>` + child.data + '</$text>';
+				} else {
+					string += child.data;
+				}
+			} else {
+				string += child.printTree( level + 1 );
+			}
+		}
+
+		if ( this.childCount ) {
+			string += '\n' + '\t'.repeat( level );
+		}
+
+		string += `</${ this.rootName || this.name }>`;
+
+		return string;
+	};
+
+	ModelElement.prototype.logTree = function() {
+		log( this.printTree() );
+	};
+
+	ModelRootElement.prototype.toString = function() {
+		return this.rootName;
+	};
+
+	ModelRootElement.prototype.log = function() {
+		log( 'ModelRootElement: ' + this );
+	};
+
+	ModelDocumentFragment.prototype.toString = function() {
+		return `documentFragment`;
+	};
+
+	ModelDocumentFragment.prototype.log = function() {
+		log( 'ModelDocumentFragment: ' + this );
+	};
+
+	ModelDocumentFragment.prototype.printTree = function() {
+		let string = 'ModelDocumentFragment: [';
+
+		for ( let child of this.getChildren() ) {
+			string += '\n';
+
+			if ( child instanceof ModelText ) {
+				const textAttrs = mapToTags( child._attrs );
+
+				string += '\t'.repeat( 1 );
+
+				if ( textAttrs !== '' ) {
+					string += `<$text${ textAttrs }>` + child.data + '</$text>';
+				} else {
+					string += child.data;
+				}
+			} else {
+				string += child.printTree( 1 );
+			}
+		}
+
+		string += '\n]';
+
+		return string;
+	};
+
+	ModelDocumentFragment.prototype.logTree = function() {
+		log( this.printTree() );
+	};
+
+	Operation.prototype.log = function() {
+		log( this.toString() );
+	};
+
+	AttributeOperation.prototype.toString = function() {
+		return getClassName( this ) + ': ' +
+			`"${ this.key }": ${ JSON.stringify( this.oldValue ) } -> ${ JSON.stringify( this.newValue ) }, ${ this.range }`;
+	};
+
+	InsertOperation.prototype.toString = function() {
+		return getClassName( this ) + ': ' +
+			`[ ${ this.nodes.length } ] -> ${ this.position }`;
+	};
+
+	MarkerOperation.prototype.toString = function() {
+		return getClassName( this ) + ': ' +
+			`"${ this.name }": ${ this.oldRange } -> ${ this.newRange }`;
+	};
+
+	MoveOperation.prototype.toString = function() {
+		const range = ModelRange.createFromPositionAndShift( this.sourcePosition, this.howMany );
+
+		return getClassName( this ) + ': ' +
+			`${ range } -> ${ this.targetPosition }`;
+	};
+
+	NoOperation.prototype.toString = function() {
+		return 'NoOperation';
+	};
+
+	RenameOperation.prototype.toString = function() {
+		return getClassName( this ) + ': ' +
+			`${ this.position }: "${ this.oldName }" -> "${ this.newName }"`;
+	};
+
+	RootAttributeOperation.prototype.toString = function() {
+		return getClassName( this ) + ': ' +
+			`"${ this.key }": ${ JSON.stringify( this.oldValue ) } -> ${ JSON.stringify( this.newValue ) }, ${ this.root.rootName }`;
+	};
+
+	Delta.prototype.log = function() {
+		log( this.toString() );
+	};
+
+	Delta.prototype.logAll = function() {
+		log( '--------------------' );
+
+		this.log();
+
+		for ( let op of this.operations ) {
+			op.log();
+		}
+	};
+
+	AttributeDelta.prototype.toString = function() {
+		return getClassName( this ) + ': ' +
+			`"${ this.key }": -> ${ JSON.stringify( this.value ) }, ${ this.range }, ${ this.operations.length } ops`;
+	};
+
+	InsertDelta.prototype.toString = function() {
+		const op = this._insertOperation;
+
+		return getClassName( this ) + ': ' +
+			`[ ${ op.nodes.length } ] -> ${ op.position }`;
+	};
+
+	MarkerDelta.prototype.toString = function() {
+		const op = this.operations[ 0 ];
+
+		return getClassName( this ) + ': ' +
+			`"${ op.name }": ${ op.oldRange } -> ${ op.newRange }`;
+	};
+
+	MergeDelta.prototype.toString = function() {
+		return getClassName( this ) + ': ' +
+			this.position.toString();
+	};
+
+	MoveDelta.prototype.toString = function() {
+		const opStrings = [];
+
+		for ( let op of this.operations ) {
+			const range = ModelRange.createFromPositionAndShift( op.sourcePosition, op.howMany );
+
+			opStrings.push( `${ range } -> ${ op.targetPosition }` );
+		}
+
+		return getClassName( this ) + ': ' +
+			opStrings.join( '; ' );
+	};
+
+	RenameDelta.prototype.toString = function() {
+		const op = this.operations[ 0 ];
+
+		return getClassName( this ) + ': ' +
+			`${ op.position }: "${ op.oldName }" -> "${ op.newName }"`;
+	};
+
+	RootAttributeDelta.prototype.toString = function() {
+		const op = this.operations[ 0 ];
+
+		return getClassName( this ) + ': ' +
+			`"${ op.key }": ${ JSON.stringify( op.oldValue ) } -> ${ JSON.stringify( op.newValue ) }, ${ op.root.rootName }`;
+	};
+
+	SplitDelta.prototype.toString = function() {
+		return getClassName( this ) + ': ' +
+			this.position.toString();
+	};
+
+	UnwrapDelta.prototype.toString = function() {
+		return getClassName( this ) + ': ' +
+			this.position.toString();
+	};
+
+	WrapDelta.prototype.toString = function() {
+		const wrapElement = this._insertOperation.nodes.getNode( 0 );
+
+		return getClassName( this ) + ': ' +
+			`${ this.range } -> ${ wrapElement }`;
+	};
+
+	ViewElement.prototype.printTree = function( level = 0 ) {
+		let string = '';
+
+		string += '\t'.repeat( level ) + `<${ this.name }${ mapToTags( this.getAttributes() ) }>`;
+
+		for ( let child of this.getChildren() ) {
+			if ( child instanceof ViewText ) {
+				string += '\n' + '\t'.repeat( level + 1 ) + child.data;
+			} else {
+				string += '\n' + child.printTree( level + 1 );
+			}
+		}
+
+		if ( this.childCount ) {
+			string += '\n' + '\t'.repeat( level );
+		}
+
+		string += `</${ this.name }>`;
+
+		return string;
+	};
+
+	ViewElement.prototype.logTree = function() {
+		log( this.printTree() );
+	};
+
+	ViewDocumentFragment.prototype.printTree = function() {
+		let string = 'ViewDocumentFragment: [';
+
+		for ( let child of this.getChildren() ) {
+			if ( child instanceof ViewText ) {
+				string += '\n' + '\t'.repeat( 1 ) + child.data;
+			} else {
+				string += '\n' + child.printTree( 1 );
+			}
+		}
+
+		string += '\n]';
+
+		return string;
+	};
+
+	ViewDocumentFragment.prototype.logTree = function() {
+		log( this.printTree() );
+	};
+}
+
+function enableDocumentTools() {
+	const _modelDocumentApplyOperation = ModelDocument.prototype.applyOperation;
+
+	ModelDocument.prototype.applyOperation = function( operation ) {
+		log( 'Applying ' + operation );
+
+		_modelDocumentApplyOperation.call( this, operation );
+	};
+
+	ModelDocument.prototype.log = function( version = null ) {
+		version = version === null ? this.version : version;
+
+		logDocument( this, version );
+	};
+
+	ViewDocument.prototype.log = function( version ) {
+		logDocument( this, version );
+	};
+
+	Editor.prototype.logModel = function( version = null ) {
+		version = version === null ? this.document.version : version;
+
+		this.document.log( version );
+	};
+
+	Editor.prototype.logView = function( version ) {
+		this.editing.view.log( version );
+	};
+
+	Editor.prototype.logDocuments = function( version = null ) {
+		version = version === null ? this.document.version : version;
+
+		this.logModel( version );
+		this.logView( version );
+	};
+
+	function logDocument( document, version ) {
+		log( '--------------------' );
+
+		if ( document[ treeDump ][ version ] ) {
+			log( document[ treeDump ][ version ] );
+		} else {
+			log( 'Tree log unavailable for given version: ' + version );
+		}
+	}
+}
+
+/**
+ * Plugin that enables debugging features on the editor's model and view documents.
+ */
+class DebugPlugin extends Plugin {
+	constructor( editor ) {
+		super( editor );
+
+		const modelDocument = this.editor.document;
+		const viewDocument = this.editor.editing.view;
+
+		modelDocument[ treeDump ] = [];
+		viewDocument[ treeDump ] = [];
+
+		dumpTrees( modelDocument, modelDocument.version );
+		dumpTrees( viewDocument, modelDocument.version );
+
+		modelDocument.on( 'change', () => {
+			dumpTrees( modelDocument, modelDocument.version );
+			dumpTrees( viewDocument, modelDocument.version );
+		}, { priority: 'lowest' } );
+	}
+}
+
+// Helper function, stores `document` state for given `version` as a string in private property.
+function dumpTrees( document, version ) {
+	let string = '';
+
+	for ( let root of document.roots.values() ) {
+		string += root.printTree() + '\n';
+	}
+
+	document[ treeDump ][ version ] = string.substr( 0, string.length - 1 ); // Remove the last "\n".
+
+	const overflow = document[ treeDump ].length - maxTreeDumpLength;
+
+	if ( overflow > 0 ) {
+		document[ treeDump ][ overflow - 1 ] = null;
+	}
+}
+
+// Helper function, returns class name of given `Delta` or `Operation`.
+// @param {module:engine/model/delta/delta~Delta|module:engine/model/operation/operation~Operation}
+// @returns {String} Class name.
+function getClassName( obj ) {
+	const path = obj.constructor.className.split( '.' );
+
+	return path[ path.length - 1 ];
+}
+
+// Helper function, converts map to {"key1":"value1","key2":"value2"} format.
+// @param {Map} map Map to convert.
+// @returns {String} Converted map.
+function mapString( map ) {
+	const obj = {};
+
+	for ( let entry of map ) {
+		obj[ entry[ 0 ] ] = entry[ 1 ];
+	}
+
+	return JSON.stringify( obj );
+}
+
+// Helper function, converts map to key1="value1" key2="value1" format.
+// @param {Map} map Map to convert.
+// @returns {String} Converted map.
+function mapToTags( map ) {
+	let string = '';
+
+	for ( let entry of map ) {
+		string += ` ${ entry[ 0 ] }=${ JSON.stringify( entry[ 1 ] ) }`;
+	}
+
+	return string;
+}

+ 8 - 9
packages/ckeditor5-engine/src/model/document.js

@@ -104,10 +104,9 @@ export default class Document {
 		 * {@link #getRoot} to manipulate it.
 		 *
 		 * @readonly
-		 * @protected
 		 * @member {Map}
 		 */
-		this._roots = new Map();
+		this.roots = new Map();
 
 		// Add events that will ensure selection correctness.
 		this.selection.on( 'change:range', () => {
@@ -192,7 +191,7 @@ export default class Document {
 	 * @returns {module:engine/model/rootelement~RootElement} Created root.
 	 */
 	createRoot( elementName = '$root', rootName = 'main' ) {
-		if ( this._roots.has( rootName ) ) {
+		if ( this.roots.has( rootName ) ) {
 			/**
 			 * Root with specified name already exists.
 			 *
@@ -207,7 +206,7 @@ export default class Document {
 		}
 
 		const root = new RootElement( this, elementName, rootName );
-		this._roots.set( rootName, root );
+		this.roots.set( rootName, root );
 
 		return root;
 	}
@@ -251,7 +250,7 @@ export default class Document {
 	 * @returns {module:engine/model/rootelement~RootElement} Root registered under given name.
 	 */
 	getRoot( name = 'main' ) {
-		if ( !this._roots.has( name ) ) {
+		if ( !this.roots.has( name ) ) {
 			/**
 			 * Root with specified name does not exist.
 			 *
@@ -264,7 +263,7 @@ export default class Document {
 			);
 		}
 
-		return this._roots.get( name );
+		return this.roots.get( name );
 	}
 
 	/**
@@ -274,7 +273,7 @@ export default class Document {
 	 * @returns {Boolean}
 	 */
 	hasRoot( name ) {
-		return this._roots.has( name );
+		return this.roots.has( name );
 	}
 
 	/**
@@ -283,7 +282,7 @@ export default class Document {
 	 * @returns {Array.<String>} Roots names.
 	 */
 	getRootNames() {
-		return Array.from( this._roots.keys() ).filter( ( name ) => name != graveyardName );
+		return Array.from( this.roots.keys() ).filter( ( name ) => name != graveyardName );
 	}
 
 	/**
@@ -360,7 +359,7 @@ export default class Document {
 	 * @returns {module:engine/model/rootelement~RootElement} The default root for this document.
 	 */
 	_getDefaultRoot() {
-		for ( let root of this._roots.values() ) {
+		for ( let root of this.roots.values() ) {
 			if ( root !== this.graveyard ) {
 				return root;
 			}

+ 687 - 0
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -0,0 +1,687 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import enableEngineDebug from '../../src/dev-utils/enableenginedebug';
+import StandardEditor from '@ckeditor/ckeditor5-core/src/editor/standardeditor';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import ModelPosition from '../../src/model/position';
+import ModelRange from '../../src/model/range';
+import ModelText from '../../src/model/text';
+import ModelElement from '../../src/model/element';
+import AttributeOperation from '../../src/model/operation/attributeoperation';
+import InsertOperation from '../../src/model/operation/insertoperation';
+import MarkerOperation from '../../src/model/operation/markeroperation';
+import MoveOperation from '../../src/model/operation/moveoperation';
+import NoOperation from '../../src/model/operation/nooperation';
+import RenameOperation from '../../src/model/operation/renameoperation';
+import RootAttributeOperation from '../../src/model/operation/rootattributeoperation';
+import RemoveOperation from '../../src/model/operation/removeoperation';
+import Delta from '../../src/model/delta/delta';
+import AttributeDelta from '../../src/model/delta/attributedelta';
+import { RootAttributeDelta } from '../../src/model/delta/attributedelta';
+import InsertDelta from '../../src/model/delta/insertdelta';
+import MarkerDelta from '../../src/model/delta/markerdelta';
+import MergeDelta from '../../src/model/delta/mergedelta';
+import MoveDelta from '../../src/model/delta/movedelta';
+import RenameDelta from '../../src/model/delta/renamedelta';
+import SplitDelta from '../../src/model/delta/splitdelta';
+import UnwrapDelta from '../../src/model/delta/unwrapdelta';
+import WrapDelta from '../../src/model/delta/wrapdelta';
+import ModelDocument from '../../src/model/document';
+import ModelDocumentFragment from '../../src/model/documentfragment';
+
+import ViewDocument from '../../src/view/document';
+import ViewAttributeElement from '../../src/view/attributeelement';
+import ViewContainerElement from '../../src/view/containerelement';
+import ViewText from '../../src/view/text';
+import ViewDocumentFragment from '../../src/view/documentfragment';
+
+/* global document */
+
+describe( 'enableEngineDebug', () => {
+	it( 'should return plugin class', () => {
+		const result = enableEngineDebug();
+
+		expect( result.prototype ).to.be.instanceof( Plugin );
+	} );
+
+	it( 'should not throw when called multiple times', () => {
+		enableEngineDebug();
+		enableEngineDebug();
+		const result = enableEngineDebug();
+
+		expect( result.prototype ).to.be.instanceof( Plugin );
+	} );
+} );
+
+describe( 'debug tools', () => {
+	let DebugPlugin, log;
+
+	class TestEditor extends StandardEditor {
+		constructor( ...args ) {
+			super( ...args );
+
+			this.document.createRoot( 'main' );
+			this.editing.createRoot( this.element, 'main' );
+		}
+	}
+
+	before( () => {
+		log = sinon.spy();
+		DebugPlugin = enableEngineDebug( log );
+	} );
+
+	afterEach( () => {
+		log.reset();
+	} );
+
+	describe( 'should provide logging tools', () => {
+		let modelDoc, modelRoot, modelElement, modelDocFrag;
+
+		beforeEach( () => {
+			modelDoc = new ModelDocument();
+			modelRoot = modelDoc.createRoot();
+			modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
+			modelDocFrag = new ModelDocumentFragment( [ new ModelText( 'bar' ) ] );
+		} );
+
+		it( 'for ModelText', () => {
+			const foo = new ModelText( 'foo', { foo: 'bar' } );
+
+			expect( foo.toString() ).to.equal( '#foo' );
+
+			foo.log();
+			expect( log.calledWithExactly( 'ModelText: #foo' ) ).to.be.true;
+
+			foo.logExtended();
+			expect( log.calledWithExactly( 'ModelText: #foo, attrs: {"foo":"bar"}' ) ).to.be.true;
+		} );
+
+		it( 'for ModelElement', () => {
+			const paragraph = new ModelElement( 'paragraph', { foo: 'bar' }, new ModelText( 'foo' ) );
+
+			expect( paragraph.toString() ).to.equal( '<paragraph>' );
+
+			paragraph.log();
+			expectLog( 'ModelElement: <paragraph>' );
+
+			paragraph.logExtended();
+			expectLog( 'ModelElement: <paragraph>, 1 children, attrs: {"foo":"bar"}' );
+
+			sinon.spy( paragraph, 'logExtended' );
+			paragraph.logAll();
+			expect( paragraph.logExtended.called ).to.be.true;
+			expectLog( 'ModelText: #foo' );
+		} );
+
+		it( 'for ModelRootElement', () => {
+			modelRoot.log();
+			expectLog( 'ModelRootElement: main' );
+		} );
+
+		it( 'for ModelDocumentFragment', () => {
+			modelDocFrag.log();
+			expectLog( 'ModelDocumentFragment: documentFragment' );
+		} );
+
+		it( 'for ModelPosition', () => {
+			const posInRoot = new ModelPosition( modelRoot, [ 0, 1, 0 ] );
+			const posInElement = new ModelPosition( modelElement, [ 0 ] );
+			const posInDocFrag = new ModelPosition( modelDocFrag, [ 2, 3 ] );
+
+			expect( posInRoot.toString() ).to.equal( 'main [ 0, 1, 0 ]' );
+			expect( posInElement.toString() ).to.equal( '<paragraph> [ 0 ]' );
+			expect( posInDocFrag.toString() ).to.equal( 'documentFragment [ 2, 3 ]' );
+
+			posInRoot.log();
+			expectLog( 'ModelPosition: main [ 0, 1, 0 ]' );
+		} );
+
+		it( 'for ModelRange', () => {
+			const rangeInRoot = ModelRange.createIn( modelRoot );
+			const rangeInElement = ModelRange.createIn( modelElement );
+			const rangeInDocFrag = ModelRange.createIn( modelDocFrag );
+
+			expect( rangeInRoot.toString() ).to.equal( 'main [ 0 ] - [ 0 ]' );
+			expect( rangeInElement.toString() ).to.equal( '<paragraph> [ 0 ] - [ 3 ]' );
+			expect( rangeInDocFrag.toString() ).to.equal( 'documentFragment [ 0 ] - [ 3 ]' );
+
+			rangeInRoot.log();
+			expectLog( 'ModelRange: main [ 0 ] - [ 0 ]' );
+		} );
+
+		describe( 'for operations', () => {
+			beforeEach( () => {
+				modelRoot.appendChildren( [ new ModelText( 'foobar' ) ] );
+			} );
+
+			it( 'AttributeOperation', () => {
+				const op = new AttributeOperation( ModelRange.createIn( modelRoot ), 'key', null, { foo: 'bar' }, 0 );
+
+				expect( op.toString() ).to.equal( 'AttributeOperation: "key": null -> {"foo":"bar"}, main [ 0 ] - [ 6 ]' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'InsertOperation', () => {
+				const op = new InsertOperation( ModelPosition.createAt( modelRoot, 3 ), [ new ModelText( 'abc' ) ], 0 );
+
+				expect( op.toString() ).to.equal( 'InsertOperation: [ 1 ] -> main [ 3 ]' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'MarkerOperation', () => {
+				const op = new MarkerOperation( 'marker', null, ModelRange.createIn( modelRoot ), modelDoc.markers, 0 );
+
+				expect( op.toString() ).to.equal( 'MarkerOperation: "marker": null -> main [ 0 ] - [ 6 ]' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'MoveOperation', () => {
+				const op = new MoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( modelRoot, 6 ), 0 );
+
+				expect( op.toString() ).to.equal( 'MoveOperation: main [ 1 ] - [ 3 ] -> main [ 6 ]' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'NoOperation', () => {
+				const op = new NoOperation( 0 );
+
+				expect( op.toString() ).to.equal( 'NoOperation' );
+
+				op.log();
+				expect( log.calledWithExactly( 'NoOperation' ) ).to.be.true;
+			} );
+
+			it( 'RenameOperation', () => {
+				const op = new RenameOperation( ModelPosition.createAt( modelRoot, 1 ), 'old', 'new', 0 );
+
+				expect( op.toString() ).to.equal( 'RenameOperation: main [ 1 ]: "old" -> "new"' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'RootAttributeOperation', () => {
+				const op = new RootAttributeOperation( modelRoot, 'key', 'old', null, 0 );
+
+				expect( op.toString() ).to.equal( 'RootAttributeOperation: "key": "old" -> null, main' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'for deltas', () => {
+			it( 'Delta', () => {
+				const delta = new Delta();
+				const op = { log: sinon.spy() };
+				delta.addOperation( op );
+
+				sinon.spy( delta, 'log' );
+				delta.logAll();
+
+				expect( op.log.called ).to.be.true;
+				expect( delta.log.called ).to.be.true;
+			} );
+
+			it( 'AttributeDelta', () => {
+				modelRoot.appendChildren( new ModelText( 'foobar' ) );
+
+				const delta = new AttributeDelta();
+				const op = new AttributeOperation( ModelRange.createIn( modelRoot ), 'key', null, { foo: 'bar' }, 0 );
+
+				delta.addOperation( op );
+
+				expect( delta.toString() ).to.equal( 'AttributeDelta: "key": -> {"foo":"bar"}, main [ 0 ] - [ 6 ], 1 ops' );
+				delta.log();
+
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'InsertDelta', () => {
+				const delta = new InsertDelta();
+				const op = new InsertOperation( ModelPosition.createAt( modelRoot, 3 ), [ new ModelText( 'abc' ) ], 0 );
+
+				delta.addOperation( op );
+
+				expect( delta.toString() ).to.equal( 'InsertDelta: [ 1 ] -> main [ 3 ]' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'MarkerDelta', () => {
+				modelRoot.appendChildren( new ModelText( 'foobar' ) );
+
+				const delta = new MarkerDelta();
+				const op = new MarkerOperation( 'marker', null, ModelRange.createIn( modelRoot ), modelDoc.markers, 0 );
+
+				delta.addOperation( op );
+
+				expect( delta.toString() ).to.equal( 'MarkerDelta: "marker": null -> main [ 0 ] - [ 6 ]' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'MergeDelta', () => {
+				const otherRoot = modelDoc.createRoot( '$root', 'otherRoot' );
+				const firstEle = new ModelElement( 'paragraph' );
+				const removedEle = new ModelElement( 'paragraph', null, [ new ModelText( 'foo' ) ] );
+
+				otherRoot.appendChildren( [ firstEle, removedEle ] );
+
+				const delta = new MergeDelta();
+				const move = new MoveOperation( ModelPosition.createAt( removedEle, 0 ), 3, ModelPosition.createAt( firstEle, 0 ), 0 );
+				const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, 1 );
+
+				delta.addOperation( move );
+				delta.addOperation( remove );
+
+				expect( delta.toString() ).to.equal( 'MergeDelta: otherRoot [ 1 ]' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'MoveDelta', () => {
+				const delta = new MoveDelta();
+				const move1 = new MoveOperation( ModelPosition.createAt( modelRoot, 0 ), 1, ModelPosition.createAt( modelRoot, 3 ), 0 );
+				const move2 = new MoveOperation( ModelPosition.createAt( modelRoot, 1 ), 1, ModelPosition.createAt( modelRoot, 6 ), 0 );
+
+				delta.addOperation( move1 );
+				delta.addOperation( move2 );
+
+				expect( delta.toString() ).to.equal( 'MoveDelta: main [ 0 ] - [ 1 ] -> main [ 3 ]; main [ 1 ] - [ 2 ] -> main [ 6 ]' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'RenameDelta', () => {
+				const delta = new RenameDelta();
+				const op = new RenameOperation( ModelPosition.createAt( modelRoot, 1 ), 'old', 'new', 0 );
+
+				delta.addOperation( op );
+
+				expect( delta.toString() ).to.equal( 'RenameDelta: main [ 1 ]: "old" -> "new"' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'RootAttributeDelta', () => {
+				const delta = new RootAttributeDelta();
+				const op = new RootAttributeOperation( modelRoot, 'key', 'old', null, 0 );
+
+				delta.addOperation( op );
+
+				expect( delta.toString() ).to.equal( 'RootAttributeDelta: "key": "old" -> null, main' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'SplitDelta', () => {
+				const otherRoot = modelDoc.createRoot( 'main', 'otherRoot' );
+				const splitEle = new ModelElement( 'paragraph', null, [ new ModelText( 'foo' ) ] );
+
+				otherRoot.appendChildren( [ splitEle ] );
+
+				const delta = new SplitDelta();
+				const insert = new InsertOperation( ModelPosition.createAt( otherRoot, 1 ), [ new ModelElement( 'paragraph' ) ], 0 );
+				const move = new MoveOperation( ModelPosition.createAt( splitEle, 1 ), 2, new ModelPosition( otherRoot, [ 1, 0 ] ), 1 );
+
+				delta.addOperation( insert );
+				delta.addOperation( move );
+
+				expect( delta.toString() ).to.equal( 'SplitDelta: otherRoot [ 0, 1 ]' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'UnwrapDelta', () => {
+				const otherRoot = modelDoc.createRoot( 'main', 'otherRoot' );
+				const unwrapEle = new ModelElement( 'paragraph', null, [ new ModelText( 'foo' ) ] );
+
+				otherRoot.appendChildren( [ unwrapEle ] );
+
+				const delta = new UnwrapDelta();
+				const move = new MoveOperation( ModelPosition.createAt( unwrapEle, 0 ), 3, ModelPosition.createAt( otherRoot, 0 ), 1 );
+				const remove = new RemoveOperation( ModelPosition.createAt( otherRoot, 3 ), 1, 0 );
+
+				delta.addOperation( move );
+				delta.addOperation( remove );
+
+				expect( delta.toString() ).to.equal( 'UnwrapDelta: otherRoot [ 0 ]' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'WrapDelta', () => {
+				const delta = new WrapDelta();
+
+				const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 6 ), new ModelElement( 'paragraph' ), 0 );
+				const move = new MoveOperation( ModelPosition.createAt( modelRoot, 0 ), 6, new ModelPosition( modelRoot, [ 1, 0 ] ), 1 );
+
+				delta.addOperation( insert );
+				delta.addOperation( move );
+
+				expect( delta.toString() ).to.equal( 'WrapDelta: main [ 0 ] - [ 6 ] -> <paragraph>' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+		} );
+
+		it( 'for applied operations', () => {
+			const delta = new InsertDelta();
+			const op = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), [ new ModelText( 'foo' ) ], 0 );
+			delta.addOperation( op );
+
+			modelDoc.applyOperation( op );
+
+			expect( log.calledWithExactly( 'Applying InsertOperation: [ 1 ] -> main [ 0 ]' ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'should provide tree printing tools', () => {
+		it( 'for model', () => {
+			const modelDoc = new ModelDocument();
+			const modelRoot = modelDoc.createRoot();
+
+			modelRoot.appendChildren( [
+				new ModelElement( 'paragraph', { foo: 'bar' }, [
+					new ModelText( 'This is ' ), new ModelText( 'bold', { bold: true } ), new ModelText( '.' )
+				] ),
+				new ModelElement( 'listItem', { type: 'numbered', indent: 0 }, new ModelText( 'One.' ) ),
+			] );
+
+			const modelRootTree = modelRoot.printTree();
+
+			expect( modelRootTree ).to.equal(
+				'<main>' +
+				'\n\t<paragraph foo="bar">' +
+				'\n\t\tThis is ' +
+				'\n\t\t<$text bold=true>bold</$text>' +
+				'\n\t\t.' +
+				'\n\t</paragraph>' +
+				'\n\t<listItem type="numbered" indent=0>' +
+				'\n\t\tOne.' +
+				'\n\t</listItem>' +
+				'\n</main>'
+			);
+
+			modelRoot.logTree();
+			expect( log.calledWithExactly( modelRootTree ) ).to.be.true;
+
+			const modelParagraph = modelRoot.getChild( 0 );
+			const modelParagraphTree = modelParagraph.printTree();
+			expect( modelParagraphTree ).to.equal(
+				'<paragraph foo="bar">' +
+				'\n\tThis is ' +
+				'\n\t<$text bold=true>bold</$text>' +
+				'\n\t.' +
+				'\n</paragraph>'
+			);
+
+			log.reset();
+			modelParagraph.logTree();
+			expect( log.calledWithExactly( modelParagraphTree ) ).to.be.true;
+
+			const modelDocFrag = new ModelDocumentFragment( [
+				new ModelText( 'This is ' ), new ModelText( 'bold', { bold: true } ), new ModelText( '.' ),
+				new ModelElement( 'paragraph', { foo: 'bar' }, [
+					new ModelText( 'This is ' ), new ModelText( 'bold', { bold: true } ), new ModelText( '.' )
+				] )
+			] );
+
+			const modelDocFragTree = modelDocFrag.printTree();
+			expect( modelDocFragTree ).to.equal(
+				'ModelDocumentFragment: [' +
+				'\n\tThis is ' +
+				'\n\t<$text bold=true>bold</$text>' +
+				'\n\t.' +
+				'\n\t<paragraph foo="bar">' +
+				'\n\t\tThis is ' +
+				'\n\t\t<$text bold=true>bold</$text>' +
+				'\n\t\t.' +
+				'\n\t</paragraph>' +
+				'\n]'
+			);
+
+			log.reset();
+			modelDocFrag.logTree();
+			expect( log.calledWithExactly( modelDocFragTree ) ).to.be.true;
+		} );
+
+		it( 'for view', () => {
+			const viewDoc = new ViewDocument();
+			const viewRoot = viewDoc.createRoot( 'div' );
+
+			viewRoot.appendChildren( [
+				new ViewContainerElement( 'p', { foo: 'bar' }, [
+					new ViewText( 'This is ' ), new ViewAttributeElement( 'b', null, new ViewText( 'bold' ) ), new ViewText( '.' )
+				] ),
+				new ViewContainerElement( 'ol', null, [
+					new ViewContainerElement( 'li', null, new ViewText( 'One.' ) )
+				] )
+			] );
+
+			const viewRootTree = viewRoot.printTree();
+
+			expect( viewRootTree ).to.equal(
+				'<div>' +
+				'\n\t<p foo="bar">' +
+				'\n\t\tThis is ' +
+				'\n\t\t<b>' +
+				'\n\t\t\tbold' +
+				'\n\t\t</b>' +
+				'\n\t\t.' +
+				'\n\t</p>' +
+				'\n\t<ol>' +
+				'\n\t\t<li>' +
+				'\n\t\t\tOne.' +
+				'\n\t\t</li>' +
+				'\n\t</ol>' +
+				'\n</div>'
+			);
+
+			viewRoot.logTree();
+			expect( log.calledWithExactly( viewRootTree ) ).to.be.true;
+
+			const viewParagraph = viewRoot.getChild( 0 );
+			const viewParagraphTree = viewParagraph.printTree();
+			expect( viewParagraphTree ).to.equal(
+				'<p foo="bar">' +
+				'\n\tThis is ' +
+				'\n\t<b>' +
+				'\n\t\tbold' +
+				'\n\t</b>' +
+				'\n\t.' +
+				'\n</p>'
+			);
+
+			log.reset();
+			viewParagraph.logTree();
+			expect( log.calledWithExactly( viewParagraphTree ) ).to.be.true;
+
+			const viewDocFrag = new ViewDocumentFragment( [
+				new ViewText( 'Text.' ),
+				new ViewContainerElement( 'p', { foo: 'bar' }, [
+					new ViewText( 'This is ' ), new ViewAttributeElement( 'b', null, new ViewText( 'bold' ) ), new ViewText( '.' )
+				] )
+			] );
+
+			const viewDocFragTree = viewDocFrag.printTree();
+			expect( viewDocFragTree ).to.equal(
+				'ViewDocumentFragment: [' +
+				'\n\tText.' +
+				'\n\t<p foo="bar">' +
+				'\n\t\tThis is ' +
+				'\n\t\t<b>' +
+				'\n\t\t\tbold' +
+				'\n\t\t</b>' +
+				'\n\t\t.' +
+				'\n\t</p>' +
+				'\n]'
+			);
+
+			log.reset();
+			viewDocFrag.logTree();
+			expect( log.calledWithExactly( viewDocFragTree ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'should store model and view trees state', () => {
+		let editor;
+
+		beforeEach( () => {
+			const div = document.createElement( 'div' );
+
+			return TestEditor.create( div, {
+				plugins: [ DebugPlugin ]
+			} ).then( ( _editor ) => {
+				editor = _editor;
+			} );
+		} );
+
+		it( 'should store model and view state after each applied operation', () => {
+			const model = editor.document;
+			const modelRoot = model.getRoot();
+			const view = editor.editing.view;
+
+			const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), new ModelText( 'foobar' ), 0 );
+			model.applyOperation( wrapInDelta( insert ) );
+
+			const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, 1 );
+			model.applyOperation( wrapInDelta( remove ) );
+
+			log.reset();
+
+			model.log( 0 );
+			expectLog(
+				'<$graveyard></$graveyard>' +
+				'\n<main></main>'
+			);
+
+			model.log( 1 );
+			expectLog(
+				'<$graveyard></$graveyard>' +
+				'\n<main>' +
+				'\n\tfoobar' +
+				'\n</main>'
+			);
+
+			model.log( 2 );
+			expectLog(
+				'<$graveyard>' +
+				'\n\t<$graveyardHolder>' +
+				'\n\t\too' +
+				'\n\t</$graveyardHolder>' +
+				'\n</$graveyard>' +
+				'\n<main>' +
+				'\n\tfbar' +
+				'\n</main>'
+			);
+
+			model.log();
+			expectLog(
+				'<$graveyard>' +
+				'\n\t<$graveyardHolder>' +
+				'\n\t\too' +
+				'\n\t</$graveyardHolder>' +
+				'\n</$graveyard>' +
+				'\n<main>' +
+				'\n\tfbar' +
+				'\n</main>'
+			);
+
+			view.log( 0 );
+			expectLog(
+				'<div></div>'
+			);
+
+			view.log( 1 );
+			expectLog(
+				'<div>' +
+				'\n\tfoobar' +
+				'\n</div>'
+			);
+
+			view.log( 2 );
+			expectLog(
+				'<div>' +
+				'\n\tfbar' +
+				'\n</div>'
+			);
+
+			sinon.spy( model, 'log' );
+			sinon.spy( view, 'log' );
+
+			editor.logModel( 1 );
+			expect( model.log.calledWithExactly( 1 ) ).to.be.true;
+
+			editor.logView( 2 );
+			expect( view.log.calledWithExactly( 2 ) ).to.be.true;
+
+			model.log.reset();
+			view.log.reset();
+
+			editor.logModel();
+			expect( model.log.calledWithExactly( 2 ) ).to.be.true;
+
+			model.log.reset();
+			view.log.reset();
+
+			editor.logDocuments();
+			expect( model.log.calledWithExactly( 2 ) ).to.be.true;
+			expect( view.log.calledWithExactly( 2 ) ).to.be.true;
+
+			model.log.reset();
+			view.log.reset();
+
+			editor.logDocuments( 1 );
+			expect( model.log.calledWithExactly( 1 ) ).to.be.true;
+			expect( view.log.calledWithExactly( 1 ) ).to.be.true;
+		} );
+
+		it( 'should remove old states', () => {
+			const model = editor.document;
+			const modelRoot = model.getRoot();
+
+			for ( let i = 0; i < 25; i++ ) {
+				const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), new ModelText( 'foobar' ), model.version );
+				model.applyOperation( wrapInDelta( insert ) );
+			}
+
+			model.log( 0 );
+			expectLog( 'Tree log unavailable for given version: 0' );
+		} );
+	} );
+
+	function expectLog( expectedLogMsg ) {
+		expect( log.calledWithExactly( expectedLogMsg ) ).to.be.true;
+		log.reset();
+	}
+} );
+
+function wrapInDelta( op ) {
+	const delta = new Delta();
+	delta.addOperation( op );
+
+	return op;
+}

+ 4 - 4
packages/ckeditor5-engine/tests/model/document/document.js

@@ -23,8 +23,8 @@ describe( 'Document', () => {
 
 	describe( 'constructor()', () => {
 		it( 'should create Document with no data, empty graveyard and selection set to default range', () => {
-			expect( doc ).to.have.property( '_roots' ).that.is.instanceof( Map );
-			expect( doc._roots.size ).to.equal( 1 );
+			expect( doc ).to.have.property( 'roots' ).that.is.instanceof( Map );
+			expect( doc.roots.size ).to.equal( 1 );
 			expect( doc.graveyard ).to.be.instanceof( RootElement );
 			expect( doc.graveyard.maxOffset ).to.equal( 0 );
 			expect( count( doc.selection.getRanges() ) ).to.equal( 1 );
@@ -50,7 +50,7 @@ describe( 'Document', () => {
 		it( 'should create a new RootElement with default element and root names, add it to roots map and return it', () => {
 			let root = doc.createRoot();
 
-			expect( doc._roots.size ).to.equal( 2 );
+			expect( doc.roots.size ).to.equal( 2 );
 			expect( root ).to.be.instanceof( RootElement );
 			expect( root.maxOffset ).to.equal( 0 );
 			expect( root ).to.have.property( 'name', '$root' );
@@ -60,7 +60,7 @@ describe( 'Document', () => {
 		it( 'should create a new RootElement with custom element and root names, add it to roots map and return it', () => {
 			let root = doc.createRoot( 'customElementName', 'customRootName' );
 
-			expect( doc._roots.size ).to.equal( 2 );
+			expect( doc.roots.size ).to.equal( 2 );
 			expect( root ).to.be.instanceof( RootElement );
 			expect( root.maxOffset ).to.equal( 0 );
 			expect( root ).to.have.property( 'name', 'customElementName' );