Skip to content
This repository has been archived by the owner on Aug 1, 2024. It is now read-only.

Commit

Permalink
RELNOTES: add a custom pluggable equality interface for findDifferenc…
Browse files Browse the repository at this point in the history
…es and assertObjectEquals.

PiperOrigin-RevId: 404035566
Change-Id: I36e5227563015ffe4448187da2b577073040e476
  • Loading branch information
varomodt authored and copybara-github committed Oct 18, 2021
1 parent 1b14228 commit 99d0955
Show file tree
Hide file tree
Showing 2 changed files with 241 additions and 0 deletions.
187 changes: 187 additions & 0 deletions closure/goog/testing/asserts.js
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,162 @@ goog.testing.asserts.ARRAY_TYPES = {
'BigUint64Array': true
};

/**
* The result of a comparison performed by an EqualityFunction: if undefined,
* the inputs are equal; otherwise, a human-readable description of their
* inequality.
*
* @typedef {string|undefined}
*/
goog.testing.asserts.ComparisonResult;

/**
* A equality predicate.
*
* The first two arguments are the values to be compared. The third is an
* equality function which can be used to recursively apply findDifferences.
*
* An example comparison implementation for Array could be:
*
* function arrayEq(a, b, eq) {
* if (a.length !== b.length) {
* return "lengths unequal";
* }
*
* const differences = [];
* for (let i = 0; i < a.length; i++) {
* // Use the findDifferences implementation to perform recursive
* // comparisons.
* const diff = eq(a[i], b[i], eq);
* if (diff) {
* differences[i] = diff;
* }
* }
*
* if (differences) {
* return `found array differences: ${differences}`;
* }
*
* // Otherwise return undefined, indicating no differences.
* return undefined;
* }
*
* @typedef {function(?, ?, !goog.testing.asserts.EqualityFunction):
* ?goog.testing.asserts.ComparisonResult}
*/
goog.testing.asserts.EqualityFunction;

/**
* A map from prototype to custom equality matcher.
*
* @type {!Map<!Object, !goog.testing.asserts.EqualityFunction>}
* @private
*/
goog.testing.asserts.CUSTOM_EQUALITY_MATCHERS = new Map();

/**
* Returns the custom equality predicate for a given prototype, or else
* undefined.
*
* @param {?Object} prototype
* @return {!goog.testing.asserts.EqualityFunction|undefined}
* @private
*/
goog.testing.asserts.getCustomEquality = function(prototype) {
for (; (prototype != null) && (typeof prototype === 'object') &&
(prototype !== Object.prototype);
prototype = Object.getPrototypeOf(prototype)) {
const matcher = goog.testing.asserts.CUSTOM_EQUALITY_MATCHERS.get(
/** @type {!Object} */ (prototype));
if (matcher) {
return matcher;
}
}
return undefined;
};

/**
* Returns the most specific custom equality predicate which can be applied to
* both arguments, or else undefined.
*
* @param {!Object} obj1
* @param {!Object} obj2
* @return {!goog.testing.asserts.EqualityFunction|undefined}
* @private
*/
goog.testing.asserts.getMostSpecificCustomEquality = function(obj1, obj2) {
for (let prototype = Object.getPrototypeOf(obj1); (prototype != null) &&
(typeof prototype === 'object') && (prototype !== Object.prototype);
prototype = Object.getPrototypeOf(prototype)) {
if (prototype.isPrototypeOf(obj2)) {
return goog.testing.asserts.getCustomEquality(prototype);
}
}

// Otherwise, obj1 and obj2 did not share a common ancestor other than
// Object.prototype so we cannot have a comparator.
return undefined;
};

/**
* Executes a custom equality function
*
* @param {!goog.testing.asserts.EqualityFunction} comparator
* @param {!Object} obj1
* @param {!Object} obj2
* @param {string} path of the current field being checked.
* @return {?goog.testing.asserts.ComparisonResult}
* @private
*/
goog.testing.asserts.applyCustomEqualityFunction = function(
comparator, obj1, obj2, path) {
const /* !goog.testing.asserts.EqualityFunction */ callback =
(left, right, unusedEq) => {
const result = goog.testing.asserts.findDifferences(left, right);
return result ? (path ? path + ': ' : '') + result : undefined;
};
return comparator(obj1, obj2, callback);
};

/**
* Marks the given prototype as having equality semantics provided by the given
* custom equality function.
*
* This will cause findDifferences and assertObjectEquals to use the given
* function when comparing objects with this prototype. When comparing two
* objects with different prototypes, the equality (if any) attached to their
* lowest common ancestor in the prototype hierarchy will be used.
*
* @param {!Object} prototype
* @param {!goog.testing.asserts.EqualityFunction} fn
*/
goog.testing.asserts.registerComparator = function(prototype, fn) {
// First check that there is no comparator currently defined for this
// prototype.
if (goog.testing.asserts.CUSTOM_EQUALITY_MATCHERS.has(prototype)) {
throw new Error('duplicate comparator installation for ' + prototype);
}

// We cannot install custom equality matchers on Object.prototype, as it
// would replace all other comparisons.
if (prototype === Object.prototype) {
throw new Error('cannot customize root object comparator');
}

goog.testing.asserts.CUSTOM_EQUALITY_MATCHERS.set(prototype, fn);
};

/**
* Clears the custom equality function currently applied to the given prototype.
* Returns true if a function was removed.
*
* @param {!Object} prototype
* @return {boolean} whether a comparator was removed.
*/
goog.testing.asserts.clearCustomComparator = function(prototype) {
return goog.testing.asserts.CUSTOM_EQUALITY_MATCHERS.delete(prototype);
};

/**
* Determines if two items of any type match, and formulates an error message
* if not.
Expand Down Expand Up @@ -859,6 +1015,37 @@ goog.testing.asserts.findDifferences = function(
var typeOfVar2 = _trueTypeOf(var2);

if (typeOfVar1 === typeOfVar2) {
// For two objects of the same type, if one is a prototype of another, use
// the custom equality function for the more generic of the two
// prototypes, if available.
if (var1 && typeof var1 === 'object') {
try {
const o1 = /** @type {!Object} */ (var1);
const o2 = /** @type {!Object} */ (var2);
const comparator =
goog.testing.asserts.getMostSpecificCustomEquality(o1, o2);
if (comparator) {
const result = goog.testing.asserts.applyCustomEqualityFunction(
comparator, o1, o2, path);
if (result != null) {
failures.push((path ? path + ': ' : '') + result);
if (!path) {
rootFailed = true;
}
}
return;
}
} catch (e) {
// Catch and log errors from custom comparators but fall back onto
// ordinary comparisons. Such errors can occur, e.g. with proxies or
// when the prototypes of a polyfill are not traversable.
//
// If you see a failure due to this line, please do not use
// findDifferences or assertObjectEquals on these argument types.
goog.global.console.error('Error in custom comparator: ' + e);
}
}

const isArrayBuffer = typeOfVar1 === 'ArrayBuffer';
if (isArrayBuffer) {
// Since ArrayBuffer instances can't themselves be iterated through,
Expand Down
54 changes: 54 additions & 0 deletions closure/goog/testing/asserts_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,60 @@ testSuite({
createBinTree(4, null), createBinTree(5, null)));
},

testFindDifferences_customEquality() {
const A = class {};
const B = class extends A {};
const C = class extends A {};
const D = class extends C {};

// Asserts that the result of findDifferences on a and b results in the
// given failure. Because findDifferences will not output the actual failure
// message for root types, we have to parse the failure message for types
// with paths.
const assertFindDifferencesFailure = (a, b, failure) => assertEquals(
failure,
asserts.findDifferences([a], [b])
.split('\n')[1] // There will be one failure, on the 0th element.
.split(':')[1] // We want the message on the RHS of the index.
.substring(1) // Skip the leading space.
);

// Test registration of one comparator. All subtypes of A should use this
// comparator.
const aDifferences = 'hello';
asserts.registerComparator(A.prototype, (a, b, cmp) => aDifferences);
assertFindDifferencesFailure(new A(), new A(), aDifferences);
assertFindDifferencesFailure(new B(), new C(), aDifferences);
assertFindDifferencesFailure(new C(), new B(), aDifferences);
assertFindDifferencesFailure(new C(), new A(), aDifferences);

// Test registration of two comparators on subtypes. We should only use
// the comparator for B if _both_ arguments are B.
const bDifferences = 'goodbye';
asserts.registerComparator(B.prototype, (a, b, cmp) => bDifferences);
assertFindDifferencesFailure(new A(), new A(), aDifferences);
assertFindDifferencesFailure(new B(), new C(), aDifferences);
assertFindDifferencesFailure(new C(), new B(), aDifferences);
assertFindDifferencesFailure(new B(), new B(), bDifferences);

// Test registration of comparators on disjoint types. We should use the
// comparator for C on its subclass D, and use A otherwise.
const cDifferences = 'hello again';
asserts.registerComparator(C.prototype, (a, b, cmp) => cDifferences);
assertFindDifferencesFailure(new C(), new D(), cDifferences);
assertFindDifferencesFailure(new B(), new D(), aDifferences);
assertFindDifferencesFailure(new A(), new D(), aDifferences);

// Test that we can clear out these comparators correctly. Note that if
// a test above fails, we can end up with some prototypes still in our
// global registration list, but because the classes are anonymous, they
// cannot break other code.
asserts.clearCustomComparator(B.prototype);
asserts.clearCustomComparator(C.prototype);
assertFindDifferencesFailure(new C(), new D(), aDifferences);
asserts.clearCustomComparator(A.prototype);
},

testStringSamePrefix() {
assertThrowsJsUnitException(
() => {
Expand Down

0 comments on commit 99d0955

Please sign in to comment.