-
-
Notifications
You must be signed in to change notification settings - Fork 5
fix(spectator): resolve input aliases in props and setInput #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
dominicbachmann
merged 7 commits into
openng-org:main
from
nicobytes:fix/props-input-aliases
Sep 25, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
f1e90d3
test(spectator): cover props and setInput with aliased input names
nicobytes aca0c01
fix(spectator): resolve input aliases in props and setInput
nicobytes 297b00a
feat(spectator): type props to accept input alias names
nicobytes 0bee5a1
test(spectator): assert props accepts alias names in the type tests
nicobytes 0f32bce
docs: document alias support in props and setInput
nicobytes db11017
test(spectator): type-align the colliding-input fixture
nicobytes 154c2ae
test(spectator): pin that absent props is a no-op
nicobytes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: ` | ||
| <div data-test="props--name">{{ name }}</div> | ||
| <div data-test="props--age">{{ numOfYears }}</div> | ||
| `, | ||
| 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: ` | ||
| <div data-test="props--name">{{ name() }}</div> | ||
| <div data-test="props--age">{{ numOfYears() }}</div> | ||
| <div data-test="props--nickname">{{ nickname() }}</div> | ||
| `, | ||
| standalone: true, | ||
| }) | ||
| class DummyComponent { | ||
| public name = input.required<string>({ 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: ` | ||
| <div data-test="props--title">{{ title }}</div> | ||
| <div data-test="props--count">{{ count() }}</div> | ||
| `, | ||
| 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: ` | ||
| <div data-test="props--years">{{ numOfYears }}</div> | ||
| <div data-test="props--age">{{ age }}</div> | ||
| `, | ||
| 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: `<div data-test="props--name">{{ name }}</div>`, | ||
| 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<string, unknown>); | ||
|
|
||
| expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('createRoutingFactory', () => { | ||
| @Component({ | ||
| selector: 'app-root', | ||
| template: `<div data-test="props--name">{{ name }}</div>`, | ||
| 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'); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
38 changes: 38 additions & 0 deletions
38
projects/spectator/src/lib/internals/resolve-input-name.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<unknown>): (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; | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.