Skip to content

Commit 0b955b8

Browse files
crisbetoAndrewKushnir
authored andcommitted
test(language-service): add tests for model inputs (#54387)
Updates the language service tests to cover `model()` inputs. PR Close #54387
1 parent 6897b76 commit 0b955b8

File tree

5 files changed

+286
-3
lines changed

5 files changed

+286
-3
lines changed

packages/language-service/test/completions_spec.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,74 @@ describe('completions', () => {
388388
});
389389
});
390390

391+
describe('model inputs', () => {
392+
const directiveWithModel = {
393+
'Dir': `
394+
@Directive({
395+
selector: '[dir]',
396+
})
397+
export class Dir {
398+
twoWayValue = model<string>();
399+
}
400+
`
401+
};
402+
403+
it('should return completions for both properties and events', () => {
404+
const {templateFile} = setup(`<button dir ></button>`, ``, directiveWithModel);
405+
templateFile.moveCursorToText(`<button dir ¦>`);
406+
const completions = templateFile.getCompletionsAtPosition();
407+
408+
expectContain(completions, DisplayInfoKind.PROPERTY, ['[twoWayValue]']);
409+
expectContain(completions, DisplayInfoKind.PROPERTY, ['[(twoWayValue)]']);
410+
expectContain(completions, DisplayInfoKind.EVENT, ['(twoWayValueChange)']);
411+
});
412+
413+
it('should return property access completions in the property side of the binding', () => {
414+
const {templateFile} = setup(`<input dir [twoWayValue]="'foo'.">`, '', directiveWithModel);
415+
templateFile.moveCursorToText(`dir [twoWayValue]="'foo'.¦">`);
416+
417+
const completions = templateFile.getCompletionsAtPosition();
418+
expectContain(
419+
completions, ts.ScriptElementKind.memberFunctionElement,
420+
[`charAt`, 'toLowerCase', /* etc. */]);
421+
});
422+
423+
it('should return property access completions in the event side of the binding', () => {
424+
const {templateFile} =
425+
setup(`<input dir (twoWayValueChange)="'foo'.">`, '', directiveWithModel);
426+
templateFile.moveCursorToText(`dir (twoWayValueChange)="'foo'.¦">`);
427+
428+
const completions = templateFile.getCompletionsAtPosition();
429+
expectContain(
430+
completions, ts.ScriptElementKind.memberFunctionElement,
431+
[`charAt`, 'toLowerCase', /* etc. */]);
432+
});
433+
434+
it('should return property access completions in a two-way binding', () => {
435+
const {templateFile} = setup(`<input dir [(twoWayValue)]="'foo'.">`, '', directiveWithModel);
436+
templateFile.moveCursorToText(`dir [(twoWayValue)]="'foo'.¦">`);
437+
438+
const completions = templateFile.getCompletionsAtPosition();
439+
expectContain(
440+
completions, ts.ScriptElementKind.memberFunctionElement,
441+
[`charAt`, 'toLowerCase', /* etc. */]);
442+
});
443+
444+
it('should return completions of string literals, number literals, `true`, ' +
445+
'`false`, `null` and `undefined`',
446+
() => {
447+
const {templateFile} =
448+
setup(`<input dir (twoWayValueChange)="$event.">`, '', directiveWithModel);
449+
templateFile.moveCursorToText('dir (twoWayValueChange)="$event.¦">');
450+
451+
const completions = templateFile.getCompletionsAtPosition();
452+
453+
expectContain(
454+
completions, ts.ScriptElementKind.memberFunctionElement,
455+
[`charAt`, 'toLowerCase', /* etc. */]);
456+
});
457+
});
458+
391459
describe('for blocks', () => {
392460
const completionPrefixes = ['@', '@i'];
393461

@@ -1680,7 +1748,15 @@ function setup(
16801748
const env = LanguageServiceTestEnv.setup();
16811749
const project = env.addProject('test', {
16821750
'test.ts': `
1683-
import {Component, input, output, Directive, NgModule, Pipe, TemplateRef} from '@angular/core';
1751+
import {Component,
1752+
input,
1753+
output,
1754+
model,
1755+
Directive,
1756+
NgModule,
1757+
Pipe,
1758+
TemplateRef,
1759+
} from '@angular/core';
16841760
16851761
${functionDeclarations}
16861762

packages/language-service/test/definitions_spec.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,103 @@ describe('definitions', () => {
208208
assertFileNames([def3, def2, def], ['dir3.ts', 'dir2.ts', 'dir.ts']);
209209
});
210210

211+
it('gets definitions for all model inputs when attribute matches more than one in a static attribute',
212+
() => {
213+
initMockFileSystem('Native');
214+
const files = {
215+
'app.ts': `
216+
import {Component, NgModule} from '@angular/core';
217+
import {CommonModule} from '@angular/common';
218+
219+
@Component({templateUrl: 'app.html'})
220+
export class AppCmp {}
221+
`,
222+
'app.html': '<div dir inputA="abc"></div>',
223+
'dir.ts': `
224+
import {Directive, model} from '@angular/core';
225+
226+
@Directive({selector: '[dir]'})
227+
export class MyDir {
228+
inputA = model('');
229+
}`,
230+
'dir2.ts': `
231+
import {Directive, model} from '@angular/core';
232+
233+
@Directive({selector: '[dir]'})
234+
export class MyDir2 {
235+
inputA = model('');
236+
}`
237+
238+
};
239+
const env = LanguageServiceTestEnv.setup();
240+
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
241+
const template = project.openFile('app.html');
242+
template.moveCursorToText('inpu¦tA="abc"');
243+
244+
const {textSpan, definitions} = getDefinitionsAndAssertBoundSpan(env, template);
245+
expect(template.contents.slice(textSpan.start, textSpan.start + textSpan.length))
246+
.toBe('inputA');
247+
248+
expect(definitions.length).toBe(2);
249+
const [def, def2] = definitions;
250+
expect(def.textSpan).toContain('inputA');
251+
expect(def2.textSpan).toContain('inputA');
252+
// TODO(atscott): investigate why the text span includes more than just 'inputA'
253+
// assertTextSpans([def, def2], ['inputA']);
254+
assertFileNames([def, def2], ['dir2.ts', 'dir.ts']);
255+
});
256+
257+
it('gets definitions for all model inputs when attribute matches more than one in a two-way binding',
258+
() => {
259+
initMockFileSystem('Native');
260+
const files = {
261+
'app.ts': `
262+
import {Component, NgModule} from '@angular/core';
263+
import {CommonModule} from '@angular/common';
264+
265+
@Component({templateUrl: 'app.html'})
266+
export class AppCmp {
267+
value = 'abc';
268+
}
269+
`,
270+
'app.html': '<div dir [(inputA)]="value"></div>',
271+
'dir.ts': `
272+
import {Directive, model} from '@angular/core';
273+
274+
@Directive({selector: '[dir]'})
275+
export class MyDir {
276+
inputA = model('');
277+
}`,
278+
'dir2.ts': `
279+
import {Directive, model} from '@angular/core';
280+
281+
@Directive({selector: '[dir]'})
282+
export class MyDir2 {
283+
inputA = model('');
284+
}`
285+
286+
};
287+
const env = LanguageServiceTestEnv.setup();
288+
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
289+
const template = project.openFile('app.html');
290+
template.moveCursorToText('[(inpu¦tA)]="value"');
291+
292+
const {textSpan, definitions} = getDefinitionsAndAssertBoundSpan(env, template);
293+
expect(template.contents.slice(textSpan.start, textSpan.start + textSpan.length))
294+
.toBe('inputA');
295+
296+
expect(definitions.length).toBe(4);
297+
const [def, def2, def3, def4] = definitions;
298+
299+
// We have four definitons to the `inputA` member, because each `model()`
300+
// instance implicitly creates both an input and an output.
301+
expect(def.textSpan).toContain('inputA');
302+
expect(def2.textSpan).toContain('inputA');
303+
expect(def3.textSpan).toContain('inputA');
304+
expect(def4.textSpan).toContain('inputA');
305+
assertFileNames([def, def2, def3, def4], ['dir2.ts', 'dir.ts', 'dir2.ts', 'dir.ts']);
306+
});
307+
211308
it('should go to the pre-compiled style sheet', () => {
212309
initMockFileSystem('Native');
213310
const files = {

packages/language-service/test/quick_info_spec.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {createModuleAndProjectWithDeclarations, LanguageServiceTestEnv, Project}
1414
function quickInfoSkeleton(): {[fileName: string]: string} {
1515
return {
1616
'app.ts': `
17-
import {Component, Directive, EventEmitter, Input, NgModule, Output, Pipe, PipeTransform} from '@angular/core';
17+
import {Component, Directive, EventEmitter, Input, NgModule, Output, Pipe, PipeTransform, model} from '@angular/core';
1818
import {CommonModule} from '@angular/common';
1919
2020
export interface Address {
@@ -71,6 +71,14 @@ function quickInfoSkeleton(): {[fileName: string]: string} {
7171
@Output() modelChange!: EventEmitter<string>;
7272
}
7373
74+
@Directive({
75+
selector: '[signal-model]',
76+
exportAs: 'signalModel',
77+
})
78+
export class SignalModel {
79+
signalModel = model<string>();
80+
}
81+
7482
@Directive({selector: 'button[custom-button][compound]'})
7583
export class CompoundCustomButtonDirective {
7684
@Input() config?: {color?: string};
@@ -82,6 +90,7 @@ function quickInfoSkeleton(): {[fileName: string]: string} {
8290
CompoundCustomButtonDirective,
8391
StringModel,
8492
TestComponent,
93+
SignalModel,
8594
],
8695
imports: [
8796
CommonModule,
@@ -237,6 +246,15 @@ describe('quick info', () => {
237246
expectedDisplayString: '(property) StringModel.model: string'
238247
});
239248
});
249+
250+
it('should work for signal-based two-way binding providers', () => {
251+
expectQuickInfo({
252+
templateOverride: `<test-comp signal-model [(signa¦lModel)]="title"></test-comp>`,
253+
expectedSpanText: 'signalModel',
254+
expectedDisplayString:
255+
'(property) SignalModel.signalModel: ModelSignal<string | undefined>'
256+
});
257+
});
240258
});
241259

242260
describe('outputs', () => {

packages/language-service/test/references_and_rename_spec.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,35 @@ describe('find references and rename locations', () => {
12851285
assertTextSpans(refs, ['model', 'modelChange']);
12861286
});
12871287

1288+
it('should get references to model() input binding', () => {
1289+
const files = {
1290+
'dir.ts': `
1291+
import {Directive, model} from '@angular/core';
1292+
1293+
@Directive({selector: '[signal-model]'})
1294+
export class SignalModel {
1295+
signalModel = model<string>();
1296+
}`,
1297+
'app.ts': `
1298+
import {Component} from '@angular/core';
1299+
1300+
@Component({template: '<div signal-model [(signalModel)]="title"></div>'})
1301+
export class AppCmp {
1302+
title = 'title';
1303+
}`
1304+
};
1305+
1306+
env = LanguageServiceTestEnv.setup();
1307+
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
1308+
const file = project.openFile('app.ts');
1309+
file.moveCursorToText('[(signal¦Model)]');
1310+
1311+
const refs = getReferencesAtPosition(file)!;
1312+
expect(refs.length).toBe(2);
1313+
assertFileNames(refs, ['dir.ts', 'app.ts']);
1314+
assertTextSpans(refs, ['signalModel']);
1315+
});
1316+
12881317
describe('directives', () => {
12891318
describe('when cursor is on the directive class', () => {
12901319
let file: OpenBuffer;
@@ -1513,7 +1542,7 @@ describe('find references and rename locations', () => {
15131542
import {Component} from '@angular/core';
15141543
15151544
@Component({
1516-
template: '@if (x; as aliasX) { {{aliasX}} {{aliasX + "second"}} }',
1545+
template: '@if (x; as aliasX) { {{aliasX}} {{aliasX + "second"}} }',
15171546
standalone: true
15181547
})
15191548
export class AppCmp {

packages/language-service/test/type_definitions_spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,69 @@ describe('type definitions', () => {
111111
});
112112
});
113113

114+
describe('model inputs', () => {
115+
const files = {
116+
'app.ts': `
117+
import {Component, Directive, model} from '@angular/core';
118+
119+
@Directive({
120+
selector: 'my-dir',
121+
standalone: true
122+
})
123+
export class MyDir {
124+
twoWayValue = model<string>();
125+
}
126+
127+
@Component({
128+
templateUrl: 'app.html',
129+
standalone: true,
130+
imports: [MyDir],
131+
})
132+
export class AppCmp {
133+
noop() {}
134+
value = 'hello';
135+
}
136+
`,
137+
'app.html': `Will be overridden`,
138+
};
139+
140+
it('should return the definition for the property side of a two-way binding', () => {
141+
initMockFileSystem('Native');
142+
env = LanguageServiceTestEnv.setup();
143+
const project = env.addProject('test', files);
144+
const definitions = getTypeDefinitionsAndAssertBoundSpan(
145+
project, {templateOverride: `<my-dir [twoWa¦yValue]="value" />`});
146+
147+
expect(definitions.length).toBe(1);
148+
assertTextSpans(definitions, ['ModelSignal']);
149+
assertFileNames(definitions, ['index.d.ts']);
150+
});
151+
152+
it('should return the definition for the event side of a two-way binding', () => {
153+
initMockFileSystem('Native');
154+
env = LanguageServiceTestEnv.setup();
155+
const project = env.addProject('test', files);
156+
const definitions = getTypeDefinitionsAndAssertBoundSpan(
157+
project, {templateOverride: `<my-dir (twoWayV¦alueChange)="noop()" />`});
158+
159+
expect(definitions.length).toBe(1);
160+
assertTextSpans(definitions, ['ModelSignal']);
161+
assertFileNames(definitions, ['index.d.ts']);
162+
});
163+
164+
it('should return the definition of a two-way binding', () => {
165+
initMockFileSystem('Native');
166+
env = LanguageServiceTestEnv.setup();
167+
const project = env.addProject('test', files);
168+
const definitions = getTypeDefinitionsAndAssertBoundSpan(
169+
project, {templateOverride: `<my-dir [(twoWa¦yValue)]="value" />`});
170+
171+
expect(definitions.length).toBe(1);
172+
assertTextSpans(definitions, ['ModelSignal']);
173+
assertFileNames(definitions, ['index.d.ts']);
174+
});
175+
});
176+
114177
function getTypeDefinitionsAndAssertBoundSpan(
115178
project: Project, {templateOverride}: {templateOverride: string}) {
116179
const text = templateOverride.replace('¦', '');

0 commit comments

Comments
 (0)