From b8414a4515c8b7a9021c1a592ea30820818dbb2e Mon Sep 17 00:00:00 2001 From: satanTime Date: Sun, 2 Apr 2023 12:07:11 +0200 Subject: [PATCH] feat(core): support of HostDirectives #5117 --- .eslintrc.yml | 3 +- docs/articles/guides/host-directive.md | 115 +++++++++++ docs/articles/guides/mock/host-directive.md | 149 ++++++++++++++ docs/sidebars.js | 2 + examples/MockHostDirective/test.spec.ts | 96 +++++++++ examples/TestHostDirective/test.spec.ts | 57 ++++++ libs/ng-mocks/src/lib/common/core.config.ts | 1 + libs/ng-mocks/src/lib/common/core.types.ts | 1 + libs/ng-mocks/src/lib/common/func.get-type.ts | 2 + .../src/lib/mock-module/mock-ng-def.ts | 36 +++- .../lib/mock-render/func.create-wrapper.ts | 19 +- .../src/lib/mock/decorate-declaration.ts | 10 +- tests/issue-5117/base.spec.ts | 102 ++++++++++ tests/issue-5117/coverage.spec.ts | 182 ++++++++++++++++++ tests/issue-5117/input.spec.ts | 88 +++++++++ tests/issue-5117/output.spec.ts | 112 +++++++++++ tests/issue-5117/test.spec.ts | 163 ++++++++++++++++ 17 files changed, 1127 insertions(+), 11 deletions(-) create mode 100644 docs/articles/guides/host-directive.md create mode 100644 docs/articles/guides/mock/host-directive.md create mode 100644 examples/MockHostDirective/test.spec.ts create mode 100644 examples/TestHostDirective/test.spec.ts create mode 100644 tests/issue-5117/base.spec.ts create mode 100644 tests/issue-5117/coverage.spec.ts create mode 100644 tests/issue-5117/input.spec.ts create mode 100644 tests/issue-5117/output.spec.ts create mode 100644 tests/issue-5117/test.spec.ts diff --git a/.eslintrc.yml b/.eslintrc.yml index 8ab511acda..760d8be7c9 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -88,10 +88,11 @@ overrides: - 500 max-lines-per-function: - error - - 100 + - 150 '@angular-eslint/no-input-rename': off '@angular-eslint/no-output-rename': off + '@angular-eslint/no-outputs-metadata-property': off '@typescript-eslint/no-explicit-any': off '@typescript-eslint/no-namespace': off diff --git a/docs/articles/guides/host-directive.md b/docs/articles/guides/host-directive.md new file mode 100644 index 0000000000..785a1dc8c8 --- /dev/null +++ b/docs/articles/guides/host-directive.md @@ -0,0 +1,115 @@ +--- +title: How to test a host directive in Angular application +description: Covering an Angular host directive with tests +sidebar_label: Host Directive +--- + +Let's imagine we have a component with a host directive which adds the `name` attribute. + +The code of the directive: + +```ts +@Directive({ + selector: 'host', + standalone: true, +}) +class HostDirective { + @HostBinding('attr.name') @Input() input?: string; +} +``` + +The code of the component: + +```ts +@Component({ + selector: 'target', + hostDirectives: [ + { + directive: HostDirective, + inputs: ['input'], + }, + ], + template: 'target', +}) +class TargetComponent { + // tons of logic we want to ignore +} +``` + +The component can be heavy, and, in an ideal test, the logic of the component should be ignored, +so the focus would stay on the directive and how it behaves. + +[`MockBuilder`](../api/MockBuilder.md) knows how to mock the component +and how to keep one or some of its host directives as they are. + +In order to do so, the host directive should be kept, and its component should be mocked: + +```ts +beforeEach(() => MockBuilder(HostDirective, TargetComponent)); +``` + +Profit! + +To access the directive in a test, [`ngMocks.findInstnace`](../api/ngMocks/findInstance.md) can be used. + +```ts +it('keeps host directives', () => { + const fixture = MockRender(TargetComponent, { input: 'test' }); + + const directive = ngMocks.findInstance(HostDirective); + expect(directive.input).toEqual('test'); + expect(ngMocks.formatHtml(fixture)).toContain(' name="test"'); +}); +``` + +## Live example + +- [Try it on CodeSandbox](https://codesandbox.io/s/github/help-me-mom/ng-mocks-sandbox/tree/tests?file=/src/examples/TestHostDirective/test.spec.ts&initialpath=%3Fspec%3DTestHostDirective) +- [Try it on StackBlitz](https://stackblitz.com/github/help-me-mom/ng-mocks-sandbox/tree/tests?file=src/examples/TestHostDirective/test.spec.ts&initialpath=%3Fspec%3DTestHostDirective) + +```ts title="https://github.com/help-me-mom/ng-mocks/blob/master/examples/TestHostDirective/test.spec.ts" +import { + Component, + Directive, + HostBinding, + Input, +} from '@angular/core'; + +import { MockBuilder, MockRender, ngMocks } from 'ng-mocks'; + +@Directive({ + selector: 'host', + standalone: true, +}) +class HostDirective { + @HostBinding('attr.name') @Input() input?: string; + + public hostTestHostDirective() {} +} + +@Component({ + selector: 'target', + hostDirectives: [ + { + directive: HostDirective, + inputs: ['input'], + }, + ], + template: 'target', +}) +class TargetComponent { + public targetTestHostDirective() {} +} + +describe('TestHostDirective', () => { + beforeEach(() => MockBuilder(HostDirective, TargetComponent)); + + it('keeps host directives', () => { + const fixture = MockRender(TargetComponent, { input: 'test' }); + + const directive = ngMocks.findInstance(HostDirective); + expect(directive.input).toEqual('test'); + expect(ngMocks.formatHtml(fixture)).toContain(' name="test"'); + }); +}); +``` diff --git a/docs/articles/guides/mock/host-directive.md b/docs/articles/guides/mock/host-directive.md new file mode 100644 index 0000000000..15f732d68f --- /dev/null +++ b/docs/articles/guides/mock/host-directive.md @@ -0,0 +1,149 @@ +--- +title: How to mock a host directive +description: Mocking an Angular host directive +sidebar_label: Host Directive +--- + +It can happen that a component hast a host directive which should be mocked in a test. + +There are several ways how `ng-mocks` can mock host directives: + +- [`MockBuilder`](../../api/MockBuilder.md#shallow-flag) and its [`shallow`](../../api/MockBuilder.md#shallow-flag) flag +- [`MockBuilder`](../../api/MockBuilder.md) constructor +- `TestBed` + +## `shallow` flag + +It's the easiest and recommended way which covers all host directives automatically, so there is no need to specify all of them. + +To mock all host directives, simply provide [`shallow`](../../api/MockBuilder.md#shallow-flag) flag in [`MockBuilder.mock`](../../api/MockBuilder.md#mock): + +```ts +beforeEach(() => + MockBuilder().mock(TargetComponent, { shallow: true }), +); +``` + +Now, all host directives and their dependencies will be mocks. + +## `MockBuilder` + +[`MockBuilder`](../../api/MockBuilder.md) is useful, when only one or some host directives should be mocked. + +To do so, the host directives should be specified as the second parameter of [`MockBuilder`](../../api/MockBuilder.md): + +```ts +beforeEach(() => MockBuilder(TargetComponent, HostDirective)); +``` + +That's it, now `TargetComponent` will have a mock of `HostDirective`. + +## TestBed + +If you use `TestBed`, you should mock the desired host directive with [`MockDirective`](../../api/MockDirective.md) +and import / declare its component. + +For example, if the name of the component is `TargetComponent` and its host directive is called `HostDirective`, +then `TestBed` can be defined like that: + +```ts +beforeEach(() => + TestBed.configureTestingModule({ + imports: [MockDirective(HostDirective)], // mocking the host directive + declarations: [TargetComponent], // declaring the component under test + }).compileComponents(), +); +``` + +Profit! Under the hood `TargetComponent` will be redefined to use a mock of `HostDirective`. + +## Live example + +- [Try it on CodeSandbox](https://codesandbox.io/s/github/help-me-mom/ng-mocks-sandbox/tree/tests?file=/src/examples/MockHostDirective/test.spec.ts&initialpath=%3Fspec%3DMockHostDirective) +- [Try it on StackBlitz](https://stackblitz.com/github/help-me-mom/ng-mocks-sandbox/tree/tests?file=src/examples/MockHostDirective/test.spec.ts&initialpath=%3Fspec%3DMockHostDirective) + +```ts title="https://github.com/help-me-mom/ng-mocks/blob/master/examples/MockHostDirective/test.spec.ts" +import { + Component, + Directive, + EventEmitter, + Input, + Output, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { + MockBuilder, + MockDirective, + MockRender, + ngMocks, +} from 'ng-mocks'; + +@Directive({ + selector: 'host', + standalone: true, +}) +class HostDirective { + @Input() input?: string; + @Output() output = new EventEmitter(); + + public hostMockHostDirective() {} +} + +@Component({ + selector: 'target', + hostDirectives: [ + { + directive: HostDirective, + inputs: ['input'], + outputs: ['output'], + }, + ], + template: 'target', +}) +class TargetComponent { + public targetMockHostDirective() {} +} + +describe('MockHostDirective', () => { + describe('TestBed', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [MockDirective(HostDirective)], + declarations: [TargetComponent], + }), + ); + + it('mocks host directives', () => { + const fixture = TestBed.createComponent(TargetComponent); + + const directive = ngMocks.findInstance(fixture, HostDirective); + expect(directive).toBeDefined(); + }); + }); + + describe('MockBuilder', () => { + beforeEach(() => MockBuilder(TargetComponent, HostDirective)); + + it('mocks host directives', () => { + MockRender(TargetComponent, { input: 'test' }); + + const directive = ngMocks.findInstance(HostDirective); + expect(directive.input).toEqual('test'); + }); + }); + + describe('MockBuilder:shallow', () => { + beforeEach(() => + MockBuilder().mock(TargetComponent, { shallow: true }), + ); + + it('mocks host directives', () => { + MockRender(TargetComponent, { input: 'test' }); + + const directive = ngMocks.findInstance(HostDirective); + expect(directive.input).toEqual('test'); + }); + }); +}); +``` diff --git a/docs/sidebars.js b/docs/sidebars.js index c3c1696641..ce50d98b62 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -150,6 +150,7 @@ module.exports = { 'guides/directive-structural', 'guides/directive-structural-context', 'guides/directive-standalone', + 'guides/host-directive', 'guides/pipe', 'guides/pipe-standalone', 'guides/view-child', @@ -171,6 +172,7 @@ module.exports = { collapsed: false, items: [ 'guides/mock/directive-structural-let-of', + 'guides/mock/host-directive', 'guides/mock/activated-route', 'guides/mock/dynamic-components', ], diff --git a/examples/MockHostDirective/test.spec.ts b/examples/MockHostDirective/test.spec.ts new file mode 100644 index 0000000000..85b879819f --- /dev/null +++ b/examples/MockHostDirective/test.spec.ts @@ -0,0 +1,96 @@ +import { + Component, + Directive, + EventEmitter, + Input, + Output, + VERSION, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { + MockBuilder, + MockDirective, + MockRender, + ngMocks, +} from 'ng-mocks'; + +@Directive( + { + selector: 'host', + standalone: true, + } as never /* TODO: remove after upgrade to a14 */, +) +class HostDirective { + @Input() input?: string; + @Output() output = new EventEmitter(); + + public hostMockHostDirective() {} +} + +@Component( + { + selector: 'target', + hostDirectives: [ + { + directive: HostDirective, + inputs: ['input'], + outputs: ['output'], + }, + ], + template: 'target', + } as never /* TODO: remove after upgrade to a15 */, +) +class TargetComponent { + public targetMockHostDirective() {} +} + +describe('MockHostDirective', () => { + if (Number.parseInt(VERSION.major, 10) < 15) { + it('needs a15+', () => { + expect(true).toBeTruthy(); + }); + + return; + } + + describe('TestBed', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [MockDirective(HostDirective)], + declarations: [TargetComponent], + }).compileComponents(), + ); + + it('mocks host directives', () => { + const fixture = TestBed.createComponent(TargetComponent); + + const directive = ngMocks.findInstance(fixture, HostDirective); + expect(directive).toBeDefined(); + }); + }); + + describe('MockBuilder', () => { + beforeEach(() => MockBuilder(TargetComponent, HostDirective)); + + it('mocks host directives', () => { + MockRender(TargetComponent, { input: 'test' }); + + const directive = ngMocks.findInstance(HostDirective); + expect(directive.input).toEqual('test'); + }); + }); + + describe('MockBuilder:shallow', () => { + beforeEach(() => + MockBuilder().mock(TargetComponent, { shallow: true }), + ); + + it('mocks host directives', () => { + MockRender(TargetComponent, { input: 'test' }); + + const directive = ngMocks.findInstance(HostDirective); + expect(directive.input).toEqual('test'); + }); + }); +}); diff --git a/examples/TestHostDirective/test.spec.ts b/examples/TestHostDirective/test.spec.ts new file mode 100644 index 0000000000..ed744479dd --- /dev/null +++ b/examples/TestHostDirective/test.spec.ts @@ -0,0 +1,57 @@ +import { + Component, + Directive, + HostBinding, + Input, + VERSION, +} from '@angular/core'; + +import { MockBuilder, MockRender, ngMocks } from 'ng-mocks'; + +@Directive( + { + selector: 'host', + standalone: true, + } as never /* TODO: remove after upgrade to a14 */, +) +class HostDirective { + @HostBinding('attr.name') @Input() input?: string; + + public hostTestHostDirective() {} +} + +@Component( + { + selector: 'target', + hostDirectives: [ + { + directive: HostDirective, + inputs: ['input'], + }, + ], + template: 'target', + } as never /* TODO: remove after upgrade to a15 */, +) +class TargetComponent { + public targetTestHostDirective() {} +} + +describe('TestHostDirective', () => { + if (Number.parseInt(VERSION.major, 10) < 15) { + it('needs a15+', () => { + expect(true).toBeTruthy(); + }); + + return; + } + + beforeEach(() => MockBuilder(HostDirective, TargetComponent)); + + it('keeps host directives', () => { + const fixture = MockRender(TargetComponent, { input: 'test' }); + + const directive = ngMocks.findInstance(HostDirective); + expect(directive.input).toEqual('test'); + expect(ngMocks.formatHtml(fixture)).toContain(' name="test"'); + }); +}); diff --git a/libs/ng-mocks/src/lib/common/core.config.ts b/libs/ng-mocks/src/lib/common/core.config.ts index 90fe722bc1..3f6089e81f 100644 --- a/libs/ng-mocks/src/lib/common/core.config.ts +++ b/libs/ng-mocks/src/lib/common/core.config.ts @@ -37,6 +37,7 @@ export default { dependencies: [ 'declarations', + 'hostDirectives', 'entryComponents', 'bootstrap', 'providers', diff --git a/libs/ng-mocks/src/lib/common/core.types.ts b/libs/ng-mocks/src/lib/common/core.types.ts index 079e0b66a9..f2d86abc48 100644 --- a/libs/ng-mocks/src/lib/common/core.types.ts +++ b/libs/ng-mocks/src/lib/common/core.types.ts @@ -58,6 +58,7 @@ export type DebugNodeSelector = */ export type dependencyKeys = | 'declarations' + | 'hostDirectives' | 'entryComponents' | 'bootstrap' | 'providers' diff --git a/libs/ng-mocks/src/lib/common/func.get-type.ts b/libs/ng-mocks/src/lib/common/func.get-type.ts index df2b7cbce7..19165726e6 100644 --- a/libs/ng-mocks/src/lib/common/func.get-type.ts +++ b/libs/ng-mocks/src/lib/common/func.get-type.ts @@ -5,5 +5,7 @@ export default (provider: any): any => { ? provider.provide : isNgModuleDefWithProviders(provider) ? provider.ngModule + : provider && typeof provider === 'object' && provider.directive + ? provider.directive : provider; }; diff --git a/libs/ng-mocks/src/lib/mock-module/mock-ng-def.ts b/libs/ng-mocks/src/lib/mock-module/mock-ng-def.ts index 24d871db04..988563fe9a 100644 --- a/libs/ng-mocks/src/lib/mock-module/mock-ng-def.ts +++ b/libs/ng-mocks/src/lib/mock-module/mock-ng-def.ts @@ -1,4 +1,4 @@ -import { Component, Directive, NgModule, Pipe, Provider } from '@angular/core'; +import { NgModule, Provider } from '@angular/core'; import CoreDefStack from '../common/core.def-stack'; import { flatten } from '../common/core.helpers'; @@ -19,6 +19,21 @@ const configureProcessMetaKeys = ( resolveProvider: (def: Provider) => any, ): Array<[dependencyKeys, (def: any) => any]> => [ ['declarations', resolve], + [ + 'hostDirectives', + (data: T) => { + const def = funcGetType(data); + const directive = resolve(def); + return directive === def + ? data + : data == def + ? directive + : { + ...data, + directive, + }; + }, + ], ['imports', resolve], ['entryComponents', resolve], ['bootstrap', resolve], @@ -28,14 +43,16 @@ const configureProcessMetaKeys = ( ['schemas', v => v], ]; -const processMeta = ( - ngModule: Partial> & { +const processMeta = < + T extends Partial> & { skipMarkProviders?: boolean; }, +>( + ngModule: T, resolve: (def: any) => any, resolveProvider: (def: Provider) => any, -): NgModule => { - const mockModuleDef: Partial = {}; +): Partial => { + const mockModuleDef: Partial = {}; const keys = configureProcessMetaKeys(resolve, resolveProvider); const cachePipe = ngMocksUniverse.flags.has('cachePipe'); @@ -118,13 +135,16 @@ const addExports = ( } }; -export default ( - ngModuleDef: NgModule & { +export default < + T extends NgModule & { + hostDirectives?: Array; skipMarkProviders?: boolean; skipExports?: boolean; }, +>( + ngModuleDef: T, ngModule?: Type, -): [boolean, NgModule, Map] => { +): [boolean, Partial, Map] => { const hasResolver = ngMocksUniverse.config.has('mockNgDefResolver'); if (!hasResolver) { ngMocksUniverse.config.set('mockNgDefResolver', new CoreDefStack()); diff --git a/libs/ng-mocks/src/lib/mock-render/func.create-wrapper.ts b/libs/ng-mocks/src/lib/mock-render/func.create-wrapper.ts index 0e2502b976..fddb8e5927 100644 --- a/libs/ng-mocks/src/lib/mock-render/func.create-wrapper.ts +++ b/libs/ng-mocks/src/lib/mock-render/func.create-wrapper.ts @@ -117,7 +117,24 @@ export default ( return ctor; } - const mockTemplate = funcGenerateTemplate(template, { ...meta, bindings }); + const inputs = meta.inputs ? [...meta.inputs] : []; + const outputs = meta.outputs ? [...meta.outputs] : []; + if (meta.hostDirectives) { + for (const hostDirective of meta.hostDirectives) { + if (typeof hostDirective !== 'object' || !hostDirective.directive) { + continue; + } + + if (hostDirective.inputs) { + inputs.push(...hostDirective.inputs); + } + if (hostDirective.outputs) { + outputs.push(...hostDirective.outputs); + } + } + } + + const mockTemplate = funcGenerateTemplate(template, { selector: meta.selector, inputs, outputs, bindings }); const options: Component = { providers: flags.providers, selector: 'mock-render', diff --git a/libs/ng-mocks/src/lib/mock/decorate-declaration.ts b/libs/ng-mocks/src/lib/mock/decorate-declaration.ts index 71e0f74175..3189127498 100644 --- a/libs/ng-mocks/src/lib/mock/decorate-declaration.ts +++ b/libs/ng-mocks/src/lib/mock/decorate-declaration.ts @@ -40,6 +40,7 @@ export default ( NgModule & { hostBindings?: Array<[string, any]>; hostListeners?: Array<[string, any, any]>; + hostDirectives?: Array | { directive: AnyType }>; imports?: any[]; standalone?: boolean; }, @@ -50,7 +51,7 @@ export default ( ngMocksUniverse.config.set('mockNgDefResolver', new CoreDefStack()); } - const options: T & { imports?: any[]; standalone?: boolean } = { + const options: T & { imports?: any[]; hostDirectives?: any[]; standalone?: boolean } = { ...params, }; @@ -71,6 +72,13 @@ export default ( } } + if (meta.hostDirectives) { + const [, { hostDirectives }] = mockNgDef({ hostDirectives: meta.hostDirectives, skipExports: true }); + if (hostDirectives?.length) { + options.hostDirectives = hostDirectives; + } + } + const { setControlValueAccessor, providers } = cloneProviders( source, mock, diff --git a/tests/issue-5117/base.spec.ts b/tests/issue-5117/base.spec.ts new file mode 100644 index 0000000000..2fd90c2379 --- /dev/null +++ b/tests/issue-5117/base.spec.ts @@ -0,0 +1,102 @@ +import { + Component, + Directive, + EventEmitter, + Input, + Output, + VERSION, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { isMockOf, MockBuilder, MockRender, ngMocks } from 'ng-mocks'; + +@Directive( + { + selector: 'base', + standalone: true, + } as never /* TODO: remove after upgrade to a14 */, +) +class BaseDirective { + @Input() public readonly input: string | undefined = undefined; + @Output() public readonly output = new EventEmitter(); + + public base5117base() {} +} + +@Component( + { + selector: 'target', + template: '', + hostDirectives: [BaseDirective], + } as never /* TODO: remove after upgrade to a15 */, +) +class TargetComponent { + @Input() public readonly input: string | undefined = undefined; + @Output() public readonly output = new EventEmitter(); + + public target5117base() {} +} + +describe('issue-5117:base', () => { + if (Number.parseInt(VERSION.major, 10) < 15) { + it('needs a15+', () => { + expect(true).toBeTruthy(); + }); + + return; + } + + describe('real', () => { + beforeEach(() => + TestBed.configureTestingModule({ + declarations: [TargetComponent], + }).compileComponents(), + ); + + it('binds host directives correctly', () => { + const input = 'input'; + let outputCalled = false; + const output = () => (outputCalled = true); + + MockRender(TargetComponent, { + input, + output, + }); + + // default state + expect(outputCalled).toEqual(false); + + // inputs and outputs aren't exposed + const base = ngMocks.findInstance(BaseDirective); + expect(isMockOf(base, BaseDirective)).toEqual(false); // real + expect(base.input).toEqual(undefined); + base.output.emit(); + expect(outputCalled).toEqual(false); + }); + }); + + describe('mock', () => { + beforeEach(() => MockBuilder([TargetComponent], [BaseDirective])); + + it('binds host directives correctly', () => { + const input = 'input'; + let outputCalled = false; + const output = () => (outputCalled = true); + + MockRender(TargetComponent, { + input, + output, + }); + + // default state + expect(outputCalled).toEqual(false); + + // inputs and outputs aren't exposed + const base = ngMocks.findInstance(BaseDirective); + expect(isMockOf(base, BaseDirective)).toEqual(true); // mock + expect(base.input).toEqual(undefined); + base.output.emit(); + expect(outputCalled).toEqual(false); + }); + }); +}); diff --git a/tests/issue-5117/coverage.spec.ts b/tests/issue-5117/coverage.spec.ts new file mode 100644 index 0000000000..6ce90e0a0f --- /dev/null +++ b/tests/issue-5117/coverage.spec.ts @@ -0,0 +1,182 @@ +import { + Component, + Directive, + EventEmitter, + Input, + Output, + VERSION, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { isMockOf, MockDirectives, ngMocks } from 'ng-mocks'; + +@Directive( + { + selector: 'base', + standalone: true, + } as never /* TODO: remove after upgrade to a14 */, +) +class BaseDirective { + @Input() public readonly input: string | undefined = undefined; + @Output() public readonly output = new EventEmitter(); + + public base5117coverage() {} +} + +@Directive( + { + selector: 'input', + standalone: true, + hostDirectives: [BaseDirective], + } as never /* TODO: remove after upgrade to a15 */, +) +class InputDirective { + @Input() public readonly input: string | undefined = undefined; + + public input5117coverage() {} +} + +@Directive( + { + selector: 'output', + standalone: true, + hostDirectives: [ + { + directive: InputDirective, + inputs: ['input: customInput'], + }, + ], + } as never /* TODO: remove after upgrade to a15 */, +) +class OutputDirective { + @Input() public readonly input: string | undefined = undefined; + @Output() public readonly output = new EventEmitter(); + + public output25117coverage() {} +} + +@Component( + { + selector: 'target', + template: '', + hostDirectives: [ + { + directive: OutputDirective, + inputs: ['input: customInput'], + outputs: ['output: customOutput'], + }, + ], + } as never /* TODO: remove after upgrade to a15 */, +) +class TargetComponent { + @Input() public readonly input: string | undefined = undefined; + @Output() public readonly output = new EventEmitter(); + + public target5117coverage() {} +} + +@Component({ + selector: 'render', + template: + '', +}) +class RenderComponent { + public readonly input = 'input'; + public outputCalled = false; + public readonly output = () => (this.outputCalled = true); + public readonly customInput = 'customInput'; + public customOutputCalled = false; + public readonly customOutput = () => + (this.customOutputCalled = true); + + public render5117coverage() {} +} + +describe('issue-5117:coverage', () => { + if (Number.parseInt(VERSION.major, 10) < 15) { + it('needs a15+', () => { + expect(true).toBeTruthy(); + }); + + return; + } + + describe('real', () => { + beforeEach(() => + TestBed.configureTestingModule({ + declarations: [TargetComponent, RenderComponent], + }).compileComponents(), + ); + + it('binds host directives correctly', () => { + const fixture = TestBed.createComponent(RenderComponent); + fixture.detectChanges(); + + // default state + const component = ngMocks.findInstance(RenderComponent); + expect(component.outputCalled).toEqual(false); + expect(component.customOutputCalled).toEqual(false); + + // inputs and outputs aren't exposed + const base = ngMocks.findInstance(BaseDirective); + expect(isMockOf(base, BaseDirective)).toEqual(false); // real + expect(base.input).toEqual(undefined); + base.output.emit(); + expect(component.outputCalled).toEqual(false); + + // inputs are exposed + const input = ngMocks.findInstance(InputDirective); + expect(isMockOf(input, InputDirective)).toEqual(false); // real + expect(input.input).toEqual(component.customInput); // a bug in angular? nested input + + // outputs are exposed + const output = ngMocks.findInstance(OutputDirective); + expect(isMockOf(output, OutputDirective)).toEqual(false); // real + expect(output.input).toEqual(component.customInput); + output.output.emit(); + expect(component.customOutputCalled).toEqual(true); + }); + }); + + describe('mock', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: MockDirectives( + OutputDirective, + InputDirective, + BaseDirective, + ), + declarations: [TargetComponent, RenderComponent], + }).compileComponents(), + ); + + it('binds host directives correctly', () => { + const fixture = TestBed.createComponent(RenderComponent); + fixture.detectChanges(); + + // default state + const component = ngMocks.findInstance(RenderComponent); + expect(component.outputCalled).toEqual(false); + expect(component.customOutputCalled).toEqual(false); + + // inputs and outputs aren't exposed + const base = ngMocks.findInstance(BaseDirective); + expect(isMockOf(base, BaseDirective)).toEqual(true); // mock + expect(base.input).toEqual(undefined); + base.output.emit(); + expect(component.outputCalled).toEqual(false); + + // inputs are exposed + const input = ngMocks.findInstance(InputDirective); + expect(isMockOf(input, InputDirective)).toEqual(true); // mock + expect(input.input).toEqual(component.customInput); // a bug in angular? nested input + + // outputs are exposed + const output = ngMocks.findInstance(OutputDirective); + expect(isMockOf(output, OutputDirective)).toEqual(true); // mock + expect(output.input).toEqual(component.customInput); + output.output.emit(); + expect(component.customOutputCalled).toEqual(true); + }); + }); +}); diff --git a/tests/issue-5117/input.spec.ts b/tests/issue-5117/input.spec.ts new file mode 100644 index 0000000000..15c3b5d6a5 --- /dev/null +++ b/tests/issue-5117/input.spec.ts @@ -0,0 +1,88 @@ +import { Component, Directive, Input, VERSION } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { isMockOf, MockBuilder, MockRender, ngMocks } from 'ng-mocks'; + +@Directive( + { + selector: 'input', + standalone: true, + } as never /* TODO: remove after upgrade to a14 */, +) +class InputDirective { + @Input() public readonly input: string | undefined = undefined; + + public input5117input() {} +} + +@Component( + { + selector: 'target', + template: '', + hostDirectives: [ + { + directive: InputDirective, + inputs: ['input: customInput'], + }, + ], + } as never /* TODO: remove after upgrade to a15 */, +) +class TargetComponent { + @Input() public readonly input: string | undefined = undefined; + + public target5117input() {} +} + +describe('issue-5117:input', () => { + if (Number.parseInt(VERSION.major, 10) < 15) { + it('needs a15+', () => { + expect(true).toBeTruthy(); + }); + + return; + } + + describe('real', () => { + beforeEach(() => + TestBed.configureTestingModule({ + declarations: [TargetComponent], + }).compileComponents(), + ); + + it('binds host directives correctly', () => { + const input = 'input'; + const customInput = 'customInput'; + + MockRender(TargetComponent, { + input, + customInput, + }); + + // inputs are exposed + const inputDirective = ngMocks.findInstance(InputDirective); + expect(isMockOf(inputDirective, InputDirective)).toEqual(false); // real + expect(inputDirective.input).toEqual(customInput); // a bug in angular? nested input + }); + }); + + describe('mock', () => { + beforeEach(() => + MockBuilder([TargetComponent], [InputDirective]), + ); + + it('binds host directives correctly', () => { + const input = 'input'; + const customInput = 'customInput'; + + MockRender(TargetComponent, { + input, + customInput, + }); + + // inputs are exposed + const inputDirective = ngMocks.findInstance(InputDirective); + expect(isMockOf(inputDirective, InputDirective)).toEqual(true); // mock + expect(inputDirective.input).toEqual(customInput); // a bug in angular? nested input + }); + }); +}); diff --git a/tests/issue-5117/output.spec.ts b/tests/issue-5117/output.spec.ts new file mode 100644 index 0000000000..5758b3839f --- /dev/null +++ b/tests/issue-5117/output.spec.ts @@ -0,0 +1,112 @@ +import { + Component, + Directive, + EventEmitter, + Output, + VERSION, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { isMockOf, MockBuilder, MockRender, ngMocks } from 'ng-mocks'; + +@Directive( + { + selector: 'output', + standalone: true, + } as never /* TODO: remove after upgrade to a14 */, +) +class OutputDirective { + @Output() public readonly output = new EventEmitter(); + + public output25117output() {} +} + +@Component( + { + selector: 'target', + template: '', + hostDirectives: [ + { + directive: OutputDirective, + outputs: ['output: customOutput'], + }, + ], + } as never /* TODO: remove after upgrade to a15 */, +) +class TargetComponent { + @Output() public readonly output = new EventEmitter(); + + public target5117output() {} +} + +describe('issue-5117:output', () => { + if (Number.parseInt(VERSION.major, 10) < 15) { + it('needs a15+', () => { + expect(true).toBeTruthy(); + }); + + return; + } + + describe('real', () => { + beforeEach(() => + TestBed.configureTestingModule({ + declarations: [TargetComponent], + }).compileComponents(), + ); + + it('binds host directives correctly', () => { + let outputCalled = false; + const output = () => (outputCalled = true); + let customOutputCalled = false; + const customOutput = () => (customOutputCalled = true); + + MockRender(TargetComponent, { + output, + customOutput, + }); + + // default state + expect(outputCalled).toEqual(false); + expect(customOutputCalled).toEqual(false); + + // outputs are exposed + const outputDirective = ngMocks.findInstance(OutputDirective); + expect(isMockOf(outputDirective, OutputDirective)).toEqual( + false, + ); // real + outputDirective.output.emit(); + expect(customOutputCalled).toEqual(true); + }); + }); + + describe('mock', () => { + beforeEach(() => + MockBuilder([TargetComponent], [OutputDirective]), + ); + + it('binds host directives correctly', () => { + let outputCalled = false; + const output = () => (outputCalled = true); + let customOutputCalled = false; + const customOutput = () => (customOutputCalled = true); + + MockRender(TargetComponent, { + output, + customOutput, + }); + + // default state + expect(outputCalled).toEqual(false); + expect(customOutputCalled).toEqual(false); + + // outputs are exposed + const outputDirective = ngMocks.findInstance(OutputDirective); + expect(isMockOf(outputDirective, OutputDirective)).toEqual( + true, + ); // mock + outputDirective.output.emit(); + expect(customOutputCalled).toEqual(true); + }); + }); +}); diff --git a/tests/issue-5117/test.spec.ts b/tests/issue-5117/test.spec.ts new file mode 100644 index 0000000000..60eaddd3d9 --- /dev/null +++ b/tests/issue-5117/test.spec.ts @@ -0,0 +1,163 @@ +import { + Component, + Directive, + EventEmitter, + Input, + Output, + VERSION, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { isMockOf, MockBuilder, MockRender, ngMocks } from 'ng-mocks'; + +@Directive( + { + selector: 'target1', + standalone: true, + hostDirectives: [], + } as never /* TODO: remove after upgrade to a15 */, +) +class Target1Directive { + @Input() public readonly input: string | undefined = undefined; + @Output() public readonly output = new EventEmitter(); + + public target15117() {} +} + +@Directive( + { + selector: 'target2', + standalone: true, + hostDirectives: [Target1Directive], + } as never /* TODO: remove after upgrade to a15 */, +) +class Target2Directive { + @Input() public readonly input: string | undefined = undefined; + @Output() public readonly output = new EventEmitter(); + + public target25117() {} +} + +@Component( + { + selector: 'target', + template: '', + hostDirectives: [ + { + directive: Target2Directive, + inputs: ['input: customInput'], + outputs: ['output: customOutput'], + }, + ], + } as never /* TODO: remove after upgrade to a15 */, +) +class TargetComponent { + @Input() public readonly input: string | undefined = undefined; + @Output() public readonly output = new EventEmitter(); + + public target5117() {} +} + +@Component({ + selector: 'render', + template: + '', +}) +class RenderComponent { + public readonly input = 'input'; + public outputCalled = false; + public readonly output = () => (this.outputCalled = true); + public readonly customInput = 'customInput'; + public customOutputCalled = false; + public readonly customOutput = () => + (this.customOutputCalled = true); + + public render5117() {} +} + +// @see https://github.com/help-me-mom/ng-mocks/issues/5117 +// @see https://angular.io/guide/directive-composition-api +describe('issue-5117', () => { + if (Number.parseInt(VERSION.major, 10) < 15) { + it('needs a15+', () => { + expect(true).toBeTruthy(); + }); + + return; + } + + describe('real', () => { + beforeEach(() => + TestBed.configureTestingModule({ + declarations: [TargetComponent, RenderComponent], + }).compileComponents(), + ); + + it('binds host directives correctly', () => { + const fixture = TestBed.createComponent(RenderComponent); + fixture.detectChanges(); + + // default state + const component = ngMocks.findInstance(RenderComponent); + expect(component.outputCalled).toEqual(false); + expect(component.customOutputCalled).toEqual(false); + + // inputs and outputs aren't exposed + const dir1 = ngMocks.findInstance(Target1Directive); + expect(isMockOf(dir1, Target1Directive)).toEqual(false); // real + expect(dir1.input).toEqual(undefined); + dir1.output.emit(); + expect(component.outputCalled).toEqual(false); + + // inputs and outputs are exposed + const dir2 = ngMocks.findInstance(Target2Directive); + expect(isMockOf(dir2, Target2Directive)).toEqual(false); // real + expect(dir2.input).toEqual(component.customInput); + dir2.output.emit(); + expect(component.customOutputCalled).toEqual(true); + }); + }); + + describe('mock', () => { + beforeEach(() => + MockBuilder(TargetComponent, [ + Target1Directive, + Target2Directive, + ]), + ); + + it('binds host directives correctly', () => { + const input = 'input'; + let outputCalled = false; + const output = () => (outputCalled = true); + const customInput = 'customInput'; + let customOutputCalled = false; + const customOutput = () => (customOutputCalled = true); + + MockRender(TargetComponent, { + input, + output, + customInput, + customOutput, + }); + + // default state + expect(outputCalled).toEqual(false); + expect(customOutputCalled).toEqual(false); + + // inputs and outputs aren't exposed + const dir1 = ngMocks.findInstance(Target1Directive); + expect(isMockOf(dir1, Target1Directive)).toEqual(true); // mock + expect(dir1.input).toEqual(undefined); + dir1.output.emit(); + expect(outputCalled).toEqual(false); + + // inputs and outputs are exposed + const dir2 = ngMocks.findInstance(Target2Directive); + expect(isMockOf(dir2, Target2Directive)).toEqual(true); // mock + expect(dir2.input).toEqual(customInput); + dir2.output.emit(); + expect(customOutputCalled).toEqual(true); + }); + }); +});