From e672605120617e3c3cf204a74093d8850aaa0c8a Mon Sep 17 00:00:00 2001 From: BreadInvasion Date: Fri, 17 Jan 2025 14:19:29 -0600 Subject: [PATCH] Integrated `chai-subset` and added assert-based negation to containSubset PR Feedback --- lib/chai/core/assertions.js | 86 +++++++++++++ lib/chai/interface/assert.js | 47 +++++++- test/subset.js | 225 +++++++++++++++++++++++++++++++++++ 3 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 test/subset.js diff --git a/lib/chai/core/assertions.js b/lib/chai/core/assertions.js index f18da8a9..ab487fa9 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,88 @@ 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 {void} + */ +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 eo = expected[key]; + var ao = actual[key]; + if (typeof eo === 'object' && eo !== null && ao !== null) { + return compareSubset(eo, ao); + } + if (typeof eo === 'function') { + return eo(ao); + } + return ao === eo; + }); +} + +/** + * ### .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 8f0fa9bc..1608a448 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. * @@ -3176,4 +3218,7 @@ assert.isNotEmpty = function (val, msg) { )('isFrozen', 'frozen')('isNotFrozen', 'notFrozen')('isEmpty', 'empty')( 'isNotEmpty', 'notEmpty' -)('isCallable', 'isFunction')('isNotCallable', 'isNotFunction'); +)('isCallable', 'isFunction')('isNotCallable', 'isNotFunction')( + 'containsSubset', + 'containSubset' +); 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 + }); + }); + }); +});