From c112930554c780fe6694cd6ee173834e01ed68f1 Mon Sep 17 00:00:00 2001 From: Simon Mumenthaler Date: Tue, 9 Jul 2024 10:23:12 +0200 Subject: [PATCH 1/4] new option `subscribeToOutputs` for render and rerender --- projects/testing-library/src/lib/models.ts | 36 ++++- .../src/lib/testing-library.ts | 146 ++++++++++++------ projects/testing-library/tests/render.spec.ts | 71 ++++++++- 3 files changed, 199 insertions(+), 54 deletions(-) diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index fad3394..e69244c 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,8 +1,14 @@ -import { Type, DebugElement } from '@angular/core'; -import {ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed} from '@angular/core/testing'; +import { Type, DebugElement, OutputRef } from '@angular/core'; +import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; +export type SubscribeToOutputsKeysWithCallback = { + [key in keyof T as T[key] extends OutputRef ? key : never]?: T[key] extends OutputRef + ? (val: U) => void + : never; +}; + export type RenderResultQueries = { [P in keyof Q]: BoundFunction }; export interface RenderResult extends RenderResultQueries { /** @@ -60,7 +66,7 @@ export interface RenderResult extend rerender: ( properties?: Pick< RenderTemplateOptions, - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'subscribeToOutputs' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => Promise; /** @@ -205,12 +211,12 @@ export interface RenderComponentOptions { ... } + * const sendValue = new EventEmitter(); * await render(AppComponent, { * componentOutputs: { * send: { @@ -220,6 +226,24 @@ export interface RenderComponentOptions; + + /** + * @description + * An object to subscribe to EventEmitters/Observables of the component + * + * @default + * {} + * + * @example + * const sendValue = (value) => { ... } + * await render(AppComponent, { + * subscribeToOutputs: { + * send: (_v:any) => void + * } + * }) + */ + subscribeToOutputs?: SubscribeToOutputsKeysWithCallback; + /** * @description * A collection of providers to inject dependencies of the component. @@ -379,7 +403,7 @@ export interface RenderComponentOptions { diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 9b57b50..905debd 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -5,6 +5,8 @@ import { isStandalone, NgZone, OnChanges, + OutputRef, + OutputRefSubscription, SimpleChange, SimpleChanges, Type, @@ -25,9 +27,17 @@ import { waitForOptions as dtlWaitForOptions, within as dtlWithin, } from '@testing-library/dom'; -import { ComponentOverride, RenderComponentOptions, RenderResult, RenderTemplateOptions } from './models'; +import { + ComponentOverride, + RenderComponentOptions, + RenderResult, + RenderTemplateOptions, + SubscribeToOutputsKeysWithCallback, +} from './models'; import { getConfig } from './config'; +type SubscribedOutput = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription]; + const mountedFixtures = new Set>(); const safeInject = TestBed.inject || TestBed.get; @@ -57,6 +67,7 @@ export async function render( componentProperties = {}, componentInputs = {}, componentOutputs = {}, + subscribeToOutputs = {}, componentProviders = [], childComponentOverrides = [], componentImports: componentImports, @@ -165,7 +176,55 @@ export async function render( let detectChanges: () => void; - const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs); + let renderedPropKeys = Object.keys(componentProperties); + let renderedInputKeys = Object.keys(componentInputs); + let renderedOutputKeys = Object.keys(componentOutputs); + let subscribedOutputs: SubscribedOutput[] = []; + + const renderFixture = async ( + properties: Partial, + inputs: Partial, + outputs: Partial, + subscribeTo: SubscribeToOutputsKeysWithCallback, + ): Promise> => { + const createdFixture: ComponentFixture = await createComponent(componentContainer); + setComponentProperties(createdFixture, properties); + setComponentInputs(createdFixture, inputs); + setComponentOutputs(createdFixture, outputs); + subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo); + + if (removeAngularAttributes) { + createdFixture.nativeElement.removeAttribute('ng-version'); + const idAttribute = createdFixture.nativeElement.getAttribute('id'); + if (idAttribute && idAttribute.startsWith('root')) { + createdFixture.nativeElement.removeAttribute('id'); + } + } + + mountedFixtures.add(createdFixture); + + let isAlive = true; + createdFixture.componentRef.onDestroy(() => (isAlive = false)); + + if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) { + const changes = getChangesObj(null, componentProperties); + createdFixture.componentInstance.ngOnChanges(changes); + } + + detectChanges = () => { + if (isAlive) { + createdFixture.detectChanges(); + } + }; + + if (detectChangesOnRender) { + detectChanges(); + } + + return createdFixture; + }; + + const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, subscribeToOutputs); if (deferBlockStates) { if (Array.isArray(deferBlockStates)) { @@ -177,13 +236,10 @@ export async function render( } } - let renderedPropKeys = Object.keys(componentProperties); - let renderedInputKeys = Object.keys(componentInputs); - let renderedOutputKeys = Object.keys(componentOutputs); const rerender = async ( properties?: Pick< RenderTemplateOptions, - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'subscribeToOutputs' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => { const newComponentInputs = properties?.componentInputs ?? {}; @@ -205,6 +261,22 @@ export async function render( setComponentOutputs(fixture, newComponentOutputs); renderedOutputKeys = Object.keys(newComponentOutputs); + // first unsubscribe the no longer available or changed callback-fns + const newSubscribeToOutputs: SubscribeToOutputsKeysWithCallback = properties?.subscribeToOutputs ?? {}; + for (const [key, cb, subscription] of subscribedOutputs) { + // when no longer provided or when the callback has changed + if (!(key in newSubscribeToOutputs) || cb !== (newSubscribeToOutputs as any)[key]) { + subscription.unsubscribe(); + } + } + // then subscribe the new callback-fns + subscribedOutputs = Object.entries(newSubscribeToOutputs).map(([key, cb]) => { + const existing = subscribedOutputs.find(([k]) => k === key); + return existing && existing[1] === cb + ? existing // nothing to do + : subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void); + }); + const newComponentProps = properties?.componentProperties ?? {}; const changesInComponentProps = update( fixture, @@ -249,47 +321,6 @@ export async function render( : console.log(dtlPrettyDOM(element, maxLength, options)), ...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)), }; - - async function renderFixture( - properties: Partial, - inputs: Partial, - outputs: Partial, - ): Promise> { - const createdFixture = await createComponent(componentContainer); - setComponentProperties(createdFixture, properties); - setComponentInputs(createdFixture, inputs); - setComponentOutputs(createdFixture, outputs); - - if (removeAngularAttributes) { - createdFixture.nativeElement.removeAttribute('ng-version'); - const idAttribute = createdFixture.nativeElement.getAttribute('id'); - if (idAttribute && idAttribute.startsWith('root')) { - createdFixture.nativeElement.removeAttribute('id'); - } - } - - mountedFixtures.add(createdFixture); - - let isAlive = true; - createdFixture.componentRef.onDestroy(() => (isAlive = false)); - - if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) { - const changes = getChangesObj(null, componentProperties); - createdFixture.componentInstance.ngOnChanges(changes); - } - - detectChanges = () => { - if (isAlive) { - createdFixture.detectChanges(); - } - }; - - if (detectChangesOnRender) { - detectChanges(); - } - - return createdFixture; - } } async function createComponent(component: Type): Promise> { @@ -355,6 +386,27 @@ function setComponentInputs( } } +function subscribeToComponentOutputs( + fixture: ComponentFixture, + listeners: SubscribeToOutputsKeysWithCallback, +): SubscribedOutput[] { + // with Object.entries we lose the type information of the key and callback, therefore we need to cast them + return Object.entries(listeners).map(([key, cb]) => + subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void), + ); +} + +function subscribeToComponentOutput( + fixture: ComponentFixture, + key: keyof SutType, + cb: (val: any) => void, +): SubscribedOutput { + const eventEmitter = (fixture.componentInstance as any)[key] as OutputRef; + const subscription = eventEmitter.subscribe(cb); + fixture.componentRef.onDestroy(subscription.unsubscribe.bind(subscription)); + return [key, cb, subscription]; +} + function overrideComponentImports(sut: Type | string, imports: (Type | any[])[] | undefined) { if (imports) { if (typeof sut === 'function' && isStandalone(sut)) { diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index 56f4608..09e4763 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -10,12 +10,14 @@ import { Injectable, EventEmitter, Output, + ElementRef, + inject, } from '@angular/core'; import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TestBed } from '@angular/core/testing'; import { render, fireEvent, screen } from '../src/public_api'; import { ActivatedRoute, Resolve, RouterModule } from '@angular/router'; -import { map } from 'rxjs'; +import { fromEvent, map } from 'rxjs'; import { AsyncPipe, NgIf } from '@angular/common'; @Component({ @@ -183,6 +185,73 @@ describe('componentOutputs', () => { }); }); +describe('subscribeToOutputs', () => { + @Component({ template: ``, standalone: true }) + class TestFixtureWithEventEmitterComponent { + @Output() readonly event = new EventEmitter(); + } + + @Component({ template: ``, standalone: true }) + class TestFixtureWithDerivedEventComponent { + @Output() readonly event = fromEvent(inject(ElementRef).nativeElement, 'click'); + } + + it('should subscribe passed listener to the component EventEmitter', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithEventEmitterComponent, { subscribeToOutputs: { event: spy } }); + fixture.componentInstance.event.emit(); + expect(spy).toHaveBeenCalled(); + }); + + it('should unsubscribe on rerender without listener', async () => { + const spy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + subscribeToOutputs: { event: spy }, + }); + + await rerender({}); + + fixture.componentInstance.event.emit(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not unsubscribe when same listener function is used on rerender', async () => { + const spy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + subscribeToOutputs: { event: spy }, + }); + + await rerender({ subscribeToOutputs: { event: spy } }); + + fixture.componentInstance.event.emit(); + expect(spy).toHaveBeenCalled(); + }); + + it('should unsubscribe old and subscribe new listener function on rerender', async () => { + const firstSpy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + subscribeToOutputs: { event: firstSpy }, + }); + + const newSpy = jest.fn(); + await rerender({ subscribeToOutputs: { event: newSpy } }); + + fixture.componentInstance.event.emit(); + + expect(firstSpy).not.toHaveBeenCalled(); + expect(newSpy).toHaveBeenCalled(); + }); + + it('should subscribe passed listener to derived component outputs', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithDerivedEventComponent, { + subscribeToOutputs: { event: spy }, + }); + fireEvent.click(fixture.nativeElement); + expect(spy).toHaveBeenCalled(); + }); +}); + describe('animationModule', () => { @NgModule({ declarations: [FixtureComponent], From b935d101319684b66e6af58473a3f5d90f8b0ae2 Mon Sep 17 00:00:00 2001 From: Simon Mumenthaler Date: Tue, 16 Jul 2024 22:26:03 +0200 Subject: [PATCH 2/4] add test with functional output api --- projects/testing-library/tests/render.spec.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index 09e4763..a19cc6e 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -12,7 +12,9 @@ import { Output, ElementRef, inject, + output, } from '@angular/core'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TestBed } from '@angular/core/testing'; import { render, fireEvent, screen } from '../src/public_api'; @@ -196,6 +198,11 @@ describe('subscribeToOutputs', () => { @Output() readonly event = fromEvent(inject(ElementRef).nativeElement, 'click'); } + @Component({ template: ``, standalone: true }) + class TestFixtureWithFunctionalOutputComponent { + readonly event = output(); + } + it('should subscribe passed listener to the component EventEmitter', async () => { const spy = jest.fn(); const { fixture } = await render(TestFixtureWithEventEmitterComponent, { subscribeToOutputs: { event: spy } }); @@ -242,7 +249,7 @@ describe('subscribeToOutputs', () => { expect(newSpy).toHaveBeenCalled(); }); - it('should subscribe passed listener to derived component outputs', async () => { + it('should subscribe passed listener to a derived component output', async () => { const spy = jest.fn(); const { fixture } = await render(TestFixtureWithDerivedEventComponent, { subscribeToOutputs: { event: spy }, @@ -250,6 +257,28 @@ describe('subscribeToOutputs', () => { fireEvent.click(fixture.nativeElement); expect(spy).toHaveBeenCalled(); }); + + it('should subscribe passed listener to a functional component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithFunctionalOutputComponent, { + subscribeToOutputs: { event: spy }, + }); + fixture.componentInstance.event.emit('test'); + expect(spy).toHaveBeenCalledWith('test'); + }); + + it('should subscribe passed listener to a functional derived component output', async () => { + @Component({ template: ``, standalone: true }) + class TestFixtureWithFunctionalDerivedEventComponent { + readonly event = outputFromObservable(fromEvent(inject(ElementRef).nativeElement, 'click')); + } + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithFunctionalDerivedEventComponent, { + subscribeToOutputs: { event: spy }, + }); + fireEvent.click(fixture.nativeElement); + expect(spy).toHaveBeenCalled(); + }); }); describe('animationModule', () => { From 46bc4a0f7d0f8bcabb2bb58d134de0c9829c3724 Mon Sep 17 00:00:00 2001 From: Simon Mumenthaler Date: Tue, 16 Jul 2024 22:34:30 +0200 Subject: [PATCH 3/4] rename option to --- projects/testing-library/src/lib/models.ts | 10 +++++----- .../src/lib/testing-library.ts | 18 ++++++++--------- projects/testing-library/tests/render.spec.ts | 20 +++++++++---------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index e69244c..6482787 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -3,7 +3,7 @@ import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from ' import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; -export type SubscribeToOutputsKeysWithCallback = { +export type OutputRefKeysWithCallback = { [key in keyof T as T[key] extends OutputRef ? key : never]?: T[key] extends OutputRef ? (val: U) => void : never; @@ -66,7 +66,7 @@ export interface RenderResult extend rerender: ( properties?: Pick< RenderTemplateOptions, - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'subscribeToOutputs' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => Promise; /** @@ -211,7 +211,7 @@ export interface RenderComponentOptions { ... } * await render(AppComponent, { - * subscribeToOutputs: { + * on: { * send: (_v:any) => void * } * }) */ - subscribeToOutputs?: SubscribeToOutputsKeysWithCallback; + on?: OutputRefKeysWithCallback; /** * @description diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 905debd..0ceda24 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -32,7 +32,7 @@ import { RenderComponentOptions, RenderResult, RenderTemplateOptions, - SubscribeToOutputsKeysWithCallback, + OutputRefKeysWithCallback, } from './models'; import { getConfig } from './config'; @@ -67,7 +67,7 @@ export async function render( componentProperties = {}, componentInputs = {}, componentOutputs = {}, - subscribeToOutputs = {}, + on = {}, componentProviders = [], childComponentOverrides = [], componentImports: componentImports, @@ -185,7 +185,7 @@ export async function render( properties: Partial, inputs: Partial, outputs: Partial, - subscribeTo: SubscribeToOutputsKeysWithCallback, + subscribeTo: OutputRefKeysWithCallback, ): Promise> => { const createdFixture: ComponentFixture = await createComponent(componentContainer); setComponentProperties(createdFixture, properties); @@ -224,7 +224,7 @@ export async function render( return createdFixture; }; - const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, subscribeToOutputs); + const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, on); if (deferBlockStates) { if (Array.isArray(deferBlockStates)) { @@ -239,7 +239,7 @@ export async function render( const rerender = async ( properties?: Pick< RenderTemplateOptions, - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'subscribeToOutputs' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => { const newComponentInputs = properties?.componentInputs ?? {}; @@ -262,15 +262,15 @@ export async function render( renderedOutputKeys = Object.keys(newComponentOutputs); // first unsubscribe the no longer available or changed callback-fns - const newSubscribeToOutputs: SubscribeToOutputsKeysWithCallback = properties?.subscribeToOutputs ?? {}; + const newObservableSubscriptions: OutputRefKeysWithCallback = properties?.on ?? {}; for (const [key, cb, subscription] of subscribedOutputs) { // when no longer provided or when the callback has changed - if (!(key in newSubscribeToOutputs) || cb !== (newSubscribeToOutputs as any)[key]) { + if (!(key in newObservableSubscriptions) || cb !== (newObservableSubscriptions as any)[key]) { subscription.unsubscribe(); } } // then subscribe the new callback-fns - subscribedOutputs = Object.entries(newSubscribeToOutputs).map(([key, cb]) => { + subscribedOutputs = Object.entries(newObservableSubscriptions).map(([key, cb]) => { const existing = subscribedOutputs.find(([k]) => k === key); return existing && existing[1] === cb ? existing // nothing to do @@ -388,7 +388,7 @@ function setComponentInputs( function subscribeToComponentOutputs( fixture: ComponentFixture, - listeners: SubscribeToOutputsKeysWithCallback, + listeners: OutputRefKeysWithCallback, ): SubscribedOutput[] { // with Object.entries we lose the type information of the key and callback, therefore we need to cast them return Object.entries(listeners).map(([key, cb]) => diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index a19cc6e..68370cc 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -187,7 +187,7 @@ describe('componentOutputs', () => { }); }); -describe('subscribeToOutputs', () => { +describe('on', () => { @Component({ template: ``, standalone: true }) class TestFixtureWithEventEmitterComponent { @Output() readonly event = new EventEmitter(); @@ -205,7 +205,7 @@ describe('subscribeToOutputs', () => { it('should subscribe passed listener to the component EventEmitter', async () => { const spy = jest.fn(); - const { fixture } = await render(TestFixtureWithEventEmitterComponent, { subscribeToOutputs: { event: spy } }); + const { fixture } = await render(TestFixtureWithEventEmitterComponent, { on: { event: spy } }); fixture.componentInstance.event.emit(); expect(spy).toHaveBeenCalled(); }); @@ -213,7 +213,7 @@ describe('subscribeToOutputs', () => { it('should unsubscribe on rerender without listener', async () => { const spy = jest.fn(); const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { - subscribeToOutputs: { event: spy }, + on: { event: spy }, }); await rerender({}); @@ -225,10 +225,10 @@ describe('subscribeToOutputs', () => { it('should not unsubscribe when same listener function is used on rerender', async () => { const spy = jest.fn(); const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { - subscribeToOutputs: { event: spy }, + on: { event: spy }, }); - await rerender({ subscribeToOutputs: { event: spy } }); + await rerender({ on: { event: spy } }); fixture.componentInstance.event.emit(); expect(spy).toHaveBeenCalled(); @@ -237,11 +237,11 @@ describe('subscribeToOutputs', () => { it('should unsubscribe old and subscribe new listener function on rerender', async () => { const firstSpy = jest.fn(); const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { - subscribeToOutputs: { event: firstSpy }, + on: { event: firstSpy }, }); const newSpy = jest.fn(); - await rerender({ subscribeToOutputs: { event: newSpy } }); + await rerender({ on: { event: newSpy } }); fixture.componentInstance.event.emit(); @@ -252,7 +252,7 @@ describe('subscribeToOutputs', () => { it('should subscribe passed listener to a derived component output', async () => { const spy = jest.fn(); const { fixture } = await render(TestFixtureWithDerivedEventComponent, { - subscribeToOutputs: { event: spy }, + on: { event: spy }, }); fireEvent.click(fixture.nativeElement); expect(spy).toHaveBeenCalled(); @@ -261,7 +261,7 @@ describe('subscribeToOutputs', () => { it('should subscribe passed listener to a functional component output', async () => { const spy = jest.fn(); const { fixture } = await render(TestFixtureWithFunctionalOutputComponent, { - subscribeToOutputs: { event: spy }, + on: { event: spy }, }); fixture.componentInstance.event.emit('test'); expect(spy).toHaveBeenCalledWith('test'); @@ -274,7 +274,7 @@ describe('subscribeToOutputs', () => { } const spy = jest.fn(); const { fixture } = await render(TestFixtureWithFunctionalDerivedEventComponent, { - subscribeToOutputs: { event: spy }, + on: { event: spy }, }); fireEvent.click(fixture.nativeElement); expect(spy).toHaveBeenCalled(); From 54d9e1731e6592e43c0b16dcde85d4ec1c90e44c Mon Sep 17 00:00:00 2001 From: Simon Mumenthaler Date: Thu, 18 Jul 2024 12:22:29 +0200 Subject: [PATCH 4/4] fix type `OutputRefKeysWithCallback` and add typings tests --- projects/testing-library/src/lib/models.ts | 8 ++-- projects/testing-library/tests/render.spec.ts | 40 ++++++++++++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 6482787..3cf053a 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,10 +1,12 @@ -import { Type, DebugElement, OutputRef } from '@angular/core'; +import { Type, DebugElement, OutputRef, EventEmitter } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; export type OutputRefKeysWithCallback = { - [key in keyof T as T[key] extends OutputRef ? key : never]?: T[key] extends OutputRef + [key in keyof T]?: T[key] extends EventEmitter + ? (val: U) => void + : T[key] extends OutputRef ? (val: U) => void : never; }; @@ -229,7 +231,7 @@ export interface RenderComponentOptions { readonly event = output(); } + @Component({ template: ``, standalone: true }) + class TestFixtureWithFunctionalDerivedEventComponent { + readonly event = outputFromObservable(fromEvent(inject(ElementRef).nativeElement, 'click')); + } + it('should subscribe passed listener to the component EventEmitter', async () => { const spy = jest.fn(); const { fixture } = await render(TestFixtureWithEventEmitterComponent, { on: { event: spy } }); @@ -268,10 +273,6 @@ describe('on', () => { }); it('should subscribe passed listener to a functional derived component output', async () => { - @Component({ template: ``, standalone: true }) - class TestFixtureWithFunctionalDerivedEventComponent { - readonly event = outputFromObservable(fromEvent(inject(ElementRef).nativeElement, 'click')); - } const spy = jest.fn(); const { fixture } = await render(TestFixtureWithFunctionalDerivedEventComponent, { on: { event: spy }, @@ -279,6 +280,35 @@ describe('on', () => { fireEvent.click(fixture.nativeElement); expect(spy).toHaveBeenCalled(); }); + + it('OutputRefKeysWithCallback is correctly typed', () => { + const fnWithVoidArg = (_: void) => void 0; + const fnWithNumberArg = (_: number) => void 0; + const fnWithStringArg = (_: string) => void 0; + const fnWithMouseEventArg = (_: MouseEvent) => void 0; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + function _test(_on: OutputRefKeysWithCallback) {} + + // @ts-expect-error + _test({ event: fnWithNumberArg }); + _test({ event: fnWithVoidArg }); + + // @ts-expect-error + _test({ event: fnWithNumberArg }); + _test({ event: fnWithMouseEventArg }); + + // @ts-expect-error + _test({ event: fnWithNumberArg }); + _test({ event: fnWithStringArg }); + + // @ts-expect-error + _test({ event: fnWithNumberArg }); + _test({ event: fnWithMouseEventArg }); + + // add a statement so the test succeeds + expect(true).toBeTruthy(); + }); }); describe('animationModule', () => {