From 8aac3c45b95e342495cd99bf46cb37312972e053 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sun, 4 Feb 2024 22:57:13 +0100 Subject: [PATCH] test(core): add runtime acceptance tests for model inputs (#54252) Sets up the runtime tests for model inputs. PR Close #54252 --- .../acceptance/authoring/model_inputs_spec.ts | 546 ++++++++++++++++++ .../authoring/signal_inputs_spec.ts | 41 +- 2 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/acceptance/authoring/model_inputs_spec.ts diff --git a/packages/core/test/acceptance/authoring/model_inputs_spec.ts b/packages/core/test/acceptance/authoring/model_inputs_spec.ts new file mode 100644 index 00000000000..c90765eaca4 --- /dev/null +++ b/packages/core/test/acceptance/authoring/model_inputs_spec.ts @@ -0,0 +1,546 @@ +/*! + * @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.io/license + */ + +import {Component, Directive, EventEmitter, Input, model, OnChanges, Output, signal, SimpleChange, SimpleChanges, ViewChild} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; + +describe('model inputs', () => { + it('should support two-way binding to a signal', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model(0); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = signal(1); + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + // Initial value. + expect(host.value()).toBe(1); + expect(host.dir.value()).toBe(1); + + // Changing the value from within the directive. + host.dir.value.set(2); + fixture.detectChanges(); + expect(host.value()).toBe(2); + expect(host.dir.value()).toBe(2); + + // Changing the value from the outside. + host.value.set(3); + fixture.detectChanges(); + expect(host.value()).toBe(3); + expect(host.dir.value()).toBe(3); + }); + + it('should support two-way binding to a non-signal value', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model(0); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = 1; + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + // Initial value. + expect(host.value).toBe(1); + expect(host.dir.value()).toBe(1); + + // Changing the value from within the directive. + host.dir.value.set(2); + fixture.detectChanges(); + expect(host.value).toBe(2); + expect(host.dir.value()).toBe(2); + + // Changing the value from the outside. + host.value = 3; + fixture.detectChanges(); + expect(host.value).toBe(3); + expect(host.dir.value()).toBe(3); + }); + + it('should support two-way binding a signal to a non-model input/output pair', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + @Input() value = 0; + @Output() valueChange = new EventEmitter(); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = signal(1); + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + // Initial value. + expect(host.value()).toBe(1); + expect(host.dir.value).toBe(1); + + // Changing the value from within the directive. + host.dir.value = 2; + host.dir.valueChange.emit(2); + fixture.detectChanges(); + expect(host.value()).toBe(2); + expect(host.dir.value).toBe(2); + + // Changing the value from the outside. + host.value.set(3); + fixture.detectChanges(); + expect(host.value()).toBe(3); + expect(host.dir.value).toBe(3); + }); + + it('should support a one-way property binding to a model', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model(0); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = 1; + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + // Initial value. + expect(host.value).toBe(1); + expect(host.dir.value()).toBe(1); + + // Changing the value from within the directive. + host.dir.value.set(2); + fixture.detectChanges(); + expect(host.value).toBe(1); + expect(host.dir.value()).toBe(2); + + // Changing the value from the outside. + host.value = 3; + fixture.detectChanges(); + expect(host.value).toBe(3); + expect(host.dir.value()).toBe(3); + }); + + it('should emit to the change output when the model changes', () => { + const emittedValues: number[] = []; + + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model(0); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + + changed(value: number) { + emittedValues.push(value); + } + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + expect(emittedValues).toEqual([]); + + host.dir.value.set(1); + fixture.detectChanges(); + expect(emittedValues).toEqual([1]); + + // Same value should not emit. + host.dir.value.set(1); + fixture.detectChanges(); + expect(emittedValues).toEqual([1]); + + host.dir.value.update(value => value * 5); + fixture.detectChanges(); + expect(emittedValues).toEqual([1, 5]); + }); + + it('should not emit to the change event when then property binding changes', () => { + const emittedValues: number[] = []; + + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model(0); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = signal(1); + + changed(value: number) { + emittedValues.push(value); + } + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + expect(emittedValues).toEqual([]); + + host.value.set(2); + fixture.detectChanges(); + expect(emittedValues).toEqual([]); + }); + + it('should support binding to the model input and output separately', () => { + const emittedValues: number[] = []; + + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model(0); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = signal(1); + + changed(value: number) { + emittedValues.push(value); + } + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + expect(host.value()).toBe(1); + expect(host.dir.value()).toBe(1); + expect(emittedValues).toEqual([]); + + host.dir.value.set(2); + fixture.detectChanges(); + expect(host.value()).toBe(1); + expect(host.dir.value()).toBe(2); + expect(emittedValues).toEqual([2]); + + host.value.set(3); + fixture.detectChanges(); + expect(host.value()).toBe(3); + expect(host.dir.value()).toBe(3); + expect(emittedValues).toEqual([2]); + }); + + it('should support two-way binding to a model with an alias', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model(0, {alias: 'alias'}); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = signal(1); + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + // Initial value. + expect(host.value()).toBe(1); + expect(host.dir.value()).toBe(1); + + // Changing the value from within the directive. + host.dir.value.set(2); + fixture.detectChanges(); + expect(host.value()).toBe(2); + expect(host.dir.value()).toBe(2); + + // Changing the value from the outside. + host.value.set(3); + fixture.detectChanges(); + expect(host.value()).toBe(3); + expect(host.dir.value()).toBe(3); + }); + + it('should support binding to an aliased model input and output separately', () => { + const emittedValues: number[] = []; + + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model(0, {alias: 'alias'}); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = signal(1); + + changed(value: number) { + emittedValues.push(value); + } + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + expect(host.value()).toBe(1); + expect(host.dir.value()).toBe(1); + expect(emittedValues).toEqual([]); + + host.dir.value.set(2); + fixture.detectChanges(); + expect(host.value()).toBe(1); + expect(host.dir.value()).toBe(2); + expect(emittedValues).toEqual([2]); + + host.value.set(3); + fixture.detectChanges(); + expect(host.value()).toBe(3); + expect(host.dir.value()).toBe(3); + expect(emittedValues).toEqual([2]); + }); + + it('should throw if a required model input is accessed too early', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model.required(); + + constructor() { + this.value(); + } + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + value = 1; + } + + expect(() => TestBed.createComponent(App)) + .toThrowError(/Model is required but no value is available yet/); + }); + + it('should stop emitting to the output on destroy', () => { + let emittedEvents = 0; + + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = model(0); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + + changed() { + emittedEvents++; + } + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const modelRef = fixture.componentInstance.dir.value; + expect(emittedEvents).toBe(0); + + modelRef.set(1); + fixture.detectChanges(); + expect(emittedEvents).toBe(1); + + fixture.destroy(); + modelRef.set(2); + expect(emittedEvents).toBe(1); + }); + + it('should support inherited model inputs', () => { + @Directive() + abstract class BaseDir { + value = model(0); + } + + @Directive({selector: '[dir]', standalone: true}) + class Dir extends BaseDir { + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = signal(1); + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + // Initial value. + expect(host.value()).toBe(1); + expect(host.dir.value()).toBe(1); + + // Changing the value from within the directive. + host.dir.value.set(2); + fixture.detectChanges(); + expect(host.value()).toBe(2); + expect(host.dir.value()).toBe(2); + + // Changing the value from the outside. + host.value.set(3); + fixture.detectChanges(); + expect(host.value()).toBe(3); + expect(host.dir.value()).toBe(3); + }); + + it('should allow a two-way-bound signal to be bound in the template', () => { + @Directive({ + selector: '[dir]', + standalone: true, + host: { + '(click)': 'increment()', + } + }) + class Dir { + value = model(0); + + increment() { + this.value.update(previous => previous + 1); + } + } + + @Component({ + template: ' Current value: {{value()}}', + standalone: true, + imports: [Dir], + }) + class App { + value = signal(1); + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Current value: 1'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Current value: 2'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Current value: 3'); + }); + + it('should support ngOnChanges for two-way model bindings', () => { + const changes: SimpleChange[] = []; + + @Directive({selector: '[dir]', standalone: true}) + class Dir implements OnChanges { + value = model(0); + + ngOnChanges(allCahnges: SimpleChanges): void { + if (allCahnges['value']) { + changes.push(allCahnges['value']); + } + } + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = signal(1); + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(changes).toEqual([jasmine.objectContaining({ + previousValue: undefined, + currentValue: 1, + firstChange: true, + })]); + + fixture.componentInstance.value.set(2); + fixture.detectChanges(); + + expect(changes).toEqual([ + jasmine.objectContaining({ + previousValue: undefined, + currentValue: 1, + firstChange: true, + }), + jasmine.objectContaining({ + previousValue: 1, + currentValue: 2, + firstChange: false, + }), + ]); + }); +}); diff --git a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts index 8440f2105cf..485e5fa90a0 100644 --- a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts +++ b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Component, computed, Directive, effect, input} from '@angular/core'; +import {Component, computed, Directive, effect, EventEmitter, input, Output, ViewChild} from '@angular/core'; import {TestBed} from '@angular/core/testing'; describe('signal inputs', () => { @@ -231,4 +231,43 @@ describe('signal inputs', () => { expect(fixture.nativeElement.textContent).toBe('input:2'); }); + + // TODO(crisbeto): we may not want to support this combination. Will discuss with the team. + it('should support two-way binding to signal input and @Output decorated member', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = input(0); + @Output() valueChange = new EventEmitter(); + } + + @Component({ + template: '
', + standalone: true, + imports: [Dir], + }) + class App { + @ViewChild(Dir) dir!: Dir; + value = 1; + } + + const fixture = TestBed.createComponent(App); + const host = fixture.componentInstance; + fixture.detectChanges(); + + // Initial value. + expect(host.value).toBe(1); + expect(host.dir.value()).toBe(1); + + // Changing the value from within the directive. + host.dir.valueChange.emit(2); + fixture.detectChanges(); + expect(host.value).toBe(2); + expect(host.dir.value()).toBe(2); + + // Changing the value from the outside. + host.value = 3; + fixture.detectChanges(); + expect(host.value).toBe(3); + expect(host.dir.value()).toBe(3); + }); });