Skip to content

Commit

Permalink
test(core): add type signature test for signal input API (angular#53571)
Browse files Browse the repository at this point in the history
Adds tests that allow us to ensure that the `input` API works as
expected and that resulting return types match our expectations- without
silently regressing in the future, or missing potential edge-cases.

Testing signatures is hard because of covariance and contravariance,
especially when it comes to the different semantics of `ReadT` and
`WriteT` of input signals. We enable reliable testing by validating the
`d.ts` of the "fake directive class". This ensures clear results,
compared to relying on e.g. type assertions that might
accidentally/silently pass due to covariance/contravariance or
biavariance in the type system.

PR Close angular#53571
  • Loading branch information
devversion authored and danieljancar committed Jan 26, 2024
1 parent c86cc6a commit acd4dfa
Show file tree
Hide file tree
Showing 3 changed files with 184 additions and 0 deletions.
25 changes: 25 additions & 0 deletions packages/core/test/authoring/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
load("//tools:defaults.bzl", "nodejs_test", "ts_library")

ts_library(
name = "signal_input_signature_test_lib",
srcs = ["signal_input_signature_test.ts"],
deps = ["//packages/core"],
)

ts_library(
name = "type_tester_lib",
srcs = ["type_tester.ts"],
deps = [
"@npm//@types/node",
"@npm//typescript",
],
)

nodejs_test(
name = "type_test",
data = [
":signal_input_signature_test_lib",
":type_tester_lib",
],
entry_point = ":type_tester.ts",
)
98 changes: 98 additions & 0 deletions packages/core/test/authoring/signal_input_signature_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* @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
*/

/**
* @fileoverview
* This file contains various signal `input()` patterns and ensures
* the resulting types match our expectations (via comments asserting the `.d.ts`).
*/

import {input} from '../../src/authoring/input';
// import preserved to simplify `.d.ts` emit and simplify the `type_tester` logic.
import {InputSignal} from '../../src/authoring/input_signal';

export class InputSignatureTest {
/** string | undefined */
noInitialValueExplicitRead = input<string>();
/** boolean */
initialValueBooleanNoType = input(false);
/** string */
initialValueStringNoType = input('bla');
/** number */
initialValueNumberNoType = input(0);
/** string[] */
initialValueObjectNoType = input([] as string[]);
/** number */
initialValueEmptyOptions = input(1, {});

/** @internal */
// @ts-expect-error Transform is needed
__explicitWriteTWithoutTransformForbidden = input<string, string>('bla__');

/** number, string | number */
noInitialValueWithTransform = input.required({transform: (_v: number|string) => 0});

/** number, string | number */
initialValueWithTransform = input(0, {transform: (_v: number|string) => 0});

/** boolean | undefined, string | boolean */
undefinedInitialValueWithTransform = input(undefined, {transform: (_v: boolean|string) => true});

/** {works: boolean;}, string | boolean */
complexTransformWithInitialValue = input({works: true}, {
transform: (_v: boolean|string) => ({works: !!_v}),
});

/** RegExp */
nonPrimitiveInitialValue = input(/default regex/);

/** string, string | null */
requiredExplicitReadAndWriteButNoTransform =
input.required<string, string|null>({transform: _v => ''});

/** string, string | null */
withInitialValueExplicitReadAndWrite = input<string, string|null>('', {transform: bla => ''});

/** string | undefined */
withNoInitialValue = input<string>();

/** string */
requiredNoInitialValue = input.required<string>();
/** string | undefined */
requiredNoInitialValueExplicitUndefined = input.required<string|undefined>();

/** string, string | boolean */
requiredWithTransform =
input.required<string, string|boolean>({transform: (v: string|boolean) => ''});

/** @internal */
__requiredWithTransformButNoWriteT = input.required<string>({
// @ts-expect-error
transform: (v: string|boolean) => ''
});

/** string, string | boolean */
requiredWithTransformInferenceNoExplicitGeneric =
input.required({transform: (v: string|boolean) => ''});

// Unknown as `WriteT` is acceptable because the user explicitly opted into handling
// the transform- so they will need to work with the `unknown` values.
/** string, unknown */
requiredTransformButNoTypes = input.required({transform: (v) => ''});

/** unknown */
noInitialValueNoType = input();
/** string */
requiredNoInitialValueNoType = input.required<string>();

/** @internal */
__shouldErrorIfInitialValueWithRequired = input.required({
// @ts-expect-error
initialValue: 0,
});
}
61 changes: 61 additions & 0 deletions packages/core/test/authoring/type_tester.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @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 fs from 'fs';
import path from 'path';
import ts from 'typescript';
import url from 'url';

const containingDir = path.dirname(url.fileURLToPath(import.meta.url));
const testDtsFile = path.join(containingDir, 'signal_input_signature_test.d.ts');

async function main() {
const fileContent = fs.readFileSync(testDtsFile, 'utf8');
const sourceFile = ts.createSourceFile('test.ts', fileContent, ts.ScriptTarget.ESNext, true);
const testClazz =
sourceFile.statements.find((s): s is ts.ClassDeclaration => ts.isClassDeclaration(s))!;
let failing = false;

for (const member of testClazz.members) {
if (!ts.isPropertyDeclaration(member)) {
continue;
}

const leadingCommentRanges = ts.getLeadingCommentRanges(sourceFile.text, member.getFullStart());
const leadingComments = leadingCommentRanges?.map(r => sourceFile.text.substring(r.pos, r.end));

if (leadingComments === undefined || leadingComments.length === 0) {
throw new Error(`No expected type for: ${member.name.getText()}`);
}

// strip comment start, and beginning (plus whitespace).
let expectedTypeComment = leadingComments[0].replace(/(^\/\*\*?\s*|\s*\*+\/$)/g, '');
// expand shorthands where ReadT is the same as WriteT.
if (!expectedTypeComment.includes(',')) {
expectedTypeComment = `${expectedTypeComment}, ${expectedTypeComment}`;
}

const expectedType = `InputSignal<${expectedTypeComment}>`;
// strip excess whitespace or newlines.
const got = member.type?.getText().replace(/(\n+|\s\s+)/g, '');

if (expectedType !== got) {
console.error(`${member.name.getText()}: expected: ${expectedType}, got: ${got}`);
failing = true;
}
}

if (failing) {
throw new Error('Failing assertions');
}
}

main().catch(e => {
console.error(e);
process.exitCode = 1;
});

0 comments on commit acd4dfa

Please sign in to comment.