8
0
Эх сурвалжийг харах

Merge branch 't/52b'. Closes #52, closes #53, closes #202.

Aleksander Nowodzinski 10 жил өмнө
parent
commit
9280c62e85
31 өөрчлөгдсөн 1349 нэмэгдсэн , 61 устгасан
  1. 122 0
      packages/ckeditor5-engine/src/creator.js
  2. 90 0
      packages/ckeditor5-engine/src/editable/editable.js
  3. 41 0
      packages/ckeditor5-engine/src/editable/editableview.js
  4. 16 5
      packages/ckeditor5-engine/src/editor.js
  5. 32 0
      packages/ckeditor5-engine/src/editorui.js
  6. 7 0
      packages/ckeditor5-engine/src/plugin.js
  7. 37 0
      packages/ckeditor5-engine/tests/_utils/ui/boxededitorui/boxededitorui.js
  8. 23 0
      packages/ckeditor5-engine/tests/_utils/ui/boxededitorui/boxededitoruiview.js
  9. 34 0
      packages/ckeditor5-engine/tests/_utils/ui/boxlesseditorui/boxlesseditorui.js
  10. 16 0
      packages/ckeditor5-engine/tests/_utils/ui/editable/framed/framededitable.js
  11. 53 0
      packages/ckeditor5-engine/tests/_utils/ui/editable/framed/framededitableview.js
  12. 11 0
      packages/ckeditor5-engine/tests/_utils/ui/editable/inline/inlineeditable.js
  13. 28 0
      packages/ckeditor5-engine/tests/_utils/ui/editable/inline/inlineeditableview.js
  14. 73 13
      packages/ckeditor5-engine/tests/_utils/utils.js
  15. 3 0
      packages/ckeditor5-engine/tests/creator/creator.html
  16. 208 0
      packages/ckeditor5-engine/tests/creator/creator.js
  17. 59 0
      packages/ckeditor5-engine/tests/creator/manual/_utils/creator/classiccreator.js
  18. 56 0
      packages/ckeditor5-engine/tests/creator/manual/_utils/creator/inlinecreator.js
  19. 8 0
      packages/ckeditor5-engine/tests/creator/manual/creator-classic.html
  20. 49 0
      packages/ckeditor5-engine/tests/creator/manual/creator-classic.js
  21. 19 0
      packages/ckeditor5-engine/tests/creator/manual/creator-classic.md
  22. 8 0
      packages/ckeditor5-engine/tests/creator/manual/creator-inline.html
  23. 45 0
      packages/ckeditor5-engine/tests/creator/manual/creator-inline.js
  24. 14 0
      packages/ckeditor5-engine/tests/creator/manual/creator-inline.md
  25. 79 0
      packages/ckeditor5-engine/tests/editable/editable.js
  26. 66 0
      packages/ckeditor5-engine/tests/editable/editableview.js
  27. 41 3
      packages/ckeditor5-engine/tests/editor/creator.js
  28. 27 0
      packages/ckeditor5-engine/tests/editor/editor.js
  29. 24 0
      packages/ckeditor5-engine/tests/editorui.js
  30. 3 31
      packages/ckeditor5-engine/tests/observablemixin.js
  31. 57 9
      packages/ckeditor5-engine/tests/utils-tests/utils.js

+ 122 - 0
packages/ckeditor5-engine/src/creator.js

@@ -15,4 +15,126 @@ import Plugin from './plugin.js';
  */
 
 export default class Creator extends Plugin {
+	/**
+	 * The element used to {@link #_replaceElement replace} the editor element.
+	 *
+	 * @private
+	 * @property {HTMLElement} _elementReplacemenet
+	 */
+
+	/**
+	 * The creator's trigger. This method is called by the editor to finalize
+	 * the editor creation.
+	 *
+	 * @returns {Promise}
+	 */
+	create() {
+		if ( this.editor.ui ) {
+			return this.editor.ui.init();
+		} else {
+			return Promise.resolve();
+		}
+	}
+
+	/**
+	 * Method called by the editor on its destruction. It should destroy what the creator created.
+	 *
+	 * @returns {Promise}
+	 */
+	destroy() {
+		super.destroy();
+
+		if ( this._elementReplacement ) {
+			this._restoreElement();
+		}
+
+		const ui = this.editor.ui;
+		let promise = Promise.resolve();
+
+		if ( ui ) {
+			promise = promise
+				.then( ui.destroy.bind( ui ) )
+				.then( () => {
+					this.editor.ui = null;
+				} );
+		}
+
+		return promise;
+	}
+
+	/**
+	 * Updates the {@link core.Editor#element editor element}'s content with the data.
+	 */
+	updateEditorElement() {
+		Creator.setDataInElement( this.editor.element, this.editor.getData() );
+	}
+
+	/**
+	 * Loads the data from the {@link core.Editor#element editor element} to the editable.
+	 */
+	loadDataFromEditorElement() {
+		this.editor.setData( Creator.getDataFromElement( this.editor.element ) );
+	}
+
+	/**
+	 * Gets data from a given source element.
+	 *
+	 * @param {HTMLElement} el The element from which the data will be retrieved.
+	 * @returns {String} The data string.
+	 */
+	static getDataFromElement( el ) {
+		if ( el instanceof HTMLTextAreaElement ) {
+			return el.value;
+		}
+
+		return el.innerHTML;
+	}
+
+	/**
+	 * Sets data in a given element.
+	 *
+	 * @param {HTMLElement} el The element in which the data will be set.
+	 * @param {String} data The data string.
+	 */
+	static setDataInElement( el, data ) {
+		if ( el instanceof HTMLTextAreaElement ) {
+			el.value = data;
+		}
+
+		el.innerHTML = data;
+	}
+
+	/**
+	 * Hides the {@link core.Editor#element editor element} and inserts the the given element
+	 * (usually, editor's UI main element) next to it.
+	 *
+	 * The effect of this method will be automatically reverted by {@link #destroy}.
+	 *
+	 * @protected
+	 * @param {HTMLElement} [newElement] The replacement element. If not passed, then the main editor's UI view element
+	 * will be used.
+	 */
+	_replaceElement( newElement ) {
+		if ( !newElement ) {
+			newElement = this.editor.ui.view.element;
+		}
+
+		this._elementReplacement = newElement;
+
+		const editorEl = this.editor.element;
+
+		editorEl.style.display = 'none';
+		editorEl.parentNode.insertBefore( newElement, editorEl.nextSibling );
+	}
+
+	/**
+	 * Restores what the {@link #_replaceElement} did.
+	 *
+	 * @protected
+	 */
+	_restoreElement() {
+		this.editor.element.style.display = '';
+		this._elementReplacement.remove();
+		this._elementReplacement = null;
+	}
 }

+ 90 - 0
packages/ckeditor5-engine/src/editable/editable.js

@@ -0,0 +1,90 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Controller from '../ui/controller.js';
+import Model from '../ui/model.js';
+import utils from '../utils.js';
+import ObservableMixin from '../observablemixin.js';
+
+/**
+ * @class core.editable.Editable
+ * @extends core.ui.Controller
+ * @mixins core.ObservableMixin
+ */
+export default class Editable extends Controller {
+	/**
+	 * Creates a new instance of the Editable class.
+	 *
+	 * @constructor
+	 */
+	constructor( editor ) {
+		super();
+
+		this.editor = editor;
+
+		/**
+		 * Whether the editable is in read-write or read-only mode.
+		 *
+		 * @property {Boolean} isEditable
+		 */
+		this.set( 'isEditable', true );
+
+		/**
+		 * Whether the editable is focused.
+		 *
+		 * @readonly
+		 * @property {Boolean} isFocused
+		 */
+		this.set( 'isFocused', false );
+
+		/**
+		 * @private {Model} _viewModel
+		 */
+	}
+
+	/**
+	 * The model for the editable view.
+	 *
+	 * @readonly
+	 * @property {core.ui.Model} viewModel
+	 */
+	get viewModel() {
+		if ( this._viewModel ) {
+			return this._viewModel;
+		}
+
+		const viewModel = new Model( {
+			isFocused: this.isFocused
+		} );
+		this._viewModel = viewModel;
+
+		viewModel.bind( 'isEditable' ).to( this );
+		this.bind( 'isFocused' ).to( viewModel );
+
+		return viewModel;
+	}
+
+	/**
+	 * Temporary implementation (waiting for integration with the data model).
+	 *
+	 * @param {String} data HTML to be loaded.
+	 */
+	setData( data ) {
+		this.view.editableElement.innerHTML = data;
+	}
+
+	/**
+	 * Temporary implementation (waiting for integration with the data model).
+	 *
+	 * @returns {String} HTML string.
+	 */
+	getData() {
+		return this.view.editableElement.innerHTML;
+	}
+}
+
+utils.mix( Editable, ObservableMixin );

+ 41 - 0
packages/ckeditor5-engine/src/editable/editableview.js

@@ -0,0 +1,41 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import View from '../ui/view.js';
+import CKEditorError from '../ckeditorerror.js';
+
+export default class EditableView extends View {
+	/**
+	 * The element which is the main editable element (usually the one with `contentEditable="true"`).
+	 *
+	 * @readonly
+	 * @property {HTMLElement} editableElement
+	 */
+
+	setEditableElement( editableElement ) {
+		if ( this.editableElement ) {
+			throw new CKEditorError(
+				'editableview-cannot-override-editableelement: The editableElement cannot be overriden.'
+			);
+		}
+
+		this.editableElement = editableElement;
+		this.editableElement.contentEditable = this.model.isEditable;
+
+		this.listenTo( editableElement, 'focus', () => {
+			this.model.isFocused = true;
+		} );
+
+		this.listenTo( editableElement, 'blur', () => {
+			this.model.isFocused = false;
+		} );
+
+		this.listenTo( this.model, 'change:isEditable', ( evt, value ) => {
+			editableElement.contentEditable = value;
+		} );
+	}
+}

+ 16 - 5
packages/ckeditor5-engine/src/editor.js

@@ -62,8 +62,8 @@ export default class Editor {
 		/**
 		 * The chosen creator.
 		 *
-		 * @property {Creator} _creator
 		 * @protected
+		 * @property {Creator} _creator
 		 */
 	}
 
@@ -140,12 +140,23 @@ export default class Editor {
 		const that = this;
 
 		this.fire( 'destroy' );
+		this.stopListening();
+
+		return Promise.resolve()
+			.then( () => {
+				return that._creator && that._creator.destroy();
+			} )
+			.then( () => {
+				delete this.element;
+			} );
+	}
 
-		delete this.element;
+	setData( data ) {
+		this.editable.setData( data );
+	}
 
-		return Promise.resolve().then( () => {
-			return that._creator && that._creator.destroy();
-		} );
+	getData() {
+		return this.editable.getData();
 	}
 }
 

+ 32 - 0
packages/ckeditor5-engine/src/editorui.js

@@ -0,0 +1,32 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Controller from './ui/controller.js';
+import utils from './utils.js';
+import ObservableMixin from './observablemixin.js';
+
+/**
+ * Base class for the editor main view controllers.
+ *
+ * @class core.EditorUI
+ * @extends core.ui.Controller
+ * @mixins core.ObservableMixin
+ */
+
+export default class EditorUI extends Controller {
+	constructor( editor ) {
+		super();
+
+		/**
+		 * @readonly
+		 * @property {core.Editor}
+		 */
+		this.editor = editor;
+	}
+}
+
+utils.mix( EditorUI, ObservableMixin );

+ 7 - 0
packages/ckeditor5-engine/src/plugin.js

@@ -50,6 +50,13 @@ export default class Plugin {
 	 * @returns {null/Promise}
 	 */
 	init() {}
+
+	/**
+	 * Destroys the plugin.
+	 *
+	 * TODO waits to be implemented (#186).
+	 */
+	destroy() {}
 }
 
 utils.mix( Plugin, ObservableMixin );

+ 37 - 0
packages/ckeditor5-engine/tests/_utils/ui/boxededitorui/boxededitorui.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import EditorUI from '/ckeditor5/core/editorui.js';
+import ControllerCollection from '/ckeditor5/core/ui/controllercollection.js';
+
+export default class BoxedEditorUI extends EditorUI {
+	constructor( editor ) {
+		super( editor );
+
+		this.collections.add( new ControllerCollection( 'main' ) );
+
+		const config = editor.config;
+
+		/**
+		 * @property {Number} width
+		 */
+		this.set( 'width', config.get( 'ui.width' ) );
+
+		/**
+		 * @property {Number} height
+		 */
+		this.set( 'height', config.get( 'ui.height' ) );
+	}
+
+	/**
+	 * @readonly
+	 * @property {Model} viewModel
+	 */
+	get viewModel() {
+		return this;
+	}
+}

+ 23 - 0
packages/ckeditor5-engine/tests/_utils/ui/boxededitorui/boxededitoruiview.js

@@ -0,0 +1,23 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import View from '/ckeditor5/core/ui/view.js';
+
+export default class BoxedEditorUIView extends View {
+	constructor( model ) {
+		super( model );
+
+		this.template = {
+			tag: 'div',
+			attrs: {
+				'class': [ 'ck-chrome' ]
+			}
+		};
+
+		this.register( 'main', el => el );
+	}
+}

+ 34 - 0
packages/ckeditor5-engine/tests/_utils/ui/boxlesseditorui/boxlesseditorui.js

@@ -0,0 +1,34 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import EditorUI from '/ckeditor5/core/editorui.js';
+import ControllerCollection from '/ckeditor5/core/ui/controllercollection.js';
+
+export default class BoxlessEditorUI extends EditorUI {
+	constructor( editor ) {
+		super( editor );
+
+		this.collections.add( new ControllerCollection( 'editable' ) );
+
+		/**
+		 * @private
+		 * @property {View} _view
+		 */
+	}
+
+	get view() {
+		return this._view;
+	}
+
+	set view( view ) {
+		if ( view ) {
+			this._view = view;
+
+			view.register( 'editable', true );
+		}
+	}
+}

+ 16 - 0
packages/ckeditor5-engine/tests/_utils/ui/editable/framed/framededitable.js

@@ -0,0 +1,16 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Editable from '/ckeditor5/core/editable/editable.js';
+
+export default class FramedEditable extends Editable {
+	constructor( editor ) {
+		super( editor );
+
+		this.viewModel.bind( 'width', 'height' ).to( editor.ui );
+	}
+}

+ 53 - 0
packages/ckeditor5-engine/tests/_utils/ui/editable/framed/framededitableview.js

@@ -0,0 +1,53 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import EditableView from '/ckeditor5/core/editable/editableview.js';
+
+export default class FramedEditableView extends EditableView {
+	constructor( model ) {
+		super( model );
+
+		// Here's the tricky part - we must return the promise from init()
+		// because iframe loading may be asynchronous. However, we can't start
+		// listening to 'load' in init(), because at this point the element is already in the DOM
+		// and the 'load' event might already be fired.
+		// So here we store both - the promise and the deferred object so we're able to resolve
+		// the promise in _iframeLoaded.
+		this._iframePromise = new Promise( ( resolve, reject ) => {
+			this._iframeDeferred = { resolve, reject };
+		} );
+
+		this.template = {
+			tag: 'iframe',
+			attributes: {
+				class: [ 'ck-framededitable' ],
+				// It seems that we need to allow scripts in order to be able to listen to events.
+				// TODO: Research that. Perhaps the src must be set?
+				sandbox: 'allow-same-origin allow-scripts',
+				width: this.bindToAttribute( 'width' ),
+				height: this.bindToAttribute( 'height' )
+			},
+			on: {
+				load: 'loaded'
+			}
+		};
+
+		this.on( 'loaded', this._iframeLoaded, this );
+	}
+
+	init() {
+		super.init();
+
+		return this._iframePromise;
+	}
+
+	_iframeLoaded() {
+		this.setEditableElement( this.element.contentDocument.body );
+
+		this._iframeDeferred.resolve();
+	}
+}

+ 11 - 0
packages/ckeditor5-engine/tests/_utils/ui/editable/inline/inlineeditable.js

@@ -0,0 +1,11 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Editable from '/ckeditor5/core/editable/editable.js';
+
+export default class InlineEditable extends Editable {
+}

+ 28 - 0
packages/ckeditor5-engine/tests/_utils/ui/editable/inline/inlineeditableview.js

@@ -0,0 +1,28 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import EditableView from '/ckeditor5/core/editable/editableview.js';
+
+export default class InlineEditableView extends EditableView {
+	constructor( model, editableElement ) {
+		super( model );
+
+		this.element = editableElement;
+	}
+
+	init() {
+		this.setEditableElement( this.element );
+
+		super.init();
+	}
+
+	destroy() {
+		super.destroy();
+
+		this.editableElement.contentEditable = false;
+	}
+}

+ 73 - 13
packages/ckeditor5-engine/tests/_utils/utils.js

@@ -5,17 +5,15 @@
 
 'use strict';
 
+/* global console:false */
+
 import amdUtils from '/tests/_utils/amd.js';
+import EmitterMixin from '/ckeditor5/core/emittermixin.js';
 
 const utils = {
 	/**
 	 * Defines CKEditor plugin which is a mock of an editor creator.
 	 *
-	 * If `proto` is not set or it does not define `create()` and `destroy()` methods,
-	 * then they will be set to Sinon spies. Therefore the shortest usage is:
-	 *
-	 *		testUtils.defineEditorCreatorMock( 'test1' );
-	 *
 	 * The mocked creator is available under:
 	 *
 	 *		editor.plugins.get( 'creator-thename' );
@@ -34,14 +32,6 @@ const utils = {
 				}
 			}
 
-			if ( !TestCreator.prototype.create ) {
-				TestCreator.prototype.create = sinon.spy().named( creatorName + '-create' );
-			}
-
-			if ( !TestCreator.prototype.destroy ) {
-				TestCreator.prototype.destroy = sinon.spy().named( creatorName + '-destroy' );
-			}
-
 			return TestCreator;
 		} );
 	},
@@ -62,6 +52,76 @@ const utils = {
 		}
 
 		return count;
+	},
+
+	/**
+	 * Creates an instance inheriting from {@link core.EmitterMixin} with one additional method `observe()`.
+	 * It allows observing changes to attributes in objects being {@link core.Observable observable}.
+	 *
+	 * The `observe()` method accepts:
+	 *
+	 * * `{String} observableName` – Identifier for the observable object. E.g. `"Editable"` when
+	 * you observe one of editor's editables. This name will be displayed on the console.
+	 * * `{core.Observable observable} – The object to observe.
+	 *
+	 * Typical usage:
+	 *
+	 *		const observer = utils.createObserver();
+	 *		observer.observe( 'Editable', editor.editables.current );
+	 *
+	 *		// Stop listening (method from the EmitterMixin):
+	 *		observer.stopListening();
+	 *
+	 * @returns {Emitter} The observer.
+	 */
+	createObserver() {
+		const observer = Object.create( EmitterMixin, {
+			observe: {
+				value: function observe( observableName, observable ) {
+					observer.listenTo( observable, 'change', ( evt, propertyName, value, oldValue ) => {
+						console.log( `[Change in ${ observableName }] ${ propertyName } = '${ value }' (was '${ oldValue }')` );
+					} );
+
+					return observer;
+				}
+			}
+		} );
+
+		return observer;
+	},
+
+	/**
+	 * Checkes wether observable properties are properly bound to each other.
+	 *
+	 * Syntax given that observable `A` is bound to observables [`B`, `C`, ...]:
+	 *
+	 *		assertBinding( A,
+	 *			{ initial `A` attributes },
+	 *			[
+	 *				[ B, { new `B` attributes } ],
+	 *				[ C, { new `C` attributes } ],
+	 *				...
+	 *			],
+	 *			{ `A` attributes after [`B`, 'C', ...] changed }
+	 *		);
+	 */
+	assertBinding( observable, stateBefore, data, stateAfter ) {
+		let key, pair;
+
+		for ( key in stateBefore ) {
+			expect( observable[ key ] ).to.be.equal( stateBefore[ key ] );
+		}
+
+		// Change attributes of bound observables.
+		for ( pair of data ) {
+			for ( key in pair[ 1 ] ) {
+				pair[ 0 ][ key ] = pair[ 1 ][ key ];
+			}
+		}
+
+		for ( key in stateAfter ) {
+			expect( observable[ key ] ).to.be.equal( stateAfter[ key ] );
+		}
 	}
 };
 

+ 3 - 0
packages/ckeditor5-engine/tests/creator/creator.html

@@ -0,0 +1,3 @@
+<textarea id="getData-textarea">&lt;b&gt;foo&lt;/b&gt;</textarea>
+<div id="getData-div"><b>foo</b></div>
+<template id="getData-template"><b>foo</b></template>

+ 208 - 0
packages/ckeditor5-engine/tests/creator/creator.js

@@ -0,0 +1,208 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: creator */
+
+'use strict';
+
+import testUtils from '/tests/_utils/utils.js';
+import Creator from '/ckeditor5/core/creator.js';
+import Editor from '/ckeditor5/core/editor.js';
+import Plugin from '/ckeditor5/core/plugin.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'Creator', () => {
+	let creator, editor;
+
+	beforeEach( () => {
+		const editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		editor = new Editor( editorElement );
+		creator = new Creator( editor );
+	} );
+
+	describe( 'create', () => {
+		it( 'should init the UI', () => {
+			const promise = new Promise( () => {} );
+
+			editor.ui = {
+				init: sinon.stub().returns( promise )
+			};
+
+			const ret = creator.create();
+
+			expect( ret ).to.equal( promise );
+			expect( editor.ui.init.called ).to.be.true;
+		} );
+
+		it( 'should not fail when there is no UI', () => {
+			expect( editor.ui ).to.not.exist;
+
+			return creator.create()
+				.then(); // Just checking whether a promise was returned.
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'calls super.destroy', () => {
+			const pluginSpy  = testUtils.sinon.spy( Plugin.prototype, 'destroy' );
+
+			editor.ui = {
+				destroy() {}
+			};
+
+			creator.destroy();
+
+			expect( pluginSpy.called ).to.be.true;
+		} );
+
+		it( 'should destroy the UI (sync)', () => {
+			const uiSpy = sinon.spy();
+
+			editor.ui = {
+				destroy: uiSpy
+			};
+
+			return creator.destroy()
+				.then( () => {
+					expect( uiSpy.called ).to.be.true;
+					expect( editor.ui ).to.be.null;
+				} );
+		} );
+
+		it( 'should destroy the UI (async)', () => {
+			const uiSpy = sinon.stub().returns( Promise.resolve() );
+
+			editor.ui = {
+				destroy: uiSpy
+			};
+
+			return creator.destroy()
+				.then( () => {
+					expect( uiSpy.called ).to.be.true;
+					expect( editor.ui ).to.be.null;
+				} );
+		} );
+
+		it( 'should wait until UI is destroyed (async)', () => {
+			let resolved = false;
+			let resolve;
+			const uiSpy = sinon.stub().returns(
+				new Promise( ( r ) => {
+					resolve = r;
+				} )
+			);
+
+			editor.ui = {
+				destroy: uiSpy
+			};
+
+			// Is there an easier method to verify whether the promise chain isn't broken? ;/
+			setTimeout( () => {
+				resolved = true;
+				resolve( 'foo' );
+			} );
+
+			return creator.destroy()
+				.then( () => {
+					expect( resolved ).to.be.true;
+				} );
+		} );
+
+		it( 'should restore the replaced element', () => {
+			const spy = testUtils.sinon.stub( creator, '_restoreElement' );
+			const element = document.createElement( 'div' );
+
+			editor.ui = {
+				destroy() {}
+			};
+
+			creator._replaceElement( element );
+			creator.destroy();
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+	} );
+
+	describe( 'updateEditorElement', () => {
+		it( 'should pass data to the element', () => {
+			editor.editable = {
+				getData() {
+					return 'foo';
+				}
+			};
+
+			creator.updateEditorElement();
+
+			expect( editor.element.innerHTML ).to.equal( 'foo' );
+		} );
+	} );
+
+	describe( 'loadDataFromEditorElement', () => {
+		it( 'should pass data to the element', () => {
+			editor.editable = {
+				setData: sinon.spy()
+			};
+
+			editor.element.innerHTML = 'foo';
+			creator.loadDataFromEditorElement();
+
+			expect( editor.editable.setData.args[ 0 ][ 0 ] ).to.equal( 'foo' );
+		} );
+	} );
+
+	describe( 'getDataFromElement', () => {
+		[ 'textarea', 'template', 'div' ].forEach( ( elementName ) => {
+			it( 'should return the content of a ' + elementName, function() {
+				const data = Creator.getDataFromElement( document.getElementById( 'getData-' + elementName ) );
+				expect( data ).to.equal( '<b>foo</b>' );
+			} );
+		} );
+	} );
+
+	describe( 'setDataInElement', () => {
+		[ 'textarea', 'template', 'div' ].forEach( ( elementName ) => {
+			it( 'should set the content of a ' + elementName, () => {
+				const el = document.createElement( elementName );
+				const expectedData = '<b>foo</b>';
+
+				Creator.setDataInElement( el, expectedData );
+
+				const actualData = Creator.getDataFromElement( el );
+				expect( actualData ).to.equal( actualData );
+			} );
+		} );
+	} );
+
+	describe( '_replaceElement', () => {
+		it( 'should use editor ui element when arg not provided', () => {
+			editor.ui = {
+				view: {
+					element: document.createElement( 'div' )
+				}
+			};
+
+			creator._replaceElement();
+
+			expect( editor.element.nextSibling ).to.equal( editor.ui.view.element );
+		} );
+	} );
+
+	describe( '_restoreElement', () => {
+		it( 'should remove the replacement element', () => {
+			const element = document.createElement( 'div' );
+
+			creator._replaceElement( element );
+
+			expect( editor.element.nextSibling ).to.equal( element );
+
+			creator._restoreElement();
+
+			expect( element.parentNode ).to.be.null;
+		} );
+	} );
+} );

+ 59 - 0
packages/ckeditor5-engine/tests/creator/manual/_utils/creator/classiccreator.js

@@ -0,0 +1,59 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Creator from '/ckeditor5/core/creator.js';
+import BoxedEditorUI from '/tests/core/_utils/ui/boxededitorui/boxededitorui.js';
+import BoxedEditorUIView from '/tests/core/_utils/ui/boxededitorui/boxededitoruiview.js';
+import FramedEditable from '/tests/core/_utils/ui/editable/framed/framededitable.js';
+import FramedEditableView from '/tests/core/_utils/ui/editable/framed/framededitableview.js';
+
+export default class ClassicCreator extends Creator {
+	constructor( editor ) {
+		super( editor );
+
+		editor.ui = this._createEditorUI();
+	}
+
+	create() {
+		this._replaceElement();
+		this._setupEditable();
+
+		return super.create()
+			.then( () => this.loadDataFromEditorElement() );
+	}
+
+	destroy() {
+		this.updateEditorElement();
+
+		return super.destroy();
+	}
+
+	_setupEditable() {
+		const editable = this._createEditable();
+
+		this.editor.editable = editable;
+		this.editor.ui.collections.get( 'main' ).add( editable );
+	}
+
+	_createEditable() {
+		const editable = new FramedEditable( this.editor );
+		const editableView = new FramedEditableView( editable.viewModel );
+
+		editable.view = editableView;
+
+		return editable;
+	}
+
+	_createEditorUI() {
+		const editorUI = new BoxedEditorUI( this.editor );
+		const editorUIView = new BoxedEditorUIView( editorUI.viewModel );
+
+		editorUI.view = editorUIView;
+
+		return editorUI;
+	}
+}

+ 56 - 0
packages/ckeditor5-engine/tests/creator/manual/_utils/creator/inlinecreator.js

@@ -0,0 +1,56 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Creator from '/ckeditor5/core/creator.js';
+import View from '/ckeditor5/core/ui/view.js';
+import BoxlessEditorUI from '/tests/core/_utils/ui/boxlesseditorui/boxlesseditorui.js';
+import InlineEditable from '/tests/core/_utils/ui/editable/inline/inlineeditable.js';
+import InlineEditableView from '/tests/core/_utils/ui/editable/inline/inlineeditableview.js';
+
+export default class InlineCreator extends Creator {
+	constructor( editor ) {
+		super( editor );
+
+		editor.ui = this._createEditorUI();
+	}
+
+	create() {
+		this._setupEditable();
+
+		return super.create()
+			.then( () => this.loadDataFromEditorElement() );
+	}
+
+	destroy() {
+		this.updateEditorElement();
+
+		return super.destroy();
+	}
+
+	_setupEditable() {
+		this.editor.editable = this._createEditable();
+
+		this.editor.ui.collections.get( 'editable' ).add( this.editor.editable );
+	}
+
+	_createEditable() {
+		const editable = new InlineEditable( this.editor );
+		const editableView = new InlineEditableView( editable.viewModel, this.editor.element );
+
+		editable.view = editableView;
+
+		return editable;
+	}
+
+	_createEditorUI() {
+		const editorUI = new BoxlessEditorUI( this.editor );
+
+		editorUI.view = new View( editorUI.viewModel );
+
+		return editorUI;
+	}
+}

+ 8 - 0
packages/ckeditor5-engine/tests/creator/manual/creator-classic.html

@@ -0,0 +1,8 @@
+<div id="editor">
+	<h1>Hello world!</h1>
+	<p>This is an editor instance.</p>
+</div>
+
+<button id="destroyEditor">Destroy editor</button>
+<button id="initEditor">Init editor</button>
+

+ 49 - 0
packages/ckeditor5-engine/tests/creator/manual/creator-classic.js

@@ -0,0 +1,49 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console:false */
+
+'use strict';
+
+import CKEDITOR from '/ckeditor.js';
+import ClassicCreator from '/tests/core/creator/manual/_utils/creator/classiccreator.js';
+import testUtils from '/tests/core/_utils/utils.js';
+
+let editor, observer;
+
+function initEditor() {
+	CKEDITOR.create( '#editor', {
+		creator: ClassicCreator,
+		ui: {
+			width: 400,
+			height: 400
+		}
+	} )
+	.then( ( newEditor ) => {
+		console.log( 'Editor was initialized', newEditor );
+		console.log( 'You can now play with it using global `editor` variable.' );
+
+		window.editor = editor = newEditor;
+
+		observer = testUtils.createObserver();
+		observer.observe( 'Editable', editor.editable );
+	} );
+}
+
+function destroyEditor() {
+	editor.destroy()
+		.then( () => {
+			window.editor = null;
+			editor = null;
+
+			observer.stopListening();
+			observer = null;
+
+			console.log( 'Editor was destroyed' );
+		} );
+}
+
+document.getElementById( 'initEditor' ).addEventListener( 'click', initEditor );
+document.getElementById( 'destroyEditor' ).addEventListener( 'click', destroyEditor );

+ 19 - 0
packages/ckeditor5-engine/tests/creator/manual/creator-classic.md

@@ -0,0 +1,19 @@
+@bender-ui: collapsed
+
+1. Click "Init editor".
+2. Expected:
+  * Framed editor should be created.
+  * It should be rectangular (400x400).
+  * Original element should disappear.
+3. Click "Destroy editor".
+4. Expected:
+  * Editor should be destroyed.
+  * Original element should be visible.
+  * The element should contain its data (updated).
+
+## Notes:
+
+* You can play with:
+  * `editor.editable.isEditable`,
+  * `editor.ui.width/height`.
+* Changes to `editable.isFocused/isEditable` should be logged to the console.

+ 8 - 0
packages/ckeditor5-engine/tests/creator/manual/creator-inline.html

@@ -0,0 +1,8 @@
+<div id="editor">
+	<h1>Hello world!</h1>
+	<p>This is an editor instance.</p>
+</div>
+
+<button id="destroyEditor">Destroy editor</button>
+<button id="initEditor">Init editor</button>
+

+ 45 - 0
packages/ckeditor5-engine/tests/creator/manual/creator-inline.js

@@ -0,0 +1,45 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console:false */
+
+'use strict';
+
+import CKEDITOR from '/ckeditor.js';
+import InlineCreator from '/tests/core/creator/manual/_utils/creator/inlinecreator.js';
+import testUtils from '/tests/core/_utils/utils.js';
+
+let editor, observer;
+
+function initEditor() {
+	CKEDITOR.create( '#editor', {
+		creator: InlineCreator
+	} )
+	.then( ( newEditor ) => {
+		console.log( 'Editor was initialized', newEditor );
+		console.log( 'You can now play with it using global `editor` variable.' );
+
+		window.editor = editor = newEditor;
+
+		observer = testUtils.createObserver();
+		observer.observe( 'Editable', editor.editable );
+	} );
+}
+
+function destroyEditor() {
+	editor.destroy()
+		.then( () => {
+			window.editor = null;
+			editor = null;
+
+			observer.stopListening();
+			observer = null;
+
+			console.log( 'Editor was destroyed' );
+		} );
+}
+
+document.getElementById( 'initEditor' ).addEventListener( 'click', initEditor );
+document.getElementById( 'destroyEditor' ).addEventListener( 'click', destroyEditor );

+ 14 - 0
packages/ckeditor5-engine/tests/creator/manual/creator-inline.md

@@ -0,0 +1,14 @@
+@bender-ui: collapsed
+
+1. Click "Init editor".
+2. Expected:
+  * Inline editor should be created.
+3. Click "Destroy editor".
+4. Expected:
+  * Editor should be destroyed (the element should not be editable).
+  * The element should contain its data (updated).
+
+## Notes:
+
+* You can play with `editor.editable.isEditable`.
+* Changes to `editable.isFocused/isEditable` should be logged to the console.

+ 79 - 0
packages/ckeditor5-engine/tests/editable/editable.js

@@ -0,0 +1,79 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: editable */
+
+'use strict';
+
+import Editor from '/ckeditor5/core/editor.js';
+import Editable from '/ckeditor5/core/editable/editable.js';
+import Model from '/ckeditor5/core/ui/model.js';
+import coreTestUtils from '/tests/core/_utils/utils.js';
+
+describe( 'Editable', () => {
+	let editable, editor;
+
+	beforeEach( () => {
+		editor = new Editor();
+		editable = new Editable( editor );
+	} );
+
+	describe( 'constructor', () => {
+		it( 'sets all the properties', () => {
+			expect( editable ).to.have.property( 'editor', editor );
+			expect( editable ).to.have.property( 'isFocused', false );
+			expect( editable ).to.have.property( 'isEditable', true );
+		} );
+	} );
+
+	describe( 'viewModel', () => {
+		it( 'returns a model instance', () => {
+			expect( editable.viewModel ).to.be.an.instanceof( Model );
+		} );
+
+		it( 'always returns the same instance', () => {
+			expect( editable.viewModel ).to.equal( editable.viewModel );
+		} );
+
+		it( 'constains editable attributes', () => {
+			expect( editable.viewModel ).to.have.property( 'isEditable', true );
+			expect( editable.viewModel ).to.have.property( 'isFocused', false );
+		} );
+
+		it( 'binds this.isFocused to editable', () => {
+			coreTestUtils.assertBinding(
+				editable,
+				{ isFocused: false },
+				[
+					[ editable.viewModel, { isFocused: true } ]
+				],
+				{ isFocused: true }
+			);
+		} );
+
+		it( 'binds editable.isEditable to itself', () => {
+			coreTestUtils.assertBinding(
+				editable.viewModel,
+				{ isEditable: true },
+				[
+					[ editable, { isEditable: false } ]
+				],
+				{ isEditable: false }
+			);
+		} );
+	} );
+
+	// These are temporary implementation, so tests do nothing beside ensuring 100% CC.
+	describe( 'getData() and setData()', () => {
+		it( 'exist', () => {
+			editable.view = {
+				editableElement: document.createElement( 'div' )
+			};
+
+			editable.getData();
+			editable.setData();
+		} );
+	} );
+} );

+ 66 - 0
packages/ckeditor5-engine/tests/editable/editableview.js

@@ -0,0 +1,66 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: editable */
+
+'use strict';
+
+import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
+import EditableView from '/ckeditor5/core/editable/editableview.js';
+import Model from '/ckeditor5/core/ui/model.js';
+
+describe( 'EditableView', () => {
+	let model, view, editableElement;
+
+	beforeEach( () => {
+		model = new Model( { isEditable: true, isFocused: false } );
+		view = new EditableView( model );
+		editableElement = document.createElement( 'div' );
+
+		return view.init();
+	} );
+
+	describe( 'setEditableElement', () => {
+		it( 'sets the editableElement property', () => {
+			view.setEditableElement( editableElement );
+
+			expect( view ).to.have.property( 'editableElement', editableElement );
+		} );
+
+		it( 'throws when trying to use it twice', () => {
+			view.setEditableElement( editableElement );
+
+			expect( view ).to.have.property( 'editableElement', editableElement );
+
+			expect( () => {
+				view.setEditableElement( editableElement );
+			} ).to.throw( CKEditorError, /^editableview-cannot-override-editableelement/ );
+		} );
+
+		it( 'sets the contentEditable attribute to model.isEditable', () => {
+			view.setEditableElement( editableElement );
+
+			expect( editableElement.contentEditable ).to.equal( 'true' );
+
+			model.isEditable = false;
+
+			expect( editableElement.contentEditable ).to.equal( 'false' );
+		} );
+
+		it( 'sets the model.isFocused based on element#focus and element#blur events', () => {
+			view.setEditableElement( editableElement );
+
+			expect( model.isFocused ).to.equal( false );
+
+			editableElement.dispatchEvent( new Event( 'focus' ) );
+
+			expect( model.isFocused ).to.equal( true );
+
+			editableElement.dispatchEvent( new Event( 'blur' ) );
+
+			expect( model.isFocused ).to.equal( false );
+		} );
+	} );
+} );

+ 41 - 3
packages/ckeditor5-engine/tests/editor/creator.js

@@ -5,6 +5,8 @@
 
 'use strict';
 
+/* bender-tags: editor, creator */
+
 import amdUtils from '/tests/_utils/amd.js';
 import testUtils from '/tests/_utils/utils.js';
 import coreTestUtils from '/tests/core/_utils/utils.js';
@@ -26,13 +28,20 @@ function initEditor( config ) {
 testUtils.createSinonSandbox();
 
 before( () => {
-	coreTestUtils.defineEditorCreatorMock( 'test1' );
+	coreTestUtils.defineEditorCreatorMock( 'test1', {
+		create: sinon.spy(),
+		destroy: sinon.spy()
+	} );
 
 	coreTestUtils.defineEditorCreatorMock( 'test-throw-on-many1' );
 	coreTestUtils.defineEditorCreatorMock( 'test-throw-on-many2' );
 
-	coreTestUtils.defineEditorCreatorMock( 'test-config1' );
-	coreTestUtils.defineEditorCreatorMock( 'test-config2' );
+	coreTestUtils.defineEditorCreatorMock( 'test-config1', {
+		create: sinon.spy()
+	} );
+	coreTestUtils.defineEditorCreatorMock( 'test-config2', {
+		create: sinon.spy()
+	} );
 
 	amdUtils.define( 'test3', [ 'core/plugin' ], ( Plugin ) => {
 		return class extends Plugin {};
@@ -61,6 +70,17 @@ before( () => {
 			}
 		};
 	} );
+
+	amdUtils.define( 'creator-destroy-order', [ 'core/creator' ], ( Creator ) => {
+		return class extends Creator {
+			create() {}
+
+			destroy() {
+				editor._elementInsideCreatorDestroy = this.editor.element;
+				editor._destroyOrder.push( 'creator' );
+			}
+		};
+	} );
 } );
 
 afterEach( () => {
@@ -171,4 +191,22 @@ describe( 'destroy', () => {
 				expect( err ).to.have.property( 'message', 'Catch me - destroy.' );
 			} );
 	} );
+
+	it( 'should do things in the correct order', () => {
+		return initEditor( {
+				creator: 'creator-destroy-order'
+			} )
+			.then( () => {
+				editor._destroyOrder = [];
+				editor.on( 'destroy', () => {
+					editor._destroyOrder.push( 'event' );
+				} );
+
+				return editor.destroy();
+			} )
+			.then( () => {
+				expect( editor._elementInsideCreatorDestroy ).to.not.be.undefined;
+				expect( editor._destroyOrder ).to.deep.equal( [ 'event', 'creator' ] );
+			} );
+	} );
 } );

+ 27 - 0
packages/ckeditor5-engine/tests/editor/editor.js

@@ -5,6 +5,8 @@
 
 'use strict';
 
+/* bender-tags: editor */
+
 import amdUtils from '/tests/_utils/amd.js';
 import coreTestUtils from '/tests/core/_utils/utils.js';
 import Editor from '/ckeditor5/core/editor.js';
@@ -179,6 +181,31 @@ describe( 'destroy', () => {
 	} );
 } );
 
+describe( 'setData', () => {
+	it( 'should set data on the editable', () => {
+		const editor = new Editor( element );
+		editor.editable = {
+			setData: sinon.spy()
+		};
+
+		editor.setData( 'foo' );
+
+		expect( editor.editable.setData.calledOnce ).to.be.true;
+		expect( editor.editable.setData.args[ 0 ][ 0 ] ).to.equal( 'foo' );
+	} );
+
+	it( 'should get data from the editable', () => {
+		const editor = new Editor( element );
+		editor.editable = {
+			getData() {
+				return 'bar';
+			}
+		};
+
+		expect( editor.getData() ).to.equal( 'bar' );
+	} );
+} );
+
 /**
  * @param {String} name Name of the plugin.
  * @param {String[]} deps Dependencies of the plugin (only other plugins).

+ 24 - 0
packages/ckeditor5-engine/tests/editorui.js

@@ -0,0 +1,24 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Editor from '/ckeditor5/core/editor.js';
+import EditorUI from '/ckeditor5/core/editorui.js';
+
+describe( 'EditorUI', () => {
+	let editor, editorUI;
+
+	beforeEach( () => {
+		editor = new Editor();
+		editorUI = new EditorUI( editor );
+	} );
+
+	describe( 'constructor', () => {
+		it( 'sets all the properties', () => {
+			expect( editorUI ).to.have.property( 'editor', editor );
+		} );
+	} );
+} );

+ 3 - 31
packages/ckeditor5-engine/tests/observablemixin.js

@@ -6,12 +6,15 @@
 'use strict';
 
 import testUtils from '/tests/_utils/utils.js';
+import coreTestUtils from '/tests/core/_utils/utils.js';
 import ObservableMixin from '/ckeditor5/core/observablemixin.js';
 import EmitterMixin from '/ckeditor5/core/emittermixin.js';
 import EventInfo from '/ckeditor5/core/eventinfo.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 import utils from '/ckeditor5/core/utils.js';
 
+const assertBinding = coreTestUtils.assertBinding;
+
 testUtils.createSinonSandbox();
 
 describe( 'ObservableMixin', () => {
@@ -781,35 +784,4 @@ describe( 'Observable', () => {
 			);
 		} );
 	} );
-
-	// Syntax given that observable `A` is bound to observables [`B`, `C`, ...]:
-	//
-	//		assertBinding( A,
-	//			{ initial `A` attributes },
-	//			[
-	//				[ B, { new `B` attributes } ],
-	//				[ C, { new `C` attributes } ],
-	//				...
-	//			],
-	//			{ `A` attributes after [`B`, 'C', ...] changed }
-	//		);
-	//
-	function assertBinding( observable, stateBefore, data, stateAfter ) {
-		let key, pair;
-
-		for ( key in stateBefore ) {
-			expect( observable[ key ] ).to.be.equal( stateBefore[ key ] );
-		}
-
-		// Change attributes of bound observables.
-		for ( pair of data ) {
-			for ( key in pair[ 1 ] ) {
-				pair[ 0 ][ key ] = pair[ 1 ][ key ];
-			}
-		}
-
-		for ( key in stateAfter ) {
-			expect( observable[ key ] ).to.be.equal( stateAfter[ key ] );
-		}
-	}
 } );

+ 57 - 9
packages/ckeditor5-engine/tests/utils-tests/utils.js

@@ -5,13 +5,18 @@
 
 'use strict';
 
+import testUtils from '/tests/_utils/utils.js';
 import amdTestUtils from '/tests/_utils/amd.js';
 import coreTestUtils from '/tests/core/_utils/utils.js';
+import Model from '/ckeditor5/core/ui/model.js';
 import Creator from '/ckeditor5/core/creator.js';
+import EmitterMixin from '/ckeditor5/core/emittermixin.js';
 
 let createFn3 = () => {};
 let destroyFn3 = () => {};
 
+testUtils.createSinonSandbox();
+
 coreTestUtils.defineEditorCreatorMock( 'test1' );
 coreTestUtils.defineEditorCreatorMock( 'test2', {
 	foo: 1,
@@ -48,23 +53,66 @@ describe( 'coreTestUtils.defineEditorCreatorMock()', () => {
 	it( 'should copy properties from the second argument', () => {
 		expect( TestCreator2.prototype ).to.have.property( 'foo', 1 );
 		expect( TestCreator2.prototype ).to.have.property( 'bar', 2 );
-	} );
-
-	it( 'should create spies for create() and destroy() if not defined', () => {
-		expect( TestCreator1.prototype.create ).to.have.property( 'called', false, 'test1.create' );
-		expect( TestCreator1.prototype.destroy ).to.have.property( 'called', false, 'test1.destroy' );
-		expect( TestCreator2.prototype.create ).to.have.property( 'called', false, 'test2.create' );
-		expect( TestCreator2.prototype.destroy ).to.have.property( 'called', false, 'test2.destroy' );
 
-		// Not spies:
 		expect( TestCreator3.prototype ).to.have.property( 'create', createFn3 );
 		expect( TestCreator3.prototype ).to.have.property( 'destroy', destroyFn3 );
 	} );
 } );
 
 describe( 'coreTestUtils.getIteratorCount()', () => {
-	it( 'should returns number of editable items ', () => {
+	it( 'should returns number of editable items', () => {
 		const count = coreTestUtils.getIteratorCount( [ 1, 2, 3, 4, 5 ] );
 		expect( count ).to.equal( 5 );
 	} );
 } );
+
+describe( 'coreTestUtils.createObserver()', () => {
+	let observable, observable2, observer;
+
+	beforeEach( () => {
+		observer = coreTestUtils.createObserver();
+		observable = new Model( { foo: 0, bar: 0 } );
+		observable2 = new Model( { foo: 0, bar: 0 } );
+	} );
+
+	it( 'should create an observer', () => {
+		function Emitter() {}
+		Emitter.prototype = EmitterMixin;
+
+		expect( observer  ).to.be.instanceof( Emitter );
+		expect( observer.observe ).is.a( 'function' );
+		expect( observer.stopListening ).is.a( 'function' );
+	} );
+
+	describe( 'Observer', () => {
+		/* global console:false  */
+
+		it( 'logs changes in the observable', () => {
+			const spy = testUtils.sinon.stub( console, 'log' );
+
+			observer.observe( 'Some observable', observable );
+			observer.observe( 'Some observable 2', observable2 );
+
+			observable.foo = 1;
+			expect( spy.callCount ).to.equal( 1 );
+
+			observable.foo = 2;
+			observable2.bar = 3;
+			expect( spy.callCount ).to.equal( 3 );
+		} );
+
+		it( 'stops listening when asked to do so', () => {
+			const spy = testUtils.sinon.stub( console, 'log' );
+
+			observer.observe( 'Some observable', observable );
+
+			observable.foo = 1;
+			expect( spy.callCount ).to.equal( 1 );
+
+			observer.stopListening();
+
+			observable.foo = 2;
+			expect( spy.callCount ).to.equal( 1 );
+		} );
+	} );
+} );