diff --git a/CHANGELOG.md b/CHANGELOG.md index 5094a4d1ec..252075908d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ -## [11.2.2](https://github.com/ike18t/ng-mocks/compare/v11.2.1...v11.2.2) (2020-12-05) +## [11.2.3](https://github.com/ike18t/ng-mocks/compare/v11.2.2...v11.2.3) (2020-12-10) -### Performance Improvements +### Bug Fixes -* build with artifacts ([a1fc37f](https://github.com/ike18t/ng-mocks/commit/a1fc37fef8bafcc54208c2a994410c68d54b71bb)) +* **#246:** auto spy covers control value accessor too ([5c5b003](https://github.com/ike18t/ng-mocks/commit/5c5b003312b08664909f41b478806f02ca5051ee)), closes [#246](https://github.com/ike18t/ng-mocks/issues/246) [#246](https://github.com/ike18t/ng-mocks/issues/246) +* **#248:** handling null and undefined in declarations ([13b9e4e](https://github.com/ike18t/ng-mocks/commit/13b9e4e36f500ca4de511cb125321a3d918ae5e8)), closes [#248](https://github.com/ike18t/ng-mocks/issues/248) [#248](https://github.com/ike18t/ng-mocks/issues/248) +* correct overriding order for pipes ([750153d](https://github.com/ike18t/ng-mocks/commit/750153da57d3ad727439356daf3b86e27c7e8937)) -## [11.2.1](https://github.com/ike18t/ng-mocks/compare/v11.2.0...v11.2.1) (2020-12-05) +## [11.2.2](https://github.com/ike18t/ng-mocks/compare/v11.2.0...v11.2.2) (2020-12-05) ### Bug Fixes diff --git a/README.md b/README.md index 2b77a51b2e..572335eb9d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [services](#how-to-create-a-mock-provider) and [modules](#how-to-create-a-mock-module) in tests for Angular 5+ applications. -When you need a [mock child component](#how-to-create-a-mock-component), +When you have [a noisy child component](#how-to-create-a-mock-component), or any other [annoying dependency](#how-to-turn-annoying-declarations-into-mocks-in-an-angular-application), `ng-mocks` has tools to turn these declarations into their mocks, keeping interfaces as they are, but suppressing their implementation. @@ -269,6 +269,8 @@ Profit. [like](https://github.com/ike18t/ng-mocks), [share](https://twitter.com/intent/tweet?text=Check+ng-mocks+package&url=https%3A%2F%2Fgithub.com%2Fike18t%2Fng-mocks)! +Have a question still? Don't hesitate to [contact us](#find-an-issue-or-have-a-question-or-a-request). + [to the top](#table-of-contents). Below more detailed documentation begins, please bear with us. @@ -313,6 +315,9 @@ It covers everything you need to turn a component into its mock declaration. - `MockComponent( MyComponent )` - returns a mock class of `MyComponent` component. - `MockComponents( MyComponent1, SomeComponent2, ... )` - returns an array of mocks. +> **NOTE**: Information about [form control and their mocks](#how-to-create-a-mock-form-control) +> is in a different section. + **A mock component** respects the interface of its original component as a type of `MockedComponent` and provides: @@ -322,7 +327,7 @@ a type of `MockedComponent` and provides: - supports `@ContentChild` with an `$implicit` context - `__render('id', $implicit, variables)` - renders a template - `__hide('id')` - hides a rendered template -- supports `FormsModule`, `ReactiveFormsModule` and `ControlValueAccessor` +- supports [`FormsModule`, `ReactiveFormsModule` and `ControlValueAccessor`](#how-to-create-a-mock-form-control) - `__simulateChange()` - calls `onChanged` on the mock component bound to a `FormControl` - `__simulateTouch()` - calls `onTouched` on the mock component bound to a `FormControl` - supports `exportAs` @@ -503,6 +508,9 @@ It turns a directive into its mock declaration. - `MockDirective( MyDirective )` - returns a mock class of `MyDirective` directive. - `MockDirectives( MyDirective1, MyDirective2, ... )` - returns an array of mocks. +> **NOTE**: Information about [form control and their mocks](#how-to-create-a-mock-form-control) +> is in a different section. + **a mock directive** respects the interface of its original directive as a type of `MockedDirective` and provides: @@ -510,7 +518,7 @@ a type of `MockedDirective` and provides: - the same `Inputs` and `Outputs` with alias support - supports structural directives - `__render($implicit, variables)` - renders content -- supports `FormsModule`, `ReactiveFormsModule` and `ControlValueAccessor` +- supports [`FormsModule`, `ReactiveFormsModule` and `ControlValueAccessor`](#how-to-create-a-mock-form-control) - `__simulateChange()` - calls `onChanged` on the mock component bound to a `FormControl` - `__simulateTouch()` - calls `onTouched` on the mock component bound to a `FormControl` - supports `exportAs` @@ -744,8 +752,12 @@ and call [`MockRender`](#mockrender): ```typescript describe('Test', () => { - // Do not forget to return the promise of MockBuilder. - beforeEach(() => MockBuilder(TargetComponent).mock(DependencyPipe)); + beforeEach(() => { + return MockBuilder(TargetComponent).mock( + DependencyPipe, + value => `mock:${value}`, + ); + }); it('should create', () => { const fixture = MockRender(TargetComponent); @@ -1301,14 +1313,35 @@ describe('MockObservable', () => { ### How to create a mock form control -`ng-mocks` respects `ControlValueAccessor` interface if a directive, or a component implements it. +`ng-mocks` respects `ControlValueAccessor` interface if [a directive](#how-to-create-a-mock-directive), +or [a component](#how-to-create-a-mock-component) implements it. Apart from that, `ng-mocks` provides helper functions to emit changes and touches. -A mock object of `ControlValueAccessor` provides: +it supports both `FormsModule` and `ReactiveFormsModule`: -- `__simulateChange()` - calls `onChanged` on the mock component bound to a `FormControl` +- `ngModel` +- `ngModelChange` +- `formControl` +- `NG_VALUE_ACCESSOR` +- `ControlValueAccessor` +- `NG_VALIDATORS` +- `Validator` +- `NG_ASYNC_VALIDATORS` +- `AsyncValidator` + +A mock object of `ControlValueAccessor` additionally implements `MockControlValueAccessor` and provides: + +- `__simulateChange(value: any)` - calls `onChanged` on the mock component bound to a `FormControl` - `__simulateTouch()` - calls `onTouched` on the mock component bound to a `FormControl` +* [`isMockControlValueAccessor(instance)`](#ismockcontrolvalueaccessor) - to verify `MockControlValueAccessor` + +A mock object of `Validator` or `AsyncValidator` additionally implements `MockValidator` and provides: + +- `__simulateValidatorChange()` - calls `updateValueAndValidity` on the mock component bound to a `FormControl` + +* [`isMockValidator(instance)`](#ismockvalidator) - to verify `MockValidator` +
Click to see a usage example of a mock FormControl with ReactiveForms in Angular tests

@@ -1320,6 +1353,24 @@ to play with. ```typescript describe('MockReactiveForms', () => { + // That's our spy on writeValue calls. + // With auto spy this code isn't needed. + const writeValue = jasmine.createSpy('writeValue'); + // in case of jest + // const writeValue = jest.fn(); + + // Because of early calls of writeValue, we need to install + // the spy in the ctor call. + beforeAll(() => + MockInstance(DependencyComponent, () => ({ + writeValue, + })), + ); + + // To avoid influence in other tests + // we need to reset MockInstance effects. + afterAll(MockReset); + beforeEach(() => { return MockBuilder(TestedComponent) .mock(DependencyComponent) @@ -1334,17 +1385,20 @@ describe('MockReactiveForms', () => { const mockControl = ngMocks.find(DependencyComponent) .componentInstance; + // During initialization it should be called + // with null. + expect(writeValue).toHaveBeenCalledWith(null); + // Let's simulate its change, like a user does it. - if (isMockOf(mockControl, DependencyComponent, 'c')) { + if (isMockControlValueAccessor(mockControl)) { mockControl.__simulateChange('foo'); } expect(component.formControl.value).toBe('foo'); // Let's check that change on existing formControl // causes calls of `writeValue` on the mock component. - spyOn(mockControl, 'writeValue'); component.formControl.setValue('bar'); - expect(mockControl.writeValue).toHaveBeenCalledWith('bar'); + expect(writeValue).toHaveBeenCalledWith('bar'); }); }); ``` @@ -1363,6 +1417,24 @@ to play with. ```typescript describe('MockForms', () => { + // That's our spy on writeValue calls. + // With auto spy this code isn't needed. + const writeValue = jasmine.createSpy('writeValue'); + // in case of jest + // const writeValue = jest.fn(); + + // Because of early calls of writeValue, we need to install + // the spy in the ctor call. + beforeAll(() => + MockInstance(DependencyComponent, () => ({ + writeValue, + })), + ); + + // To avoid influence in other tests + // we need to reset MockInstance effects. + afterAll(MockReset); + beforeEach(() => { return MockBuilder(TestedComponent) .mock(DependencyComponent) @@ -1377,8 +1449,12 @@ describe('MockForms', () => { const mockControl = ngMocks.find(DependencyComponent) .componentInstance; + // During initialization it should be called + // with null. + expect(writeValue).toHaveBeenCalledWith(null); + // Let's simulate its change, like a user does it. - if (isMockOf(mockControl, DependencyComponent, 'c')) { + if (isMockControlValueAccessor(mockControl)) { mockControl.__simulateChange('foo'); fixture.detectChanges(); await fixture.whenStable(); @@ -1387,11 +1463,10 @@ describe('MockForms', () => { // Let's check that change on existing value // causes calls of `writeValue` on the mock component. - spyOn(mockControl, 'writeValue'); component.value = 'bar'; fixture.detectChanges(); await fixture.whenStable(); - expect(mockControl.writeValue).toHaveBeenCalledWith('bar'); + expect(writeValue).toHaveBeenCalledWith('bar'); }); }); ``` @@ -2633,6 +2708,8 @@ ngMocks.stub(instance, { For example, they are useful in situations when we want to render `ChildContent` of a mock component, or to touch a mock form control. +- [isMockControlValueAccessor](#ismockcontrolvalueaccessor) +- [isMockValidator](#ismockvalidator) - [isMockOf](#ismockof) - [isMockedNgDefOf](#ismockedngdefof) - [getMockedNgDefOf](#getmockedngdefof) @@ -2640,20 +2717,68 @@ or to touch a mock form control. - [getSourceOfMock](#getsourceofmock) - [isNgInjectionToken](#isnginjectiontoken) +#### isMockControlValueAccessor + +This function helps when you need to access callbacks +which were set via `registerOnChange` and `registerOnTouched` +on a mock object that implements `ControlValueAccessor`, +and to call `__simulateChange`, `__simulateTouch` to trigger them. +It verifies whether an instance respects `MockControlValueAccessor` interface. + +You need it when you get an error like: + +- `Property '__simulateChange' does not exist on type ...` +- `Property '__simulateTouch' does not exist on type ...` + +```typescript +const instance = ngMocks.findInstance(MyCustomFormControl); +// instance.__simulateChange('foo'); // doesn't work. +if (isMockControlValueAccessor(instance)) { + // now works + instance.__simulateChange('foo'); + instance.__simulateTouch(); +} +``` + +#### isMockValidator + +The function is useful when you need to access the callback +which was set via `registerOnValidatorChange` +on a mock object that implements `Validator` or `AsyncValidator`, +and to call `__simulateValidatorChange` to trigger it. +It verifies whether an instance respects `MockValidator` interface. + +You need it when you get an error like: + +- `Property '__simulateValidatorChange' does not exist on type ...` + +```typescript +const instance = ngMocks.findInstance(MyValidatorDirective); +// instance.simulateValidatorChange(); // doesn't work. +if (isMockValidator(instance)) { + // now works + instance.__simulateValidatorChange(); +} +``` + #### isMockOf -This function helps when we want to use `ng-mocks` tools for rendering or change simulation, +This function helps when we want to use `ng-mocks` tools for rendering, but typescript doesn't recognize `instance` as a mock object. -You need it when you get an error like: +You need this when you get an error like: -- Property '\_\_render' does not exist on type ... -- Property '\_\_simulateChange' does not exist on type ... +- `Property '__render' does not exist on type ...` +- `Property '__hide' does not exist on type ...` ```typescript -if (isMockOf(instance, SomeClass, 'c')) { - instance.__render('block'); - instance.__simulateChange(123); +if (isMockOf(instance, SomeComponent, 'c')) { + instance.__render('block', '$implicit'); + instance.__hide('block'); +} +if (isMockOf(instance, StructuralDirective, 'd')) { + instance.__render('$implicit'); + instance.__hide(); } ``` @@ -2751,7 +2876,10 @@ const createComponent = createComponentFactory({ }); ``` -Profit. Subscribe, like, share! +Profit. +[Subscribe](https://github.com/ike18t/ng-mocks), +[like](https://github.com/ike18t/ng-mocks), +[share](https://twitter.com/intent/tweet?text=Check+ng-mocks+package&url=https%3A%2F%2Fgithub.com%2Fike18t%2Fng-mocks)! [to the top](#table-of-contents) diff --git a/examples/MockForms/test.spec.ts b/examples/MockForms/test.spec.ts index 91b8ed5113..48da706d11 100644 --- a/examples/MockForms/test.spec.ts +++ b/examples/MockForms/test.spec.ts @@ -1,10 +1,19 @@ +// tslint:disable strict-type-predicates + import { Component, forwardRef } from '@angular/core'; import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, } from '@angular/forms'; -import { isMockOf, MockBuilder, MockRender, ngMocks } from 'ng-mocks'; +import { + isMockControlValueAccessor, + MockBuilder, + MockInstance, + MockRender, + MockReset, + ngMocks, +} from 'ng-mocks'; @Component({ providers: [ @@ -37,6 +46,27 @@ class TestedComponent { } describe('MockForms', () => { + // That's our spy on writeValue calls. + // With auto spy this code isn't needed. + const writeValue = + typeof jest === 'undefined' + ? jasmine.createSpy('writeValue') + : jest.fn(); + // in case of jest + // const writeValue = jest.fn(); + + // Because of early calls of writeValue, we need to install + // the spy in the ctor call. + beforeAll(() => + MockInstance(DependencyComponent, () => ({ + writeValue, + })), + ); + + // To avoid influence in other tests + // we need to reset MockInstance effects. + afterAll(MockReset); + beforeEach(() => { return MockBuilder(TestedComponent) .mock(DependencyComponent) @@ -51,8 +81,12 @@ describe('MockForms', () => { const mockControl = ngMocks.find(DependencyComponent) .componentInstance; + // During initialization it should be called + // with null. + expect(writeValue).toHaveBeenCalledWith(null); + // Let's simulate its change, like a user does it. - if (isMockOf(mockControl, DependencyComponent, 'c')) { + if (isMockControlValueAccessor(mockControl)) { mockControl.__simulateChange('foo'); fixture.detectChanges(); await fixture.whenStable(); @@ -61,10 +95,9 @@ describe('MockForms', () => { // Let's check that change on existing value // causes calls of `writeValue` on the mock component. - spyOn(mockControl, 'writeValue'); component.value = 'bar'; fixture.detectChanges(); await fixture.whenStable(); - expect(mockControl.writeValue).toHaveBeenCalledWith('bar'); + expect(writeValue).toHaveBeenCalledWith('bar'); }); }); diff --git a/examples/MockReactiveForms/test.spec.ts b/examples/MockReactiveForms/test.spec.ts index 06edeb32ce..7353f36880 100644 --- a/examples/MockReactiveForms/test.spec.ts +++ b/examples/MockReactiveForms/test.spec.ts @@ -1,3 +1,5 @@ +// tslint:disable strict-type-predicates + import { Component, forwardRef } from '@angular/core'; import { ControlValueAccessor, @@ -5,7 +7,14 @@ import { NG_VALUE_ACCESSOR, ReactiveFormsModule, } from '@angular/forms'; -import { isMockOf, MockBuilder, MockRender, ngMocks } from 'ng-mocks'; +import { + isMockControlValueAccessor, + MockBuilder, + MockInstance, + MockRender, + MockReset, + ngMocks, +} from 'ng-mocks'; @Component({ providers: [ @@ -33,6 +42,27 @@ class TestedComponent { } describe('MockReactiveForms', () => { + // That's our spy on writeValue calls. + // With auto spy this code isn't needed. + const writeValue = + typeof jest === 'undefined' + ? jasmine.createSpy('writeValue') + : jest.fn(); + // in case of jest + // const writeValue = jest.fn(); + + // Because of early calls of writeValue, we need to install + // the spy in the ctor call. + beforeAll(() => + MockInstance(DependencyComponent, () => ({ + writeValue, + })), + ); + + // To avoid influence in other tests + // we need to reset MockInstance effects. + afterAll(MockReset); + beforeEach(() => { return MockBuilder(TestedComponent) .mock(DependencyComponent) @@ -47,16 +77,19 @@ describe('MockReactiveForms', () => { const mockControl = ngMocks.find(DependencyComponent) .componentInstance; + // During initialization it should be called + // with null. + expect(writeValue).toHaveBeenCalledWith(null); + // Let's simulate its change, like a user does it. - if (isMockOf(mockControl, DependencyComponent, 'c')) { + if (isMockControlValueAccessor(mockControl)) { mockControl.__simulateChange('foo'); } expect(component.formControl.value).toBe('foo'); // Let's check that change on existing formControl // causes calls of `writeValue` on the mock component. - spyOn(mockControl, 'writeValue'); component.formControl.setValue('bar'); - expect(mockControl.writeValue).toHaveBeenCalledWith('bar'); + expect(writeValue).toHaveBeenCalledWith('bar'); }); }); diff --git a/index.ts b/index.ts index daf02aaa78..1660dd4134 100644 --- a/index.ts +++ b/index.ts @@ -3,14 +3,16 @@ export * from './lib/common/core.tokens'; export * from './lib/common/core.types'; export * from './lib/common/func.get-mocked-ng-def-of'; export * from './lib/common/func.get-source-of-mock'; +export * from './lib/common/func.is-mock-control-value-accessor'; export * from './lib/common/func.is-mock-of'; +export * from './lib/common/func.is-mock-validator'; export * from './lib/common/func.is-mocked-ng-def-of'; export * from './lib/common/func.is-ng-def'; export * from './lib/common/func.is-ng-injection-token'; export * from './lib/common/func.is-ng-module-def-with-providers'; export * from './lib/common/func.is-ng-type'; export { Mock } from './lib/common/mock'; -export * from './lib/common/mock-control-value-accessor'; +export { MockControlValueAccessor, MockValidator } from './lib/common/mock-control-value-accessor'; export * from './lib/mock-builder/mock-builder'; export * from './lib/mock-builder/types'; diff --git a/lib/common/func.get-provider.ts b/lib/common/func.get-provider.ts new file mode 100644 index 0000000000..6ca2d262fd --- /dev/null +++ b/lib/common/func.get-provider.ts @@ -0,0 +1,3 @@ +export default (provider: any): any => { + return provider && typeof provider === 'object' && provider.provide ? provider.provide : provider; +}; diff --git a/lib/common/func.is-mock-control-value-accessor.spec.ts b/lib/common/func.is-mock-control-value-accessor.spec.ts new file mode 100644 index 0000000000..fc71917b8c --- /dev/null +++ b/lib/common/func.is-mock-control-value-accessor.spec.ts @@ -0,0 +1,76 @@ +import { Component, Directive, Injector } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +import { MockComponent } from '../mock-component/mock-component'; +import { MockDirective } from '../mock-directive/mock-directive'; +import { ngMocks } from '../mock-helper/mock-helper'; +import { MockService } from '../mock-service/mock-service'; + +import { isMockControlValueAccessor } from './func.is-mock-control-value-accessor'; + +@Component({ + selector: 'target', + template: '', +}) +class TargetComponent { + public writeValue(obj: any) { + return obj; + } +} + +@Directive({ + selector: '[target]', +}) +class TargetDirective { + public writeValue(obj: any) { + return obj; + } +} + +describe('isMockControlValueAccessor', () => { + it('does not decorate components by default', () => { + const instanceReal = new TargetComponent(); + expect(isMockControlValueAccessor(instanceReal)).toEqual(false); + + const mockClass = MockComponent(TargetComponent); + const instanceDefault = new mockClass(); + expect(isMockControlValueAccessor(instanceDefault)).toEqual( + false, + ); + + const ngControl = {}; + const injector = MockService(Injector); + ngMocks.stub(injector, 'get'); + spyOn(injector, 'get') + .withArgs(NgControl, undefined, 0b1010) + .and.returnValue(ngControl); + + const instanceInjected = new mockClass(null, injector); + expect(isMockControlValueAccessor(instanceInjected)).toEqual( + true, + ); + }); + + it('does not decorate directives by default', () => { + const instanceReal = new TargetDirective(); + expect(isMockControlValueAccessor(instanceReal)).toEqual(false); + + const mockClass = MockDirective(TargetDirective); + const instanceDefault = new mockClass(); + expect(isMockControlValueAccessor(instanceDefault)).toEqual( + false, + ); + + const ngControl = {}; + const injector = MockService(Injector); + ngMocks.stub(injector, 'get'); + spyOn(injector, 'get') + .withArgs(NgControl, undefined, 0b1010) + .and.returnValue(ngControl); + + const instanceInjected = new mockClass(injector); + expect(isMockControlValueAccessor(instanceInjected)).toEqual( + true, + ); + }); +}); diff --git a/lib/common/func.is-mock-control-value-accessor.ts b/lib/common/func.is-mock-control-value-accessor.ts new file mode 100644 index 0000000000..25ae64b152 --- /dev/null +++ b/lib/common/func.is-mock-control-value-accessor.ts @@ -0,0 +1,13 @@ +import funcIsMock from './func.is-mock'; +import { MockControlValueAccessor } from './mock-control-value-accessor'; + +/** + * @see https://github.com/ike18t/ng-mocks#ismockcontrolvalueaccessor + */ +export const isMockControlValueAccessor = (value: T): value is T & MockControlValueAccessor => { + if (!funcIsMock(value)) { + return false; + } + + return !!value.__ngMocksConfig.isControlValueAccessor; +}; diff --git a/lib/common/func.is-mock-of.ts b/lib/common/func.is-mock-of.ts index 8fbec46851..d123b3203c 100644 --- a/lib/common/func.is-mock-of.ts +++ b/lib/common/func.is-mock-of.ts @@ -6,6 +6,7 @@ import { MockedModule } from '../mock-module/types'; import { MockedPipe } from '../mock-pipe/types'; import { Type } from './core.types'; +import funcIsMock from './func.is-mock'; import { isNgDef } from './func.is-ng-def'; /** @@ -49,8 +50,7 @@ export function isMockOf(instance: any, declaration: Type): instance is T; export function isMockOf(instance: any, declaration: Type, ngType?: any): instance is T { return ( - typeof instance === 'object' && - instance.__ngMocksMock && + funcIsMock(instance) && instance.constructor === declaration && (ngType ? isNgDef(instance.constructor, ngType) : isNgDef(instance.constructor)) ); diff --git a/lib/common/func.is-mock-validator.spec.ts b/lib/common/func.is-mock-validator.spec.ts new file mode 100644 index 0000000000..e47b1bce41 --- /dev/null +++ b/lib/common/func.is-mock-validator.spec.ts @@ -0,0 +1,105 @@ +import { + Component, + Directive, + forwardRef, + Injector, +} from '@angular/core'; +import { + AbstractControl, + AsyncValidator, + NgControl, + NG_ASYNC_VALIDATORS, + NG_VALIDATORS, + ValidationErrors, + Validator, +} from '@angular/forms'; + +import { MockComponent } from '../mock-component/mock-component'; +import { MockDirective } from '../mock-directive/mock-directive'; +import { ngMocks } from '../mock-helper/mock-helper'; +import { MockService } from '../mock-service/mock-service'; + +import { isMockValidator } from './func.is-mock-validator'; +import { + MockAsyncValidatorProxy, + MockValidatorProxy, +} from './mock-control-value-accessor-proxy'; + +@Component({ + providers: [ + { + provide: NG_VALIDATORS, + useClass: forwardRef(() => TargetComponent), + }, + ], + selector: 'target', + template: '', +}) +class TargetComponent implements Validator { + public validate(control: AbstractControl): ValidationErrors | null { + return control ? null : {}; + } +} + +@Directive({ + providers: [ + { + provide: NG_ASYNC_VALIDATORS, + useClass: forwardRef(() => TargetDirective), + }, + ], + selector: '[target]', +}) +class TargetDirective implements AsyncValidator { + public async validate( + control: AbstractControl, + ): Promise { + return Promise.resolve(control ? null : {}); + } +} + +describe('isMockValidator', () => { + it('does not decorate components by default', () => { + const instanceReal = new TargetComponent(); + expect(isMockValidator(instanceReal)).toEqual(false); + + const mockClass = MockComponent(TargetComponent); + const instanceDefault = new mockClass(); + expect(isMockValidator(instanceDefault)).toEqual(false); + + const ngControl = { + _rawValidators: [new MockValidatorProxy(mockClass)], + valueAccessor: {}, + }; + const injector = MockService(Injector); + ngMocks.stub(injector, 'get'); + spyOn(injector, 'get') + .withArgs(NgControl, undefined, 0b1010) + .and.returnValue(ngControl); + + const instanceInjected = new mockClass(null, injector); + expect(isMockValidator(instanceInjected)).toEqual(true); + }); + + it('does not decorate directives by default', () => { + const instanceReal = new TargetDirective(); + expect(isMockValidator(instanceReal)).toEqual(false); + + const mockClass = MockDirective(TargetDirective); + const instanceDefault = new mockClass(); + expect(isMockValidator(instanceDefault)).toEqual(false); + + const ngControl = { + _rawValidators: [new MockAsyncValidatorProxy(mockClass)], + valueAccessor: {}, + }; + const injector = MockService(Injector); + ngMocks.stub(injector, 'get'); + spyOn(injector, 'get') + .withArgs(NgControl, undefined, 0b1010) + .and.returnValue(ngControl); + + const instanceInjected = new mockClass(injector); + expect(isMockValidator(instanceInjected)).toEqual(true); + }); +}); diff --git a/lib/common/func.is-mock-validator.ts b/lib/common/func.is-mock-validator.ts new file mode 100644 index 0000000000..e0ee3703ff --- /dev/null +++ b/lib/common/func.is-mock-validator.ts @@ -0,0 +1,13 @@ +import funcIsMock from './func.is-mock'; +import { MockValidator } from './mock-control-value-accessor'; + +/** + * @see https://github.com/ike18t/ng-mocks#ismockvalidator + */ +export const isMockValidator = (value: T): value is T & MockValidator => { + if (!funcIsMock(value)) { + return false; + } + + return !!(value as any).__ngMocksConfig.isValidator; +}; diff --git a/lib/common/func.is-mock.ts b/lib/common/func.is-mock.ts new file mode 100644 index 0000000000..6fc5e835f8 --- /dev/null +++ b/lib/common/func.is-mock.ts @@ -0,0 +1,5 @@ +import { MockConfig } from './mock'; + +export default (value: T): value is T & MockConfig => { + return value && typeof value === 'object' && !!(value as any).__ngMocksConfig; +}; diff --git a/lib/common/func.is-ng-injection-token.ts b/lib/common/func.is-ng-injection-token.ts index da06868d27..62ccc7ae0b 100644 --- a/lib/common/func.is-ng-injection-token.ts +++ b/lib/common/func.is-ng-injection-token.ts @@ -6,4 +6,4 @@ import { InjectionToken } from '@angular/core'; * @see https://github.com/ike18t/ng-mocks#isnginjectiontoken */ export const isNgInjectionToken = (token: any): token is InjectionToken => - typeof token === 'object' && token.ngMetadataName === 'InjectionToken'; + token && typeof token === 'object' && token.ngMetadataName === 'InjectionToken'; diff --git a/lib/common/func.is-ng-module-def-with-providers.ts b/lib/common/func.is-ng-module-def-with-providers.ts index 52a66fbebd..db206ec32b 100644 --- a/lib/common/func.is-ng-module-def-with-providers.ts +++ b/lib/common/func.is-ng-module-def-with-providers.ts @@ -11,4 +11,7 @@ export interface NgModuleWithProviders { // Checks if an object implements ModuleWithProviders. export const isNgModuleDefWithProviders = (declaration: any): declaration is NgModuleWithProviders => - declaration.ngModule !== undefined && isNgDef(declaration.ngModule, 'm'); + declaration && + typeof declaration === 'object' && + declaration.ngModule !== undefined && + isNgDef(declaration.ngModule, 'm'); diff --git a/lib/common/mock-control-value-accessor-proxy.ts b/lib/common/mock-control-value-accessor-proxy.ts new file mode 100644 index 0000000000..60e788e50c --- /dev/null +++ b/lib/common/mock-control-value-accessor-proxy.ts @@ -0,0 +1,76 @@ +// tslint:disable variable-name ban-ts-ignore + +import { AsyncValidator, ControlValueAccessor, ValidationErrors, Validator } from '@angular/forms'; + +import { AnyType } from '../common/core.types'; + +import { MockControlValueAccessor, MockValidator } from './mock-control-value-accessor'; + +const applyProxy = (proxy: any, method: string, value: any, storage?: string) => { + if (proxy.instance && storage) { + proxy.instance[storage] = value; + } + if (proxy.instance && proxy.instance[method]) { + return proxy.instance[method](value); + } +}; + +export class MockControlValueAccessorProxy implements ControlValueAccessor { + public instance?: Partial; + + public constructor(public readonly target?: AnyType) {} + + public registerOnChange(fn: any): void { + applyProxy(this, 'registerOnChange', fn, '__simulateChange'); + } + + public registerOnTouched(fn: any): void { + applyProxy(this, 'registerOnTouched', fn, '__simulateTouch'); + } + + public setDisabledState(isDisabled: boolean): void { + applyProxy(this, 'setDisabledState', isDisabled); + } + + public writeValue(value: any): void { + applyProxy(this, 'writeValue', value); + } +} + +export class MockValidatorProxy implements Validator { + public instance?: Partial; + + public constructor(public readonly target?: AnyType) {} + + public registerOnValidatorChange(fn: any): void { + applyProxy(this, 'registerOnValidatorChange', fn, '__simulateValidatorChange'); + } + + public validate(control: any): ValidationErrors | null { + if (this.instance && this.instance.validate) { + return this.instance.validate(control); + } + + return null; + } +} + +export class MockAsyncValidatorProxy implements AsyncValidator { + public instance?: Partial; + + public constructor(public readonly target?: AnyType) {} + + public registerOnValidatorChange(fn: any): void { + applyProxy(this, 'registerOnValidatorChange', fn, '__simulateValidatorChange'); + } + + public validate(control: any): any { + if (this.instance && this.instance.validate) { + const result: any = this.instance.validate(control); + + return result === undefined ? Promise.resolve(null) : result; + } + + return Promise.resolve(null); + } +} diff --git a/lib/common/mock-control-value-accessor.ts b/lib/common/mock-control-value-accessor.ts index 140a4f1452..f0b2520c25 100644 --- a/lib/common/mock-control-value-accessor.ts +++ b/lib/common/mock-control-value-accessor.ts @@ -1,42 +1,63 @@ // tslint:disable variable-name ban-ts-ignore -import { AbstractControl, ControlValueAccessor, ValidationErrors, Validator } from '@angular/forms'; - import { Mock } from './mock'; -export class MockControlValueAccessor extends Mock implements ControlValueAccessor, Validator { - public readonly __ngMocksMockControlValueAccessor: true = true; - - // istanbul ignore next - // @ts-ignore - public __simulateChange = (value: any) => {}; - +/** + * @deprecated use isMockControlValueAccessor or isMockValidator instead + * @see https://github.com/ike18t/ng-mocks#ismockcontrolvalueaccessor + * @see https://github.com/ike18t/ng-mocks#ismockvalidator + */ +export class LegacyControlValueAccessor extends Mock { + /** + * @deprecated use isMockControlValueAccessor instead + * @see https://github.com/ike18t/ng-mocks#ismockcontrolvalueaccessor + */ + public __simulateChange(value: any): void; // istanbul ignore next - public __simulateTouch = () => {}; - - // istanbul ignore next - public __simulateValidatorChange = () => {}; - - public registerOnChange(fn: (value: any) => void): void { - this.__simulateChange = fn; + public __simulateChange() { + // nothing to do. } - public registerOnTouched(fn: () => void): void { - this.__simulateTouch = fn; + // istanbul ignore next + /** + * @deprecated use isMockControlValueAccessor instead + * @see https://github.com/ike18t/ng-mocks#ismockcontrolvalueaccessor + */ + public __simulateTouch() { + // nothing to do. } - public registerOnValidatorChange(fn: () => void): void { - this.__simulateValidatorChange = fn; + // istanbul ignore next + /** + * @deprecated use isMockValidator instead + * @see https://github.com/ike18t/ng-mocks#ismockvalidator + */ + public __simulateValidatorChange() { + // nothing to do. } +} - // @ts-ignore - public setDisabledState(isDisabled: boolean): void {} - - // @ts-ignore - public validate(control: AbstractControl): ValidationErrors | null { - return null; - } +/** + * @see https://github.com/ike18t/ng-mocks#ismockcontrolvalueaccessor + */ +export interface MockControlValueAccessor { + /** + * @see https://github.com/ike18t/ng-mocks#how-to-create-a-mock-form-control + */ + __simulateChange(value: any): void; + + /** + * @see https://github.com/ike18t/ng-mocks#how-to-create-a-mock-form-control + */ + __simulateTouch(): void; +} - // @ts-ignore - public writeValue(value: any) {} +/** + * @see https://github.com/ike18t/ng-mocks#ismockvalidator + */ +export interface MockValidator { + /** + * @see https://github.com/ike18t/ng-mocks#how-to-create-a-mock-form-control + */ + __simulateValidatorChange(): void; } diff --git a/lib/common/mock-of.ts b/lib/common/mock-of.ts index ad91506f79..83dfdc7367 100644 --- a/lib/common/mock-of.ts +++ b/lib/common/mock-of.ts @@ -9,7 +9,7 @@ import { ngMocksMockConfig } from './mock'; // by name (which will now include the original class' name. // Additionally, if we set breakpoints, we can inspect the actual class being // replaced with a mock copy by looking into the 'mockOf' property on the class. -export const MockOf = (mockClass: AnyType, config?: ngMocksMockConfig) => (constructor: AnyType) => { +export const MockOf = (mockClass: AnyType, config: ngMocksMockConfig = {}) => (constructor: AnyType) => { Object.defineProperties(constructor, { mockOf: { value: mockClass }, name: { value: `MockOf${mockClass.name}` }, diff --git a/lib/common/mock.spec.ts b/lib/common/mock.spec.ts index 8f6d70c222..0e5592c5e2 100644 --- a/lib/common/mock.spec.ts +++ b/lib/common/mock.spec.ts @@ -3,18 +3,25 @@ import { Component, Directive, + EventEmitter, NgModule, Pipe, PipeTransform, } from '@angular/core'; import { ControlValueAccessor } from '@angular/forms'; +import { + MockAsyncValidatorProxy, + MockControlValueAccessorProxy, + MockValidatorProxy, +} from '../common/mock-control-value-accessor-proxy'; import { MockComponent } from '../mock-component/mock-component'; import { MockDirective } from '../mock-directive/mock-directive'; import { MockModule } from '../mock-module/mock-module'; import { MockPipe } from '../mock-pipe/mock-pipe'; import { Type } from './core.types'; +import { isMockOf } from './func.is-mock-of'; import { Mock } from './mock'; import { MockOf } from './mock-of'; @@ -68,7 +75,9 @@ class ChildComponentClass return typeof this.childValue; } - public writeValue = (obj: any) => obj; + public writeValue(obj: any) { + return obj; + } } @Directive({ @@ -93,7 +102,9 @@ class ChildDirectiveClass return typeof this.childValue; } - public writeValue = (obj: any) => obj; + public writeValue(obj: any) { + return obj; + } } @Pipe({ @@ -115,10 +126,7 @@ describe('Mock', () => { it('should affect as MockModule', () => { const instance = new (MockModule(ChildModuleClass))(); expect(instance).toEqual(jasmine.any(ChildModuleClass)); - expect((instance as any).__ngMocksMock).toEqual(true); - expect( - (instance as any).__ngMocksMockControlValueAccessor, - ).toEqual(undefined); + expect(isMockOf(instance, ChildModuleClass, 'm')).toEqual(true); expect(instance.parentMethod()).toBeUndefined( 'mock to an empty function', ); @@ -128,15 +136,18 @@ describe('Mock', () => { }); it('should affect as MockComponent', () => { + const proxy = new MockControlValueAccessorProxy( + ChildComponentClass, + ); const instance = new (MockComponent(ChildComponentClass))(); expect(instance).toEqual(jasmine.any(ChildComponentClass)); - expect((instance as any).__ngMocksMock).toEqual(true); - expect( - (instance as any).__ngMocksMockControlValueAccessor, - ).toEqual(true); + expect(isMockOf(instance, ChildComponentClass, 'c')).toEqual( + true, + ); + proxy.instance = instance; const spy = jasmine.createSpy('spy'); - instance.registerOnChange(spy); + proxy.registerOnChange(spy); instance.__simulateChange('test'); expect(spy).toHaveBeenCalledWith('test'); @@ -145,15 +156,18 @@ describe('Mock', () => { }); it('should affect as MockDirective', () => { + const proxy = new MockControlValueAccessorProxy( + ChildComponentClass, + ); const instance = new (MockDirective(ChildDirectiveClass))(); expect(instance).toEqual(jasmine.any(ChildDirectiveClass)); - expect((instance as any).__ngMocksMock).toEqual(true); - expect( - (instance as any).__ngMocksMockControlValueAccessor, - ).toEqual(true); + expect(isMockOf(instance, ChildDirectiveClass, 'd')).toEqual( + true, + ); + proxy.instance = instance; const spy = jasmine.createSpy('spy'); - instance.registerOnChange(spy); + proxy.registerOnChange(spy); instance.__simulateChange('test'); expect(spy).toHaveBeenCalledWith('test'); @@ -164,10 +178,7 @@ describe('Mock', () => { it('should affect as MockPipe', () => { const instance = new (MockPipe(ChildPipeClass))(); expect(instance).toEqual(jasmine.any(ChildPipeClass)); - expect((instance as any).__ngMocksMock).toEqual(true); - expect( - (instance as any).__ngMocksMockControlValueAccessor, - ).toEqual(undefined); + expect(isMockOf(instance, ChildPipeClass, 'p')).toEqual(true); expect(instance.parentMethod()).toBeUndefined(); expect(instance.childMethod()).toBeUndefined(); }); @@ -203,31 +214,33 @@ describe('Mock prototype', () => { } it('should get all mock things and in the same time respect prototype', () => { + const proxy = new MockControlValueAccessorProxy(CustomComponent); const mockDef = MockComponent(CustomComponent); const mock = new mockDef(); expect(mock).toEqual(jasmine.any(CustomComponent)); + proxy.instance = mock; // checking that it was processed through Mock - expect(mock.__ngMocksMock as any).toBe(true); - expect(mock.__ngMocksMockControlValueAccessor as any).toBe(true); + expect((mock as any).__ngMocksConfig).toBeDefined(); + expect((mock as any).__simulateChange).toBeDefined(); // checking that it was processed through MockControlValueAccessor const spy = jasmine.createSpy('spy'); - mock.registerOnChange(spy); + proxy.registerOnChange(spy); mock.__simulateChange('test'); expect(spy).toHaveBeenCalledWith('test'); - // properties are replaced with their mock coplies too + // properties are replaced with their mock objects too expect(mock.test1).toBeUndefined(); (mock as any).test1 = 'MyCustomValue'; expect(mock.test1).toEqual('MyCustomValue'); - // properties are replaced with their mock coplies too + // properties are replaced with their mock objects too expect(mock.test2).toBeUndefined(); (mock as any).test2 = 'MyCustomValue'; expect(mock.test2).toEqual('MyCustomValue'); - // properties are replaced with their mock coplies too + // properties are replaced with their mock objects too expect(mock.test).toBeUndefined(); (mock as any).test = 'MyCustomValue'; expect(mock.test).toEqual('MyCustomValue'); @@ -239,12 +252,15 @@ describe('definitions', () => { class TargetComponent {} @MockOf(TargetComponent, { - outputs: ['__ngMocksMock'], + outputs: ['__ngMocksConfig', 'test'], }) class TestComponent extends Mock {} const instance: any = new TestComponent(); - expect(instance.__ngMocksMock).toEqual(true); + expect(instance.__ngMocksConfig).not.toEqual( + jasmine.any(EventEmitter), + ); + expect(instance.test).toEqual(jasmine.any(EventEmitter)); }); it('adds missed properties to the instance', () => { @@ -293,4 +309,25 @@ describe('definitions', () => { const instance: any = new TestComponent(); expect(instance.test).toEqual(false); }); + + it('allows empty instance of MockControlValueAccessorProxy', () => { + const proxy = new MockControlValueAccessorProxy(); + proxy.registerOnChange(undefined); + proxy.registerOnTouched(undefined); + proxy.setDisabledState(true); + proxy.setDisabledState(false); + proxy.writeValue(undefined); + }); + + it('allows empty instance of MockValidatorProxy', () => { + const proxy = new MockValidatorProxy(); + proxy.registerOnValidatorChange(undefined); + proxy.validate(undefined); + }); + + it('allows empty instance of MockAsyncValidatorProxy', () => { + const proxy = new MockAsyncValidatorProxy(); + proxy.registerOnValidatorChange(undefined); + proxy.validate(undefined); + }); }); diff --git a/lib/common/mock.ts b/lib/common/mock.ts index 426f3a9030..839ea22bd4 100644 --- a/lib/common/mock.ts +++ b/lib/common/mock.ts @@ -1,7 +1,7 @@ // tslint:disable variable-name import { EventEmitter, Injector, Optional } from '@angular/core'; -import { NgControl } from '@angular/forms'; +import { FormControlDirective, NgControl } from '@angular/forms'; import { mapValues } from '../common/core.helpers'; import { AnyType } from '../common/core.types'; @@ -9,14 +9,16 @@ import { IMockBuilderConfig } from '../mock-builder/types'; import mockHelperStub from '../mock-helper/mock-helper.stub'; import helperMockService from '../mock-service/helper.mock-service'; +import funcIsMock from './func.is-mock'; +import { MockControlValueAccessorProxy } from './mock-control-value-accessor-proxy'; import ngMocksUniverse from './ng-mocks-universe'; -const applyNgValueAccessor = (instance: Mock, injector?: Injector) => { - if (injector && instance.__ngMocksConfig && instance.__ngMocksConfig.setNgValueAccessor) { +const setValueAccessor = (instance: MockConfig, injector?: Injector) => { + if (injector && instance.__ngMocksConfig && instance.__ngMocksConfig.setControlValueAccessor) { try { const ngControl = (injector.get as any)(/* A5 */ NgControl, undefined, 0b1010); if (ngControl && !ngControl.valueAccessor) { - ngControl.valueAccessor = instance; + ngControl.valueAccessor = new MockControlValueAccessorProxy(instance.constructor); } } catch (e) { // nothing to do. @@ -24,9 +26,57 @@ const applyNgValueAccessor = (instance: Mock, injector?: Injector) => { } }; -const applyOutputs = (instance: Mock & Record) => { +const getRelatedNgControl = (injector: Injector): FormControlDirective => { + try { + return (injector.get as any)(/* A5 */ NgControl, undefined, 0b1010); + } catch (e) { + return (injector.get as any)(/* A5 */ FormControlDirective, undefined, 0b1010); + } +}; + +// connecting to NG_VALUE_ACCESSOR +const installValueAccessor = (ngControl: any, instance: any) => { + if (!ngControl.valueAccessor.instance && ngControl.valueAccessor.target === instance.constructor) { + ngControl.valueAccessor.instance = instance; + helperMockService.mock(instance, 'registerOnChange'); + helperMockService.mock(instance, 'registerOnTouched'); + helperMockService.mock(instance, 'setDisabledState'); + helperMockService.mock(instance, 'writeValue'); + instance.__ngMocksConfig.isControlValueAccessor = true; + } +}; + +// connecting to NG_VALIDATORS +// connecting to NG_ASYNC_VALIDATORS +const installValidator = (validators: any[], instance: any) => { + for (const validator of validators) { + if (!validator.instance && validator.target === instance.constructor) { + validator.instance = instance; + helperMockService.mock(instance, 'registerOnValidatorChange'); + helperMockService.mock(instance, 'validate'); + instance.__ngMocksConfig.isValidator = true; + } + } +}; + +const applyNgValueAccessor = (instance: any, injector?: Injector) => { + setValueAccessor(instance, injector); + + if (injector) { + try { + const ngControl: any = getRelatedNgControl(injector); + installValueAccessor(ngControl, instance); + installValidator(ngControl._rawValidators, instance); + installValidator(ngControl._rawAsyncValidators, instance); + } catch (e) { + // nothing to do. + } + } +}; + +const applyOutputs = (instance: MockConfig & Record) => { const mockOutputs = []; - for (const output of instance.__ngMocksConfig?.outputs || []) { + for (const output of instance.__ngMocksConfig.outputs || []) { mockOutputs.push(output.split(':')[0]); } @@ -73,14 +123,20 @@ const applyProps = (instance: Mock & Record, prototype: AnyType< export type ngMocksMockConfig = { config?: IMockBuilderConfig; + init?: (instance: any) => void; + isControlValueAccessor?: boolean; + isValidator?: boolean; outputs?: string[]; - setNgValueAccessor?: boolean; + setControlValueAccessor?: boolean; viewChildRefs?: Map; }; const applyOverrides = (instance: any, mockOf: any, injector?: Injector): void => { const configGlobal: Set | undefined = ngMocksUniverse.getOverrides().get(mockOf); const callbacks = configGlobal ? mapValues(configGlobal) : []; + if (instance.__ngMocksConfig.init) { + callbacks.push(instance.__ngMocksConfig.init); + } if (ngMocksUniverse.config.get(mockOf)?.init) { callbacks.push(ngMocksUniverse.config.get(mockOf).init); } @@ -94,18 +150,24 @@ const applyOverrides = (instance: any, mockOf: any, injector?: Injector): void = } }; +export interface MockConfig { + __ngMocksConfig: ngMocksMockConfig; +} + export class Mock { - public readonly __ngMocksConfig?: ngMocksMockConfig; - public readonly __ngMocksMock: true = true; + protected __ngMocksConfig!: ngMocksMockConfig; public constructor(@Optional() injector?: Injector) { const mockOf = (this.constructor as any).mockOf; - applyNgValueAccessor(this, injector); - applyOutputs(this); - applyPrototype(this, Object.getPrototypeOf(this)); - applyMethods(this, mockOf.prototype); - applyProps(this, mockOf.prototype); + // istanbul ignore else + if (funcIsMock(this)) { + applyNgValueAccessor(this, injector); + applyOutputs(this); + applyPrototype(this, Object.getPrototypeOf(this)); + applyMethods(this, mockOf.prototype); + applyProps(this, mockOf.prototype); + } // and faking prototype Object.setPrototypeOf(this, mockOf.prototype); diff --git a/lib/mock-builder/mock-builder.promise.ts b/lib/mock-builder/mock-builder.promise.ts index bd1c4bb81b..071a531cec 100644 --- a/lib/mock-builder/mock-builder.promise.ts +++ b/lib/mock-builder/mock-builder.promise.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { flatten, mapValues } from '../common/core.helpers'; import { Type } from '../common/core.types'; +import funcGetProvider from '../common/func.get-provider'; import { isNgDef } from '../common/func.is-ng-def'; import { isNgModuleDefWithProviders } from '../common/func.is-ng-module-def-with-providers'; @@ -36,8 +37,8 @@ const parseProvider = ( multi: boolean; provide: any; } => { - const provide = typeof provider === 'object' && provider.provide ? provider.provide : provider; - const multi = typeof provider === 'object' && provider.provide && provider.multi; + const provide = funcGetProvider(provider); + const multi = provide !== provider && provider.multi; return { multi, diff --git a/lib/mock-builder/promise/add-requested-providers.ts b/lib/mock-builder/promise/add-requested-providers.ts index f60ec504bb..3e60ade143 100644 --- a/lib/mock-builder/promise/add-requested-providers.ts +++ b/lib/mock-builder/promise/add-requested-providers.ts @@ -1,4 +1,5 @@ import { extractDependency, flatten, mapValues } from '../../common/core.helpers'; +import funcGetProvider from '../../common/func.get-provider'; import ngMocksUniverse from '../../common/ng-mocks-universe'; import { BuilderData, NgMeta } from './types'; @@ -11,7 +12,7 @@ export default (ngModule: NgMeta, { providerDef }: BuilderData): void => { // Analyzing providers. for (const provider of flatten(ngModule.providers)) { - const provide = typeof provider === 'object' && (provider as any).provide ? (provider as any).provide : provider; + const provide = funcGetProvider(provider); ngMocksUniverse.touches.add(provide); if (provide !== provider && (provider as any).deps) { diff --git a/lib/mock-component/mock-component.ts b/lib/mock-component/mock-component.ts index f2c44cdd5a..27a12f140e 100644 --- a/lib/mock-component/mock-component.ts +++ b/lib/mock-component/mock-component.ts @@ -13,18 +13,17 @@ import { getTestBed } from '@angular/core/testing'; import coreReflectDirectiveResolve from '../common/core.reflect.directive-resolve'; import { Type } from '../common/core.types'; import { getMockedNgDefOf } from '../common/func.get-mocked-ng-def-of'; -import { MockControlValueAccessor } from '../common/mock-control-value-accessor'; +import funcIsMock from '../common/func.is-mock'; +import { MockConfig } from '../common/mock'; +import { LegacyControlValueAccessor } from '../common/mock-control-value-accessor'; import ngMocksUniverse from '../common/ng-mocks-universe'; import decorateDeclaration from '../mock/decorate-declaration'; import { MockedComponent } from './types'; -const mixRender = ( - instance: MockControlValueAccessor & Record, - changeDetector: ChangeDetectorRef, -): void => { +const mixRender = (instance: MockConfig & Record, changeDetector: ChangeDetectorRef): void => { // istanbul ignore next - const refs = instance.__ngMocksConfig?.viewChildRefs || new Map(); + const refs = instance.__ngMocksConfig.viewChildRefs || new Map(); // Providing a method to render any @ContentChild based on its selector. instance.__render = (contentChildSelector: string, $implicit?: any, variables?: Record) => { @@ -45,12 +44,9 @@ const mixRender = ( }; }; -const mixHide = ( - instance: MockControlValueAccessor & Record, - changeDetector: ChangeDetectorRef, -): void => { +const mixHide = (instance: MockConfig & Record, changeDetector: ChangeDetectorRef): void => { // istanbul ignore next - const refs = instance.__ngMocksConfig?.viewChildRefs || new Map(); + const refs = instance.__ngMocksConfig.viewChildRefs || new Map(); // Providing method to hide any @ContentChild based on its selector. instance.__hide = (contentChildSelector: string) => { @@ -62,12 +58,14 @@ const mixHide = ( }; }; -class ComponentMockBase extends MockControlValueAccessor implements AfterContentInit { +class ComponentMockBase extends LegacyControlValueAccessor implements AfterContentInit { // istanbul ignore next public constructor(changeDetector: ChangeDetectorRef, injector: Injector) { super(injector); - mixRender(this, changeDetector); - mixHide(this, changeDetector); + if (funcIsMock(this)) { + mixRender(this, changeDetector); + mixHide(this, changeDetector); + } } public ngAfterContentInit(): void { diff --git a/lib/mock-component/types.ts b/lib/mock-component/types.ts index 45d85dd144..e9c0cff322 100644 --- a/lib/mock-component/types.ts +++ b/lib/mock-component/types.ts @@ -1,7 +1,7 @@ -import { MockControlValueAccessor } from '../common/mock-control-value-accessor'; +import { LegacyControlValueAccessor } from '../common/mock-control-value-accessor'; export type MockedComponent = T & - MockControlValueAccessor & { + LegacyControlValueAccessor & { /** * Helper function to hide rendered @ContentChild() template. */ diff --git a/lib/mock-directive/mock-directive.ts b/lib/mock-directive/mock-directive.ts index 81ee968989..d17883c415 100644 --- a/lib/mock-directive/mock-directive.ts +++ b/lib/mock-directive/mock-directive.ts @@ -4,13 +4,13 @@ import { getTestBed } from '@angular/core/testing'; import coreReflectDirectiveResolve from '../common/core.reflect.directive-resolve'; import { Type } from '../common/core.types'; import { getMockedNgDefOf } from '../common/func.get-mocked-ng-def-of'; -import { MockControlValueAccessor } from '../common/mock-control-value-accessor'; +import { LegacyControlValueAccessor } from '../common/mock-control-value-accessor'; import ngMocksUniverse from '../common/ng-mocks-universe'; import decorateDeclaration from '../mock/decorate-declaration'; import { MockedDirective } from './types'; -class DirectiveMockBase extends MockControlValueAccessor implements OnInit { +class DirectiveMockBase extends LegacyControlValueAccessor implements OnInit { // istanbul ignore next public constructor( injector: Injector, diff --git a/lib/mock-directive/types.ts b/lib/mock-directive/types.ts index 75a97e9815..d16c1db7e9 100644 --- a/lib/mock-directive/types.ts +++ b/lib/mock-directive/types.ts @@ -1,9 +1,9 @@ import { ElementRef, TemplateRef, ViewContainerRef } from '@angular/core'; -import { MockControlValueAccessor } from '../common/mock-control-value-accessor'; +import { LegacyControlValueAccessor } from '../common/mock-control-value-accessor'; export type MockedDirective = T & - MockControlValueAccessor & { + LegacyControlValueAccessor & { /** * Pointer to current element in case of Attribute Directives. */ diff --git a/lib/mock-helper/mock-helper.guts.ts b/lib/mock-helper/mock-helper.guts.ts index 6b2ac61cb4..d7b4722a0c 100644 --- a/lib/mock-helper/mock-helper.guts.ts +++ b/lib/mock-helper/mock-helper.guts.ts @@ -2,6 +2,7 @@ import { TestModuleMetadata } from '@angular/core/testing'; import { flatten, mapValues } from '../common/core.helpers'; import coreReflectModuleResolve from '../common/core.reflect.module-resolve'; +import funcGetProvider from '../common/func.get-provider'; import { isNgDef } from '../common/func.is-ng-def'; import { isNgInjectionToken } from '../common/func.is-ng-injection-token'; import { isNgModuleDefWithProviders } from '../common/func.is-ng-module-def-with-providers'; @@ -105,7 +106,7 @@ const handleDestructuring = (data: Data, def: any, callback: any): void => { }; const resolveProvider = ({ skip, keep, providers, exclude }: Data, def: any): void => { - const provider = typeof def === 'object' && def.provide ? def.provide : def; + const provider = funcGetProvider(def); skip.add(provider); if (exclude.has(provider)) { return; diff --git a/lib/mock-pipe/mock-pipe.ts b/lib/mock-pipe/mock-pipe.ts index 757877969f..3ca055eac1 100644 --- a/lib/mock-pipe/mock-pipe.ts +++ b/lib/mock-pipe/mock-pipe.ts @@ -1,4 +1,4 @@ -import { Injector, Optional, Pipe, PipeTransform } from '@angular/core'; +import { Pipe, PipeTransform } from '@angular/core'; import { getTestBed } from '@angular/core/testing'; import coreReflectPipeResolve from '../common/core.reflect.pipe-resolve'; @@ -20,19 +20,17 @@ export function MockPipes(...pipes: Array>): Array, transform?: PipeTransform['transform']): Type => { @Pipe(coreReflectPipeResolve(pipe)) - @MockOf(pipe) - class PipeMock extends Mock { - public constructor(@Optional() injector?: Injector) { - super(injector); - - // need to override overrides + @MockOf(pipe, { + init: (instance: PipeTransform) => { if (transform) { - (this as any).transform = transform; - } else if (!(this as any).transform) { - helperMockService.mock(this, 'transform', `${this.constructor.name}.transform`); + instance.transform = transform; } - } - } + if (!instance.transform) { + helperMockService.mock(instance, 'transform', `${instance.constructor.name}.transform`); + } + }, + }) + class PipeMock extends Mock {} return PipeMock; }; diff --git a/lib/mock-service/helper.resolve-provider.ts b/lib/mock-service/helper.resolve-provider.ts index b8533ccf56..9dfb4148e0 100644 --- a/lib/mock-service/helper.resolve-provider.ts +++ b/lib/mock-service/helper.resolve-provider.ts @@ -1,5 +1,6 @@ import { extractDependency } from '../common/core.helpers'; import { NG_MOCKS_INTERCEPTORS } from '../common/core.tokens'; +import funcGetProvider from '../common/func.get-provider'; import { isNgInjectionToken } from '../common/func.is-ng-injection-token'; import ngMocksUniverse from '../common/ng-mocks-universe'; @@ -64,7 +65,7 @@ const parseProvider = ( multi: boolean; provide: any; } => { - const provide = typeof provider === 'object' && provider.provide ? provider.provide : provider; + const provide = funcGetProvider(provider); const multi = provider !== provide && !!provider.multi; return { @@ -172,7 +173,7 @@ const isPreconfiguredDependency = (provider: any, provide: any): boolean => { export default (provider: any, resolutions: Map, changed?: () => void) => { const { provide, multi, change } = parseProvider(provider, changed); // we shouldn't touch our system providers. - if (typeof provider === 'object' && provider.useExisting && provider.useExisting.__ngMocksSkip) { + if (provider && typeof provider === 'object' && provider.useExisting && provider.useExisting.__ngMocksSkip) { return provider; } if (isPreconfiguredDependency(provider, provide)) { diff --git a/lib/mock-service/mock-provider.ts b/lib/mock-service/mock-provider.ts index 32ab6cc44e..e4d3b5f0ff 100644 --- a/lib/mock-service/mock-provider.ts +++ b/lib/mock-service/mock-provider.ts @@ -2,6 +2,7 @@ import { Provider } from '@angular/core'; import coreConfig from '../common/core.config'; import { Type } from '../common/core.types'; +import funcGetProvider from '../common/func.get-provider'; import { isNgInjectionToken } from '../common/func.is-ng-injection-token'; import ngMocksUniverse from '../common/ng-mocks-universe'; @@ -116,7 +117,7 @@ const isNeverMockToken = (provide: any): boolean => isNgInjectionToken(provide) && neverMockToken.indexOf(provide.toString()) !== -1; export default function (provider: any): Provider | undefined { - const provide = typeof provider === 'object' && provider.provide ? provider.provide : provider; + const provide = funcGetProvider(provider); if (isNeverMockFunction(provide)) { return provider; diff --git a/lib/mock/clone-providers.ts b/lib/mock/clone-providers.ts index 5df770f20d..3ed410d9d6 100644 --- a/lib/mock/clone-providers.ts +++ b/lib/mock/clone-providers.ts @@ -1,34 +1,56 @@ import { Provider } from '@angular/core'; -import { NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { NG_ASYNC_VALIDATORS, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; import { flatten } from '../common/core.helpers'; import { AnyType } from '../common/core.types'; +import funcGetProvider from '../common/func.get-provider'; +import { + MockAsyncValidatorProxy, + MockControlValueAccessorProxy, + MockValidatorProxy, +} from '../common/mock-control-value-accessor-proxy'; import helperMockService from '../mock-service/helper.mock-service'; -import toExistingProvider from './to-existing-provider'; +import toFactoryProvider from './to-factory-provider'; -export default (mockType: AnyType, providers?: any[]): { providers: Provider[]; setNgValueAccessor?: boolean } => { +const processProvider = (provider: any, mockType: AnyType, resolutions: Map): any => { + if (provider === NG_VALIDATORS) { + return toFactoryProvider(provider, () => new MockValidatorProxy(mockType)); + } + if (provider === NG_ASYNC_VALIDATORS) { + return toFactoryProvider(provider, () => new MockAsyncValidatorProxy(mockType)); + } + if (provider === NG_VALUE_ACCESSOR) { + return toFactoryProvider(provider, () => new MockControlValueAccessorProxy(mockType)); + } + + return helperMockService.resolveProvider(provider, resolutions); +}; + +export default ( + mockType: AnyType, + providers?: any[], +): { + providers: Provider[]; + setControlValueAccessor?: boolean; +} => { const result: Provider[] = []; - let setNgValueAccessor: boolean | undefined; + let setControlValueAccessor: boolean | undefined; const resolutions = new Map(); for (const provider of flatten(providers || /* istanbul ignore next */ [])) { - const provide = provider && typeof provider === 'object' && provider.provide ? provider.provide : provider; - if (provide === NG_VALIDATORS) { - result.push(toExistingProvider(provide, mockType, true)); - } else if (setNgValueAccessor === undefined && provide === NG_VALUE_ACCESSOR) { - setNgValueAccessor = false; - result.push(toExistingProvider(provide, mockType, true)); - } else { - const mock = helperMockService.resolveProvider(provider, resolutions); - if (mock) { - result.push(mock); - } + const provide = funcGetProvider(provider); + if (provide === NG_VALUE_ACCESSOR) { + setControlValueAccessor = false; + } + const mock = processProvider(provide, mockType, resolutions); + if (mock) { + result.push(mock); } } return { providers: result, - setNgValueAccessor, + setControlValueAccessor, }; }; diff --git a/lib/mock/decorate-declaration.ts b/lib/mock/decorate-declaration.ts index e1a291a44d..deb6fccb64 100644 --- a/lib/mock/decorate-declaration.ts +++ b/lib/mock/decorate-declaration.ts @@ -26,14 +26,14 @@ export default ( const providers = [toExistingProvider(source, mock), ...data.providers]; const options: T = { ...params, providers }; - if (data.setNgValueAccessor === undefined) { - data.setNgValueAccessor = + if (data.setControlValueAccessor === undefined) { + data.setControlValueAccessor = helperMockService.extractMethodsFromPrototype(source.prototype).indexOf('writeValue') !== -1; } MockOf(source, { config: ngMocksUniverse.config.get(source), outputs: meta.outputs, - setNgValueAccessor: data.setNgValueAccessor, + setControlValueAccessor: data.setControlValueAccessor, viewChildRefs: meta.viewChildRefs, })(mock); diff --git a/lib/mock/to-existing-provider.ts b/lib/mock/to-existing-provider.ts index 84a7dd2207..7cca44b3e0 100644 --- a/lib/mock/to-existing-provider.ts +++ b/lib/mock/to-existing-provider.ts @@ -2,8 +2,7 @@ import { forwardRef } from '@angular/core'; import { AnyType, Type } from '../common/core.types'; -export default (provide: AnyType, type: AnyType, multi = false) => ({ - ...(multi ? { multi } : {}), +export default (provide: AnyType, type: AnyType) => ({ provide, useExisting: (() => { const value: Type & { __ngMocksSkip?: boolean } = forwardRef(() => type); diff --git a/lib/mock/to-factory-provider.ts b/lib/mock/to-factory-provider.ts new file mode 100644 index 0000000000..ecd3f6805d --- /dev/null +++ b/lib/mock/to-factory-provider.ts @@ -0,0 +1,7 @@ +import { AnyType } from '../common/core.types'; + +export default (provide: AnyType, useFactory: any) => ({ + multi: true, + provide, + useFactory, +}); diff --git a/package.json b/package.json index 062c8c4b80..474ae7bd7f 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,14 @@ ] }, "renovate": { - "enabled": true + "ignorePaths": [ + "e2e/**" + ], + "rangeStrategy": "pin", + "rebaseWhen": "conflicted", + "separateMajorMinor": true, + "separateMinorPatch": true, + "separateMultipleMajor": true, + "includeForks": true } } diff --git a/tests/issue-145/components.spec.ts b/tests/issue-145/components.spec.ts index 99113cbf95..966d0ffe0b 100644 --- a/tests/issue-145/components.spec.ts +++ b/tests/issue-145/components.spec.ts @@ -35,7 +35,7 @@ export class ComponentValueAccessor {} }) export class ComponentValidator {} -describe('issue-145', () => { +describe('issue-145:components', () => { it('does not add NG_VALUE_ACCESSOR to components', () => { const mock = MockComponent(ComponentDefault); const { providers } = directiveResolver.resolve(mock); @@ -58,7 +58,7 @@ describe('issue-145', () => { { multi: true, provide: NG_VALUE_ACCESSOR, - useExisting: jasmine.anything(), + useFactory: jasmine.anything(), }, ]); }); @@ -74,7 +74,7 @@ describe('issue-145', () => { { multi: true, provide: NG_VALIDATORS, - useExisting: jasmine.anything(), + useFactory: jasmine.anything(), }, ]); }); diff --git a/tests/issue-145/directives.spec.ts b/tests/issue-145/directives.spec.ts index e71b80ba89..934146ea70 100644 --- a/tests/issue-145/directives.spec.ts +++ b/tests/issue-145/directives.spec.ts @@ -33,7 +33,7 @@ export class DirectiveValueAccessor {} export class DirectiveValidator {} // providers should be added to directives only in case if they were specified in the original directive. -describe('issue-145', () => { +describe('issue-145:directives', () => { it('does not add NG_VALUE_ACCESSOR to directives', () => { const mock = MockDirective(DirectiveDefault); const { providers } = directiveResolver.resolve(mock); @@ -56,7 +56,7 @@ describe('issue-145', () => { { multi: true, provide: NG_VALUE_ACCESSOR, - useExisting: jasmine.anything(), + useFactory: jasmine.anything(), }, ]); }); @@ -72,7 +72,7 @@ describe('issue-145', () => { { multi: true, provide: NG_VALIDATORS, - useExisting: jasmine.anything(), + useFactory: jasmine.anything(), }, ]); }); diff --git a/tests/issue-246/test.spec.ts b/tests/issue-246/test.spec.ts new file mode 100644 index 0000000000..56d13bcaad --- /dev/null +++ b/tests/issue-246/test.spec.ts @@ -0,0 +1,229 @@ +import { Component, Directive, forwardRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + AbstractControl, + ControlValueAccessor, + FormControl, + NG_ASYNC_VALIDATORS, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { MockBuilder, MockRender, ngMocks } from 'ng-mocks'; +import { isMockControlValueAccessor } from 'ng-mocks/dist/lib/common/func.is-mock-control-value-accessor'; +import { isMockValidator } from 'ng-mocks/dist/lib/common/func.is-mock-validator'; + +@Component({ + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TargetComponent), + }, + ], + selector: 'target', + template: '{{ providedValue }}', +}) +class TargetComponent implements ControlValueAccessor { + public providedChange: any; + public providedDisable: any; + public providedTouch: any; + public providedValue: any; + + public registerOnChange(fn: any): void { + this.providedChange = fn; + } + + public registerOnTouched(fn: any): void { + this.providedTouch = fn; + } + + public setDisabledState(isDisabled: boolean): void { + this.providedDisable = isDisabled; + } + + public writeValue(obj: any): void { + this.providedValue = obj; + } +} + +@Directive({ + providers: [ + { + multi: true, + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TargetDirective), + }, + ], + selector: '[target]', +}) +class TargetDirective implements Validator { + public provideChange: any; + public provideControl: any; + + public registerOnValidatorChange(fn: () => void): void { + this.provideChange = fn; + } + + public validate(control: AbstractControl): ValidationErrors | null { + this.provideControl = control; + + return { + target: true, + }; + } +} + +@Directive({ + providers: [ + { + multi: true, + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => TargetAsyncDirective), + }, + ], + selector: '[targetAsync]', +}) +class TargetAsyncDirective implements Validator { + public provideChange: any; + public provideControl: any; + + public registerOnValidatorChange(fn: () => void): void { + this.provideChange = fn; + } + + public async validate( + control: AbstractControl, + ): Promise { + this.provideControl = control; + + return { + targetAsync: true, + }; + } +} + +describe('issue-246:real', () => { + beforeEach(async () => { + return TestBed.configureTestingModule({ + declarations: [ + TargetComponent, + TargetDirective, + TargetAsyncDirective, + ], + imports: [ReactiveFormsModule], + }).compileComponents(); + }); + + it('turns value control accessor into auto mocks', async () => { + const control = new FormControl('246'); + + // default render. + const fixture = MockRender( + ``, + { + control, + }, + ); + await fixture.whenStable(); + + expect(control.touched).toEqual(false); + expect(control.dirty).toEqual(false); + expect(control.errors).toEqual({ + target: true, + }); + + // async fails independently only. + spyOn( + ngMocks.findInstance(TargetDirective), + 'validate', + ).and.returnValue(null); + control.updateValueAndValidity(); + await fixture.whenStable(); + expect(control.errors).toEqual({ + targetAsync: true, + }); + }); +}); + +describe('issue-246:mock', () => { + beforeEach(() => + MockBuilder(ReactiveFormsModule) + .mock(TargetComponent) + .mock(TargetDirective) + .mock(TargetAsyncDirective), + ); + + it('turns value control accessor into auto mocks', async () => { + const control = new FormControl('246'); + + // default render. + const fixture = MockRender( + ``, + { + control, + }, + ); + expect(control.touched).toEqual(false); + expect(control.dirty).toEqual(false); + + const component = ngMocks.findInstance(TargetComponent); + expect(component.registerOnChange).toHaveBeenCalledTimes(1); + expect(component.registerOnTouched).toHaveBeenCalledTimes(1); + expect(component.writeValue).toHaveBeenCalledWith('246'); + expect(component.writeValue).toHaveBeenCalledTimes(1); + + const directive = ngMocks.findInstance(TargetDirective); + expect(directive.registerOnValidatorChange).toHaveBeenCalledTimes( + 1, + ); + expect(directive.validate).toHaveBeenCalledWith(control); + expect(directive.validate).toHaveBeenCalledTimes(1); + + const directiveAsync = ngMocks.findInstance(TargetAsyncDirective); + expect( + directiveAsync.registerOnValidatorChange, + ).toHaveBeenCalledTimes(1); + expect(directiveAsync.validate).toHaveBeenCalledWith(control); + expect(directiveAsync.validate).toHaveBeenCalledTimes(1); + + // checking that touch works. + if (isMockControlValueAccessor(component)) { + component.__simulateTouch(); + } + expect(control.touched).toEqual(true); + + // checking that change works. + if (isMockControlValueAccessor(component)) { + component.__simulateChange('fixed'); + } + expect(control.value).toEqual('fixed'); + + // checking async errors. + if (isMockValidator(directiveAsync)) { + spyOn(directiveAsync, 'validate').and.returnValue( + Promise.resolve({ + targetAsync: true, + }), + ); + directiveAsync.__simulateValidatorChange(); + } + await fixture.whenStable(); + expect(control.errors).toEqual({ + targetAsync: true, + }); + + // checking sync errors, they block async validators. + if (isMockValidator(directive)) { + spyOn(directive, 'validate').and.returnValue({ + test: true, + }); + directive.__simulateValidatorChange(); + } + expect(control.errors).toEqual({ + test: true, + }); + }); +}); diff --git a/tests/issue-248/test.spec.ts b/tests/issue-248/test.spec.ts new file mode 100644 index 0000000000..ddfcfd935e --- /dev/null +++ b/tests/issue-248/test.spec.ts @@ -0,0 +1,53 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MockModule } from 'ng-mocks'; + +@NgModule({ + declarations: [[undefined, null]], +}) +class Target1Module {} + +@NgModule({ + exports: [[undefined, null]], +}) +class Target2Module {} + +@NgModule({ + imports: [ + [undefined, null], + { + ngModule: CommonModule, + providers: [[undefined, null]], + }, + ], +}) +class Target3Module {} + +@NgModule({ + providers: [[undefined, null]], +}) +class Target4Module {} + +@NgModule({ + declarations: [[undefined, null]], + exports: [[undefined, null]], + imports: [ + [undefined, null], + { + ngModule: CommonModule, + providers: [[undefined, null]], + }, + ], + providers: [[undefined, null]], +}) +class Target5Module {} + +describe('issue-248', () => { + it('does not fail', () => { + MockModule(Target1Module); + MockModule(Target2Module); + MockModule(Target3Module); + MockModule(Target4Module); + MockModule(Target5Module); + }); +});