8
0
Просмотр исходного кода

A rough implementation of Template#extend.

Aleksander Nowodzinski 9 лет назад
Родитель
Сommit
1fbf68506a
2 измененных файлов с 836 добавлено и 49 удалено
  1. 188 24
      packages/ckeditor5-ui/src/template.js
  2. 648 25
      packages/ckeditor5-ui/tests/template.js

+ 188 - 24
packages/ckeditor5-ui/src/template.js

@@ -28,6 +28,8 @@ export default class Template {
 	 * @param {ui.TemplateDefinition} def The definition of the template.
 	 */
 	constructor( def ) {
+		normalize( def );
+
 		/**
 		 * Definition of this template.
 		 *
@@ -71,6 +73,59 @@ export default class Template {
 		return this._renderNode( this.definition, node );
 	}
 
+	/**
+	 * Extends {@link ui.Template#definition} with additional content.
+	 *
+	 *		const bind = Template.bind( observable, emitterInstance );
+	 *		const template = new Template( {
+	 *			tag: 'p',
+	 *			attributes: {
+	 *				class: 'a',
+	 *				data-x: bind.to( 'foo' )
+	 *			},
+	 *			children: [
+	 *				{
+	 *					tag: 'span',
+	 *					attributes: {
+	 *						class: 'b'
+	 *					},
+	 *					children: [
+	 *						'Span.'
+	 *					]
+	 *				}
+	 *			]
+	 *		 } );
+	 *
+	 *		template.extend( {
+	 *			attributes: {
+	 *				class: 'b',
+	 *				data-x: bind.to( 'bar' )
+	 *			},
+	 *			children: [
+	 *				{
+	 *					attributes: {
+	 *						class: 'c'
+	 *					}
+	 *				},
+	 *				'Paragraph.'
+	 *			]
+	 *		} );
+	 *
+	 * the `template.render().outerHTML` is
+	 *
+	 *		<p class="a b" data-x="{ observable.foo } { observable.bar }">
+	 *			<span class="b c">Span.</span>
+	 *			Paragraph.
+	 *		</p>
+	 *
+	 * @param {ui.TemplateDefinition} extDef An extension to existing definition.
+	 */
+	extend( extDef ) {
+		normalize( extDef );
+
+		extendTemplateDefinition( this.definition, extDef );
+	}
+
 	/**
 	 * Renders a DOM Node from definition.
 	 *
@@ -81,12 +136,15 @@ export default class Template {
 	 * @returns {HTMLElement} A rendered Node.
 	 */
 	_renderNode( def, applyNode, intoFragment ) {
-		normalize( def );
+		let isInvalid;
 
-		// When applying, a definition cannot have "tag" and "text" at the same time.
-		// When rendering, a definition must have either "tag" or "text": XOR( def.tag, def.text ).
-		const isInvalid = applyNode ?
-			( def.tag && def.text ) : ( def.tag ? def.text : !def.text );
+		if ( applyNode ) {
+			// When applying, a definition cannot have "tag" and "text" at the same time.
+			isInvalid = def.tag && def.text;
+		} else {
+			// When rendering, a definition must have either "tag" or "text": XOR( def.tag, def.text ).
+			isInvalid = def.tag ? def.text : !def.text;
+		}
 
 		if ( isInvalid ) {
 			/**
@@ -154,7 +212,7 @@ export default class Template {
 		// 		{ text: [ 'all', 'are', 'static' ] }
 		// 		{ text: [ 'foo' ] }
 		else {
-			textNode.textContent = valueSchemaOrText.text;
+			textNode.textContent = valueSchemaOrText.text.join( '' );
 		}
 
 		return textNode;
@@ -549,7 +607,8 @@ function getAttributeUpdater( el, attrName, ns = null ) {
  * See:
  *  * {@link normalizeAttributes}
  *  * {@link normalizeListeners}
- *  * {@link normalizeTextChildren}
+ *  * {@link normalizeTextString}
+ *  * {@link normalizeTextDefinition}
  *
  * @ignore
  * @private
@@ -557,6 +616,10 @@ function getAttributeUpdater( el, attrName, ns = null ) {
  * @returns {Array}
  */
 function normalize( def ) {
+	if ( def.text ) {
+		normalizeTextDefinition( def );
+	}
+
 	if ( def.attributes ) {
 		normalizeAttributes( def.attributes );
 	}
@@ -566,7 +629,11 @@ function normalize( def ) {
 	}
 
 	if ( def.children ) {
-		normalizeTextChildren( def );
+		// Splicing children array inside so no forEach.
+		for ( let i = def.children.length; i--; ) {
+			normalizeTextString( def.children, def.children[ i ], i );
+			normalize( def.children[ i ] );
+		}
 	}
 }
 
@@ -633,10 +700,36 @@ function normalizeListeners( listeners ) {
 }
 
 /**
- * Normalizes text in "children" section of {@link ui.TemplateDefinition}.
+ * Normalizes "string" text in "children" section of {@link ui.TemplateDefinition}.
  *
  *		children: [
  *			'abc',
+ *		]
+ *
+ * becomes
+ *
+ *		children: [
+ *			{ text: [ 'abc' ] },
+ *		]
+ *
+ * @ignore
+ * @private
+ * @param {Array} children
+ * @param {ui.TemplateDefinition} child
+ * @param {Number} index
+ */
+function normalizeTextString( children, child, index ) {
+	if ( typeof child == 'string' ) {
+		children.splice( index, 1, {
+			text: [ child ]
+		} );
+	}
+}
+
+/**
+ * Normalizes text {@link ui.TemplateDefinition}.
+ *
+ *		children: [
  *			{ text: 'def' },
  *			{ text: {@link ui.TemplateBinding} }
  *		]
@@ -644,7 +737,6 @@ function normalizeListeners( listeners ) {
  * becomes
  *
  *		children: [
- *			{ text: [ 'abc' ] },
  *			{ text: [ 'def' ] },
  *			{ text: [ {@link ui.TemplateBinding} ] }
  *		]
@@ -653,20 +745,10 @@ function normalizeListeners( listeners ) {
  * @private
  * @param {ui.TemplateDefinition} def
  */
-function normalizeTextChildren( def ) {
-	def.children = def.children.map( c => {
-		if ( typeof c == 'string' ) {
-			return {
-				text: [ c ]
-			};
-		} else {
-			if ( c.text && !Array.isArray( c.text ) ) {
-				c.text = [ c.text ];
-			}
-		}
-
-		return c;
-	} );
+function normalizeTextDefinition( def ) {
+	if ( !Array.isArray( def.text ) ) {
+		def.text = [ def.text ];
+	}
 }
 
 /**
@@ -712,6 +794,88 @@ function arrayValueReducer( prev, cur ) {
 			cur === '' ? `${prev}` : `${prev} ${cur}`;
 }
 
+/**
+ * Extends one object defined in the following format:
+ *
+ *		{
+ *			key1: [Array1],
+ *			key2: [Array2],
+ *			...
+ *			keyN: [ArrayN]
+ *		}
+ *
+ * with another object of the same data format.
+ *
+ * @ignore
+ * @private
+ * @param {Object} obj Base object.
+ * @param {Object} ext Object extending base.
+ * @returns {String}
+ */
+function extendObjectValueArray( obj, ext ) {
+	for ( let a in ext ) {
+		if ( obj[ a ] ) {
+			obj[ a ].push( ...ext[ a ] || [] );
+		} else {
+			obj[ a ] = ext[ a ];
+		}
+	}
+}
+
+/**
+ * A helper for {@link ui.Template#extend}. Recursively extends {@link ui.Template#definition}
+ * with content from another definition. See {@link ui.Template#extend} to learn more.
+ *
+ * @ignore
+ * @private
+ * @param {ui.TemplateDefinition} def A base template definition.
+ * @param {ui.TemplateDefinition} extDef An extension to existing definition.
+ * @param {Array} [defSiblings] An array `def` belongs to.
+ */
+function extendTemplateDefinition( def, extDef, defSiblings ) {
+	if ( !def ) {
+		defSiblings.push( def = {} );
+	}
+
+	if ( extDef.tag && !def.tag ) {
+		def.tag = extDef.tag;
+	}
+
+	if ( extDef.attributes ) {
+		if ( !def.attributes ) {
+			def.attributes = {};
+		}
+
+		extendObjectValueArray( def.attributes, extDef.attributes );
+	}
+
+	if ( extDef.on ) {
+		if ( !def.on ) {
+			def.on = {};
+		}
+
+		extendObjectValueArray( def.on, extDef.on );
+	}
+
+	if ( extDef.text ) {
+		if ( !def.text ) {
+			def.text = [];
+		}
+
+		def.text.push( ...extDef.text );
+	}
+
+	if ( extDef.children ) {
+		if ( !def.children ) {
+			def.children = [];
+		}
+
+		extDef.children.forEach( ( extChildDef, index ) => {
+			extendTemplateDefinition( def.children[ index ], extChildDef, def.children );
+		} );
+	}
+}
+
 /**
  * A definition of {@link ui.Template}.
  * See: {@link ui.TemplateValueSchema}.

+ 648 - 25
packages/ckeditor5-ui/tests/template.js

@@ -22,32 +22,16 @@ let el, text;
 
 describe( 'Template', () => {
 	describe( 'constructor', () => {
-		it( 'accepts the definition', () => {
+		it( 'accepts template definition', () => {
 			const def = {
 				tag: 'p'
 			};
 
 			expect( new Template( def ).definition ).to.equal( def );
 		} );
-	} );
-
-	describe( 'render', () => {
-		it( 'throws when wrong template definition', () => {
-			expect( () => {
-				new Template( {} ).render();
-			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
-
-			expect( () => {
-				new Template( {
-					tag: 'p',
-					text: 'foo'
-				} ).render();
-			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
-		} );
 
 		it( 'normalizes template definition', () => {
 			const bind = Template.bind( new Model( {} ), extend( {}, DOMEmitterMixin ) );
-
 			const tpl = new Template( {
 				tag: 'p',
 				attributes: {
@@ -80,8 +64,6 @@ describe( 'Template', () => {
 				}
 			} );
 
-			tpl.render();
-
 			const def = tpl.definition;
 
 			expect( def.attributes.a[ 0 ] ).to.equal( 'foo' );
@@ -100,6 +82,21 @@ describe( 'Template', () => {
 			expect( def.on[ 'c@span' ][ 0 ].type ).to.be.a( 'symbol' );
 			expect( def.on[ 'c@span' ][ 1 ].type ).to.be.a( 'symbol' );
 		} );
+	} );
+
+	describe( 'render', () => {
+		it( 'throws when wrong template definition', () => {
+			expect( () => {
+				new Template( {} ).render();
+			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
+
+			expect( () => {
+				new Template( {
+					tag: 'p',
+					text: 'foo'
+				} ).render();
+			} ).to.throw( CKEditorError, /ui-template-wrong-syntax/ );
+		} );
 
 		it( 'creates a HTMLElement', () => {
 			const el = new Template( {
@@ -233,11 +230,17 @@ describe( 'Template', () => {
 		it( 'creates multiple child Text Nodes', () => {
 			const el = new Template( {
 				tag: 'p',
-				children: [ 'a', 'b', { text: 'c' }, 'd' ]
+				children: [
+					'a',
+					'b',
+					{ text: 'c' },
+					'd',
+					{ text: [ 'e', 'f' ] }
+				]
 			} ).render();
 
-			expect( el.childNodes ).to.have.length( 4 );
-			expect( el.outerHTML ).to.be.equal( '<p>abcd</p>' );
+			expect( el.childNodes ).to.have.length( 5 );
+			expect( el.outerHTML ).to.be.equal( '<p>abcdef</p>' );
 		} );
 
 		it( 'activates model bindings – root', () => {
@@ -274,17 +277,20 @@ describe( 'Template', () => {
 						tag: 'span',
 						children: [
 							{
-								text: bind.to( 'foo' )
+								text: [
+									bind.to( 'foo' ),
+									'static'
+								]
 							}
 						]
 					}
 				]
 			} ).render();
 
-			expect( el.firstChild.textContent ).to.equal( 'bar' );
+			expect( el.firstChild.textContent ).to.equal( 'bar static' );
 
 			observable.foo = 'baz';
-			expect( el.firstChild.textContent ).to.equal( 'baz' );
+			expect( el.firstChild.textContent ).to.equal( 'baz static' );
 		} );
 	} );
 
@@ -1080,6 +1086,611 @@ describe( 'Template', () => {
 			} );
 		} );
 	} );
+
+	describe( 'extend', () => {
+		let observable, emitter, bind;
+
+		beforeEach( () => {
+			observable = new Model( {
+				foo: 'bar',
+				baz: 'qux'
+			} );
+
+			emitter = extend( {}, DOMEmitterMixin );
+			bind = Template.bind( observable, emitter );
+		} );
+
+		describe( 'attributes', () => {
+			it( 'extends existing - simple', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							a: 'c'
+						}
+					},
+					'<p a="b c"></p>'
+				);
+			} );
+
+			it( 'extends existing - complex #1', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: [ 'b', 'c' ]
+						}
+					},
+					{
+						attributes: {
+							a: 'd'
+						}
+					},
+					'<p a="b c d"></p>'
+				);
+			} );
+
+			it( 'extends existing - complex #2', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: {
+								value: 'b'
+							}
+						}
+					},
+					{
+						attributes: {
+							a: [ 'c', 'd' ]
+						}
+					},
+					'<p a="b c d"></p>'
+				);
+			} );
+
+			it( 'extends existing - complex #3', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: [ 'b' ]
+						}
+					},
+					{
+						attributes: {
+							a: [ 'c', 'd' ]
+						}
+					},
+					'<p a="b c d"></p>'
+				);
+			} );
+
+			it( 'extends existing - bindings #1', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: bind.to( 'foo' )
+						}
+					},
+					{
+						attributes: {
+							a: [ 'c', 'd' ]
+						}
+					},
+					'<p a="bar c d"></p>'
+				);
+
+				observable.foo = 'baz';
+
+				expect( el.outerHTML ).to.equal( '<p a="baz c d"></p>' );
+			} );
+
+			it( 'extends existing - bindings #2', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: [ 'b', bind.to( 'foo' ) ]
+						}
+					},
+					{
+						attributes: {
+							a: [ 'c', bind.to( 'baz' ) ]
+						}
+					},
+					'<p a="b bar c qux"></p>'
+				);
+
+				observable.foo = 'abc';
+				observable.baz = 'def';
+
+				expect( el.outerHTML ).to.equal( '<p a="b abc c def"></p>' );
+			} );
+
+			it( 'creates new - simple', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							c: 'd'
+						}
+					},
+					'<p a="b" c="d"></p>'
+				);
+			} );
+
+			it( 'creates new - array', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							c: [ 'd', 'e' ]
+						}
+					},
+					'<p a="b" c="d e"></p>'
+				);
+			} );
+
+			it( 'creates new - bindings #1', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							c: bind.to( 'foo' )
+						}
+					},
+					'<p a="b" c="bar"></p>'
+				);
+
+				observable.foo = 'abc';
+
+				expect( el.outerHTML ).to.equal( '<p a="b" c="abc"></p>' );
+			} );
+
+			it( 'creates new - bindings #2', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						attributes: {
+							a: 'b'
+						}
+					},
+					{
+						attributes: {
+							c: [ 'd', bind.to( 'foo' ) ]
+						}
+					},
+					'<p a="b" c="d bar"></p>'
+				);
+
+				observable.foo = 'abc';
+
+				expect( el.outerHTML ).to.equal( '<p a="b" c="d abc"></p>' );
+			} );
+		} );
+
+		describe( 'text', () => {
+			it( 'extends existing - simple', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							'foo'
+						]
+					},
+					{
+						children: [
+							'bar'
+						]
+					},
+					'<p>foobar</p>'
+				);
+
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - complex #1', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{ text: 'foo' }
+						]
+					},
+					{
+						children: [
+							'bar'
+						]
+					},
+					'<p>foobar</p>'
+				);
+
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - complex #2', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{ text: 'foo' }
+						]
+					},
+					{
+						children: [
+							{ text: 'bar' }
+						]
+					},
+					'<p>foobar</p>'
+				);
+
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - bindings #1', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{ text: bind.to( 'foo' ) }
+						]
+					},
+					{
+						children: [
+							'abc'
+						]
+					},
+					'<p>bar abc</p>'
+				);
+
+				observable.foo = 'asd';
+
+				expect( el.outerHTML ).to.equal( '<p>asd abc</p>' );
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - bindings #2', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							'abc'
+						]
+					},
+					{
+						children: [
+							{ text: bind.to( 'foo' ) }
+						]
+					},
+					'<p>abc bar</p>'
+				);
+
+				observable.foo = 'asd';
+
+				expect( el.outerHTML ).to.equal( '<p>abc asd</p>' );
+				expect( el.childNodes ).to.have.length( 1 );
+			} );
+
+			it( 'extends existing - bindings #3', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{ text: bind.to( 'foo' ) },
+							'X'
+						]
+					},
+					{
+						children: [
+							{ text: bind.to( 'baz' ) },
+							'Y'
+						]
+					},
+					'<p>bar quxXY</p>'
+				);
+
+				observable.foo = 'A';
+				observable.baz = 'B';
+
+				expect( el.outerHTML ).to.equal( '<p>A BXY</p>' );
+				expect( el.childNodes ).to.have.length( 2 );
+			} );
+
+			it( 'appends new - simple', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							'foo'
+						]
+					},
+					{
+						children: [
+							'',
+							'bar'
+						]
+					},
+					'<p>foobar</p>'
+				);
+
+				expect( el.childNodes ).to.have.length( 2 );
+			} );
+
+			it( 'appends new - complex', () => {
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{ text: 'foo' },
+							'bar'
+						]
+					},
+					{
+						children: [
+							'',
+							'',
+							'C'
+						]
+					},
+					'<p>foobarC</p>'
+				);
+
+				expect( el.childNodes ).to.have.length( 3 );
+			} );
+		} );
+
+		describe( 'children', () => {
+			it( 'extends existing - simple', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'foo'
+								}
+							}
+						]
+					},
+					{
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'bar'
+								}
+							}
+						]
+					},
+					'<p><span class="foo bar"></span></p>'
+				);
+			} );
+
+			it( 'extends existing - complex', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'A'
+								},
+								children: [
+									'A',
+									{
+										tag: 'span',
+										attributes: {
+											class: 'AA'
+										},
+										children: [
+											'AA'
+										]
+									}
+								]
+							}
+						]
+					},
+					{
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'B'
+								},
+								children: [
+									'B',
+									{
+										tag: 'span',
+										attributes: {
+											class: 'BB'
+										},
+										children: [
+											'BB'
+										]
+									}
+								]
+							}
+						]
+					},
+					'<p><span class="A B">AB<span class="AA BB">AABB</span></span></p>'
+				);
+			} );
+
+			it( 'appends new - element', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'foo'
+								}
+							}
+						]
+					},
+					{
+						children: [
+							{},
+							{
+								tag: 'span',
+								attributes: {
+									class: 'bar'
+								}
+							}
+						]
+					},
+					'<p><span class="foo"></span><span class="bar"></span></p>'
+				);
+			} );
+
+			it( 'appends new - text', () => {
+				extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span',
+								attributes: {
+									class: 'foo'
+								}
+							}
+						]
+					},
+					{
+						children: [
+							{},
+							'A'
+						]
+					},
+					'<p><span class="foo"></span>A</p>'
+				);
+			} );
+		} );
+
+		describe( 'listeners', () => {
+			it( 'extends existing', () => {
+				const spy1 = testUtils.sinon.spy();
+				const spy2 = testUtils.sinon.spy();
+				const spy3 = testUtils.sinon.spy();
+				const spy4 = testUtils.sinon.spy();
+				const spy5 = testUtils.sinon.spy();
+
+				observable.on( 'A', spy1 );
+				observable.on( 'C', spy2 );
+
+				observable.on( 'B', spy3 );
+				observable.on( 'D', spy4 );
+
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span'
+							}
+						],
+						on: {
+							click: bind.to( 'A' ),
+							'click@span': [
+								bind.to( 'B' ),
+								bind.to( spy5 )
+							]
+						}
+					},
+					{
+						on: {
+							click: bind.to( 'C' ),
+							'click@span': bind.to( 'D' )
+						}
+					},
+					'<p><span></span></p>'
+				);
+
+				dispatchEvent( el, 'click' );
+
+				expect( spy1.calledOnce ).to.be.true;
+				expect( spy2.calledOnce ).to.be.true;
+				expect( spy3.called ).to.be.false;
+				expect( spy4.called ).to.be.false;
+				expect( spy5.called ).to.be.false;
+
+				dispatchEvent( el.firstChild, 'click' );
+
+				expect( spy1.calledTwice ).to.be.true;
+				expect( spy2.calledTwice ).to.be.true;
+				expect( spy3.calledOnce ).to.be.true;
+				expect( spy4.calledOnce ).to.be.true;
+				expect( spy5.calledOnce ).to.be.true;
+			} );
+
+			it( 'creates new', () => {
+				const spy1 = testUtils.sinon.spy();
+				const spy2 = testUtils.sinon.spy();
+				const spy3 = testUtils.sinon.spy();
+
+				observable.on( 'A', spy1 );
+				observable.on( 'B', spy2 );
+
+				const el = extensionTest(
+					{
+						tag: 'p',
+						children: [
+							{
+								tag: 'span'
+							}
+						],
+					},
+					{
+						on: {
+							click: bind.to( 'A' ),
+							'click@span': [
+								bind.to( 'B' ),
+								bind.to( spy3 )
+							]
+						}
+					},
+					'<p><span></span></p>'
+				);
+
+				dispatchEvent( el, 'click' );
+
+				expect( spy1.calledOnce ).to.be.true;
+				expect( spy2.called ).to.be.false;
+				expect( spy3.called ).to.be.false;
+
+				dispatchEvent( el.firstChild, 'click' );
+
+				expect( spy1.calledTwice ).to.be.true;
+				expect( spy2.calledOnce ).to.be.true;
+				expect( spy3.calledOnce ).to.be.true;
+			} );
+		} );
+	} );
 } );
 
 function setElement( template ) {
@@ -1087,6 +1698,18 @@ function setElement( template ) {
 	document.body.appendChild( el );
 }
 
+function extensionTest( base, extension, expectedHtml ) {
+	const template = new Template( base );
+	template.extend( extension );
+	const el = template.render();
+
+	document.body.appendChild( el );
+
+	expect( el.outerHTML ).to.equal( expectedHtml );
+
+	return el;
+}
+
 function dispatchEvent( el, domEvtName ) {
 	if ( !el.parentNode ) {
 		throw new Error( 'To dispatch an event, element must be in DOM. Otherwise #target is null.' );