diff --git a/src/assert.js b/src/assert.js index c96b701aa..95bd69c09 100644 --- a/src/assert.js +++ b/src/assert.js @@ -4,7 +4,7 @@ import { internalStop, resetTestTimeout } from "./test"; import Logger from "./logger"; import config from "./core/config"; -import { objectType, objectValues, errorString } from "./core/utilities"; +import { objectType, objectValues, objectValuesSubset, errorString } from "./core/utilities"; import { sourceFromStacktrace } from "./core/stacktrace"; import { clearTimeout } from "./globals"; @@ -216,6 +216,36 @@ class Assert { } ); } + propContains( actual, expected, message ) { + actual = objectValuesSubset( actual, expected ); + + // The expected parameter is usually a plain object, but clone it for + // consistency with propEqual(), and to make it easy to explain that + // inheritence is not considered (on either side), and to support + // recursively checking subsets of nested objects. + expected = objectValues( expected, false ); + + this.pushResult( { + result: equiv( actual, expected ), + actual, + expected, + message + } ); + } + + notPropContains( actual, expected, message ) { + actual = objectValuesSubset( actual, expected ); + expected = objectValues( expected ); + + this.pushResult( { + result: !equiv( actual, expected ), + actual, + expected, + message, + negative: true + } ); + } + deepEqual( actual, expected, message ) { this.pushResult( { result: equiv( actual, expected ), diff --git a/src/core/utilities.js b/src/core/utilities.js index 45c9077e3..b10399d45 100644 --- a/src/core/utilities.js +++ b/src/core/utilities.js @@ -67,23 +67,54 @@ export function inArray( elem, array ) { } /** - * Makes a clone of an object using only Array or Object as base, - * and copies over the own enumerable properties. + * Recursively clone an object into a plain array or object, taking only the + * own enumerable properties. * - * @param {Object} obj - * @return {Object} New object with only the own properties (recursively). + * @param {any} obj + * @param {bool} [allowArray=true] + * @return {Object|Array} */ -export function objectValues( obj ) { - const vals = is( "array", obj ) ? [] : {}; +export function objectValues( obj, allowArray = true ) { + const vals = ( allowArray && is( "array", obj ) ) ? [] : {}; for ( const key in obj ) { if ( hasOwn.call( obj, key ) ) { const val = obj[ key ]; - vals[ key ] = val === Object( val ) ? objectValues( val ) : val; + vals[ key ] = val === Object( val ) ? objectValues( val, allowArray ) : val; } } return vals; } +/** + * Recursively clone an object into a plain object, taking only the + * subset of own enumerable properties that exist a given model. + * + * @param {any} obj + * @param {any} model + * @return {Object} + */ +export function objectValuesSubset( obj, model ) { + + // Return primitive values unchanged to avoid false positives or confusing + // results from assert.propContains(). + // E.g. an actual null or false wrongly equaling an empty object, + // or an actual string being reported as object not matching a partial object. + if ( obj !== Object( obj ) ) { + return obj; + } + + // Unlike objectValues(), subset arrays to a plain objects as well. + // This enables subsetting [20, 30] with {1: 30}. + const subset = {}; + + for ( const key in model ) { + if ( hasOwn.call( model, key ) && hasOwn.call( obj, key ) ) { + subset[ key ] = objectValuesSubset( obj[ key ], model[ key ] ); + } + } + return subset; +} + export function extend( a, b, undefOnly ) { for ( const prop in b ) { if ( hasOwn.call( b, prop ) ) { diff --git a/test/main/assert.js b/test/main/assert.js index 93de79bcd..8e834cf06 100644 --- a/test/main/assert.js +++ b/test/main/assert.js @@ -96,8 +96,7 @@ QUnit.test( "propEqual", function( assert ) { this.z = z; } Foo.prototype.doA = function() {}; - Foo.prototype.doB = function() {}; - Foo.prototype.bar = "prototype"; + Foo.prototype.bar = "non-function"; function Bar() { } @@ -158,6 +157,156 @@ QUnit.test( "propEqual", function( assert ) { ); } ); +QUnit.test( "propContains", function( assert ) { + function Foo( x, y, z ) { + this.x = x; + this.y = y; + this.z = z; + } + Foo.prototype.doA = function() {}; + Foo.prototype.bar = "non-function"; + + function Bar( x ) { + this.x = x; + } + Bar.prototype = Object.create( Foo.prototype ); + Bar.prototype.constructor = Bar; + + assert.propContains( + { a: 0, b: "something", c: true }, + { a: 0, b: "something", c: true } + ); + assert.propContains( + { a: 0, b: "something", c: true }, + { a: 0, c: true }, + "match object subset" + ); + assert.propContains( + [ "a", "b" ], + { 1: "b" }, + "match array subset via plain object" + ); + assert.propContains( + [], + {}, + "empty array contains empty object" + ); + assert.propContains( + {}, + [], + "empty object contains empty array" + ); + assert.propContains( + new Foo( 1, "2", [] ), + new Foo( 1, "2", [] ), + "deeply equal class instances" + ); + assert.propContains( + new Foo( 1, "2", [] ), + { + x: 1, + y: "2", + z: [] + }, + "match different constructor via plain object" + ); + assert.propContains( + new Foo( 1, "2", [] ), + { + x: 1 + }, + "match different constructor subset via plain object" + ); + assert.propContains( + new Foo( 1, "2", [ "x" ] ), + new Foo( 1, "2", { 0: "x" } ), + "match nested array via plain object" + ); + assert.propContains( + new Foo( 1, [ "a", "b" ], new Foo( [ "c", "d" ], new Bar(), null ) ), + { + x: 1, + y: [ "a", "b" ], + z: { + x: { 1: "d" } + } + }, + "match nested array subset via plain object" + ); + assert.propContains( + new Foo( 1, "2" ), + new Bar( 1 ), + "match subset via different constructor" + ); +} ); + +QUnit.test( "notPropContains", function( assert ) { + function Foo( x, y, z ) { + this.x = x; + this.y = y; + this.z = z; + } + Foo.prototype.doA = function() {}; + Foo.prototype.bar = "non-function"; + + function Bar( x ) { + this.x = x; + } + Bar.prototype = Object.create( Foo.prototype ); + Bar.prototype.constructor = Bar; + + assert.notPropContains( + { a: 0, b: "something", c: true }, + { a: 0, b: "different", c: true } + ); + assert.notPropContains( + { a: 0, b: "something", c: true }, + { a: 0, c: false } + ); + assert.notPropContains( + { a: 0, b: "something", c: true }, + { e: "missing" } + ); + assert.notPropContains( + new Foo( 1, "2", [] ), + { + x: 1, + y: "2", + z: [], + e: "missing" + }, + "matching and missing properties" + ); + assert.notPropContains( + new Foo( 1, "2", [] ), + { + e: "missing" + }, + "missing property" + ); + assert.notPropContains( + new Foo( 1, [], new Foo( [], new Bar(), "something" ) ), + new Foo( 1, [], new Foo( [], new Bar(), "different" ) ), + "difference in nested value" + ); + assert.notPropContains( + new Foo( 1, "2", new Foo( [ 3 ], new Bar(), null ) ), + { + x: 1, + y: "2", + z: { + e: "missing" + } + }, + "nested object with missing property" + ); + assert.notPropContains( + new Foo( 1, "2" ), + new Bar( 2 ), + "different property value via different constructor" + ); +} ); + QUnit.test( "throws", function( assert ) { function CustomError( message ) { this.message = message;