Skip to content
This repository has been archived by the owner on Jul 30, 2018. It is now read-only.

Allow custom comparisons of property values #33

Merged
merged 11 commits into from
May 5, 2017
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ special virtual DOM nodes before being sent to the virtual DOM engine for render
like `<test--widget-stub data--widget-name="<<widget class name>>"></test--widget-stub>`, where `data--widget-name` will be set
to either the widget class tag or the name of the class (*note* IE11 does not support function names, therefore it will have
`<Anonymous>` as the value of the attribute instead. The substituion occurs *after* the virtual DOM is compared on an
`.expectedRender()` assertion, so expected virtual DOM passed to that function should be as the widget will be expected to
`.expectRender()` assertion, so expected virtual DOM passed to that function should be as the widget will be expected to
return from its `.render()` implimentation.

Basic usage of `harness()` would look like this:
Expand Down Expand Up @@ -187,7 +187,7 @@ widget.expectRender(expected);
Cleans up the `harness` instance and removes the harness and other rendered DOM from the DOM. You should *always* call `.destroy()`
otherwise you will leave quite a lot of grabage DOM in the document which may have impacts on other tests you will run.

#### .expectedRender()
#### .expectRender()

Provide an expected of virtual DOM which will be compared with the actual rendered virtual DOM from the widget class. It *spies*
the result from the harnessed widget's `.render()` return and compares that with the provided expected virtual DOM. If the `actual`
Expand Down Expand Up @@ -456,6 +456,34 @@ assignProperties(expected, {
});
```

### compareProperty()

Returns an object which is used in render assertion comparisons like `harness.expectRender()` or `assertRender()`. This is designed
to allow validation of propery values that are difficult to know or obtain references to until the widget has rendered (e.g. registries or dynamically generated IDs).

The function takes a single argument of `callback` which is a function that will be called when the property value needs to be validated.
This `callback` can take up to three arguments. The first is the `value` of the property to check, the second is the `name` of the
property, and `parent` is either the actual `WidgetProperties` or `VirtualDomProperties` that this value is from. If the value is _valid_
then the function should return `true`, if the value is _not valid_ returning `false` will cause an `AssertionError` to be thrown, naming
the property which has an unexpected value.

*Note:* the type of the return value can often not be valid for the property value that you are passing it for. You may need to cast
it as `any` in order to allow TypeScript type checking to succeed.

An example of usage would be:

```ts
import { compareProperty } from '@dojo/test-extras/support/d';

const compareRegistryProperty = compareProperty((value) => {
return value instanceof Registry;
});

widget.expectRender(v('div', {}, [
w('child', { registry: compareRegistryProperty })
]));
```

### findIndex()

Returns a node identified by the supplied `index`. The first argument is the _root_ virtual DOM node (`WNode` or `HNode`) and the
Expand Down
36 changes: 13 additions & 23 deletions src/support/assertRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,19 @@ export default function assertRender(actual: DNode, expected: DNode, options?: A
});
}

if (localIsHNode(actual) && localIsHNode(expected)) {
if (actual.tag !== expected.tag) {
/* The tags do not match */
throwAssertionError(actual.tag, expected.tag, message);
if ((localIsHNode(actual) && localIsHNode(expected)) || (localIsWNode(actual) && localIsWNode(expected))) {
if (localIsHNode(actual) && localIsHNode(expected)) {
if (actual.tag !== expected.tag) {
/* The tags do not match */
throwAssertionError(actual.tag, expected.tag, message);
}
}
/* istanbul ignore else: not being tracked by TypeScript properly */
else if (localIsWNode(actual) && localIsWNode(expected)) {
if (actual.widgetConstructor !== expected.widgetConstructor) {
/* The WNode does not share the same constructor */
throwAssertionError(actual.widgetConstructor, expected.widgetConstructor, message);
}
}
const delta = diff(actual.properties, expected.properties, diffOptions);
if (delta.length) {
Expand All @@ -88,25 +97,6 @@ export default function assertRender(actual: DNode, expected: DNode, options?: A
/* We need to assert the children match */
assertChildren(actual.children, expected.children);
}
else if (localIsWNode(actual) && localIsWNode(expected)) {
if (actual.widgetConstructor !== expected.widgetConstructor) {
/* The WNode does not share the same constructor */
throwAssertionError(actual.widgetConstructor, expected.widgetConstructor, message);
}
const delta = diff(actual.properties, expected.properties, diffOptions);
if (delta.length) {
/* There are differences in the properties between the two nodes */
throwAssertionError(actual.properties, expected.properties, message);
}
if (actual.children && expected.children) {
/* We need to assert the children match */
assertChildren(actual.children, expected.children);
}
else if (actual.children || expected.children) {
/* One WNode has children, but the other doesn't */
throwAssertionError(actual.children, expected.children, message);
}
}
else if (typeof actual === 'string' && typeof expected === 'string') {
/* Both DNodes are strings */
if (actual !== expected) {
Expand Down
177 changes: 166 additions & 11 deletions src/support/compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,70 @@ export interface DiffOptions {
ignorePropertyValues?: (string | RegExp)[] | IgnorePropertyFunction;
}

/**
* Interface for a generic constructor function
*/
export interface Constructor {
new (...args: any[]): object;
prototype: object;
}

/**
* A partial property descriptor that provides the property descriptor flags supported by the
* complex property construction of `patch()`
*
* All properties are value properties, with the value being supplied by the `ConstructRecord`
*/
export interface ConstructDescriptor {
/**
* Is the property configurable?
*/
configurable?: boolean;

/**
* Is the property enumerable?
*/
enumerable?: boolean;

/**
* Is the property configurable?
*/
writable?: boolean;
}

/**
* A record that describes a constructor function and arguments necessary to create an instance of
* an object
*/
export interface AnonymousConstructRecord {
/**
* Any arguments to pass to the constructor function
*/
args?: any[];

/**
* The constructor function to use to create the instance
*/
Ctor: Constructor;

/**
* The partial descriptor that is used to set the value of the instance
*/
descriptor?: ConstructDescriptor;

/**
* Any patches to properties that need to occur on the instance
*/
propertyRecords?: (ConstructRecord | PatchRecord)[];
}

export interface ConstructRecord extends AnonymousConstructRecord {
/**
* The name of the property on the Object
*/
name: string;
}

/**
* A record that describes the mutations necessary to a property of an object to make that property look
* like another
Expand Down Expand Up @@ -67,7 +131,7 @@ export type PatchRecord = {
/**
* Additional patch records which describe the value of the property
*/
valueRecords?: (PatchRecord | SpliceRecord)[];
valueRecords?: (ConstructRecord | PatchRecord | SpliceRecord)[];
};

/**
Expand Down Expand Up @@ -100,6 +164,24 @@ export interface SpliceRecord {
start: number;
}

/**
* A record that describes how to instantiate a new object via a constructor function
* @param Ctor The constructor function
* @param args Any arguments to be passed to the constructor function
*/
/* tslint:disable:variable-name */
export function createConstructRecord(Ctor: Constructor, args?: any[], descriptor?: ConstructDescriptor): AnonymousConstructRecord {
const record: AnonymousConstructRecord = assign(objectCreate(null), { Ctor });
if (args) {
record.args = args;
}
if (descriptor) {
record.descriptor = descriptor;
}
return record;
}
/* tslint:enable:variable-name */

/**
* An internal function that returns a new patch record
*
Expand All @@ -108,7 +190,7 @@ export interface SpliceRecord {
* @param descriptor The property descriptor to be installed on the object
* @param valueRecords Any subsequenet patch recrds to be applied to the value of the descriptor
*/
function createPatchRecord(type: PatchTypes, name: string, descriptor?: PropertyDescriptor, valueRecords?: (PatchRecord | SpliceRecord)[]): PatchRecord {
function createPatchRecord(type: PatchTypes, name: string, descriptor?: PropertyDescriptor, valueRecords?: (ConstructRecord | PatchRecord | SpliceRecord)[]): PatchRecord {
const patchRecord = assign(objectCreate(null), {
type,
name
Expand Down Expand Up @@ -163,6 +245,35 @@ function createValuePropertyDescriptor(value: any, writable: boolean = true, enu
});
}

/**
* A function that returns a constructor record or `undefined` when diffing a value
*/
export type CustomDiffFunction<T> = (value: T, nameOrIndex: string | number, parent: object) => AnonymousConstructRecord | void;

/**
* A class which is used when making a custom comparison of a non-plain object or array
*/
export class CustomDiff<T> {
private _differ: CustomDiffFunction<T>;

constructor(diff: CustomDiffFunction<T>) {
this._differ = diff;
}

/**
* Get the difference of the `value`
* @param value The value to diff
* @param nameOrIndex A `string` if comparing a property or a `number` if comparing an array element
* @param parent The outer parent that this value is part of
*/
diff(value: T, nameOrIndex: string | number, parent: object): ConstructRecord | void {
const record = this._differ(value, nameOrIndex, parent);
if (record && typeof nameOrIndex === 'string') {
return assign(record, { name: nameOrIndex });
}
}
}

/**
* Internal function that detects the differences between an array and another value and returns a set of splice records that
* describe the differences
Expand Down Expand Up @@ -267,9 +378,9 @@ function diffArray(a: any[], b: any, options: DiffOptions): SpliceRecord[] {
* @param b The second plain bject to compare to
* @param options An options bag that allows configuration of the behaviour of `diffPlainObject()`
*/
function diffPlainObject(a: any, b: any, options: DiffOptions): PatchRecord[] {
function diffPlainObject(a: any, b: any, options: DiffOptions): (ConstructRecord | PatchRecord)[] {
const { allowFunctionValues = false, ignoreProperties = [], ignorePropertyValues = [] } = options;
const patchRecords: PatchRecord[] = [];
const patchRecords: (ConstructRecord | PatchRecord)[] = [];

function isIgnoredProperty(name: string) {
return Array.isArray(ignoreProperties) ? ignoreProperties.some((value) => {
Expand Down Expand Up @@ -302,7 +413,7 @@ function diffPlainObject(a: any, b: any, options: DiffOptions): PatchRecord[] {
const isValueAArray = isArray(valueA);
const isValueAPlainObject = isPlainObject(valueA);

if ((isValueAArray || isValueAPlainObject) && !(isIgnoredPropertyValue(name))) { /* non-primitive values we can diff */
if ((isValueAArray || isValueAPlainObject) && !isIgnoredPropertyValue(name)) { /* non-primitive values we can diff */
/* this is a bit complicated, but essentially if valueA and valueB are both arrays or plain objects, then
* we can diff those two values, if not, then we need to use an empty array or an empty object and diff
* the valueA with that */
Expand All @@ -314,6 +425,18 @@ function diffPlainObject(a: any, b: any, options: DiffOptions): PatchRecord[] {
patchRecords.push(createPatchRecord(type, name, createValuePropertyDescriptor(value), diff(valueA, value, options)));
}
}
else if (isCustomDiff(valueA) && !isCustomDiff(valueB)) { /* complex diff left hand */
const result = valueA.diff(valueB, name, b);
if (result) {
patchRecords.push(result);
}
}
else if (isCustomDiff(valueB)) { /* complex diff right hand */
const result = valueB.diff(valueA, name, a);
if (result) {
patchRecords.push(result);
}
}
else if (isPrimitive(valueA) || (allowFunctionValues && typeof valueA === 'function') || isIgnoredPropertyValue(name)) {
/* primitive values, functions values if allowed, or ignored property values can just be copied */
patchRecords.push(createPatchRecord(type, name, createValuePropertyDescriptor(valueA)));
Expand All @@ -336,6 +459,14 @@ function diffPlainObject(a: any, b: any, options: DiffOptions): PatchRecord[] {
return patchRecords;
}

/**
* A guard that determines if the value is a `ConstructRecord`
* @param value The value to check
*/
function isConstructRecord(value: any): value is ConstructRecord {
return Boolean(value && typeof value === 'object' && value !== null && value.Ctor && value.name);
}

/**
* A guard that determines if the value is a `PatchRecord`
*
Expand Down Expand Up @@ -383,6 +514,14 @@ function isPrimitive(value: any): value is (string | number | boolean | undefine
typeofValue === 'boolean';
}

/**
* A guard that determines if the value is a `CustomDiff`
* @param value The value to check
*/
function isCustomDiff<T>(value: any): value is CustomDiff<T> {
return typeof value === 'object' && value instanceof CustomDiff;
}

/**
* A guard that determines if the value is a `SpliceRecord`
*
Expand Down Expand Up @@ -433,6 +572,22 @@ function patchPatch(target: any, record: PatchRecord): any {
return target;
}

const defaultConstructDescriptor = {
configurable: true,
enumerable: true,
writable: true
};

function patchConstruct(target: any, record: ConstructRecord): any {
const { args, descriptor = defaultConstructDescriptor, Ctor, name, propertyRecords } = record;
const value = new Ctor(...(args || []));
if (propertyRecords) {
propertyRecords.forEach((record) => isConstructRecord(record) ? patchConstruct(value, record) : patchPatch(value, record));
}
defineProperty(target, name, assign({ value }, descriptor));
return target;
}

/**
* An internal function that take a value from array being patched and the target value from the same
* index and determines the value that should actually be patched into the target array
Expand All @@ -459,7 +614,7 @@ function resolveTargetValue(patchValue: any, targetValue: any): any {
* @param b The plain object or array to compare to
* @param options An options bag that allows configuration of the behaviour of `diff()`
*/
export function diff(a: any, b: any, options: DiffOptions = {}): (PatchRecord | SpliceRecord)[] {
export function diff(a: any, b: any, options: DiffOptions = {}): (ConstructRecord | PatchRecord | SpliceRecord)[] {
if (typeof a !== 'object' || typeof b !== 'object') {
throw new TypeError('Arguments are not of type object.');
}
Expand All @@ -485,7 +640,7 @@ export function diff(a: any, b: any, options: DiffOptions = {}): (PatchRecord |
* @param target The plain object or array that the patch records should be applied to
* @param records A set of patch records to be applied to the target
*/
export function patch(target: any, records: (PatchRecord | SpliceRecord)[]): any {
export function patch(target: any, records: (ConstructRecord | PatchRecord | SpliceRecord)[]): any {
if (!isArray(target) && !isPlainObject(target)) {
throw new TypeError('A target for a patch must be either an array or a plain object.');
}
Expand All @@ -494,10 +649,10 @@ export function patch(target: any, records: (PatchRecord | SpliceRecord)[]): any
}

records.forEach((record) => {
target = isSpliceRecord(record) ?
patchSplice(isArray(target) ?
target : [], record) : patchPatch(isPlainObject(target) ?
target : objectCreate(null), record);
target = isSpliceRecord(record)
? patchSplice(isArray(target) ? target : [], record) /* patch arrays */
: isConstructRecord(record) ? patchConstruct(target, record) /* patch complex object */
: patchPatch(isPlainObject(target) ? target : {}, record); /* patch plain object */
});
return target;
}
Loading