diff --git a/libs/ng-mocks/src/lib/common/func.extract-forward-ref.ts b/libs/ng-mocks/src/lib/common/func.extract-forward-ref.ts new file mode 100644 index 0000000000..5088f3dec7 --- /dev/null +++ b/libs/ng-mocks/src/lib/common/func.extract-forward-ref.ts @@ -0,0 +1,8 @@ +// handles forwardRef on useExisting +export default (provide: any): any => { + if (typeof provide === 'function' && provide.__forward_ref__) { + return provide(); + } + + return provide; +}; diff --git a/libs/ng-mocks/src/lib/mock-builder/mock-builder.promise.ts b/libs/ng-mocks/src/lib/mock-builder/mock-builder.promise.ts index 480057aeb5..ff7f8dc53d 100644 --- a/libs/ng-mocks/src/lib/mock-builder/mock-builder.promise.ts +++ b/libs/ng-mocks/src/lib/mock-builder/mock-builder.promise.ts @@ -103,6 +103,7 @@ export class MockBuilderPromise implements IMockBuilder { public exclude(def: any): this { this.wipe(def); this.excludeDef.add(def); + this.setConfigDef(def); return this; } @@ -215,7 +216,7 @@ export class MockBuilderPromise implements IMockBuilder { }; } - private setConfigDef(def: any, config: any): void { + private setConfigDef(def: any, config?: any): void { if (config || !this.configDef.has(def)) { this.configDef.set(def, config ?? this.configDefault); } diff --git a/libs/ng-mocks/src/lib/mock-builder/promise/extract-dep.ts b/libs/ng-mocks/src/lib/mock-builder/promise/extract-dep.ts index 8999820341..a4a208ca52 100644 --- a/libs/ng-mocks/src/lib/mock-builder/promise/extract-dep.ts +++ b/libs/ng-mocks/src/lib/mock-builder/promise/extract-dep.ts @@ -1,13 +1,6 @@ -// Extracts dependency among flags of parameters. - -const detectForwardRed = (provide: any): any => { - if (typeof provide === 'function' && provide.__forward_ref__) { - return provide(); - } - - return provide; -}; +import funcExtractForwardRef from '../../common/func.extract-forward-ref'; +// Extracts dependency among flags of parameters. export default (decorators?: any[]): any => { if (!decorators) { return; @@ -23,5 +16,5 @@ export default (decorators?: any[]): any => { } } - return detectForwardRed(provide); + return funcExtractForwardRef(provide); }; diff --git a/libs/ng-mocks/src/lib/mock-builder/promise/init-universe.ts b/libs/ng-mocks/src/lib/mock-builder/promise/init-universe.ts index a13687dd7b..953cd5a15d 100644 --- a/libs/ng-mocks/src/lib/mock-builder/promise/init-universe.ts +++ b/libs/ng-mocks/src/lib/mock-builder/promise/init-universe.ts @@ -50,6 +50,7 @@ export default ({ configDef.set(dependency, { dependency: true, + __internal: true, }); ngMocksUniverse.touches.add(dependency); } diff --git a/libs/ng-mocks/src/lib/mock-service/helper.resolve-provider.ts b/libs/ng-mocks/src/lib/mock-service/helper.resolve-provider.ts index 240f876563..0ce5328575 100644 --- a/libs/ng-mocks/src/lib/mock-service/helper.resolve-provider.ts +++ b/libs/ng-mocks/src/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 funcExtractForwardRef from '../common/func.extract-forward-ref'; import funcGetProvider from '../common/func.get-provider'; import { isNgInjectionToken } from '../common/func.is-ng-injection-token'; import ngMocksUniverse from '../common/ng-mocks-universe'; @@ -48,7 +49,7 @@ const excludeInterceptors = (provider: any, provide: any): boolean => { if (provider.useFactory || provider.useValue) { return true; } - const interceptor = provider.useExisting || provider.useClass; + const interceptor = funcExtractForwardRef(provider.useExisting) || provider.useClass; if (!ngMocksUniverse.builtProviders.has(interceptor) || ngMocksUniverse.builtProviders.get(interceptor) === null) { return true; } @@ -149,7 +150,7 @@ const areEqualDefs = (mockDef: any, provider: any, provide: any): boolean => { const isPreconfiguredDependency = (provider: any, provide: any): boolean => { // we should not touch excluded providers. - if (ngMocksUniverse.builtProviders.has(provide) && ngMocksUniverse.builtProviders.get(provide) === null) { + if (ngMocksUniverse.builtProviders.get(provide) === null) { return true; } @@ -160,16 +161,34 @@ const isPreconfiguredDependency = (provider: any, provide: any): boolean => { return excludeInterceptors(provider, provide); }; +const isPreconfiguredUseExisting = (provider: any, provide: any): boolean => { + // we should not touch non-useExisting providers. + if (!provider || typeof provider !== 'object' || !provider.useExisting) { + return false; + } + if (provider.useExisting.mockOf) { + return true; + } + + // skipping explicit declarations (not internally processed) + if (ngMocksUniverse.getResolution(provide) && !ngMocksUniverse.config.get(provide).__internal) { + return false; + } + + return ngMocksUniverse.getResolution(funcExtractForwardRef(provider.useExisting)) === 'keep'; +}; + // tries to resolve a provider based on current universe state. export default (provider: any, resolutions: Map, changed?: () => void) => { const { provide, multi, change } = parseProvider(provider, changed); - // we should not touch our system providers. - if (provider && typeof provider === 'object' && provider.useExisting && provider.useExisting.mockOf) { - return provider; - } if (isPreconfiguredDependency(provider, provide)) { return change(); } + if (isPreconfiguredUseExisting(provider, provide)) { + ngMocksUniverse.touches.add(provide); + + return provider; + } if (resolutions.has(provide)) { return createFromResolution(provide, resolutions.get(provide)); } diff --git a/libs/ng-mocks/src/lib/mock/clone-providers.ts b/libs/ng-mocks/src/lib/mock/clone-providers.ts index 173c4d802d..259189048a 100644 --- a/libs/ng-mocks/src/lib/mock/clone-providers.ts +++ b/libs/ng-mocks/src/lib/mock/clone-providers.ts @@ -3,6 +3,7 @@ import { Provider } from '@angular/core'; import coreForm from '../common/core.form'; import { flatten } from '../common/core.helpers'; import { AnyType } from '../common/core.types'; +import funcExtractForwardRef from '../common/func.extract-forward-ref'; import funcGetProvider from '../common/func.get-provider'; import { MockAsyncValidatorProxy, @@ -37,15 +38,7 @@ const processOwnUseExisting = (sourceType: AnyType, mockType: AnyType, return undefined; } - if (provider !== provide && provider.useExisting === sourceType) { - return toExistingProvider(provide, mockType); - } - if ( - provider !== provide && - provider.useExisting && - provider.useExisting.__forward_ref__ && - provider.useExisting() === sourceType - ) { + if (provider !== provide && funcExtractForwardRef(provider.useExisting) === sourceType) { return toExistingProvider(provide, mockType); } diff --git a/tests/issue-3791/test.spec.ts b/tests/issue-3791/test.spec.ts new file mode 100644 index 0000000000..a2633b74bc --- /dev/null +++ b/tests/issue-3791/test.spec.ts @@ -0,0 +1,160 @@ +import { Component, forwardRef, VERSION } from '@angular/core'; +import { + ControlValueAccessor, + FormControl, + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, +} from '@angular/forms'; + +import { MockBuilder, MockRender, ngMocks } from 'ng-mocks'; + +// A standalone CVA component +@Component( + { + selector: 'standalone-cva', + template: ``, + standalone: true, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => StandaloneCVAComponent), + multi: true, + }, + ], + } as never /* TODO: remove after upgrade to a14 */, +) +class StandaloneCVAComponent implements ControlValueAccessor { + public value = ''; + + onChange: any = () => undefined; + onTouched: any = () => undefined; + writeValue: any = () => undefined; + + registerOnChange(fn: (url: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(): void {} + + onValueChange(value: string): void { + this.value = value; + this.onChange(this.value); + } +} + +// @see https://github.com/help-me-mom/ng-mocks/issues/3778 +// The problem is that StandaloneCVAComponent provides itself as NG_VALUE_ACCESSOR, +// whereas NG_VALUE_ACCESSOR is going to be mocked. +// The fix is to keep such NG_VALUE_ACCESSOR if its useExisting points to a kept thing. +describe('issue-3791', () => { + if (Number.parseInt(VERSION.major, 10) < 14) { + it('needs >=a14', () => { + expect(true).toBeTruthy(); + }); + + return; + } + + describe('issue', () => { + beforeEach(() => + MockBuilder([ + StandaloneCVAComponent, + FormsModule, + ReactiveFormsModule, + ]), + ); + + it('does not fail on standard render', () => { + const fixture = MockRender(StandaloneCVAComponent); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('does not fail as ReactiveFormsModule', () => { + const fixture = MockRender( + ``, + { + control: new FormControl('test'), + }, + ); + + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('does not fail as FormsModule', () => { + const fixture = MockRender( + ``, + { + value: 'test', + }, + ); + + expect(fixture.componentInstance).toBeTruthy(); + }); + }); + + describe('.mock', () => { + beforeEach(() => + MockBuilder([ + StandaloneCVAComponent, + FormsModule, + ReactiveFormsModule, + ]).mock(NG_VALUE_ACCESSOR), + ); + + it('provides undefined as NG_VALUE_ACCESSOR', () => { + MockRender(StandaloneCVAComponent); + const token = ngMocks.findInstance(NG_VALUE_ACCESSOR); + expect(token).toEqual([undefined as never]); + }); + }); + + describe('.mock with a value', () => { + const mock = { + onChange: () => undefined, + onTouched: () => undefined, + writeValue: () => undefined, + } as never; + + beforeEach(() => + MockBuilder([ + StandaloneCVAComponent, + FormsModule, + ReactiveFormsModule, + ]).mock(NG_VALUE_ACCESSOR, [mock]), + ); + + it('provides a mock as NG_VALUE_ACCESSOR', () => { + MockRender(StandaloneCVAComponent); + const token = ngMocks.findInstance(NG_VALUE_ACCESSOR); + expect(token).toEqual([mock]); + }); + }); + + describe('.exclude', () => { + beforeEach(() => + MockBuilder([ + StandaloneCVAComponent, + FormsModule, + ReactiveFormsModule, + ]).exclude(NG_VALUE_ACCESSOR), + ); + + it('removes NG_VALUE_ACCESSOR from declarations', () => { + MockRender(StandaloneCVAComponent); + const token = ngMocks.findInstance( + NG_VALUE_ACCESSOR, + undefined, + ); + expect(token).toEqual(undefined); + }); + }); +});