Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Evaluate symbolic keys for deep equality assertion #64

Merged
merged 10 commits into from Mar 7, 2019
1 change: 1 addition & 0 deletions AUTHORS
Expand Up @@ -10,3 +10,4 @@ Magnar Sveen <magnars@gmail.com>
Mathias Schreck <schreck.mathias@googlemail.com>
Olle Jonsson <olle.jonsson@gmail.com>
Gunnar André Reinseth <gunnar.andre.reinseth@nrk.no>
Brandon Evans <contact@brandonmevans.com>
182 changes: 105 additions & 77 deletions lib/deep-equal.js
Expand Up @@ -18,176 +18,204 @@ var getTime = Date.prototype.getTime;
var hasOwnProperty = Object.prototype.hasOwnProperty;
var indexOf = Array.prototype.indexOf;
var keys = Object.keys;
var getOwnPropertySymbols = Object.getOwnPropertySymbols;

/**
* @name samsam.deepEqual
* @param Object first
* @param Object second
* @param Object actual
* @param Object expectation
*
* Deep equal comparison. Two values are "deep equal" if:
*
* - They are equal, according to samsam.identical
* - They are both date objects representing the same time
* - They are both arrays containing elements that are all deepEqual
* - They are objects with the same set of properties, and each property
* in ``first`` is deepEqual to the corresponding property in ``second``
* in ``actual`` is deepEqual to the corresponding property in ``expectation``
*
* Supports cyclic objects.
*/
function deepEqualCyclic(first, second, match) {
function deepEqualCyclic(actual, expectation, match) {
// used for cyclic comparison
// contain already visited objects
var objects1 = [];
var objects2 = [];
var actualObjects = [];
var expectationObjects = [];
// contain pathes (position in the object structure)
// of the already visited objects
// indexes same as in objects arrays
var paths1 = [];
var paths2 = [];
var actualPaths = [];
var expectationPaths = [];
// contains combinations of already compared objects
// in the manner: { "$1['ref']$2['ref']": true }
var compared = {};

// does the recursion for the deep equal check
return (function deepEqual(obj1, obj2, path1, path2) {
return (function deepEqual(
actualObj,
expectationObj,
actualPath,
expectationPath
) {
// If both are matchers they must be the same instance in order to be
// considered equal If we didn't do that we would end up running one
// matcher against the other
if (match && match.isMatcher(obj2)) {
if (match.isMatcher(obj1)) {
return obj1 === obj2;
if (match && match.isMatcher(expectationObj)) {
if (match.isMatcher(actualObj)) {
return actualObj === expectationObj;
}
return obj2.test(obj1);
return expectationObj.test(actualObj);
}

var type1 = typeof obj1;
var type2 = typeof obj2;
var actualType = typeof actualObj;
var expectationType = typeof expectationObj;

// == null also matches undefined
if (
obj1 === obj2 ||
isNaN(obj1) ||
isNaN(obj2) ||
obj1 == null ||
obj2 == null ||
type1 !== "object" ||
type2 !== "object"
actualObj === expectationObj ||
isNaN(actualObj) ||
isNaN(expectationObj) ||
actualObj == null ||
expectationObj == null ||
actualType !== "object" ||
expectationType !== "object"
) {
return identical(obj1, obj2);
return identical(actualObj, expectationObj);
}

// Elements are only equal if identical(expected, actual)
if (isElement(obj1) || isElement(obj2)) {
if (isElement(actualObj) || isElement(expectationObj)) {
return false;
}

var isDate1 = isDate(obj1);
var isDate2 = isDate(obj2);
if (isDate1 || isDate2) {
var isActualDate = isDate(actualObj);
var isExpectationDate = isDate(expectationObj);
if (isActualDate || isExpectationDate) {
if (
!isDate1 ||
!isDate2 ||
getTime.call(obj1) !== getTime.call(obj2)
!isActualDate ||
!isExpectationDate ||
getTime.call(actualObj) !== getTime.call(expectationObj)
) {
return false;
}
}

if (obj1 instanceof RegExp && obj2 instanceof RegExp) {
if (valueToString(obj1) !== valueToString(obj2)) {
if (actualObj instanceof RegExp && expectationObj instanceof RegExp) {
if (valueToString(actualObj) !== valueToString(expectationObj)) {
return false;
}
}

if (obj1 instanceof Error && obj2 instanceof Error) {
return obj1 === obj2;
if (actualObj instanceof Error && expectationObj instanceof Error) {
return actualObj === expectationObj;
}

var class1 = getClass(obj1);
var class2 = getClass(obj2);
var keys1 = keys(obj1);
var keys2 = keys(obj2);
var name1 = getClassName(obj1);
var name2 = getClassName(obj2);

if (isArguments(obj1) || isArguments(obj2)) {
if (obj1.length !== obj2.length) {
var actualClass = getClass(actualObj);
var expectationClass = getClass(expectationObj);
var actualKeys = keys(actualObj);
var expectationKeys = keys(expectationObj);
var actualName = getClassName(actualObj);
var expectationName = getClassName(expectationObj);
var expectationSymbols =
typeof Object.getOwnPropertySymbols === "function"
? getOwnPropertySymbols(expectationObj)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that this line was supposed to read ? Object.getOwnPropertySymbols(expectationObj)?

I just tried updating my project from sinon 7.2.7 to sinon 7.3.0, a change that includes this PR, and I get a lot of errors like this :

'Unhandled promise rejection', TypeError: undefined is not a constructor (evaluating 'getOwnPropertySymbols(expectationObj)')
deepEqual@http://localhost:9876/absolute/Users/angelika/myproject/node_modules/sinon/pkg/sinon.js?911775df668393e499a0995e2789094c48727cc3:4395:40
deepEqualCyclic@http://localhost:9876/absolute/Users/angelika/myproject/node_modules/sinon/pkg/sinon.js?911775df668393e499a0995e2789094c48727cc3:4493:7
...

They all go away when I change the line as I said.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. Actually, the line obove should also just refer to getOwnPropertySymbols because we're "caching" the function at the top of the file. I would guess that your environment does not actually have symbols support, but it gets shimmed after Sinon is loaded.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is very probable, yes. Changing the above line to refer to just getOwnPropertySymbols without Object. also gets rid of my errors.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you care to send a pull request to fix it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here it is: #66

: [];
var expectationKeysAndSymbols = expectationKeys.concat(
expectationSymbols
);

if (isArguments(actualObj) || isArguments(expectationObj)) {
if (actualObj.length !== expectationObj.length) {
return false;
}
} else {
if (
type1 !== type2 ||
class1 !== class2 ||
keys1.length !== keys2.length ||
(name1 && name2 && name1 !== name2)
actualType !== expectationType ||
actualClass !== expectationClass ||
actualKeys.length !== expectationKeys.length ||
(actualName &&
expectationName &&
actualName !== expectationName)
) {
return false;
}
}

if (isSet(obj1) || isSet(obj2)) {
if (!isSet(obj1) || !isSet(obj2) || obj1.size !== obj2.size) {
if (isSet(actualObj) || isSet(expectationObj)) {
if (
!isSet(actualObj) ||
!isSet(expectationObj) ||
actualObj.size !== expectationObj.size
) {
return false;
}

return isSubset(obj1, obj2, deepEqual);
return isSubset(actualObj, expectationObj, deepEqual);
}

return every.call(keys1, function(key) {
if (!hasOwnProperty.call(obj2, key)) {
return every.call(expectationKeysAndSymbols, function(key) {
if (!hasOwnProperty.call(actualObj, key)) {
return false;
}

var value1 = obj1[key];
var value2 = obj2[key];
var isObject1 = isObject(value1);
var isObject2 = isObject(value2);
var actualValue = actualObj[key];
var expectationValue = expectationObj[key];
var actualObject = isObject(actualValue);
var expectationObject = isObject(expectationValue);
// determines, if the objects were already visited
// (it's faster to check for isObject first, than to
// get -1 from getIndex for non objects)
var index1 = isObject1 ? indexOf.call(objects1, value1) : -1;
var index2 = isObject2 ? indexOf.call(objects2, value2) : -1;
var actualIndex = actualObject
? indexOf.call(actualObjects, actualValue)
: -1;
var expectationIndex = expectationObject
? indexOf.call(expectationObjects, expectationValue)
: -1;
// determines the new paths of the objects
// - for non cyclic objects the current path will be extended
// by current property name
// - for cyclic objects the stored path is taken
var newPath1 =
index1 !== -1
? paths1[index1]
: path1 + "[" + JSON.stringify(key) + "]";
var newPath2 =
index2 !== -1
? paths2[index2]
: path2 + "[" + JSON.stringify(key) + "]";
var combinedPath = newPath1 + newPath2;
var newActualPath =
actualIndex !== -1
? actualPaths[actualIndex]
: actualPath + "[" + JSON.stringify(key) + "]";
var newExpectationPath =
expectationIndex !== -1
? expectationPaths[expectationIndex]
: expectationPath + "[" + JSON.stringify(key) + "]";
var combinedPath = newActualPath + newExpectationPath;

// stop recursion if current objects are already compared
if (compared[combinedPath]) {
return true;
}

// remember the current objects and their paths
if (index1 === -1 && isObject1) {
objects1.push(value1);
paths1.push(newPath1);
if (actualIndex === -1 && actualObject) {
actualObjects.push(actualValue);
actualPaths.push(newActualPath);
}
if (index2 === -1 && isObject2) {
objects2.push(value2);
paths2.push(newPath2);
if (expectationIndex === -1 && expectationObject) {
expectationObjects.push(expectationValue);
expectationPaths.push(newExpectationPath);
}

// remember that the current objects are already compared
if (isObject1 && isObject2) {
if (actualObject && expectationObject) {
compared[combinedPath] = true;
}

// End of cyclic logic

// neither value1 nor value2 is a cycle
// neither actualValue nor expectationValue is a cycle
// continue with next level
return deepEqual(value1, value2, newPath1, newPath2);
return deepEqual(
actualValue,
expectationValue,
newActualPath,
newExpectationPath
);
});
})(first, second, "$1", "$2");
})(actual, expectation, "$1", "$2");
}

deepEqualCyclic.use = function(match) {
Expand Down
43 changes: 39 additions & 4 deletions lib/deep-equal.test.js
Expand Up @@ -26,6 +26,7 @@ describe("deepEqual", function() {
var func = function() {};
var obj = {};
var arr = [];
var symbol = Symbol("id");
var date = new Date();
var sameDate = new Date(date.getTime());
var sameDateWithProp = new Date(date.getTime());
Expand Down Expand Up @@ -364,6 +365,40 @@ describe("deepEqual", function() {
assert.isFalse(checkDeep);
});

it("returns false if object has different symbolic properties", function() {
var obj1 = {};
var obj2 = {};
obj1[symbol] = 42;
obj2[symbol] = 43;
var checkDeep = samsam.deepEqual(obj1, obj2);
assert.isFalse(checkDeep);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: Can we make the tests a bit more concise?

var obj1 = {[symbol]: 42};
var obj2 = {[symbol]: 43};

assert.isFalse(samsam.deepEqual(obj1, obj2));

Copy link
Contributor Author

@BrandonE BrandonE Mar 2, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I tried that first, and the linter complained about an unexpected opening bracket. Changing it to this fixed the linting error.

});

it("returns true if object has same symbolic properties", function() {
var obj1 = {};
var obj2 = {};
obj1[symbol] = 42;
obj2[symbol] = 42;
var checkDeep = samsam.deepEqual(obj1, obj2);
assert.isTrue(checkDeep);
});

it("returns false if object missing expected symbolic properties", function() {
var obj1 = {};
var obj2 = {};
obj2[symbol] = 42;
var checkDeep = samsam.deepEqual(obj1, obj2);
assert.isFalse(checkDeep);
});

it("returns true if object contains additional symbolic properties", function() {
var obj1 = {};
var obj2 = {};
obj1[symbol] = 42;
var checkDeep = samsam.deepEqual(obj1, obj2);
assert.isTrue(checkDeep);
});

it("returns false if object to null", function() {
var checkDeep = samsam.deepEqual({}, null);
assert.isFalse(checkDeep);
Expand Down Expand Up @@ -414,12 +449,12 @@ describe("deepEqual", function() {
assert.isFalse(checkDeep);
});

it("returns true if arguments to array", function() {
it("returns false if arguments to array", function() {
var gather = function() {
return arguments;
};
var checkDeep = samsam.deepEqual([1, 2, {}, []], gather(1, 2, {}, []));
assert.isTrue(checkDeep);
assert.isFalse(checkDeep);
});

it("returns true if array to arguments", function() {
Expand All @@ -430,13 +465,13 @@ describe("deepEqual", function() {
assert.isTrue(checkDeep);
});

it("returns true if arguments to array like object", function() {
it("returns false if arguments to array like object", function() {
var gather = function() {
return arguments;
};
var arrayLike = { length: 4, "0": 1, "1": 2, "2": {}, "3": [] };
var checkDeep = samsam.deepEqual(arrayLike, gather(1, 2, {}, []));
assert.isTrue(checkDeep);
assert.isFalse(checkDeep);
});

it("returns true for same error", function() {
Expand Down