diff --git a/lib/chai/core/assertions.js b/lib/chai/core/assertions.js index 80a1ce7e..3fc24d29 100644 --- a/lib/chai/core/assertions.js +++ b/lib/chai/core/assertions.js @@ -8,6 +8,7 @@ import {Assertion} from '../assertion.js'; import {AssertionError} from 'assertion-error'; import * as _ from '../utils/index.js'; +import {config} from '../config.js'; const {flag} = _; @@ -4061,3 +4062,92 @@ Assertion.addProperty('finite', function (_msg) { 'expected #{this} to not be a finite number' ); }); + +/** + * A subset-aware compare function + * + * @param {unknown} expected + * @param {unknown} actual + * @returns {boolean} + */ +function compareSubset(expected, actual) { + if (expected === actual) { + return true; + } + if (typeof actual !== typeof expected) { + return false; + } + if (typeof expected !== 'object' || expected === null) { + return expected === actual; + } + if (!actual) { + return false; + } + + if (Array.isArray(expected)) { + if (!Array.isArray(actual)) { + return false; + } + return expected.every(function (exp) { + return actual.some(function (act) { + return compareSubset(exp, act); + }); + }); + } + + if (expected instanceof Date) { + if (actual instanceof Date) { + return expected.getTime() === actual.getTime(); + } else { + return false; + } + } + + return Object.keys(expected).every(function (key) { + var expectedValue = expected[key]; + var actualValue = actual[key]; + if ( + typeof expectedValue === 'object' && + expectedValue !== null && + actualValue !== null + ) { + return compareSubset(expectedValue, actualValue); + } + if (typeof expectedValue === 'function') { + return expectedValue(actualValue); + } + return actualValue === expectedValue; + }); +} + +/** + * ### .containSubset + * + * Asserts that the target primitive/object/array structure deeply contains all provided fields + * at the same key/depth as the provided structure. + * + * When comparing arrays, the target must contain the subset of at least one of each object/value in the subset array. + * Order does not matter. + * + * expect({name: {first: "John", last: "Smith"}}).to.containSubset({name: {first: "John"}}); + * + * Add `.not` earlier in the chain to negate the assertion. This will cause the assertion to fail + * only if the target DOES contains the provided data at the expected keys/depths. + * + * @name containSubset + * @namespace BDD + * @public + */ +Assertion.addMethod('containSubset', function (expected) { + const actual = _.flag(this, 'object'); + const showDiff = config.showDiff; + + this.assert( + compareSubset(expected, actual), + 'expected #{act} to contain subset #{exp}', + 'expected #{act} to not contain subset #{exp}', + expected, + actual, + showDiff + ); +}); diff --git a/lib/chai/interface/assert.js b/lib/chai/interface/assert.js index 07a6767f..a399e928 100644 --- a/lib/chai/interface/assert.js +++ b/lib/chai/interface/assert.js @@ -3157,6 +3157,48 @@ assert.isNotEmpty = function (val, msg) { new Assertion(val, msg, assert.isNotEmpty, true).to.not.be.empty; }; +/** + * ### .containsSubset(target, subset) + * + * Asserts that the target primitive/object/array structure deeply contains all provided fields + * at the same key/depth as the provided structure. + * + * When comparing arrays, the target must contain the subset of at least one of each object/value in the subset array. + * Order does not matter. + * + * assert.containsSubset( + * [{name: {first: "John", last: "Smith"}}, {name: {first: "Jane", last: "Doe"}}], + * [{name: {first: "Jane"}}] + * ); + * + * @name containsSubset + * @alias containSubset + * @param {unknown} val + * @param {unknown} exp + * @param {string} msg _optional_ + * @namespace Assert + * @public + */ +assert.containsSubset = function (val, exp, msg) { + new Assertion(val, msg).to.containSubset(exp); +}; + +/** + * ### .doesNotContainSubset(target, subset) + * + * The negation of assert.containsSubset. + * + * @name doesNotContainSubset + * @param {unknown} val + * @param {unknown} exp + * @param {string} msg _optional_ + * @namespace Assert + * @public + */ +assert.doesNotContainSubset = function (val, exp, msg) { + new Assertion(val, msg).to.not.containSubset(exp); +}; + /** * Aliases. * @@ -3178,7 +3220,8 @@ const aliases = [ ['isEmpty', 'empty'], ['isNotEmpty', 'notEmpty'], ['isCallable', 'isFunction'], - ['isNotCallable', 'isNotFunction'] + ['isNotCallable', 'isNotFunction'], + ['containsSubset', 'containSubset'] ]; for (const [name, as] of aliases) { assert[as] = assert[name]; diff --git a/test/subset.js b/test/subset.js new file mode 100644 index 00000000..954e1105 --- /dev/null +++ b/test/subset.js @@ -0,0 +1,225 @@ +import * as chai from '../index.js'; + +describe('containsSubset', function () { + const {assert, expect} = chai; + const should = chai.Should(); + + describe('plain object', function () { + var testedObject = { + a: 'b', + c: 'd' + }; + + it('should pass for smaller object', function () { + expect(testedObject).to.containSubset({ + a: 'b' + }); + }); + + it('should pass for same object', function () { + expect(testedObject).to.containSubset({ + a: 'b', + c: 'd' + }); + }); + + it('should pass for similar, but not the same object', function () { + expect(testedObject).to.not.containSubset({ + a: 'notB', + c: 'd' + }); + }); + }); + + describe('complex object', function () { + var testedObject = { + a: 'b', + c: 'd', + e: { + foo: 'bar', + baz: { + qux: 'quux' + } + } + }; + + it('should pass for smaller object', function () { + expect(testedObject).to.containSubset({ + a: 'b', + e: { + foo: 'bar' + } + }); + }); + + it('should pass for smaller object', function () { + expect(testedObject).to.containSubset({ + e: { + foo: 'bar', + baz: { + qux: 'quux' + } + } + }); + }); + + it('should pass for same object', function () { + expect(testedObject).to.containSubset({ + a: 'b', + c: 'd', + e: { + foo: 'bar', + baz: { + qux: 'quux' + } + } + }); + }); + + it('should pass for similar, but not the same object', function () { + expect(testedObject).to.not.containSubset({ + e: { + foo: 'bar', + baz: { + qux: 'notAQuux' + } + } + }); + }); + + it('should fail if comparing when comparing objects to dates', function () { + expect(testedObject).to.not.containSubset({ + e: new Date() + }); + }); + }); + + describe('circular objects', function () { + var object = {}; + + before(function () { + object.arr = [object, object]; + object.arr.push(object.arr); + object.obj = object; + }); + + it('should contain subdocument', function () { + expect(object).to.containSubset({ + arr: [{arr: []}, {arr: []}, [{arr: []}, {arr: []}]] + }); + }); + + it('should not contain similar object', function () { + expect(object).to.not.containSubset({ + arr: [{arr: ['just random field']}, {arr: []}, [{arr: []}, {arr: []}]] + }); + }); + }); + + describe('object with compare function', function () { + it('should pass when function returns true', function () { + expect({a: 5}).to.containSubset({a: (a) => a}); + }); + + it('should fail when function returns false', function () { + expect({a: 5}).to.not.containSubset({a: (a) => !a}); + }); + + it('should pass for function with no arguments', function () { + expect({a: 5}).to.containSubset({a: () => true}); + }); + }); + + describe('comparison of non objects', function () { + it('should fail if actual subset is null', function () { + expect(null).to.not.containSubset({a: 1}); + }); + + it('should fail if expected subset is not a object', function () { + expect({a: 1}).to.not.containSubset(null); + }); + + it('should not fail for same non-object (string) variables', function () { + expect('string').to.containSubset('string'); + }); + }); + + describe('assert style of test', function () { + it('should find subset', function () { + assert.containsSubset({a: 1, b: 2}, {a: 1}); + assert.containSubset({a: 1, b: 2}, {a: 1}); + }); + + it('negated assert style should function', function () { + assert.doesNotContainSubset({a: 1, b: 2}, {a: 3}); + }); + }); + + describe('should style of test', function () { + const objectA = {a: 1, b: 2}; + + it('should find subset', function () { + objectA.should.containSubset({a: 1}); + }); + + it('negated should style should function', function () { + objectA.should.not.containSubset({a: 3}); + }); + }); + + describe('comparison of dates', function () { + it('should pass for the same date', function () { + expect(new Date('2015-11-30')).to.containSubset(new Date('2015-11-30')); + }); + + it('should pass for the same date if nested', function () { + expect({a: new Date('2015-11-30')}).to.containSubset({ + a: new Date('2015-11-30') + }); + }); + + it('should fail for a different date', function () { + expect(new Date('2015-11-30')).to.not.containSubset( + new Date('2012-02-22') + ); + }); + + it('should fail for a different date if nested', function () { + expect({a: new Date('2015-11-30')}).to.not.containSubset({ + a: new Date('2012-02-22') + }); + }); + + it('should fail for invalid expected date', function () { + expect(new Date('2015-11-30')).to.not.containSubset( + new Date('not valid date') + ); + }); + + it('should fail for invalid actual date', function () { + expect(new Date('not valid actual date')).to.not.containSubset( + new Date('not valid expected date') + ); + }); + }); + + describe('cyclic objects', () => { + it('should pass', () => { + const child = {}; + const parent = { + children: [child] + }; + child.parent = parent; + + const myObject = { + a: 1, + b: 'two', + c: parent + }; + expect(myObject).to.containSubset({ + a: 1, + c: parent + }); + }); + }); +});