diff --git a/docs/src/content/docs/testing-components.md b/docs/src/content/docs/testing-components.md index 0a1d6d06..494976de 100644 --- a/docs/src/content/docs/testing-components.md +++ b/docs/src/content/docs/testing-components.md @@ -73,6 +73,29 @@ it('should...', () => { }); ``` +`props` keys may be either the class property name or the input's public name, so +an input that declares an alias can be set either way: + +```ts +@Component({ /* ... */ }) +class UserComponent { + @Input('userName') name = ''; + age = input.required({ alias: 'userAge' }); +} + +it('should...', () => { + // by class property name — type-checked and autocompleted + spectator = createComponent({ props: { name: 'John', age: 30 } }); + + // ...or by public name + spectator = createComponent({ props: { userName: 'John', userAge: 30 } }); +}); +``` + +Because an alias cannot be derived from the type system, `props` accepts unknown +string keys. A key that matches no input is reported by Angular at runtime +(`NG0303`) rather than at compile time. + By providing `overrideComponents` options in scope of our `createComponent()` function we can define the way of overriding standalone component and it's dependencies ```ts @Component({ @@ -196,7 +219,8 @@ spectator.detectChanges(); ``` ### `setInput()` -Changes the value of an `@Input()` of the tested component: +Changes the value of an `@Input()` of the tested component. As with `props`, an +input may be addressed by its class property name or by its public name: ```ts it('should...', () => { @@ -205,6 +229,9 @@ it('should...', () => { spectator.setInput({ className: 'danger' }); + + // an input declared as `@Input('cssClass') className` answers to both + spectator.setInput('cssClass', 'danger'); }); ``` ### `output()` diff --git a/projects/spectator/jest/test/props-alias-names.spec.ts b/projects/spectator/jest/test/props-alias-names.spec.ts new file mode 100644 index 00000000..e7cb1a82 --- /dev/null +++ b/projects/spectator/jest/test/props-alias-names.spec.ts @@ -0,0 +1,227 @@ +import { Component, Input, input, model } from '@angular/core'; +import { createComponentFactory, createRoutingFactory } from '@openng/spectator/jest'; + +describe('PropsAliasNames', () => { + describe('input decorators', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name }}
+
{{ numOfYears }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + @Input({ alias: 'age' }) public numOfYears = 0; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of an @Input() with a rename alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an @Input() with an alias option', () => { + const spectator = createComponent({ props: { numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept property names and alias names in the same object', () => { + const spectator = createComponent({ props: { name: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('setInput should accept the property name of an aliased input', () => { + const spectator = createComponent(); + + spectator.setInput('name', 'John'); + spectator.setInput('numOfYears', 123); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + }); + + describe('signal inputs', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name() }}
+
{{ numOfYears() }}
+
{{ nickname() }}
+ `, + standalone: true, + }) + class DummyComponent { + public name = input.required({ alias: 'userName' }); + public numOfYears = input(0, { alias: 'age' }); + public nickname = model('', { alias: 'nick' }); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of a required signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an optional signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John', numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept the property name of a model with an alias', () => { + const spectator = createComponent({ props: { name: 'John', nickname: 'Johnny' } }); + + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123, nick: 'Johnny' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + }); + + describe('inputs without an alias', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ title }}
+
{{ count() }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input() public title = ''; + public count = input(0); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should keep working for inputs that declare no alias', () => { + const spectator = createComponent({ props: { title: 'Hello', count: 7 } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('7'); + }); + + it('props should leave a key that matches no input alone', () => { + // Angular reports the unknown key itself (NG0303); the resolver must not + // silently redirect it onto some other input. + const spectator = createComponent({ props: { title: 'Hello', notAnInput: 'ignored' } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('0'); + }); + }); + + describe('a key that is both a public name and a property name', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ numOfYears }}
+
{{ age }}
+ `, + standalone: true, + }) + class DummyComponent { + // 'age' is the public name of this input... + @Input({ alias: 'age' }) public numOfYears = 'no-years'; + // ...and the property name of this one. Both are strings on purpose: the + // type describes the property, so a collision is only expressible when the + // two inputs agree on their value type. + @Input({ alias: 'collided' }) public age = 'untouched'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should resolve the key as a public name, not as a property name', () => { + const spectator = createComponent({ props: { age: 'set-via-public-name' } }); + + expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('set-via-public-name'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched'); + }); + + it('props should still reach the shadowed input through its own public name', () => { + const spectator = createComponent({ props: { collided: 'reached' } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('reached'); + }); + }); + + describe('no props', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = 'default'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should be a no-op when it is explicitly undefined', () => { + const spectator = createComponent({ props: undefined }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('props should be a no-op when no overrides are passed at all', () => { + const spectator = createComponent(); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('setInput should be a no-op when it is given nothing', () => { + const spectator = createComponent(); + + spectator.setInput(undefined as unknown as Record); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + }); + + describe('createRoutingFactory', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + } + + const createComponent = createRoutingFactory(DummyComponent); + + it('props should accept the property name of an aliased input', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should still accept the alias name', () => { + const spectator = createComponent({ props: { userName: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + }); +}); diff --git a/projects/spectator/src/lib/internals/query.ts b/projects/spectator/src/lib/internals/query.ts index 8470bde0..531800e2 100644 --- a/projects/spectator/src/lib/internals/query.ts +++ b/projects/spectator/src/lib/internals/query.ts @@ -5,6 +5,8 @@ import { DOMSelector } from '../dom-selectors'; import { isString, QueryOptions, QueryType } from '../types'; import { type Token } from '../token'; +import { inputNameResolver } from './resolve-input-name'; + export function getChildren(debugElementRoot: DebugElement): (directiveOrSelector: QueryType, options?: QueryOptions) => R[] { return (directiveOrSelector: QueryType, options: QueryOptions = { root: false, read: undefined }): R[] => { if (directiveOrSelector instanceof DOMSelector) { @@ -30,16 +32,19 @@ export function getChildren(debugElementRoot: DebugElement): (directiveOrSele /** * @internal * Set props on a component. This is used in `createComponent` and `createRoutingFactory` since we have direct access to the componentRef. + * Keys may be either the class property name or the input's public name, since `ComponentRef.setInput()` only accepts the latter. */ export function setProps(componentRef: ComponentRef, key: K, value: V): T & { [KEY in K]: V }; export function setProps(componentRef: ComponentRef, keyValues?: KV): T & KV; export function setProps(componentRef: ComponentRef, keyOrKeyValues: any, value?: any): any { + const resolveInputName = inputNameResolver(componentRef.componentType); + if (isString(keyOrKeyValues)) { - componentRef.setInput(keyOrKeyValues, value); + componentRef.setInput(resolveInputName(keyOrKeyValues), value); } else { // eslint-disable-next-line guard-for-in for (const p in keyOrKeyValues) { - componentRef.setInput(p, keyOrKeyValues[p]); + componentRef.setInput(resolveInputName(p), keyOrKeyValues[p]); } } diff --git a/projects/spectator/src/lib/internals/resolve-input-name.ts b/projects/spectator/src/lib/internals/resolve-input-name.ts new file mode 100644 index 00000000..0976bc08 --- /dev/null +++ b/projects/spectator/src/lib/internals/resolve-input-name.ts @@ -0,0 +1,38 @@ +import { reflectComponentType, Type } from '@angular/core'; + +/** + * @internal + * Build a resolver that maps a key used in a test onto the name Angular expects. + * + * `ComponentRef.setInput()` addresses inputs by their public name, so an input + * declaring an alias — `@Input('userName')`, `input({ alias: 'userName' })` — is + * only reachable through that alias. Tests, however, are typed against the class + * property names, so both spellings have to work. + */ +export function inputNameResolver(componentType: Type): (key: string) => string { + const inputs = reflectComponentType(componentType)?.inputs; + + if (!inputs?.length) { + return (key) => key; + } + + const publicNames = new Set(inputs.map((input) => input.templateName)); + const publicNameByPropName = new Map(inputs.map((input) => [input.propName, input.templateName])); + + return (key) => { + /** + * A public name always wins. A key can be the public name of one input and + * the property name of another, and resolving it as the public name is what + * `setInput()` has always done. + */ + if (publicNames.has(key)) { + return key; + } + + /** + * Unknown keys are left untouched so that Angular still reports them + * (NG0303) instead of them being silently redirected onto another input. + */ + return publicNameByPropName.get(key) ?? key; + }; +} diff --git a/projects/spectator/src/lib/spectator-routing/create-factory.ts b/projects/spectator/src/lib/spectator-routing/create-factory.ts index e993971e..9f69c677 100644 --- a/projects/spectator/src/lib/spectator-routing/create-factory.ts +++ b/projects/spectator/src/lib/spectator-routing/create-factory.ts @@ -14,7 +14,7 @@ import { overridePipes, SpectatorOverrides, } from '../spectator/create-factory'; -import { InferInputSignals, isType } from '../types'; +import { InferInputProps, isType } from '../types'; import { SpyObject } from '../mock'; import { ActivatedRouteStub } from './activated-route-stub'; @@ -93,7 +93,7 @@ export function createRoutingFactory(typeOrOptions: Type | SpectatorRoutin }; } -function createSpectatorRouting(options: Required>, props?: InferInputSignals): SpectatorRouting { +function createSpectatorRouting(options: Required>, props?: InferInputProps): SpectatorRouting { const fixture = TestBed.createComponent(options.component, { bindings: options.bindings }); const debugElement = fixture.debugElement; diff --git a/projects/spectator/src/lib/spectator/create-factory.ts b/projects/spectator/src/lib/spectator/create-factory.ts index 2338ecad..204510c4 100644 --- a/projects/spectator/src/lib/spectator/create-factory.ts +++ b/projects/spectator/src/lib/spectator/create-factory.ts @@ -6,7 +6,7 @@ import { BaseSpectatorOptions, BaseSpectatorOverrides } from '../base/options'; import { addMatchers } from '../core'; import { setProps } from '../internals/query'; import * as customMatchers from '../matchers'; -import { InferInputSignals, isType } from '../types'; +import { InferInputProps, isType } from '../types'; import { initialSpectatorModule } from './initial-module'; import { getSpectatorDefaultOptions, SpectatorOptions } from './options'; @@ -22,7 +22,7 @@ export type SpectatorFactory = (options?: SpectatorOverrides) => Spectator */ export interface SpectatorOverrides extends BaseSpectatorOverrides { detectChanges?: boolean; - props?: InferInputSignals; + props?: InferInputProps; } /** @@ -185,7 +185,7 @@ export function createComponentFactory(typeOrOptions: Type | SpectatorOpti }; } -function createSpectator(options: Required>, props?: InferInputSignals): Spectator { +function createSpectator(options: Required>, props?: InferInputProps): Spectator { const fixture = TestBed.createComponent(options.component, { bindings: options.bindings }); const debugElement = fixture.debugElement; diff --git a/projects/spectator/src/lib/types.ts b/projects/spectator/src/lib/types.ts index 12a07fd7..3dc3cdd5 100644 --- a/projects/spectator/src/lib/types.ts +++ b/projects/spectator/src/lib/types.ts @@ -13,6 +13,23 @@ export type InferInputSignals = { [P in keyof C]+?: InferInputSignal; }; +/** + * Props accepted by `createComponent()` and `createRoutingFactory()`. + * + * Keys may be either the class property name or the input's public name, since + * an input declaring an alias is only reachable through that alias at runtime. + * Aliases cannot be derived from the type system -- neither `@Input('userName')` + * nor `input({ alias: 'userName' })` carries the literal into the type -- so the + * public names are admitted through an index signature. + * + * The union with the bare mapped type is what keeps that index signature from + * costing more than it has to: value types are still checked for every known + * input, and a `props` object typed through an `interface` or a class stays + * assignable. An unknown key is the one thing it can no longer reject, and + * Angular still reports that at runtime (NG0303). + */ +export type InferInputProps = InferInputSignals | (InferInputSignals & Record); + export type OptionalsRequired = Required> & Partial; export type AtLeastOneRequired = { diff --git a/projects/spectator/test/props-alias-names.spec.ts b/projects/spectator/test/props-alias-names.spec.ts new file mode 100644 index 00000000..b1c7b4da --- /dev/null +++ b/projects/spectator/test/props-alias-names.spec.ts @@ -0,0 +1,227 @@ +import { Component, Input, input, model } from '@angular/core'; +import { createComponentFactory, createRoutingFactory } from '@openng/spectator'; + +describe('PropsAliasNames', () => { + describe('input decorators', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name }}
+
{{ numOfYears }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + @Input({ alias: 'age' }) public numOfYears = 0; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of an @Input() with a rename alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an @Input() with an alias option', () => { + const spectator = createComponent({ props: { numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept property names and alias names in the same object', () => { + const spectator = createComponent({ props: { name: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('setInput should accept the property name of an aliased input', () => { + const spectator = createComponent(); + + spectator.setInput('name', 'John'); + spectator.setInput('numOfYears', 123); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + }); + + describe('signal inputs', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name() }}
+
{{ numOfYears() }}
+
{{ nickname() }}
+ `, + standalone: true, + }) + class DummyComponent { + public name = input.required({ alias: 'userName' }); + public numOfYears = input(0, { alias: 'age' }); + public nickname = model('', { alias: 'nick' }); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of a required signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an optional signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John', numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept the property name of a model with an alias', () => { + const spectator = createComponent({ props: { name: 'John', nickname: 'Johnny' } }); + + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123, nick: 'Johnny' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + }); + + describe('inputs without an alias', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ title }}
+
{{ count() }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input() public title = ''; + public count = input(0); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should keep working for inputs that declare no alias', () => { + const spectator = createComponent({ props: { title: 'Hello', count: 7 } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('7'); + }); + + it('props should leave a key that matches no input alone', () => { + // Angular reports the unknown key itself (NG0303); the resolver must not + // silently redirect it onto some other input. + const spectator = createComponent({ props: { title: 'Hello', notAnInput: 'ignored' } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('0'); + }); + }); + + describe('a key that is both a public name and a property name', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ numOfYears }}
+
{{ age }}
+ `, + standalone: true, + }) + class DummyComponent { + // 'age' is the public name of this input... + @Input({ alias: 'age' }) public numOfYears = 'no-years'; + // ...and the property name of this one. Both are strings on purpose: the + // type describes the property, so a collision is only expressible when the + // two inputs agree on their value type. + @Input({ alias: 'collided' }) public age = 'untouched'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should resolve the key as a public name, not as a property name', () => { + const spectator = createComponent({ props: { age: 'set-via-public-name' } }); + + expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('set-via-public-name'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched'); + }); + + it('props should still reach the shadowed input through its own public name', () => { + const spectator = createComponent({ props: { collided: 'reached' } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('reached'); + }); + }); + + describe('no props', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = 'default'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should be a no-op when it is explicitly undefined', () => { + const spectator = createComponent({ props: undefined }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('props should be a no-op when no overrides are passed at all', () => { + const spectator = createComponent(); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('setInput should be a no-op when it is given nothing', () => { + const spectator = createComponent(); + + spectator.setInput(undefined as unknown as Record); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + }); + + describe('createRoutingFactory', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + } + + const createComponent = createRoutingFactory(DummyComponent); + + it('props should accept the property name of an aliased input', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should still accept the alias name', () => { + const spectator = createComponent({ props: { userName: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + }); +}); diff --git a/projects/spectator/vitest/test/props-alias-names.spec.ts b/projects/spectator/vitest/test/props-alias-names.spec.ts new file mode 100644 index 00000000..e9e9562d --- /dev/null +++ b/projects/spectator/vitest/test/props-alias-names.spec.ts @@ -0,0 +1,227 @@ +import { Component, Input, input, model } from '@angular/core'; +import { createComponentFactory, createRoutingFactory } from '@openng/spectator/vitest'; + +describe('PropsAliasNames', () => { + describe('input decorators', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name }}
+
{{ numOfYears }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + @Input({ alias: 'age' }) public numOfYears = 0; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of an @Input() with a rename alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an @Input() with an alias option', () => { + const spectator = createComponent({ props: { numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept property names and alias names in the same object', () => { + const spectator = createComponent({ props: { name: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('setInput should accept the property name of an aliased input', () => { + const spectator = createComponent(); + + spectator.setInput('name', 'John'); + spectator.setInput('numOfYears', 123); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + }); + + describe('signal inputs', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name() }}
+
{{ numOfYears() }}
+
{{ nickname() }}
+ `, + standalone: true, + }) + class DummyComponent { + public name = input.required({ alias: 'userName' }); + public numOfYears = input(0, { alias: 'age' }); + public nickname = model('', { alias: 'nick' }); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of a required signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an optional signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John', numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept the property name of a model with an alias', () => { + const spectator = createComponent({ props: { name: 'John', nickname: 'Johnny' } }); + + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123, nick: 'Johnny' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + }); + + describe('inputs without an alias', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ title }}
+
{{ count() }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input() public title = ''; + public count = input(0); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should keep working for inputs that declare no alias', () => { + const spectator = createComponent({ props: { title: 'Hello', count: 7 } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('7'); + }); + + it('props should leave a key that matches no input alone', () => { + // Angular reports the unknown key itself (NG0303); the resolver must not + // silently redirect it onto some other input. + const spectator = createComponent({ props: { title: 'Hello', notAnInput: 'ignored' } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('0'); + }); + }); + + describe('a key that is both a public name and a property name', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ numOfYears }}
+
{{ age }}
+ `, + standalone: true, + }) + class DummyComponent { + // 'age' is the public name of this input... + @Input({ alias: 'age' }) public numOfYears = 'no-years'; + // ...and the property name of this one. Both are strings on purpose: the + // type describes the property, so a collision is only expressible when the + // two inputs agree on their value type. + @Input({ alias: 'collided' }) public age = 'untouched'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should resolve the key as a public name, not as a property name', () => { + const spectator = createComponent({ props: { age: 'set-via-public-name' } }); + + expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('set-via-public-name'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched'); + }); + + it('props should still reach the shadowed input through its own public name', () => { + const spectator = createComponent({ props: { collided: 'reached' } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('reached'); + }); + }); + + describe('no props', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = 'default'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should be a no-op when it is explicitly undefined', () => { + const spectator = createComponent({ props: undefined }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('props should be a no-op when no overrides are passed at all', () => { + const spectator = createComponent(); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('setInput should be a no-op when it is given nothing', () => { + const spectator = createComponent(); + + spectator.setInput(undefined as unknown as Record); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + }); + + describe('createRoutingFactory', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + } + + const createComponent = createRoutingFactory(DummyComponent); + + it('props should accept the property name of an aliased input', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should still accept the alias name', () => { + const spectator = createComponent({ props: { userName: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + }); +}); diff --git a/type-tests/jasmine/consumer.ts b/type-tests/jasmine/consumer.ts index 405d3ff9..e7059ed0 100644 --- a/type-tests/jasmine/consumer.ts +++ b/type-tests/jasmine/consumer.ts @@ -1,4 +1,5 @@ import { createComponentFactory, Spectator } from '@openng/spectator'; +import { input } from '@angular/core'; declare const element: HTMLElement; declare const option: HTMLOptionElement; @@ -47,6 +48,35 @@ describe('custom DOM matchers are wired into expect()', () => { }); }); +/** + * `props` accepts either the class property name or the input's public name, + * since an aliased input is only reachable through its alias at runtime (#15). + */ +class AliasedInputsComponent { + public name = input.required({ alias: 'userName' }); + public age = input(0, { alias: 'userAge' }); +} + +const createAliasedComponent = createComponentFactory(AliasedInputsComponent); + +createAliasedComponent({ props: { name: 'John', age: 30 } }); +createAliasedComponent({ props: { userName: 'John', userAge: 30 } }); +createAliasedComponent({ props: { name: 'John', userAge: 30 } }); + +// A `props` object typed through an interface has no index signature, so this is +// the guard against typing `props` as a plain intersection with `Record`. +interface AliasedProps { + name: string; +} +declare const aliasedProps: AliasedProps; +createAliasedComponent({ props: aliasedProps }); + +// Negative control: value types are still checked for known inputs. This is what +// distinguishes the shipped type from a plain union with `Record`, +// which would accept this silently. +// @ts-expect-error a string is not assignable to a number input +createAliasedComponent({ props: { name: 'John', age: 'thirty' } }); + // Reference the entry point exports so the import is used and the bundle resolves. export type Check = Spectator; export const factory = createComponentFactory; diff --git a/type-tests/jest/consumer.ts b/type-tests/jest/consumer.ts index a513ddb3..45705951 100644 --- a/type-tests/jest/consumer.ts +++ b/type-tests/jest/consumer.ts @@ -1,4 +1,5 @@ import { createComponentFactory, Spectator } from '@openng/spectator/jest'; +import { input } from '@angular/core'; declare const element: HTMLElement; declare const option: HTMLOptionElement; @@ -47,6 +48,35 @@ describe('custom DOM matchers are wired into expect()', () => { }); }); +/** + * `props` accepts either the class property name or the input's public name, + * since an aliased input is only reachable through its alias at runtime (#15). + */ +class AliasedInputsComponent { + public name = input.required({ alias: 'userName' }); + public age = input(0, { alias: 'userAge' }); +} + +const createAliasedComponent = createComponentFactory(AliasedInputsComponent); + +createAliasedComponent({ props: { name: 'John', age: 30 } }); +createAliasedComponent({ props: { userName: 'John', userAge: 30 } }); +createAliasedComponent({ props: { name: 'John', userAge: 30 } }); + +// A `props` object typed through an interface has no index signature, so this is +// the guard against typing `props` as a plain intersection with `Record`. +interface AliasedProps { + name: string; +} +declare const aliasedProps: AliasedProps; +createAliasedComponent({ props: aliasedProps }); + +// Negative control: value types are still checked for known inputs. This is what +// distinguishes the shipped type from a plain union with `Record`, +// which would accept this silently. +// @ts-expect-error a string is not assignable to a number input +createAliasedComponent({ props: { name: 'John', age: 'thirty' } }); + // Reference the entry point exports so the import is used and the bundle resolves. export type Check = Spectator; export const factory = createComponentFactory; diff --git a/type-tests/vitest/consumer.ts b/type-tests/vitest/consumer.ts index 01b7173c..e54d7205 100644 --- a/type-tests/vitest/consumer.ts +++ b/type-tests/vitest/consumer.ts @@ -1,4 +1,5 @@ import { createComponentFactory, Spectator } from '@openng/spectator/vitest'; +import { input } from '@angular/core'; import { describe, expect, it } from 'vitest'; declare const element: HTMLElement; @@ -48,6 +49,35 @@ describe('custom DOM matchers are wired into expect()', () => { }); }); +/** + * `props` accepts either the class property name or the input's public name, + * since an aliased input is only reachable through its alias at runtime (#15). + */ +class AliasedInputsComponent { + public name = input.required({ alias: 'userName' }); + public age = input(0, { alias: 'userAge' }); +} + +const createAliasedComponent = createComponentFactory(AliasedInputsComponent); + +createAliasedComponent({ props: { name: 'John', age: 30 } }); +createAliasedComponent({ props: { userName: 'John', userAge: 30 } }); +createAliasedComponent({ props: { name: 'John', userAge: 30 } }); + +// A `props` object typed through an interface has no index signature, so this is +// the guard against typing `props` as a plain intersection with `Record`. +interface AliasedProps { + name: string; +} +declare const aliasedProps: AliasedProps; +createAliasedComponent({ props: aliasedProps }); + +// Negative control: value types are still checked for known inputs. This is what +// distinguishes the shipped type from a plain union with `Record`, +// which would accept this silently. +// @ts-expect-error a string is not assignable to a number input +createAliasedComponent({ props: { name: 'John', age: 'thirty' } }); + // Reference the entry point exports so the import is used and the bundle resolves. export type Check = Spectator; export const factory = createComponentFactory;