Browse Source

Introduced Model#bind method with tests.

Aleksander Nowodzinski 10 years ago
parent
commit
fcd0c1030a
2 changed files with 984 additions and 0 deletions
  1. 436 0
      packages/ckeditor5-ui/src/model.js
  2. 548 0
      packages/ckeditor5-ui/tests/mvc/model/model.js

+ 436 - 0
packages/ckeditor5-ui/src/model.js

@@ -30,6 +30,24 @@ CKEDITOR.define( [ 'emittermixin', 'ckeditorerror', 'utils' ], ( EmitterMixin, C
 			 */
 			this._attributes = {};
 
+			/**
+			 * Map containing bindings of this model to external models.
+			 * See {@link #bind}.
+			 *
+			 * @property {Map}
+			 * @private
+			 */
+			this._boundTo = new Map();
+
+			/**
+			 * Object that stores which attributes of this model are bound.
+			 * See {@link #bind}.
+			 *
+			 * @property {Object}
+			 * @private
+			 */
+			this._bound = {};
+
 			// Extend this instance with the additional (out of state) properties.
 			if ( properties ) {
 				utils.extend( this, properties );
@@ -104,6 +122,424 @@ CKEDITOR.define( [ 'emittermixin', 'ckeditorerror', 'utils' ], ( EmitterMixin, C
 
 			this[ name ] = value;
 		}
+
+		/**
+		 * Binds model attributes to another Model instance.
+		 *
+		 * Once bound, the model will immediately share the current state of attributes
+		 * of the model it is bound to and react to the changes to these attributes
+		 * in the future.
+		 *
+		 * To release the binding use {@link #unbind}.
+		 *
+		 *		A.bind( 'a' ).to( B );
+		 *		A.bind( 'a' ).to( B, 'b' );
+		 *		A.bind( 'a', 'b' ).to( B, 'c', 'd' );
+		 *		A.bind( 'a' ).to( B, 'b' ).to( C, 'd' ).as( ( Bb, Cd ) => Bb + Cd );
+		 *
+		 * @param {String...} bindAttrs Model attributes use that will be bound to another model(s).
+		 * @returns {BindChain}
+		 */
+		bind() {
+			const bindAttrs = [].slice.call( arguments );
+
+			if ( !bindAttrs.length ) {
+				/**
+				 * No attributes supplied.
+				 *
+				 * @error model-bind-no-attrs
+				 */
+				throw new CKEditorError( 'model-bind-no-attrs: No attributes supplied.' );
+			} else if ( !isStringArray( bindAttrs ) ) {
+				/**
+				 * Attributes must be strings.
+				 *
+				 * @error model-bind-wrong-attrs
+				 */
+				throw new CKEditorError( 'model-bind-wrong-attrs: Attributes must be strings.' );
+			} else if ( ( new Set( bindAttrs ) ).size !== bindAttrs.length ) {
+				/**
+				 * Attributes must be unique.
+				 *
+				 * @error model-bind-duplicate-attrs
+				 */
+				throw new CKEditorError( 'model-bind-duplicate-attrs: Attributes must be unique.' );
+			}
+
+			bindAttrs.forEach( attrName => {
+				if ( attrName in this._bound ) {
+					/**
+					 * Cannot bind the same attribute more that once.
+					 *
+					 * @error model-bind-rebind
+					 */
+					throw new CKEditorError( 'model-bind-rebind: Cannot bind the same attribute more that once.' );
+				}
+
+				this._bound[ attrName ] = true;
+			} );
+
+			/**
+			 * @typedef BindChain
+			 * @type Object
+			 * @property {Model} _bindModel The model which initializes the binding.
+			 * @property {Array} _bindAttrs Array of `_bindModel` attributes to be bound.
+			 * @property {Array} _boundTo Array of `to()` model–attributes (`{ model: toModel, attrs: ...toAttrs }`).
+			 * @property {Object} _current The arguments of the last `to( toModel, ...toAttrs )` call, also
+			 * the last item of `_boundTo`.
+			 * @property {Function} to See {@link #_bindTo}.
+			 * @property {Function} as See {@link #_bindAs} (available after `to()` called in chain).
+			 */
+			return {
+				_bindModel: this,
+				_bindAttrs: bindAttrs,
+				_boundTo: [],
+				get _current() {
+					return this._boundTo[ this._boundTo.length - 1 ];
+				},
+				to: this._bindTo
+			};
+		}
+
+		/**
+		 * A chaining for {@link #bind} providing `.to()` interface.
+		 *
+		 * @protected
+		 * @param {Model} model A model used for binding.
+		 * @param {String...} [toAttrs] Attributes of the model used for binding.
+		 * @returns {BindChain}
+		 */
+		_bindTo() {
+			const toModel = arguments[ 0 ];
+			let toAttrs = [].slice.call( arguments, 1 );
+
+			if ( !toModel ) {
+				/**
+				 * No model supplied.
+				 *
+				 * @error model-bind-to-no-model
+				 */
+				throw new CKEditorError( 'model-bind-to-no-model: No model supplied.' );
+			}
+
+			if ( !( toModel instanceof Model ) ) {
+				/**
+				 * An instance of Model is required.
+				 *
+				 * @error model-bind-to-wrong-model:
+				 */
+				throw new CKEditorError( 'model-bind-to-wrong-model: An instance of Model is required.' );
+			}
+
+			if ( !isStringArray( toAttrs ) ) {
+				/**
+				 * Model attributes must be strings.
+				 *
+				 * @error model-bind-to-wrong-attrs
+				 */
+				throw new CKEditorError( 'model-bind-to-wrong-attrs: Model attributes must be strings.' );
+			}
+
+			// Eliminate A.bind( 'x' ).to( B, 'y', 'z' )
+			// Eliminate A.bind( 'x', 'y' ).to( B, 'z' )
+			if ( toAttrs.length && toAttrs.length !== this._bindAttrs.length ) {
+				/**
+				 * The number of attributes must match.
+				 *
+				 * @error model-bind-to-attrs-length
+				 */
+				throw new CKEditorError( 'model-bind-to-attrs-length: The number of attributes must match.' );
+			}
+
+			// Eliminate A.bind( 'x' ).to( B, 'y' ), when B.y == undefined.
+			if ( !hasAttributes( toModel, toAttrs ) ) {
+				/**
+				 * Model has no such attribute(s).
+				 *
+				 * @error model-bind-to-missing-model-attr
+				 */
+				throw new CKEditorError( 'model-bind-to-missing-model-attr: Model has no such attribute(s).' );
+			}
+
+			// Eliminate A.bind( 'x' ).to( B ), when B.x == undefined.
+			if ( !toAttrs.length && !hasAttributes( toModel, this._bindAttrs ) ) {
+				/**
+				 * Model has no such attribute(s).
+				 *
+				 * @error model-bind-to-missing-to-attr
+				 */
+				throw new CKEditorError( 'model-bind-to-missing-to-attr: Model has no such attribute(s).' );
+			}
+
+			// Eliminate A.bind( 'x', 'y' ).to( B ).to( C ) when no trailing .as().
+			// Eliminate A.bind( 'x', 'y' ).to( B, 'x', 'y' ).to( C, 'x', 'y' ).
+			if ( this._boundTo.length && ( toAttrs.length > 1 || this._bindAttrs.length > 1 ) ) {
+				/**
+				 * Chaining only allowed for a single attribute.
+				 *
+				 * @error model-bind-to-chain-multiple-attrs
+				 */
+				throw new CKEditorError( 'model-bind-to-chain-multiple-attrs: Chaining only allowed for a single attribute.' );
+			}
+
+			// When no toAttrs specified, observing MODEL attributes, like MODEL.bind( 'foo' ).to( TOMODEL )
+			if ( !toAttrs.length ) {
+				toAttrs = this._bindAttrs;
+			}
+
+			// Extend current chain with the new binding information.
+			this._boundTo.push( { model: toModel, attrs: toAttrs } );
+
+			setupBinding( this );
+
+			if ( !this.as ) {
+				this.as = this._bindModel._bindAs;
+			}
+
+			return this;
+		}
+
+		/**
+		 * A chaining for {@link #bind} providing `.as()` interface.
+		 *
+		 * @protected
+		 * @param {Function} callback A callback to combine model's attributes.
+		 */
+		_bindAs( callback ) {
+			if ( !callback ) {
+				/**
+				 * No callback function supplied.
+				 *
+				 * @error model-bind-as-no-callback
+				 */
+				throw new CKEditorError( 'model-bind-as-no-callback: No callback function supplied.' );
+			} else if ( typeof callback !== 'function' ) {
+				/**
+				 * Callback must be a Function.
+				 *
+				 * @error model-bind-as-wrong-callback
+				 */
+				throw new CKEditorError( 'model-bind-as-wrong-callback: Callback must be a Function.' );
+			}
+
+			this._callback = callback;
+
+			updateModelAttrs( this, this._bindAttrs[ 0 ] );
+		}
+
+		/**
+		 * Removes the binding created with {@link #bind}.
+		 *
+		 *		A.unbind( 'a' );
+		 *		A.unbind();
+		 *
+		 * @param {String...} [bindAttrs] Model attributes to unbound. All the bindings will
+		 * be released if not attributes provided.
+		 */
+		unbind() {
+			if ( arguments.length ) {
+				const unbindAttrs = [].slice.call( arguments );
+
+				if ( !isStringArray( unbindAttrs ) ) {
+					/**
+					 * Attributes must be strings.
+					 *
+					 * @error model-unbind-wrong-attrs
+					 */
+					throw new CKEditorError( 'model-unbind-wrong-attrs: Attributes must be strings.' );
+				}
+
+				unbindAttrs.forEach( attrName => {
+					for ( let to of this._boundTo ) {
+						// TODO, ES6 destructuring.
+						const boundModel = to[ 0 ];
+						const bindings = to[ 1 ];
+
+						for ( let boundAttrName in bindings ) {
+							if ( bindings[ boundAttrName ].has( attrName ) ) {
+								bindings[ boundAttrName ].delete( attrName );
+							}
+
+							if ( !bindings[ boundAttrName ].size ) {
+								delete bindings[ boundAttrName ];
+							}
+
+							if ( !Object.keys( bindings ).length ) {
+								this._boundTo.delete( boundModel );
+								this.stopListening( boundModel, 'change' );
+							}
+						}
+					}
+
+					delete this._bound[ attrName ];
+				} );
+			} else {
+				this._boundTo.forEach( ( bindings, boundModel ) => {
+					this.stopListening( boundModel, 'change' );
+					this._boundTo.delete( boundModel );
+				} );
+
+				this._bound = {};
+			}
+		}
+	}
+
+	/**
+	 * Check if the `model` has given `attrs`.
+	 *
+	 * @private
+	 * @param {Model} model Model to be checked.
+	 * @param {Array} arr An array of `String`.
+	 * @returns {Boolean}
+	 */
+	function hasAttributes( model, attrs ) {
+		return attrs.findIndex( a => {
+			return Object.keys( model._attributes ).indexOf( a ) === -1;
+		} ) == -1;
+	}
+
+	/**
+	 * Check if all entries of the array are of `String` type.
+	 *
+	 * @private
+	 * @param {Array} arr An array to be checked.
+	 * @returns {Boolean}
+	 */
+	function isStringArray( arr ) {
+		return arr.findIndex( a => typeof a !== 'string' ) == -1;
+	}
+
+	/**
+	 * Returns all bindings of the `chain._bindModel` to `chain._current.model`
+	 * set by {@link #updateModelBindingsToCurrent}.
+	 *
+	 *		// Given that A == _bindModel and B == _current.model
+	 *		A.bind( 'a', 'b', 'c' ).to( B, 'x', 'y', 'x' );
+	 *
+	 *		// The following object is returned
+	 *		{ x: [ 'a', 'c' ], y: [ 'b' ] }
+	 *
+	 *
+	 * @private
+	 * @param {BindChain} chain The chain initialized by {@link Model#bind}.
+	 * @returns {Object}
+	 */
+	function getModelBindingsToCurrent( chain ) {
+		return chain._bindModel._boundTo.get( chain._current.model );
+	}
+
+	/**
+	 * Updates `chain._bindModel._boundTo` with a binding for `chain._current`.
+	 * The binding can be then retrieved by {@link #getModelBindingsToCurrent}.
+	 *
+	 * @private
+	 * @param {BindChain} chain The chain initialized by {@link Model#bind}.
+	 * @returns {Object}
+	 */
+	function updateModelBindingsToCurrent( chain ) {
+		const currentBindings = getModelBindingsToCurrent( chain );
+		const bindings = currentBindings || {};
+
+		chain._current.attrs.forEach( ( attrName, index ) => {
+			( bindings[ attrName ] || ( bindings[ attrName ] = new Set() ) )
+				.add( chain._bindAttrs[ index ] );
+		} );
+
+		if ( !currentBindings ) {
+			chain._bindModel._boundTo.set( chain._current.model, bindings );
+		}
+	}
+
+	/**
+	 * Updates the model attribute with given value. If an attribute does not exist,
+	 * it is created on the fly.
+	 *
+	 * @private
+	 * @param {Model} model The model which attribute is updated.
+	 * @param {String} attrName The name of the attribute.
+	 * @param {*} value The value of the attribute.
+	 */
+	function updateModelAttr( model, attrName, value ) {
+		if ( model.attrName ) {
+			model[ attrName ] = value;
+		} else {
+			model.set( attrName, value );
+		}
+	}
+
+	/**
+	 * Updates all bound attributes of `chain._bindModel` with the `value` of
+	 * `attrName` of `chain._current` model.
+	 *
+	 *		// Given that A == _bindModel and B == _current.model
+	 *		A.bind( 'a', 'b', 'c' ).to( B, 'x', 'y', 'x' );
+	 *
+	 *		// The following is updated
+	 *		A.a = A.c = B.x;
+	 *		A.b = B.y;
+	 *
+	 * @private
+	 * @param {BindChain} chain The chain initialized by {@link Model#bind}.
+	 * @param {String} attrName One of the attributes of `chain._current`.
+	 * @param {*} value The value of the attribute.
+	 */
+	function updateModelAttrs( chain, attrName, value ) {
+		const boundAttrs = getModelBindingsToCurrent( chain )[ attrName ];
+
+		if ( !boundAttrs ) {
+			return;
+		} else if ( chain._callback ) {
+			// MODEL.bind( 'a' ).to( TOMODEL1, 'b1' )[ .to( TOMODELn, 'bn' ) ].as( callback )
+			//  \-> Collect specific attribute value in the boundTo.model (TOMODELn.bn).
+			//
+			// MODEL.bind( 'a' ).to( TOMODEL1 )[ .to( TOMODELn ) ].as( callback )
+			//  \-> Use model attribute name to collect boundTo attribute value (TOMODELn.a).
+			const values = chain._boundTo.map( boundTo => {
+				return boundTo.model[ boundTo.attrs.length ? boundTo.attrs[ 0 ] : chain.attrs[ 0 ] ];
+			} );
+
+			// Pass collected attribute values to the callback function.
+			// Whatever is returned it becomes the value of the model's attribute.
+			updateModelAttr(
+				chain._bindModel,
+				chain._bindAttrs[ 0 ],
+				chain._callback.apply( chain._bindModel, values )
+			);
+		} else {
+			// MODEL.bind( 'a' ).to( TOMODEL1 )[ .to( TOMODELn ) ];
+			//  \-> If multiple .to() models but **no** .as( callback ), then the binding is invalid.
+			if ( !chain._callback && chain._boundTo.length > 1 ) {
+				value = undefined;
+			}
+
+			for ( let boundAttrName of boundAttrs ) {
+				updateModelAttr( chain._bindModel, boundAttrName, value );
+			}
+		}
+	}
+
+	/**
+	 * Starts listening to changes in `chain._current.model` to update `chain._bindModel`
+	 * attributes. Also sets the initial state of `chain._bindModel` bound attributes.
+	 *
+	 * @private
+	 * @param {BindChain} chain The chain initialized by {@link Model#bind}.
+	 */
+	function setupBinding( chain ) {
+		// If there's already a binding between the models (`chain._bindModel` listens to
+		// `chain._current.model`), there's no need to create another `change` event listener.
+		if ( !getModelBindingsToCurrent( chain ) ) {
+			chain._bindModel.listenTo( chain._current.model, 'change', ( evt, attrName, value ) => {
+				updateModelAttrs( chain, attrName, value );
+			} );
+		}
+
+		updateModelBindingsToCurrent( chain );
+
+		// Set initial model state.
+		chain._current.attrs.forEach( attrName => {
+			updateModelAttrs( chain, attrName, chain._current.model[ attrName ] );
+		} );
 	}
 
 	utils.extend( Model.prototype, EmitterMixin );

+ 548 - 0
packages/ckeditor5-ui/tests/mvc/model/model.js

@@ -9,6 +9,8 @@ const modules = bender.amd.require( 'model', 'eventinfo', 'ckeditorerror' );
 
 let Car, car;
 
+bender.tools.createSinonSandbox();
+
 describe( 'Model', () => {
 	beforeEach( 'Create a test model instance', () => {
 		const Model = modules.model;
@@ -177,4 +179,550 @@ describe( 'Model', () => {
 			expect( truck ).to.be.an.instanceof( Model );
 		} );
 	} );
+
+	describe( 'bind', () => {
+		let Model, EventInfo, CKEditorError;
+
+		beforeEach( () => {
+			Model = modules.model;
+			EventInfo = modules.eventinfo;
+			CKEditorError = modules.ckeditorerror;
+		} );
+
+		it( 'should chain for a single attribute', () => {
+			expect( car.bind( 'color' ) ).to.contain.keys( 'to' );
+		} );
+
+		it( 'should chain for multiple attributes', () => {
+			expect( car.bind( 'color', 'year' ) ).to.contain.keys( 'to' );
+		} );
+
+		it( 'should chain for nonexistent attributes', () => {
+			expect( car.bind( 'nonexistent' ) ).to.contain.keys( 'to' );
+		} );
+
+		it( 'should throw when no attributes specified', () => {
+			const CKEditorError = modules.ckeditorerror;
+
+			expect( () => {
+				car.bind();
+			} ).to.throw( CKEditorError, /model-bind-no-attrs/ );
+		} );
+
+		it( 'should throw when attributes are not strings', () => {
+			expect( () => {
+				car.bind( new Date() );
+			} ).to.throw( CKEditorError, /model-bind-wrong-attrs/ );
+
+			expect( () => {
+				car.bind( 'color', new Date() );
+			} ).to.throw( CKEditorError, /model-bind-wrong-attrs/ );
+		} );
+
+		it( 'should throw when the same attribute is used than once', () => {
+			expect( () => {
+				car.bind( 'color', 'color' );
+			} ).to.throw( CKEditorError, /model-bind-duplicate-attrs/ );
+		} );
+
+		it( 'should throw when binding the same attribute more than once', () => {
+			expect( () => {
+				car.bind( 'color' );
+				car.bind( 'color' );
+			} ).to.throw( CKEditorError, /model-bind-rebind/ );
+		} );
+
+		describe( 'to', () => {
+			it( 'should chain', () => {
+				const returned = car.bind( 'color' ).to( new Model( { color: 'red' } ) );
+
+				expect( returned ).to.have.property( 'to' );
+				expect( returned ).to.have.property( 'as' );
+			} );
+
+			it( 'should chain multiple times', () => {
+				const returned = car.bind( 'color' )
+					.to( new Model( { color: 'red' } ) )
+					.to( new Model( { color: 'red' } ) )
+					.to( new Model( { color: 'red' } ) );
+
+				expect( returned ).to.have.property( 'to' );
+				expect( returned ).to.have.property( 'as' );
+			} );
+
+			it( 'should throw when no .to() model', () => {
+				expect( () => {
+					car.bind( 'color' ).to();
+				} ).to.throw( CKEditorError, /model-bind-to-no-model/ );
+			} );
+
+			it( 'should throw when .to() model is not Model', () => {
+				expect( () => {
+					car.bind( 'color' ).to( 'it\'s not a model' );
+				} ).to.throw( CKEditorError, /model-bind-to-wrong-model/ );
+			} );
+
+			it( 'should throw when attributes are not strings', () => {
+				expect( () => {
+					car.bind( 'color' ).to( new Model(), new Date() );
+				} ).to.throw( CKEditorError, /model-bind-to-wrong-attrs/ );
+
+				expect( () => {
+					car = new Car( { color: 'red' } );
+
+					car.bind( 'color' ).to( new Model(), 'color', new Date() );
+				} ).to.throw( CKEditorError, /model-bind-to-wrong-attrs/ );
+			} );
+
+			it( 'should throw when a number of attributes does not match', () => {
+				expect( () => {
+					const vehicle = new Car();
+
+					vehicle.bind( 'color', 'year' ).to( car, 'color' );
+				} ).to.throw( CKEditorError, /model-bind-to-attrs-length/ );
+
+				expect( () => {
+					const vehicle = new Car();
+
+					vehicle.bind( 'color' ).to( car, 'color', 'year' );
+				} ).to.throw( CKEditorError, /model-bind-to-attrs-length/ );
+
+				expect( () => {
+					const vehicle = new Car();
+
+					vehicle.bind( 'color' ).to( car, 'color' ).to( car, 'color', 'year' );
+				} ).to.throw( CKEditorError, /model-bind-to-attrs-length/ );
+			} );
+
+			it( 'should throw when binding to an nonexistent attribute in the model', () => {
+				const vehicle = new Car();
+
+				expect( () => {
+					vehicle.bind( 'color' ).to( car, 'nonexistent in car' );
+				} ).to.throw( CKEditorError, /model-bind-to-missing-model-attr/ );
+			} );
+
+			it( 'should throw when no attribute specified and those from bind() don\'t exist in to() model', () => {
+				const vehicle = new Car();
+
+				expect( () => {
+					vehicle.bind( 'nonexistent in car' ).to( car );
+				} ).to.throw( CKEditorError, /model-bind-to-missing-to-attr/ );
+			} );
+
+			it( 'should throw when to() more than once and multiple attributes', () => {
+				const car1 = new Car( { color: 'red', year: 2000 } );
+				const car2 = new Car( { color: 'red', year: 2000 } );
+
+				expect( () => {
+					const vehicle = new Car();
+
+					vehicle.bind( 'color', 'year' ).to( car1 ).to( car2 );
+				} ).to.throw( CKEditorError, /model-bind-to-chain-multiple-attrs/ );
+
+				expect( () => {
+					const vehicle = new Car();
+
+					vehicle.bind( 'color', 'year' ).to( car1, 'color', 'year' ).to( car2, 'color', 'year' );
+				} ).to.throw( CKEditorError, /model-bind-to-chain-multiple-attrs/ );
+			} );
+
+			it( 'should set new model attributes', () => {
+				const car = new Car( { color: 'green', year: 2001, type: 'pickup' } );
+				const vehicle = new Car( { 'not involved': true } );
+
+				vehicle.bind( 'color', 'year', 'type' ).to( car );
+
+				expect( vehicle._attributes ).to.have.keys( 'color', 'year', 'type', 'not involved' );
+			} );
+
+			it( 'should work when no attribute specified #1', () => {
+				const vehicle = new Car();
+
+				vehicle.bind( 'color' ).to( car );
+
+				assertBinding( vehicle,
+					{ color: car.color, year: undefined },
+					[
+						[ car, { color: 'blue', year: 1969 } ]
+					],
+					{ color: 'blue', year: undefined }
+				);
+			} );
+
+			it( 'should work for a single attribute', () => {
+				const vehicle = new Car();
+
+				vehicle.bind( 'color' ).to( car, 'color' );
+
+				assertBinding( vehicle,
+					{ color: car.color, year: undefined },
+					[
+						[ car, { color: 'blue', year: 1969 } ]
+					],
+					{ color: 'blue', year: undefined }
+				);
+			} );
+
+			it( 'should work for multiple attributes', () => {
+				const vehicle = new Car();
+
+				vehicle.bind( 'color', 'year' ).to( car, 'color', 'year' );
+
+				assertBinding( vehicle,
+					{ color: car.color, year: car.year },
+					[
+						[ car, { color: 'blue', year: 1969 } ]
+					],
+					{ color: 'blue', year: 1969 }
+				);
+			} );
+
+			it( 'should work for attributes that don\'t exist in the model', () => {
+				const vehicle = new Car();
+
+				vehicle.bind( 'nonexistent in vehicle' ).to( car, 'color' );
+
+				assertBinding( vehicle,
+					{ 'nonexistent in vehicle': car.color, color: undefined },
+					[
+						[ car, { color: 'blue', year: 1969 } ]
+					],
+					{ 'nonexistent in vehicle': 'blue', color: undefined }
+				);
+			} );
+
+			it( 'should work when using the same attribute name more than once', () => {
+				const vehicle = new Car();
+
+				vehicle.bind( 'color', 'year' ).to( car, 'year', 'year' );
+
+				assertBinding( vehicle,
+					{ color: car.year, year: car.year },
+					[
+						[ car, { color: 'blue', year: 1969 } ]
+					],
+					{ color: 1969, year: 1969 }
+				);
+			} );
+
+			it( 'should not throw when binding more than once but no as() afterwards', () => {
+				const vehicle = new Car();
+				const car1 = new Car( { color: 'red' } );
+				const car2 = new Car( { color: 'red' } );
+
+				vehicle.bind( 'color' ).to( car1 ).to( car2 );
+
+				assertBinding( vehicle,
+					{ color: undefined, year: undefined },
+					[
+						[ car, { color: 'blue', year: 1969 } ]
+					],
+					{ color: undefined, year: undefined }
+				);
+			} );
+
+			describe( 'as', () => {
+				it( 'should not chain', () => {
+					const car1 = new Car( { year: 1999 } );
+					const car2 = new Car( { year: 2000 } );
+
+					expect(
+						car.bind( 'year' ).to( car1, 'year' ).to( car2, 'year' ).as( () => {} )
+					).to.be.undefined;
+				} );
+
+				it( 'should throw when no function specified', () => {
+					const car1 = new Car( { color: 'brown' } );
+					const car2 = new Car( { color: 'green' } );
+
+					expect( () => {
+						car.bind( 'color' ).to( car1, 'color' ).to( car2, 'color' ).as();
+					} ).to.throw( CKEditorError, /model-bind-as-no-callback/ );
+				} );
+
+				it( 'should throw when not a function passed', () => {
+					const car1 = new Car( { color: 'brown' } );
+					const car2 = new Car( { color: 'green' } );
+
+					expect( () => {
+						car.bind( 'color' ).to( car1, 'color' ).to( car2, 'color' ).as( 'not-a-function' );
+					} ).to.throw( CKEditorError, /model-bind-as-wrong-callback/ );
+				} );
+
+				it( 'should set new model attributes', () => {
+					const vehicle = new Car();
+					const car1 = new Car( { type: 'pickup' } );
+					const car2 = new Car( { type: 'truck' } );
+
+					vehicle.bind( 'type' )
+						.to( car1 )
+						.to( car2 )
+						.as( ( col1, col2 ) => col1 + col2 );
+
+					expect( vehicle._attributes ).to.have.keys( [ 'type' ] );
+				} );
+
+				it( 'should work for a single attribute #1', () => {
+					const vehicle = new Car();
+					const car1 = new Car( { color: 'black' } );
+					const car2 = new Car( { color: 'brown' } );
+
+					vehicle.bind( 'color' )
+						.to( car1 )
+						.to( car2 )
+						.as( ( col1, col2 ) => col1 + col2 );
+
+					assertBinding( vehicle,
+						{ color: car1.color + car2.color, year: undefined },
+						[
+							[ car1, { color: 'black', year: 1930 } ],
+							[ car2, { color: 'green', year: 1950 } ]
+						],
+						{ color: 'blackgreen', year: undefined }
+					);
+				} );
+
+				it( 'should work for a single attribute #2', () => {
+					const vehicle = new Car();
+					const car1 = new Car( { color: 'black' } );
+					const car2 = new Car( { color: 'brown' } );
+
+					vehicle.bind( 'color' )
+						.to( car1, 'color' )
+						.to( car2, 'color' )
+						.as( ( col1, col2 ) => col1 + col2 );
+
+					assertBinding( vehicle,
+						{ color: car1.color + car2.color, year: undefined },
+						[
+							[ car1, { color: 'black', year: 1930 } ],
+							[ car2, { color: 'green', year: 1950 } ]
+						],
+						{ color: 'blackgreen', year: undefined }
+					);
+				} );
+
+				it( 'should work for a single attribute #3', () => {
+					const vehicle = new Car();
+					const car1 = new Car( { color: 'black' } );
+					const car2 = new Car( { color: 'brown' } );
+					const car3 = new Car( { color: 'yellow' } );
+
+					vehicle.bind( 'color' )
+						.to( car1 )
+						.to( car2 )
+						.to( car3 )
+						.as( ( col1, col2, col3 ) => col1 + col2 + col3 );
+
+					assertBinding( vehicle,
+						{ color: car1.color + car2.color + car3.color, year: undefined },
+						[
+							[ car1, { color: 'black', year: 1930 } ],
+							[ car2, { color: 'green', year: 1950 } ]
+						],
+						{ color: 'blackgreenyellow', year: undefined }
+					);
+				} );
+
+				it( 'should work for a single attribute #4', () => {
+					const vehicle = new Car();
+					const car1 = new Car( { color: 'black' } );
+					const car2 = new Car( { lightness: 'bright' } );
+					const car3 = new Car( { color: 'yellow' } );
+
+					vehicle.bind( 'color' )
+						.to( car1 )
+						.to( car2, 'lightness' )
+						.to( car3 )
+						.as( ( col1, lightness, col3 ) => col1 + lightness + col3 );
+
+					assertBinding( vehicle,
+						{ color: car1.color + car2.lightness + car3.color, year: undefined },
+						[
+							[ car1, { color: 'black', year: 1930 } ],
+							[ car2, { color: 'green', year: 1950 } ]
+						],
+						{ color: 'blackbrightyellow', year: undefined }
+					);
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'unbind', () => {
+		let Model, EventInfo, CKEditorError;
+
+		beforeEach( () => {
+			Model = modules.model;
+			EventInfo = modules.eventinfo;
+			CKEditorError = modules.ckeditorerror;
+		} );
+
+		it( 'should throw when non-string attribute is passed', () => {
+			expect( () => {
+				car.unbind( new Date() );
+			} ).to.throw( CKEditorError, /model-unbind-wrong-attrs/ );
+		} );
+
+		it( 'should remove all bindings', () => {
+			const vehicle = new Car();
+
+			vehicle.bind( 'color', 'year' ).to( car, 'color', 'year' );
+			vehicle.unbind();
+
+			assertBinding( vehicle,
+				{ color: 'red', year: 2015 },
+				[
+					[ car, { color: 'blue', year: 1969 } ]
+				],
+				{ color: 'red', year: 2015 }
+			);
+		} );
+
+		it( 'should remove bindings of certain attributes', () => {
+			const vehicle = new Car();
+			const car = new Car( { color: 'red', year: 2000, torque: 160 } );
+
+			vehicle.bind( 'color', 'year', 'torque' ).to( car );
+			vehicle.unbind( 'year', 'torque' );
+
+			assertBinding( vehicle,
+				{ color: 'red', year: 2000, torque: 160 },
+				[
+					[ car, { color: 'blue', year: 1969, torque: 220 } ]
+				],
+				{ color: 'blue', year: 2000, torque: 160 }
+			);
+		} );
+
+		it( 'should remove bindings of certain attributes, as()', () => {
+			const vehicle = new Car();
+			const car1 = new Car( { color: 'red' } );
+			const car2 = new Car( { color: 'blue' } );
+
+			vehicle.bind( 'color' ).to( car1 ).to( car2 ).as( ( c1, c2 ) => c1 + c2 );
+			vehicle.unbind( 'color' );
+
+			assertBinding( vehicle,
+				{ color: 'redblue' },
+				[
+					[ car1, { color: 'green' } ],
+					[ car2, { color: 'violet' } ]
+				],
+				{ color: 'redblue' }
+			);
+		} );
+
+		it( 'should process the internal structure and listeners correctly', () => {
+			const model = new Model();
+
+			const bound1 = new Model( { b1a: 'foo' } );
+			const bound2 = new Model( { b2b: 42, 'b2c': 'bar' } );
+			const bound3 = new Model( { b3d: 'baz' } );
+
+			model.bind( 'a' ).to( bound1, 'b1a' );
+			model.bind( 'b', 'c' ).to( bound2, 'b2b', 'b2c' );
+			model.bind( 'd', 'e' ).to( bound3, 'b3d', 'b3d' );
+
+			assertStructure( model,
+				{ a: true, b: true, c: true, d: true, e: true },
+				[ bound1, bound2, bound3 ],
+				[
+					{ b1a: [ 'a' ] },
+					{ b2b: [ 'b' ], b2c: [ 'c' ] },
+					{ b3d: [ 'd', 'e' ] }
+				]
+			);
+
+			model.unbind( 'c', 'd' );
+
+			assertStructure( model,
+				{ a: true, b: true, e: true },
+				[ bound1, bound2, bound3 ],
+				[
+					{ b1a: [ 'a' ] },
+					{ b2b: [ 'b' ] },
+					{ b3d: [ 'e' ] }
+				]
+			);
+
+			model.unbind( 'b' );
+
+			assertStructure( model,
+				{ a: true, e: true },
+				[ bound1, bound3 ],
+				[
+					{ b1a: [ 'a' ] },
+					{ b3d: [ 'e' ] }
+				]
+			);
+
+			model.unbind();
+
+			assertStructure( model, {}, [], [] );
+		} );
+	} );
+
+	// Syntax given that model `A` is bound to models [`B`, `C`, ...]:
+	//
+	//		assertBinding( A,
+	//			{ initial `A` attributes },
+	//			[
+	//				[ B, { new `B` attributes } ],
+	//				[ C, { new `C` attributes } ],
+	//				...
+	//			],
+	//			{ `A` attributes after [`B`, 'C', ...] changed }
+	//		);
+	//
+	function assertBinding( model, stateBefore, data, stateAfter ) {
+		let key, pair;
+
+		for ( key in stateBefore ) {
+			expect( model[ key ] ).to.be.equal( stateBefore[ key ] );
+		}
+
+		// Change attributes of bound models.
+		for ( pair of data ) {
+			for ( key in pair[ 1 ] ) {
+				pair[ 0 ][ key ] = pair[ 1 ][ key ];
+			}
+		}
+
+		for ( key in stateAfter ) {
+			expect( model[ key ] ).to.be.equal( stateAfter[ key ] );
+		}
+	}
+
+	function assertStructure( model, expectedBound, expectedModels, expectedBindings ) {
+		// Check model._bound object.
+		expect( model._bound ).to.be.deep.equal( expectedBound );
+
+		// TODO: Should be boundModels = [ ...model._boundTo.boundModels() ]
+		const boundModels = [];
+
+		for ( let boundModel of model._boundTo.keys() ) {
+			boundModels.push( boundModel );
+		}
+
+		// Check model._boundTo models.
+		expect( boundModels ).to.have.members( expectedModels );
+
+		// Check model._listeningTo models.
+		boundModels.map( boundModel => {
+			expect( model._listeningTo ).to.have.ownProperty( boundModel._emitterId );
+		} );
+
+		// Check model._boundTo model bindings.
+		expectedBindings.forEach( ( binding, index ) => {
+			expect( model._boundTo.get( expectedModels[ index ] ) )
+				.to.have.keys( Object.keys( binding ) );
+
+			Object.keys( binding ).forEach( b => {
+				expect( Array.from( model._boundTo.get( expectedModels[ index ] )[ b ] ) )
+					.to.have.members( binding[ b ] );
+			} );
+		} );
+	}
 } );