Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions libs/ng-mocks/src/lib/common/func.directive-io-parse.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import funcDirectiveIoParse from './func.directive-io-parse';

describe('funcDirectiveIoParse', () => {
it('keeps regular aliases', () => {
expect(funcDirectiveIoParse('prop:alias')).toEqual({
alias: 'alias',
name: 'prop',
});
});

it('normalizes signal model outputs from strings', () => {
expect(funcDirectiveIoParse('value:valueChange')).toEqual({
name: 'valueChange',
});
});

it('normalizes signal model outputs from objects', () => {
expect(
funcDirectiveIoParse({
alias: 'valueChange',
name: 'value',
}),
).toEqual({
name: 'valueChange',
});
});

it('keeps required metadata for signal model outputs', () => {
expect(
funcDirectiveIoParse({
alias: 'valueChange',
name: 'value',
required: true,
}),
).toEqual({
name: 'valueChange',
required: true,
});
});
});
21 changes: 14 additions & 7 deletions libs/ng-mocks/src/lib/common/func.directive-io-parse.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { DirectiveIo, DirectiveIoParsed } from './core.types';

const normalize = ({ name, alias, required }: DirectiveIoParsed): DirectiveIoParsed => {
if (name === alias || !alias) {
return required === undefined ? { name } : { name, required };
}

if (name + 'Change' === alias) {
return required === undefined ? { name: alias } : { name: alias, required };
}

return required === undefined ? { name, alias } : { name, alias, required };
};

export default function (param: DirectiveIo): DirectiveIoParsed {
if (typeof param === 'string') {
const [name, alias] = param.split(':').map(v => v.trim());

if (name === alias || !alias) {
return { name };
}

return { name, alias };
return normalize({ name, alias });
}

return param;
return normalize(param);
}
58 changes: 58 additions & 0 deletions libs/ng-mocks/src/lib/resolve/collect-declarations.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Component, EventEmitter, Output } from '@angular/core';

import funcGetGlobal from '../common/func.get-global';

import collectDeclarations from './collect-declarations';

describe('collect-declarations', () => {
Expand Down Expand Up @@ -324,4 +326,60 @@ describe('collect-declarations', () => {
const actual = collectDeclarations(TargetComponent);
expect(actual.outputs).toEqual(['output']);
});

it('reads signal model bindings from ng defs', () => {
const global = funcGetGlobal();
global.__ngMocksReflectComponentType = false;
const actual = collectDeclarations({
ɵcmp: {
declaredInputs: {
value: 'value',
},
inputs: {
value: ['value', 1, null],
},
outputs: {
valueChange: 'value',
},
standalone: true,
},
});

expect(actual.inputs).toEqual(['value']);
expect(actual.outputs).toEqual(['valueChange']);
expect(actual.standalone).toBe(true);
delete global.__ngMocksReflectComponentType;
});

it('reads string-form ng def inputs without declaredInputs', () => {
const global = funcGetGlobal();
global.__ngMocksReflectComponentType = false;
const actual = collectDeclarations({
ɵcmp: {
inputs: {
alias: 'prop',
},
},
});

expect(actual.inputs).toEqual(['prop:alias']);
expect(actual.outputs).toEqual([]);
delete global.__ngMocksReflectComponentType;
});

it('reads ng def outputs without inputs', () => {
const global = funcGetGlobal();
global.__ngMocksReflectComponentType = false;
const actual = collectDeclarations({
ɵcmp: {
outputs: {
valueChange: 'value',
},
},
});

expect(actual.inputs).toEqual([]);
expect(actual.outputs).toEqual(['valueChange']);
delete global.__ngMocksReflectComponentType;
});
});
43 changes: 38 additions & 5 deletions libs/ng-mocks/src/lib/resolve/collect-declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,15 +313,48 @@ const parseNgDef = (
},
declaration: Declaration,
): void => {
if (declaration.standalone === undefined && def.ɵcmp?.standalone !== undefined) {
declaration.standalone = def.ɵcmp.standalone;
}
if (declaration.standalone === undefined && def.ɵdir?.standalone !== undefined) {
declaration.standalone = def.ɵdir.standalone;
const ngDef = def.ɵcmp ?? def.ɵdir;

if (declaration.standalone === undefined && ngDef?.standalone !== undefined) {
declaration.standalone = ngDef.standalone;
}
if (declaration.standalone === undefined && def.ɵpipe?.standalone !== undefined) {
declaration.standalone = def.ɵpipe.standalone;
}

if (!ngDef) {
return;
}

for (const alias of Object.keys(ngDef.inputs || {})) {
const input = ngDef.inputs[alias];
const minifiedName = Array.isArray(input) ? input[0] : input;
const {
name,
alias: normalizedAlias,
required,
} = funcDirectiveIoParse({
name: ngDef.declaredInputs?.[alias] ?? minifiedName,
alias,
required: undefined,
});

addUniqueDirectiveIo(declaration, 'inputs', name, normalizedAlias, required);
}

for (const alias of Object.keys(ngDef.outputs || {})) {
const {
name,
alias: normalizedAlias,
required,
} = funcDirectiveIoParse({
name: ngDef.outputs[alias],
alias,
required: undefined,
});

addUniqueDirectiveIo(declaration, 'outputs', name, normalizedAlias, required);
}
};

/**
Expand Down
92 changes: 92 additions & 0 deletions tests/issue-10942/test.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Component, VERSION } from '@angular/core';
import * as ngCore from '@angular/core';
import { TestBed } from '@angular/core/testing';

import { MockBuilder, MockComponent, ngMocks } from 'ng-mocks';

// @see https://github.com/help-me-mom/ng-mocks/issues/10942
describe('issue-10942', () => {
if (
Number.parseInt(VERSION.major, 10) < 17 ||
typeof (ngCore as any).model !== 'function'
) {
it('needs signal model support', () => {
expect(true).toBeTruthy();
});

return;
}

const model = (ngCore as any).model as (value: string) => any;
const signal = (ngCore as any).signal as (value: string) => any;

@Component({
selector: 'target-10942-signal',
template: '',
['standalone' as never /* TODO: remove after upgrade to a14 */]: true,
})
class SignalComponent {
public value = model('');
}

@Component({
imports: [SignalComponent],
selector: 'target-10942',
template: `
<h1>{{ title() }}</h1>
<target-10942-signal [(value)]="title"></target-10942-signal>
`,
['standalone' as never /* TODO: remove after upgrade to a14 */]: true,
} as never)
class TargetComponent {
public title = signal('test-default');
}

const hasCompiledModelMetadata = () => {
const mirror = (ngCore as any).reflectComponentType?.(
SignalComponent,
);

return (
Object.keys((SignalComponent as any).ɵcmp?.inputs || {})
.length > 0 ||
Object.keys((SignalComponent as any).ɵcmp?.outputs || {})
.length > 0 ||
(mirror?.inputs?.length || 0) > 0 ||
(mirror?.outputs?.length || 0) > 0
);
};

if (!hasCompiledModelMetadata()) {
it('needs compiled signal model metadata', () => {
expect(true).toBeTruthy();
});

return;
}

beforeEach(() => MockBuilder(TargetComponent));

it('creates a mock with signal model bindings', () => {
const mockComponent = MockComponent(SignalComponent) as any;

expect(mockComponent.ɵcmp?.inputs?.value).toBeDefined();
expect(mockComponent.ɵcmp?.outputs?.valueChange).toBeDefined();
});

it('provides the signal model change emitter after change detection', () => {
const fixture = TestBed.createComponent(TargetComponent);

fixture.detectChanges();
expect(() =>
ngMocks
.output('target-10942-signal', 'valueChange')
.emit('test-new'),
).not.toThrow();
fixture.detectChanges();

expect(ngMocks.find('h1').nativeElement.innerHTML).toEqual(
'test-new',
);
});
});