From c32dbad7478b2945e7fec98d5d440aaa9a0ac2a0 Mon Sep 17 00:00:00 2001 From: Julie Ralph Date: Fri, 15 May 2015 16:42:52 -0700 Subject: [PATCH] feat(tests): add TestComponentBuilder Adds a TestComponentBuilder for use in component level tests. For usage examples, see test_component_builder_spec Closes #1812 --- .../src/mock/template_resolver_mock.ts | 25 +- modules/angular2/src/test_lib/test_bed.ts | 4 +- .../src/test_lib/test_component_builder.ts | 343 +++++++++++++++++ .../angular2/src/test_lib/test_injector.ts | 2 + .../test/mock/template_resolver_mock_spec.js | 8 +- .../test_lib/test_component_builder_spec.ts | 360 ++++++++++++++++++ 6 files changed, 725 insertions(+), 17 deletions(-) create mode 100644 modules/angular2/src/test_lib/test_component_builder.ts create mode 100644 modules/angular2/test/test_lib/test_component_builder_spec.ts diff --git a/modules/angular2/src/mock/template_resolver_mock.ts b/modules/angular2/src/mock/template_resolver_mock.ts index c50b52a752..1217ab6d20 100644 --- a/modules/angular2/src/mock/template_resolver_mock.ts +++ b/modules/angular2/src/mock/template_resolver_mock.ts @@ -4,17 +4,18 @@ import {Type, isPresent, BaseException, stringify, isBlank} from 'angular2/src/f import {View} from 'angular2/src/core/annotations_impl/view'; import {TemplateResolver} from 'angular2/src/core/compiler/template_resolver'; + export class MockTemplateResolver extends TemplateResolver { - _templates: Map; + _views: Map; _inlineTemplates: Map; - _templateCache: Map; + _viewCache: Map; _directiveOverrides: Map>; constructor() { super(); - this._templates = MapWrapper.create(); + this._views = MapWrapper.create(); this._inlineTemplates = MapWrapper.create(); - this._templateCache = MapWrapper.create(); + this._viewCache = MapWrapper.create(); this._directiveOverrides = MapWrapper.create(); } @@ -26,7 +27,7 @@ export class MockTemplateResolver extends TemplateResolver { */ setView(component: Type, view: View): void { this._checkOverrideable(component); - MapWrapper.set(this._templates, component, view); + MapWrapper.set(this._views, component, view); } /** @@ -47,7 +48,7 @@ export class MockTemplateResolver extends TemplateResolver { * @param {Type} from * @param {Type} to */ - overrideTemplateDirective(component: Type, from: Type, to: Type): void { + overrideViewDirective(component: Type, from: Type, to: Type): void { this._checkOverrideable(component); var overrides = MapWrapper.get(this._directiveOverrides, component); @@ -62,20 +63,20 @@ export class MockTemplateResolver extends TemplateResolver { /** * Returns the {@link View} for a component: - * - Set the {@link View} to the overridden template when it exists or fallback to the default + * - Set the {@link View} to the overridden view when it exists or fallback to the default * `TemplateResolver`, * see `setView`. - * - Override the directives, see `overrideTemplateDirective`. + * - Override the directives, see `overrideViewDirective`. * - Override the @View definition, see `setInlineTemplate`. * * @param component * @returns {ViewDefinition} */ resolve(component: Type): View { - var view = MapWrapper.get(this._templateCache, component); + var view = MapWrapper.get(this._viewCache, component); if (isPresent(view)) return view; - view = MapWrapper.get(this._templates, component); + view = MapWrapper.get(this._views, component); if (isBlank(view)) { view = super.resolve(component); } @@ -106,7 +107,7 @@ export class MockTemplateResolver extends TemplateResolver { view = new View({template: inlineTemplate, templateUrl: null, directives: view.directives}); } - MapWrapper.set(this._templateCache, component, view); + MapWrapper.set(this._viewCache, component, view); return view; } @@ -119,7 +120,7 @@ export class MockTemplateResolver extends TemplateResolver { * @param {Type} component */ _checkOverrideable(component: Type): void { - var cached = MapWrapper.get(this._templateCache, component); + var cached = MapWrapper.get(this._viewCache, component); if (isPresent(cached)) { throw new BaseException( diff --git a/modules/angular2/src/test_lib/test_bed.ts b/modules/angular2/src/test_lib/test_bed.ts index 0f4312d354..e2c347f658 100644 --- a/modules/angular2/src/test_lib/test_bed.ts +++ b/modules/angular2/src/test_lib/test_bed.ts @@ -23,6 +23,7 @@ import {DOM} from 'angular2/src/dom/dom_adapter'; /** * @exportedAs angular2/test + * TODO(juliemr): Deprecate in favor of TestComponentBuilder */ @Injectable() export class TestBed { @@ -61,7 +62,7 @@ export class TestBed { * @param {Type} to */ overrideDirective(component: Type, from: Type, to: Type): void { - this._injector.get(TemplateResolver).overrideTemplateDirective(component, from, to); + this._injector.get(TemplateResolver).overrideViewDirective(component, from, to); } /** @@ -107,6 +108,7 @@ export class TestBed { /** * Proxy to `AppView` return by `createView` in {@link TestBed} which offers a high level API for * tests. + * TODO(juliemr): Deprecate in favor of TestElement */ export class ViewProxy { _componentRef: ComponentRef; diff --git a/modules/angular2/src/test_lib/test_component_builder.ts b/modules/angular2/src/test_lib/test_component_builder.ts new file mode 100644 index 0000000000..fe4e3c54b4 --- /dev/null +++ b/modules/angular2/src/test_lib/test_component_builder.ts @@ -0,0 +1,343 @@ +import {Injector, bind, Injectable} from 'angular2/di'; + +import {Type, isPresent, BaseException, isBlank} from 'angular2/src/facade/lang'; +import {Promise} from 'angular2/src/facade/async'; +import {List, ListWrapper, MapWrapper} from 'angular2/src/facade/collection'; + +import {View} from 'angular2/src/core/annotations_impl/view'; + +import {ElementInjector} from 'angular2/src/core/compiler/element_injector'; +import {TemplateResolver} from 'angular2/src/core/compiler/template_resolver'; +import {AppView} from 'angular2/src/core/compiler/view'; +import {internalView} from 'angular2/src/core/compiler/view_ref'; +import { + DynamicComponentLoader, + ComponentRef +} from 'angular2/src/core/compiler/dynamic_component_loader'; +import {ElementRef} from 'angular2/src/core/compiler/element_ref'; + +import {el} from './utils'; + +import {DOCUMENT_TOKEN} from 'angular2/src/render/dom/dom_renderer'; +import {DOM} from 'angular2/src/dom/dom_adapter'; + +import {resolveInternalDomView} from 'angular2/src/render/dom/view/view'; + +/** + * @exportedAs angular2/test + * + * A TestElement contains information from the Angular compiler about an + * element and provides access to the corresponding ElementInjector and + * underlying dom Element, as well as a way to query for children. + */ +export class TestElement { + _elementInjector: ElementInjector; + + constructor(private _parentView: AppView, private _boundElementIndex: number) { + this._elementInjector = this._parentView.elementInjectors[this._boundElementIndex]; + } + + static create(elementRef: ElementRef): TestElement { + return new TestElement(internalView(elementRef.parentView), elementRef.boundElementIndex); + } + + get componentInstance(): any { + if (!isPresent(this._elementInjector)) { + return null; + } + return this._elementInjector.getComponent(); + } + + get dynamicallyCreatedComponentInstance(): any { + if (!isPresent(this._elementInjector)) { + return null; + } + return this._elementInjector.getDynamicallyLoadedComponent(); + } + + get domElement(): any { + return resolveInternalDomView(this._parentView.render).boundElements[this._boundElementIndex]; + } + + getDirectiveInstance(directiveIndex: number): any { + return this._elementInjector.getDirectiveAtIndex(directiveIndex); + } + + /** + * Get child TestElements from within the Light DOM. + * + * @return {List} + */ + get children(): List { + var thisElementBinder = this._parentView.proto.elementBinders[this._boundElementIndex]; + + return this._getChildElements(this._parentView, thisElementBinder.index); + } + + /** + * Get the root TestElement children of a component. Returns an empty + * list if the current TestElement is not a component root. + * + * @return {List} + */ + get componentViewChildren(): List { + var shadowView = this._parentView.componentChildViews[this._boundElementIndex]; + + if (!isPresent(shadowView)) { + // The current test element is not a component. + return ListWrapper.create(); + } + + return this._getChildElements(shadowView, null); + } + + triggerEventHandler(eventName, eventObj): void { + this._parentView.triggerEventHandlers(eventName, eventObj, this._boundElementIndex); + } + + hasDirective(type: Type): boolean { + if (!isPresent(this._elementInjector)) { + return false; + } + return this._elementInjector.hasDirective(type); + } + + inject(type: Type): any { + if (!isPresent(this._elementInjector)) { + return null; + } + return this._elementInjector.get(type); + } + + /** + * Return the first descendant TestElememt matching the given predicate + * and scope. + * + * @param {Function: boolean} predicate + * @param {Scope} scope + * + * @return {TestElement} + */ + query(predicate: Function, scope = Scope.all): TestElement { + var results = this.queryAll(predicate, scope); + return results.length > 0 ? results[0] : null; + } + + /** + * Return descendant TestElememts matching the given predicate + * and scope. + * + * @param {Function: boolean} predicate + * @param {Scope} scope + * + * @return {List} + */ + queryAll(predicate: Function, scope = Scope.all): List { + var elementsInScope = scope(this); + + return ListWrapper.filter(elementsInScope, predicate); + } + + _getChildElements(view: AppView, parentBoundElementIndex: number): List { + var els = ListWrapper.create(); + var parentElementBinder = null; + if (isPresent(parentBoundElementIndex)) { + parentElementBinder = view.proto.elementBinders[parentBoundElementIndex]; + } + for (var i = 0; i < view.proto.elementBinders.length; ++i) { + var binder = view.proto.elementBinders[i]; + if (binder.parent == parentElementBinder) { + ListWrapper.push(els, new TestElement(view, i)); + + var views = view.viewContainers[i]; + if (isPresent(views)) { + ListWrapper.forEach(views.views, (nextView) => { + els = ListWrapper.concat(els, this._getChildElements(nextView, null)); + }); + } + } + } + return els; + } +} + +export function inspectElement(elementRef: ElementRef): TestElement { + return TestElement.create(elementRef); +} + +/** + * @exportedAs angular2/test + */ +export class RootTestComponent extends TestElement { + _componentRef: ComponentRef; + _componentParentView: AppView; + + constructor(componentRef: ComponentRef) { + super(internalView(componentRef.hostView), 0); + + this._componentParentView = internalView(componentRef.hostView); + this._componentRef = componentRef; + } + + detectChanges(): void { + this._componentParentView.changeDetector.detectChanges(); + this._componentParentView.changeDetector.checkNoChanges(); + } + + destroy(): void { this._componentRef.dispose(); } +} + +/** + * @exportedAs angular2/test + */ +export class Scope { + static all(testElement): List { + var scope = ListWrapper.create(); + ListWrapper.push(scope, testElement); + + ListWrapper.forEach(testElement.children, + (child) => { scope = ListWrapper.concat(scope, Scope.all(child)); }); + + ListWrapper.forEach(testElement.componentViewChildren, + (child) => { scope = ListWrapper.concat(scope, Scope.all(child)); }); + + return scope; + } + static light(testElement): List { + var scope = ListWrapper.create(); + ListWrapper.forEach(testElement.children, (child) => { + ListWrapper.push(scope, child); + scope = ListWrapper.concat(scope, Scope.light(child)); + }); + return scope; + } + + static view(testElement): List { + var scope = ListWrapper.create(); + + ListWrapper.forEach(testElement.componentViewChildren, (child) => { + ListWrapper.push(scope, child); + scope = ListWrapper.concat(scope, Scope.light(child)); + }); + return scope; + } +} + +/** + * @exportedAs angular2/test + */ +export class By { + static all(): Function { return (testElement) => true; } + + static css(selector: string): Function { + return (testElement) => { return DOM.elementMatches(testElement.domElement, selector); }; + } + static directive(type: Type): Function { + return (testElement) => { return testElement.hasDirective(type); }; + } +} + +/** + * @exportedAs angular2/test + * + * Builds a RootTestComponent for use in component level tests. + */ +@Injectable() +export class TestComponentBuilder { + _injector: Injector; + _viewOverrides: Map; + _directiveOverrides: Map>; + _templateOverrides: Map; + + constructor(injector: Injector) { + this._injector = injector; + this._viewOverrides = MapWrapper.create(); + this._directiveOverrides = MapWrapper.create(); + this._templateOverrides = MapWrapper.create(); + } + + _clone(): TestComponentBuilder { + var clone = new TestComponentBuilder(this._injector); + clone._viewOverrides = MapWrapper.clone(this._viewOverrides); + clone._directiveOverrides = MapWrapper.clone(this._directiveOverrides); + clone._templateOverrides = MapWrapper.clone(this._templateOverrides); + return clone; + } + + /** + * Overrides only the html of a {@link Component}. + * All the other propoerties of the component's {@link View} are preserved. + * + * @param {Type} component + * @param {string} html + * + * @return {TestComponentBuilder} + */ + overrideTemplate(componentType: Type, template: string): TestComponentBuilder { + var clone = this._clone(); + MapWrapper.set(clone._templateOverrides, componentType, template); + return clone; + } + + /** + * Overrides a component's {@link View}. + * + * @param {Type} component + * @param {view} View + * + * @return {TestComponentBuilder} + */ + overrideView(componentType: Type, view: View): TestComponentBuilder { + var clone = this._clone(); + MapWrapper.set(clone._viewOverrides, componentType, view); + return clone; + } + + /** + * Overrides the directives from the component {@link View}. + * + * @param {Type} component + * @param {Type} from + * @param {Type} to + * + * @return {TestComponentBuilder} + */ + overrideDirective(componentType: Type, from: Type, to: Type): TestComponentBuilder { + var clone = this._clone(); + var overridesForComponent = MapWrapper.get(clone._directiveOverrides, componentType); + if (!isPresent(overridesForComponent)) { + MapWrapper.set(clone._directiveOverrides, componentType, MapWrapper.create()); + overridesForComponent = MapWrapper.get(clone._directiveOverrides, componentType); + } + MapWrapper.set(overridesForComponent, from, to); + return clone; + } + + /** + * Builds and returns a RootTestComponent. + * + * @return {Promise} + */ + createAsync(rootComponentType: Type): Promise { + var mockTemplateResolver = this._injector.get(TemplateResolver); + MapWrapper.forEach(this._viewOverrides, + (view, type) => { mockTemplateResolver.setView(type, view); }); + MapWrapper.forEach(this._templateOverrides, (template, type) => { + mockTemplateResolver.setInlineTemplate(type, template); + }); + MapWrapper.forEach(this._directiveOverrides, (overrides, component) => { + MapWrapper.forEach(overrides, (to, from) => { + mockTemplateResolver.overrideViewDirective(component, from, to); + }); + }); + + var rootEl = el('
'); + var doc = this._injector.get(DOCUMENT_TOKEN); + + // TODO(juliemr): can/should this be optional? + DOM.appendChild(doc.body, rootEl); + return this._injector.get(DynamicComponentLoader) + .loadAsRoot(rootComponentType, '#root', this._injector) + .then((componentRef) => { return new RootTestComponent(componentRef); }); + } +} diff --git a/modules/angular2/src/test_lib/test_injector.ts b/modules/angular2/src/test_lib/test_injector.ts index 18b971849b..516be0de2c 100644 --- a/modules/angular2/src/test_lib/test_injector.ts +++ b/modules/angular2/src/test_lib/test_injector.ts @@ -35,6 +35,7 @@ import {MockXHR} from 'angular2/src/mock/xhr_mock'; import {MockNgZone} from 'angular2/src/mock/ng_zone_mock'; import {TestBed} from './test_bed'; +import {TestComponentBuilder} from './test_component_builder'; import {Injector} from 'angular2/di'; @@ -113,6 +114,7 @@ function _getAppBindings() { StyleUrlResolver, StyleInliner, TestBed, + TestComponentBuilder, bind(NgZone).toClass(MockNgZone), bind(EventManager) .toFactory((zone) => diff --git a/modules/angular2/test/mock/template_resolver_mock_spec.js b/modules/angular2/test/mock/template_resolver_mock_spec.js index bea43553b7..69b177c905 100644 --- a/modules/angular2/test/mock/template_resolver_mock_spec.js +++ b/modules/angular2/test/mock/template_resolver_mock_spec.js @@ -72,7 +72,7 @@ export function main() { describe('Directive overriding', () => { it('should allow overriding a directive from the default template', () => { - resolver.overrideTemplateDirective(SomeComponent, SomeDirective, SomeOtherDirective); + resolver.overrideViewDirective(SomeComponent, SomeDirective, SomeOtherDirective); var template = resolver.resolve(SomeComponent); expect(template.directives.length).toEqual(1); expect(template.directives[0]).toBe(SomeOtherDirective); @@ -80,14 +80,14 @@ export function main() { it('should allow overriding a directive from an overriden @View', () => { resolver.setView(SomeComponent, new View({directives: [SomeOtherDirective]})); - resolver.overrideTemplateDirective(SomeComponent, SomeOtherDirective, SomeComponent); + resolver.overrideViewDirective(SomeComponent, SomeOtherDirective, SomeComponent); var template = resolver.resolve(SomeComponent); expect(template.directives.length).toEqual(1); expect(template.directives[0]).toBe(SomeComponent); }); it('should throw when the overridden directive is not present', () => { - resolver.overrideTemplateDirective(SomeComponent, SomeOtherDirective, SomeDirective); + resolver.overrideViewDirective(SomeComponent, SomeOtherDirective, SomeDirective); expect(() => { resolver.resolve(SomeComponent); }) .toThrowError('Overriden directive SomeOtherDirective not found in the template of SomeComponent'); }); @@ -95,7 +95,7 @@ export function main() { it('should not allow overriding a directive after its template has been resolved', () => { resolver.resolve(SomeComponent); expect(() => { - resolver.overrideTemplateDirective(SomeComponent, SomeDirective, SomeOtherDirective); + resolver.overrideViewDirective(SomeComponent, SomeDirective, SomeOtherDirective); }).toThrowError('The component SomeComponent has already been compiled, its configuration can not be changed'); }); }); diff --git a/modules/angular2/test/test_lib/test_component_builder_spec.ts b/modules/angular2/test/test_lib/test_component_builder_spec.ts new file mode 100644 index 0000000000..af06130bbf --- /dev/null +++ b/modules/angular2/test/test_lib/test_component_builder_spec.ts @@ -0,0 +1,360 @@ +import { + AsyncTestCompleter, + beforeEach, + ddescribe, + xdescribe, + describe, + dispatchEvent, + expect, + iit, + inject, + IS_DARTIUM, + beforeEachBindings, + it, + xit +} from 'angular2/test_lib'; + +import {TestComponentBuilder, By, Scope} from 'angular2/src/test_lib/test_component_builder'; + +import {DOM} from 'angular2/src/dom/dom_adapter'; + +import {List, ListWrapper} from 'angular2/src/facade/collection'; +import {PromiseWrapper, EventEmitter, ObservableWrapper} from 'angular2/src/facade/async'; + +import {Injectable} from 'angular2/di'; + +import { + Directive, + Component, + View, +} from 'angular2/annotations'; + +import * as viewAnn from 'angular2/src/core/annotations_impl/view'; + +import {NgIf} from 'angular2/src/directives/ng_if'; +import {NgFor} from 'angular2/src/directives/ng_for'; + +@Injectable() +class Logger { + log: List; + + constructor() { this.log = ListWrapper.create(); } + + add(thing: string) { ListWrapper.push(this.log, thing); } +} + +@Directive({selector: '[message]', properties: {'message': 'message'}}) +@Injectable() +class MessageDir { + logger: Logger; + + constructor(logger: Logger) { this.logger = logger; } + + set message(newMessage) { this.logger.add(newMessage); } +} + +@Component({selector: 'child-comp'}) +@View({ + template: `
+ Child +
+ {{childBinding}}`, + directives: [MessageDir] +}) +@Injectable() +class ChildComp { + childBinding: string; + + constructor() { this.childBinding = 'Original'; } +} + +@Component({selector: 'child-comp'}) +@View({template: `Mock`}) +@Injectable() +class MockChildComp { +} + +@Component({selector: 'parent-comp', appInjector: [Logger]}) +@View({ + template: `
+ Parent +
+ {{parentBinding}} + `, + directives: [ChildComp, MessageDir] +}) +@Injectable() +class ParentComp { + parentBinding: string; + constructor() { this.parentBinding = 'OriginalParent'; } +} + +@Component({selector: 'my-if-comp'}) +@View({template: `More`, directives: [NgIf]}) +@Injectable() +class MyIfComp { + showMore: boolean = false; +} + +@Directive({selector: 'custom-emitter', events: ['myevent']}) +@Injectable() +class CustomEmitter { + myevent: EventEmitter; + + constructor() { this.myevent = new EventEmitter(); } +} + +@Component({selector: 'events-comp'}) +@View({ + template: ` + `, + directives: [CustomEmitter] +}) +@Injectable() +class EventsComp { + clicked: boolean; + customed: boolean; + + constructor() { + this.clicked = false; + this.customed = false; + } + + handleClick() { this.clicked = true; } + + handleCustom() { this.customed = true; } +} + +@Component({selector: 'using-for', appInjector: [Logger]}) +@View({ + template: `{{thing}} +
    +
  • {{item}}
  • +
`, + directives: [NgFor, MessageDir] +}) +@Injectable() +class UsingFor { + stuff: List; + + constructor() { this.stuff = ['one', 'two', 'three']; } +} +export function main() { + describe('test component builder', function() { + it('should instantiate a component with valid DOM', + inject([TestComponentBuilder, AsyncTestCompleter], (tcb, async) => { + + tcb.createAsync(MockChildComp) + .then((rootTestComponent) => { + var childSpans = DOM.querySelectorAll(rootTestComponent.domElement, 'span'); + expect(DOM.getInnerHTML(childSpans[0])).toEqual('Mock'); + async.done(); + }); + })); + + it('should allow changing members of the component', + inject([TestComponentBuilder, AsyncTestCompleter], (tcb, async) => { + + tcb.createAsync(MyIfComp).then((rootTestComponent) => { + rootTestComponent.detectChanges(); + var childSpans = DOM.querySelectorAll(rootTestComponent.domElement, 'span'); + expect(childSpans.length).toEqual(0); + + rootTestComponent.componentInstance.showMore = true; + rootTestComponent.detectChanges(); + childSpans = DOM.querySelectorAll(rootTestComponent.domElement, 'span'); + expect(childSpans.length).toEqual(1); + + async.done(); + }); + })); + + it('should list component child elements', + inject([TestComponentBuilder, AsyncTestCompleter], (tcb, async) => { + + tcb.createAsync(ParentComp) + .then((rootTestComponent) => { + rootTestComponent.detectChanges(); + + var childEls = rootTestComponent.children; + // The root is a lone component, and has no children in the light dom. + expect(childEls.length).toEqual(0); + + var rootCompChildren = rootTestComponent.componentViewChildren; + // The root component has 3 elements in its shadow view. + expect(rootCompChildren.length).toEqual(3); + expect(DOM.hasClass(rootCompChildren[0].domElement, 'parent')).toBe(true); + expect(DOM.hasClass(rootCompChildren[1].domElement, 'parent')).toBe(true); + expect(DOM.hasClass(rootCompChildren[2].domElement, 'child-comp-class')).toBe(true); + + var nested = rootCompChildren[0].children; + expect(nested.length).toEqual(1); + expect(DOM.hasClass(nested[0].domElement, 'parentnested')).toBe(true); + + var childComponent = rootCompChildren[2]; + expect(childComponent.children.length).toEqual(0); + + var childCompChildren = childComponent.componentViewChildren; + expect(childCompChildren.length).toEqual(2); + expect(DOM.hasClass(childCompChildren[0].domElement, 'child')).toBe(true); + expect(DOM.hasClass(childCompChildren[1].domElement, 'child')).toBe(true); + + var childNested = childCompChildren[0].children; + expect(childNested.length).toEqual(1); + expect(DOM.hasClass(childNested[0].domElement, 'childnested')).toBe(true); + + async.done(); + }); + })); + + it('should list child elements within viewports', + inject([TestComponentBuilder, AsyncTestCompleter], (tcb, async) => { + + tcb.createAsync(UsingFor).then((rootTestComponent) => { + rootTestComponent.detectChanges(); + + var childEls = rootTestComponent.componentViewChildren; + // TODO should this count include the