mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
72be5be9c1
Removes the `checkTwoWayBoundEvents` flag since the code it generates is quite breaking and we never got the chance to enable it. Also it caused our tests to misrepresent how the compiler behaves for actual users.
761 lines
21 KiB
TypeScript
761 lines
21 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.dev/license
|
|
*/
|
|
|
|
import ts from 'typescript';
|
|
|
|
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
|
|
import {loadStandardTestFiles} from '../../src/ngtsc/testing';
|
|
|
|
import {NgtscTestEnvironment} from './env';
|
|
|
|
const testFiles = loadStandardTestFiles();
|
|
|
|
runInEachFileSystem(() => {
|
|
describe('initializer-based model() API', () => {
|
|
let env!: NgtscTestEnvironment;
|
|
|
|
beforeEach(() => {
|
|
env = NgtscTestEnvironment.setup(testFiles);
|
|
env.tsconfig({strictTemplates: true});
|
|
});
|
|
|
|
it('should declare an input/output pair for a field initialized to a model()', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
value = model(1);
|
|
}
|
|
`,
|
|
);
|
|
env.driveMain();
|
|
|
|
const js = env.getContents('test.js');
|
|
const dts = env.getContents('test.d.ts');
|
|
|
|
expect(js).toContain('inputs: { value: [1, "value"] }');
|
|
expect(js).toContain('outputs: { value: "valueChange" }');
|
|
expect(dts).toContain(
|
|
'static ɵdir: i0.ɵɵDirectiveDeclaration<TestDir, never, never, ' +
|
|
'{ "value": { "alias": "value"; "required": false; "isSignal": true; }; }, ' +
|
|
'{ "value": "valueChange"; }, never, never, true, never>;',
|
|
);
|
|
});
|
|
|
|
it('should declare an input/output pair for a field initialized to an aliased model()', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
value = model(1, {alias: 'alias'});
|
|
}
|
|
`,
|
|
);
|
|
env.driveMain();
|
|
|
|
const js = env.getContents('test.js');
|
|
const dts = env.getContents('test.d.ts');
|
|
|
|
expect(js).toContain('inputs: { value: [1, "alias", "value"] }');
|
|
expect(js).toContain('outputs: { value: "aliasChange" }');
|
|
expect(dts).toContain(
|
|
'static ɵdir: i0.ɵɵDirectiveDeclaration<TestDir, never, never, ' +
|
|
'{ "value": { "alias": "alias"; "required": false; "isSignal": true; }; }, ' +
|
|
'{ "value": "aliasChange"; }, never, never, true, never>;',
|
|
);
|
|
});
|
|
|
|
it('should declare an input/output pair for a field initialized to a required model()', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
value = model.required<string>();
|
|
}
|
|
`,
|
|
);
|
|
env.driveMain();
|
|
|
|
const js = env.getContents('test.js');
|
|
const dts = env.getContents('test.d.ts');
|
|
|
|
expect(js).toContain('inputs: { value: [1, "value"] }');
|
|
expect(js).toContain('outputs: { value: "valueChange" }');
|
|
expect(dts).toContain(
|
|
'static ɵdir: i0.ɵɵDirectiveDeclaration<TestDir, never, never, ' +
|
|
'{ "value": { "alias": "value"; "required": true; "isSignal": true; }; }, ' +
|
|
'{ "value": "valueChange"; }, never, never, true, never>;',
|
|
);
|
|
});
|
|
|
|
it('should report a diagnostic if a model field is decorated with @Input', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, Input, model} from '@angular/core';
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
@Input() value = model('test');
|
|
}
|
|
`,
|
|
);
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe('Using @Input with a model input is not allowed.');
|
|
});
|
|
|
|
it('should report a diagnostic if a model field is decorated with @Output', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, Output, model} from '@angular/core';
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
@Output() value = model('test');
|
|
}
|
|
`,
|
|
);
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe('Using @Output with a model input is not allowed.');
|
|
});
|
|
|
|
it('should report a diagnostic if a model input is also declared in the `inputs` field', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive({
|
|
inputs: ['value'],
|
|
})
|
|
export class TestDir {
|
|
value = model('test');
|
|
}
|
|
`,
|
|
);
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(
|
|
'Input "value" is also declared as non-signal in @Directive.',
|
|
);
|
|
});
|
|
|
|
it('should produce a diagnostic if the alias of a model cannot be analyzed', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
const ALIAS = 'bla';
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
value = model('test', {alias: ALIAS});
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(
|
|
'Alias needs to be a string that is statically analyzable.',
|
|
);
|
|
});
|
|
|
|
it('should report a diagnostic if the options of a model signal cannot be analyzed', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
const OPTIONS = {};
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
value = model('test', OPTIONS);
|
|
}
|
|
`,
|
|
);
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(
|
|
'Argument needs to be an object literal that is statically analyzable.',
|
|
);
|
|
});
|
|
|
|
it('should report a diagnostic if a model input is declared on a static member', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
static value = model('test');
|
|
}
|
|
`,
|
|
);
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(
|
|
'Input "value" is incorrectly declared as static member of "TestDir".',
|
|
);
|
|
});
|
|
|
|
it('should a diagnostic if a required model input declares an initial value', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
value = model.required({
|
|
initialValue: 'bla',
|
|
});
|
|
}
|
|
`,
|
|
);
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(
|
|
`Object literal may only specify known properties, ` +
|
|
`and 'initialValue' does not exist in type 'ModelOptions'.`,
|
|
);
|
|
});
|
|
|
|
it('should report if a signal getter is invoked in a two-way binding', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model, signal} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
value = model(1);
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value()"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = signal(0);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe('Unsupported expression in a two-way binding');
|
|
});
|
|
|
|
describe('type checking', () => {
|
|
it('should check a primitive value bound to a model input', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
value = model(1);
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = false;
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(`Type 'boolean' is not assignable to type 'number'.`);
|
|
});
|
|
|
|
it('should check a signal value bound to a model input via a two-way binding', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model, signal} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
value = model(1);
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = signal(false);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(`Type 'boolean' is not assignable to type 'number'.`);
|
|
});
|
|
|
|
it('should check two-way binding of a signal to a decorator-based input/output pair', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, Input, Output, signal, EventEmitter} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
@Input() value = 0;
|
|
@Output() valueChange = new EventEmitter<number>();
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = signal(false);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(`Type 'boolean' is not assignable to type 'number'.`);
|
|
});
|
|
|
|
it('should not allow a non-writable signal to be assigned to a model', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model, input} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
value = model(1);
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = input(0);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(
|
|
`Type 'InputSignal<number>' is not assignable to type 'number'.`,
|
|
);
|
|
});
|
|
|
|
it('should allow a model signal to be bound to another model signal', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
value = model(1);
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = model(0);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(0);
|
|
});
|
|
|
|
it('should check the event declared by a model input', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
value = model(1);
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir (valueChange)="acceptsString($event)"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
acceptsString(value: string) {}
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(
|
|
`Argument of type 'number' is not assignable to parameter of type 'string'.`,
|
|
);
|
|
});
|
|
|
|
it('should report unset required model inputs', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
value = model.required<number>();
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diagnostics = env.driveDiagnostics();
|
|
expect(diagnostics.length).toBe(1);
|
|
expect(diagnostics[0].messageText).toBe(
|
|
`Required input 'value' from directive TestDir must be specified.`,
|
|
);
|
|
});
|
|
|
|
it('should check generic two-way model binding with a primitive value', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir<T extends {id: string}> {
|
|
value = model.required<T>();
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = {id: 1};
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toEqual(
|
|
jasmine.objectContaining({
|
|
messageText: `Type '{ id: number; }' is not assignable to type '{ id: string; }'.`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should check generic two-way model binding with a signal value', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model, signal} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir<T extends {id: string}> {
|
|
value = model.required<T>();
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = signal({id: 1});
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toEqual(
|
|
jasmine.objectContaining({
|
|
messageText: `Type '{ id: number; }' is not assignable to type '{ id: string; }'.`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should report unwrapped signals assigned to a model in a one-way binding', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model, signal} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
value = model(0);
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [value]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = signal(1);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diagnostics = env.driveDiagnostics();
|
|
expect(diagnostics.length).toBe(1);
|
|
expect(diagnostics[0].messageText).toBe(
|
|
`Type 'WritableSignal<number>' is not assignable to type 'number'.`,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should allow two-way binding to a generic model input', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, model, signal} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir<T> {
|
|
value = model.required<T>();
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = signal(1);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags).toEqual([]);
|
|
});
|
|
|
|
// TODO(atscott): fix this test which was broken by #54711
|
|
xit('should not widen the type of two-way bindings on Angular versions less than 17.2', () => {
|
|
env.tsconfig({_angularCoreVersion: '16.50.60', strictTemplates: true});
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, Input, Output, EventEmitter, signal} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class TestDir {
|
|
@Input() value = 0;
|
|
@Output() valueChange = new EventEmitter<number>();
|
|
}
|
|
|
|
@Component({
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = signal(1);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText).toBe(
|
|
`Type 'WritableSignal<number>' is not assignable to type 'number'.`,
|
|
);
|
|
});
|
|
|
|
it('should widen the type of two-way bindings on supported Angular versions', () => {
|
|
['17.2.0', '17.2.0-rc.0', '0.0.0-PLACEHOLDER', '18.0.0'].forEach((version) => {
|
|
env.tsconfig({_angularCoreVersion: version, strictTemplates: true});
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Component, Directive, Input, Output, EventEmitter, signal} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
standalone: true,
|
|
})
|
|
export class TestDir {
|
|
@Input() value = 0;
|
|
@Output() valueChange = new EventEmitter<number>();
|
|
}
|
|
|
|
@Component({
|
|
standalone: true,
|
|
template: \`<div dir [(value)]="value"></div>\`,
|
|
imports: [TestDir],
|
|
})
|
|
export class TestComp {
|
|
value = signal(1);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags).withContext(`On version ${version}`).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('diagnostics', () => {
|
|
it('should error when declared using an ES private field', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[directiveName]',
|
|
})
|
|
export class TestDir {
|
|
#data = model.required<boolean>();
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diagnostics = env.driveDiagnostics();
|
|
expect(diagnostics.length).toBe(1);
|
|
expect(diagnostics).toEqual([
|
|
jasmine.objectContaining<ts.Diagnostic>({
|
|
messageText: jasmine.objectContaining<ts.DiagnosticMessageChain>({
|
|
messageText: `Cannot use "model" on a class member that is declared as ES private.`,
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('should error when declared using a `private` field', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[directiveName]',
|
|
})
|
|
export class TestDir {
|
|
private data = model.required<boolean>();
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diagnostics = env.driveDiagnostics();
|
|
expect(diagnostics.length).toBe(1);
|
|
expect(diagnostics).toEqual([
|
|
jasmine.objectContaining<ts.Diagnostic>({
|
|
messageText: jasmine.objectContaining<ts.DiagnosticMessageChain>({
|
|
messageText: `Cannot use "model" on a class member that is declared as private.`,
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('should allow using a `protected` field', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[directiveName]',
|
|
})
|
|
export class TestDir {
|
|
protected data = model.required<boolean>();
|
|
}
|
|
`,
|
|
);
|
|
|
|
const diagnostics = env.driveDiagnostics();
|
|
expect(diagnostics.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('should capture model input/output pair in the setClassMetadata call', () => {
|
|
env.write(
|
|
'test.ts',
|
|
`
|
|
import {Directive, model} from '@angular/core';
|
|
|
|
@Directive()
|
|
export class TestDir {
|
|
value = model(1);
|
|
}
|
|
`,
|
|
);
|
|
env.driveMain();
|
|
|
|
const js = env.getContents('test.js');
|
|
expect(js).toContain('import * as i0 from "@angular/core";');
|
|
expect(js).toContain(
|
|
`i0.ɵsetClassMetadata(TestDir, [{
|
|
type: Directive
|
|
}], null, { value: [` +
|
|
`{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, ` +
|
|
`{ type: i0.Output, args: ["valueChange"] }] });`,
|
|
);
|
|
});
|
|
});
|
|
});
|