refactor(core): add debug ranges to LViewDebug with matchers (#38359)

This change provides better typing for the `LView.debug` property which
is intended to be used by humans while debugging the application with
`ngDevMode` turned on.

In addition this chang also adds jasmine matchers for better asserting
that `LView` is in the correct state.

PR Close #38359
This commit is contained in:
Misko Hevery
2020-08-05 19:16:20 -07:00
committed by Andrew Kushnir
parent df7f3b04b5
commit 702958e968
15 changed files with 937 additions and 77 deletions

View File

@ -11,10 +11,13 @@ ts_library(
"**/*_perf.ts",
"domino.d.ts",
"load_domino.ts",
"is_shape_of.ts",
"jit_spec.ts",
"matchers.ts",
],
),
deps = [
":matchers",
"//packages:types",
"//packages/animations",
"//packages/animations/browser",
@ -34,6 +37,18 @@ ts_library(
],
)
ts_library(
name = "matchers",
testonly = True,
srcs = [
"is_shape_of.ts",
"matchers.ts",
],
deps = [
"//packages/core",
],
)
ts_library(
name = "domino",
testonly = True,

View File

@ -0,0 +1,22 @@
/**
* @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 {TNodeType, TNodeTypeAsString} from '@angular/core/src/render3/interfaces/node';
describe('node interfaces', () => {
describe('TNodeType', () => {
it('should agree with TNodeTypeAsString', () => {
expect(TNodeTypeAsString[TNodeType.Container]).toEqual('Container');
expect(TNodeTypeAsString[TNodeType.Projection]).toEqual('Projection');
expect(TNodeTypeAsString[TNodeType.View]).toEqual('View');
expect(TNodeTypeAsString[TNodeType.Element]).toEqual('Element');
expect(TNodeTypeAsString[TNodeType.ElementContainer]).toEqual('ElementContainer');
expect(TNodeTypeAsString[TNodeType.IcuContainer]).toEqual('IcuContainer');
});
});
});

View File

@ -0,0 +1,186 @@
/**
* @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 {TI18n} from '@angular/core/src/render3/interfaces/i18n';
import {TNode} from '@angular/core/src/render3/interfaces/node';
import {TView} from '@angular/core/src/render3/interfaces/view';
/**
* A type used to create a runtime representation of a shape of object which matches the declared
* interface at compile time.
*
* The purpose of this type is to ensure that the object must match all of the properties of a type.
* This is later used by `isShapeOf` method to ensure that a particular object has a particular
* shape.
*
* ```
* interface MyShape {
* foo: string,
* bar: number
* }
*
* const myShapeObj: {foo: '', bar: 0};
* const ExpectedPropertiesOfShape = {foo: true, bar: true};
*
* isShapeOf(myShapeObj, ExpectedPropertiesOfShape);
* ```
*
* The above code would verify that `myShapeObj` has `foo` and `bar` properties. However if later
* `MyShape` is refactored to change a set of properties we would like to have a compile time error
* that the `ExpectedPropertiesOfShape` also needs to be changed.
*
* ```
* const ExpectedPropertiesOfShape = <ShapeOf<MyShape>>{foo: true, bar: true};
* ```
* The above code will force through compile time checks that the `ExpectedPropertiesOfShape` match
* that of `MyShape`.
*
* See: `isShapeOf`
*
*/
export type ShapeOf<T> = {
[P in keyof T]: true;
};
/**
* Determines if a particular object is of a given shape (duck-type version of `instanceof`.)
*
* ```
* isShapeOf(someObj, {foo: true, bar: true});
* ```
*
* The above code will be true if the `someObj` has both `foo` and `bar` property
*
* @param obj Object to test for.
* @param shapeOf Desired shape.
*/
export function isShapeOf<T>(obj: any, shapeOf: ShapeOf<T>): obj is T {
if (typeof obj === 'object' && obj) {
return Object.keys(shapeOf).reduce(
(prev, key) => prev && obj.hasOwnProperty(key), true as boolean);
}
return false;
}
/**
* Determines if `obj` matches the shape `TI18n`.
* @param obj
*/
export function isTI18n(obj: any): obj is TI18n {
return isShapeOf<TI18n>(obj, ShapeOfTI18n);
}
const ShapeOfTI18n: ShapeOf<TI18n> = {
vars: true,
create: true,
update: true,
icus: true,
};
/**
* Determines if `obj` matches the shape `TView`.
* @param obj
*/
export function isTView(obj: any): obj is TView {
return isShapeOf<TView>(obj, ShapeOfTView);
}
const ShapeOfTView: ShapeOf<TView> = {
type: true,
id: true,
blueprint: true,
template: true,
viewQuery: true,
node: true,
firstCreatePass: true,
firstUpdatePass: true,
data: true,
bindingStartIndex: true,
expandoStartIndex: true,
staticViewQueries: true,
staticContentQueries: true,
firstChild: true,
expandoInstructions: true,
directiveRegistry: true,
pipeRegistry: true,
preOrderHooks: true,
preOrderCheckHooks: true,
contentHooks: true,
contentCheckHooks: true,
viewHooks: true,
viewCheckHooks: true,
destroyHooks: true,
cleanup: true,
components: true,
queries: true,
contentQueries: true,
schemas: true,
consts: true,
incompleteFirstPass: true,
};
/**
* Determines if `obj` matches the shape `TI18n`.
* @param obj
*/
export function isTNode(obj: any): obj is TNode {
return isShapeOf<TNode>(obj, ShapeOfTNode);
}
const ShapeOfTNode: ShapeOf<TNode> = {
type: true,
index: true,
injectorIndex: true,
directiveStart: true,
directiveEnd: true,
directiveStylingLast: true,
propertyBindings: true,
flags: true,
providerIndexes: true,
tagName: true,
attrs: true,
mergedAttrs: true,
localNames: true,
initialInputs: true,
inputs: true,
outputs: true,
tViews: true,
next: true,
projectionNext: true,
child: true,
parent: true,
projection: true,
styles: true,
stylesWithoutHost: true,
residualStyles: true,
classes: true,
classesWithoutHost: true,
residualClasses: true,
classBindings: true,
styleBindings: true,
};
/**
* Determines if `obj` is DOM `Node`.
*/
export function isDOMNode(obj: any): obj is Node {
return obj instanceof Node;
}
/**
* Determines if `obj` is DOM `Text`.
*/
export function isDOMElement(obj: any): obj is Element {
return obj instanceof Element;
}
/**
* Determines if `obj` is DOM `Text`.
*/
export function isDOMText(obj: any): obj is Text {
return obj instanceof Text;
}

View File

@ -0,0 +1,37 @@
/**
* @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 {isShapeOf, ShapeOf} from './is_shape_of';
describe('isShapeOf', () => {
const ShapeOfEmptyObject: ShapeOf<{}> = {};
it('should not match for non objects', () => {
expect(isShapeOf(null, ShapeOfEmptyObject)).toBeFalse();
expect(isShapeOf(0, ShapeOfEmptyObject)).toBeFalse();
expect(isShapeOf(1, ShapeOfEmptyObject)).toBeFalse();
expect(isShapeOf(true, ShapeOfEmptyObject)).toBeFalse();
expect(isShapeOf(false, ShapeOfEmptyObject)).toBeFalse();
expect(isShapeOf(undefined, ShapeOfEmptyObject)).toBeFalse();
});
it('should match on empty object', () => {
expect(isShapeOf({}, ShapeOfEmptyObject)).toBeTrue();
expect(isShapeOf({extra: 'is ok'}, ShapeOfEmptyObject)).toBeTrue();
});
it('should match on shape', () => {
expect(isShapeOf({required: 1}, {required: true})).toBeTrue();
expect(isShapeOf({required: true, extra: 'is ok'}, {required: true})).toBeTrue();
});
it('should not match if missing property', () => {
expect(isShapeOf({required: 1}, {required: true, missing: true})).toBeFalse();
expect(isShapeOf({required: true, extra: 'is ok'}, {required: true, missing: true}))
.toBeFalse();
});
});

View File

@ -0,0 +1,218 @@
/**
* @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 {TI18n} from '@angular/core/src/render3/interfaces/i18n';
import {TNode} from '@angular/core/src/render3/interfaces/node';
import {TView} from '@angular/core/src/render3/interfaces/view';
import {isDOMElement, isDOMText, isTI18n, isTNode, isTView} from './is_shape_of';
/**
* Generic matcher which asserts that an object is of a given shape (`shapePredicate`) and that it
* contains a subset of properties.
*
* @param name Name of `shapePredicate` to display when assertion fails.
* @param shapePredicate Predicate which verifies that the object is of correct shape.
* @param expected Expected set of properties to be found on the object.
*/
export function matchObjectShape<T>(
name: string, shapePredicate: (obj: any) => obj is T,
expected: Partial<T> = {}): jasmine.AsymmetricMatcher<T> {
const matcher = function() {};
let _actual: any = null;
matcher.asymmetricMatch = function(actual: any) {
_actual = actual;
if (!shapePredicate(actual)) return false;
for (const key in expected) {
if (expected.hasOwnProperty(key) && !jasmine.matchersUtil.equals(actual[key], expected[key]))
return false;
}
return true;
};
matcher.jasmineToString = function() {
return `${toString(_actual, false)} != ${toString(expected, true)})`;
};
function toString(obj: any, isExpected: boolean) {
if (isExpected || shapePredicate(obj)) {
const props =
Object.keys(expected).map(key => `${key}: ${JSON.stringify((obj as any)[key])}`);
if (isExpected === false) {
// Push something to let the user know that there may be other ignored properties in actual
props.push('...');
}
return `${name}({${props.length === 0 ? '' : '\n ' + props.join(',\n ') + '\n'}})`;
} else {
return JSON.stringify(obj);
}
}
return matcher;
}
/**
* Asymmetric matcher which matches a `TView` of a given shape.
*
* Expected usage:
* ```
* expect(tNode).toEqual(matchTView({type: TViewType.Root}));
* expect({
* node: tNode
* }).toEqual({
* node: matchTNode({type: TViewType.Root})
* });
* ```
*
* @param expected optional properties which the `TView` must contain.
*/
export function matchTView(expected?: Partial<TView>): jasmine.AsymmetricMatcher<TView> {
return matchObjectShape('TView', isTView, expected);
}
/**
* Asymmetric matcher which matches a `TNode` of a given shape.
*
* Expected usage:
* ```
* expect(tNode).toEqual(matchTNode({type: TNodeType.Element}));
* expect({
* node: tNode
* }).toEqual({
* node: matchTNode({type: TNodeType.Element})
* });
* ```
*
* @param expected optional properties which the `TNode` must contain.
*/
export function matchTNode(expected?: Partial<TNode>): jasmine.AsymmetricMatcher<TNode> {
return matchObjectShape('TNode', isTNode, expected);
}
/**
* Asymmetric matcher which matches a `T18n` of a given shape.
*
* Expected usage:
* ```
* expect(tNode).toEqual(matchT18n({vars: 0}));
* expect({
* node: tNode
* }).toEqual({
* node: matchT18n({vars: 0})
* });
* ```
*
* @param expected optional properties which the `TI18n` must contain.
*/
export function matchTI18n(expected?: Partial<TI18n>): jasmine.AsymmetricMatcher<TI18n> {
return matchObjectShape('TI18n', isTI18n, expected);
}
/**
* Asymmetric matcher which matches a DOM Element.
*
* Expected usage:
* ```
* expect(div).toEqual(matchT18n('div', {id: '123'}));
* expect({
* node: div
* }).toEqual({
* node: matchT18n('div', {id: '123'})
* });
* ```
*
* @param expectedTagName optional DOM tag name.
* @param expectedAttributes optional DOM element properties.
*/
export function matchDomElement(
expectedTagName: string|undefined = undefined,
expectedAttrs: {[key: string]: string|null} = {}): jasmine.AsymmetricMatcher<Element> {
const matcher = function() {};
let _actual: any = null;
matcher.asymmetricMatch = function(actual: any) {
_actual = actual;
if (!isDOMElement(actual)) return false;
if (expectedTagName && (expectedTagName.toUpperCase() !== actual.tagName.toUpperCase())) {
return false;
}
if (expectedAttrs) {
for (const attrName in expectedAttrs) {
if (expectedAttrs.hasOwnProperty(attrName)) {
const expectedAttrValue = expectedAttrs[attrName];
const actualAttrValue = actual.getAttribute(attrName);
if (expectedAttrValue !== actualAttrValue) {
return false;
}
}
}
}
return true;
};
matcher.jasmineToString = function() {
let actualStr = isDOMElement(_actual) ? `<${_actual.tagName}${toString(_actual.attributes)}>` :
JSON.stringify(_actual);
let expectedStr = `<${expectedTagName || '*'}${
Object.keys(expectedAttrs).map(key => ` ${key}=${JSON.stringify(expectedAttrs[key])}`)}>`;
return `[${actualStr} != ${expectedStr}]`;
};
function toString(attrs: NamedNodeMap) {
let text = '';
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
text += ` ${attr.name}=${JSON.stringify(attr.value)}`;
}
return text;
}
return matcher;
}
/**
* Asymmetric matcher which matches DOM text node.
*
* Expected usage:
* ```
* expect(div).toEqual(matchDomText('text'));
* expect({
* node: div
* }).toEqual({
* node: matchDomText('text')
* });
* ```
*
* @param expectedText optional DOM text.
*/
export function matchDomText(expectedText: string|undefined = undefined):
jasmine.AsymmetricMatcher<Text> {
const matcher = function() {};
let _actual: any = null;
matcher.asymmetricMatch = function(actual: any) {
_actual = actual;
if (!isDOMText(actual)) return false;
if (expectedText && (expectedText !== actual.textContent)) {
return false;
}
return true;
};
matcher.jasmineToString = function() {
let actualStr = isDOMText(_actual) ? `#TEXT: ${JSON.stringify(_actual.textContent)}` :
JSON.stringify(_actual);
let expectedStr = `#TEXT: ${JSON.stringify(expectedText)}`;
return `[${actualStr} != ${expectedStr}]`;
};
return matcher;
}

View File

@ -0,0 +1,101 @@
/**
* @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 {createTNode, createTView} from '@angular/core/src/render3/instructions/shared';
import {TNodeType} from '@angular/core/src/render3/interfaces/node';
import {TViewType} from '@angular/core/src/render3/interfaces/view';
import {onlyInIvy} from '@angular/private/testing';
import {isShapeOf, ShapeOf} from './is_shape_of';
import {matchDomElement, matchDomText, matchObjectShape, matchTNode, matchTView} from './matchers';
import {dedent} from './utils';
describe('render3 matchers', () => {
describe('matchObjectShape', () => {
interface MyShape {
propA: any;
propB: any;
}
const myShape: MyShape = {propA: 'value', propB: 3};
function isMyShape(obj: any): obj is MyShape {
return isShapeOf<MyShape>(obj, ShapeOfMyShape);
}
const ShapeOfMyShape: ShapeOf<MyShape> = {propA: true, propB: true};
function matchMyShape(expected?: Partial<MyShape>): jasmine.AsymmetricMatcher<MyShape> {
return matchObjectShape('MyShape', isMyShape, expected);
}
it('should match', () => {
expect(isMyShape(myShape)).toBeTrue();
expect(myShape).toEqual(matchMyShape());
expect(myShape).toEqual(matchMyShape({propA: 'value'}));
expect({node: myShape}).toEqual({node: matchMyShape({propA: 'value'})});
});
it('should produce human readable errors', () => {
const matcher = matchMyShape({propA: 'different'});
expect(matcher.asymmetricMatch(myShape, [])).toEqual(false);
expect(matcher.jasmineToString!()).toEqual(dedent`
MyShape({
propA: "value",
...
}) != MyShape({
propA: "different"
}))`);
});
});
describe('matchTView', () => {
const tView = createTView(TViewType.Root, 1, null, 2, 3, null, null, null, null, null);
it('should match', () => {
expect(tView).toEqual(matchTView());
expect(tView).toEqual(matchTView({type: TViewType.Root}));
expect({node: tView}).toEqual({node: matchTView({type: TViewType.Root})});
});
});
describe('matchTNode', () => {
const tView = createTView(TViewType.Root, 1, null, 2, 3, null, null, null, null, null);
const tNode = createTNode(tView, null, TNodeType.Element, 1, 'tagName', []);
it('should match', () => {
expect(tNode).toEqual(matchTNode());
expect(tNode).toEqual(matchTNode({type: TNodeType.Element, tagName: 'tagName'}));
expect({node: tNode}).toEqual({node: matchTNode({type: TNodeType.Element})});
});
});
describe('matchDomElement', () => {
const div = document.createElement('div');
div.setAttribute('name', 'Name');
it('should match', () => {
expect(div).toEqual(matchDomElement());
expect(div).toEqual(matchDomElement('div', {name: 'Name'}));
});
it('should produce human readable error', () => {
const matcher = matchDomElement('div', {name: 'other'});
expect(matcher.asymmetricMatch(div, [])).toEqual(false);
expect(matcher.jasmineToString!()).toEqual(`[<DIV name="Name"> != <div name="other">]`);
});
});
describe('matchDomText', () => {
const text = document.createTextNode('myText');
it('should match', () => {
expect(text).toEqual(matchDomText());
expect(text).toEqual(matchDomText('myText'));
});
it('should produce human readable error', () => {
const matcher = matchDomText('other text');
expect(matcher.asymmetricMatch(text, [])).toEqual(false);
expect(matcher.jasmineToString!()).toEqual(`[#TEXT: "myText" != #TEXT: "other text"]`);
});
});
});