Files
jasmine/src/core/matchers/matchersUtil.js
Steve Gravrock dec67bd535 Don't require matchers and asymmetric equality testers to pass custom object formatters back to Jasmine
This makes it easier to write high quality matchers and asymmetric equality
testers, and is also a step toward supporting custom object formatters.

Previously, Jasmine passed custom object formatters as the second argument
to matcher factories and as and the second argument to asymmetric equality
testers' `asymmetricMatch` method. Matchers and asymmetric equality testers
were responsible for passing the custom object formatters to methods like
`matchersUtil#equals`:

  function toEqual(util, customEqualityTesters) {
    return {
      compare: function(actual, expected) {
        // ...
        result.pass = util.equals(actual, expected, customEqualityTesters, diffBuilder);

And:

  ArrayContaining.prototype.asymmetricMatch = function(other, customTesters) {
    // ...
    for (var i = 0; i < this.sample.length; i++) {
      var item = this.sample[i];
      if (!j$.matchersUtil.contains(other, item, customTesters)) {
        return false;
      }
    }

With this change, that is no longer necessary. Matchers and asymmetric
equality testers can ignore the existence of custom equality testers and
still fully support them:

  function toEqual(util) {
    return {
      compare: function(actual, expected) {
        // ...
        result.pass = util.equals(actual, expected, diffBuilder);

And:

  ArrayContaining.prototype.asymmetricMatch = function(other, matchersUtil) {
    // ...
    for (var i = 0; i < this.sample.length; i++) {
      var item = this.sample[i];
      if (!matchersUtil.contains(other, item)) {
        return false;
      }
    }

The old interfaces are still supported, for now, but will be deprecated
in a future commit and removed in the next major release after that.

In addition to making matchers and custom equality testers simpler,
this change sets the stage for adding support for custom object
formatters. Those will be architecturally similar to custom equality
testers, and by injecting a `MatchersUtil` instance everywhere we can
add them without requiring user code to pass them around as used to be
the case with custom object formatters.
2020-02-10 17:25:50 -08:00

490 lines
15 KiB
JavaScript

getJasmineRequireObj().MatchersUtil = function(j$) {
// TODO: what to do about jasmine.pp not being inject? move to JSON.stringify? gut PrettyPrinter?
function MatchersUtil(options) {
options = options || {};
this.customTesters_ = options.customTesters || [];
if (!j$.isArray_(this.customTesters_)) {
throw new Error("MatchersUtil requires custom equality testers");
}
}
MatchersUtil.prototype.contains = function(haystack, needle, customTesters) {
if (j$.isSet(haystack)) {
return haystack.has(needle);
}
if ((Object.prototype.toString.apply(haystack) === '[object Array]') ||
(!!haystack && !haystack.indexOf))
{
for (var i = 0; i < haystack.length; i++) {
if (this.equals(haystack[i], needle, customTesters)) {
return true;
}
}
return false;
}
return !!haystack && haystack.indexOf(needle) >= 0;
};
MatchersUtil.prototype.buildFailureMessage = function() {
var args = Array.prototype.slice.call(arguments, 0),
matcherName = args[0],
isNot = args[1],
actual = args[2],
expected = args.slice(3),
englishyPredicate = matcherName.replace(/[A-Z]/g, function(s) { return ' ' + s.toLowerCase(); });
var message = 'Expected ' +
j$.pp(actual) +
(isNot ? ' not ' : ' ') +
englishyPredicate;
if (expected.length > 0) {
for (var i = 0; i < expected.length; i++) {
if (i > 0) {
message += ',';
}
message += ' ' + j$.pp(expected[i]);
}
}
return message + '.';
};
function isAsymmetric(obj) {
return obj && j$.isA_('Function', obj.asymmetricMatch);
}
MatchersUtil.prototype.asymmetricMatch_ = function(a, b, customTesters, diffBuilder) {
var asymmetricA = isAsymmetric(a),
asymmetricB = isAsymmetric(b),
shim = j$.asymmetricEqualityTesterArgCompatShim(this, customTesters),
result;
if (asymmetricA && asymmetricB) {
return undefined;
}
if (asymmetricA) {
result = a.asymmetricMatch(b, shim);
if (!result) {
diffBuilder.record(a, b);
}
return result;
}
if (asymmetricB) {
result = b.asymmetricMatch(a, shim);
if (!result) {
diffBuilder.record(a, b);
}
return result;
}
};
MatchersUtil.prototype.equals = function(a, b, customTestersOrDiffBuilder, diffBuilderOrNothing) {
var customTesters, diffBuilder;
if (isDiffBuilder(customTestersOrDiffBuilder)) {
diffBuilder = customTestersOrDiffBuilder;
} else {
customTesters = customTestersOrDiffBuilder;
diffBuilder = diffBuilderOrNothing;
}
customTesters = customTesters || this.customTesters_;
diffBuilder = diffBuilder || j$.NullDiffBuilder();
return this.eq_(a, b, [], [], customTesters, diffBuilder);
};
// Equality function lovingly adapted from isEqual in
// [Underscore](http://underscorejs.org)
MatchersUtil.prototype.eq_ = function(a, b, aStack, bStack, customTesters, diffBuilder) {
var result = true, self = this, i;
var asymmetricResult = this.asymmetricMatch_(a, b, customTesters, diffBuilder);
if (!j$.util.isUndefined(asymmetricResult)) {
return asymmetricResult;
}
for (i = 0; i < customTesters.length; i++) {
var customTesterResult = customTesters[i](a, b);
if (!j$.util.isUndefined(customTesterResult)) {
if (!customTesterResult) {
diffBuilder.record(a, b);
}
return customTesterResult;
}
}
if (a instanceof Error && b instanceof Error) {
result = a.message == b.message;
if (!result) {
diffBuilder.record(a, b);
}
return result;
}
// Identical objects are equal. `0 === -0`, but they aren't identical.
// See the [Harmony `egal` proposal](http://wiki.ecmascript.org/doku.php?id=harmony:egal).
if (a === b) {
result = a !== 0 || 1 / a == 1 / b;
if (!result) {
diffBuilder.record(a, b);
}
return result;
}
// A strict comparison is necessary because `null == undefined`.
if (a === null || b === null) {
result = a === b;
if (!result) {
diffBuilder.record(a, b);
}
return result;
}
var className = Object.prototype.toString.call(a);
if (className != Object.prototype.toString.call(b)) {
diffBuilder.record(a, b);
return false;
}
switch (className) {
// Strings, numbers, dates, and booleans are compared by value.
case '[object String]':
// Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is
// equivalent to `new String("5")`.
result = a == String(b);
if (!result) {
diffBuilder.record(a, b);
}
return result;
case '[object Number]':
// `NaN`s are equivalent, but non-reflexive. An `egal` comparison is performed for
// other numeric values.
result = a != +a ? b != +b : (a === 0 ? 1 / a == 1 / b : a == +b);
if (!result) {
diffBuilder.record(a, b);
}
return result;
case '[object Date]':
case '[object Boolean]':
// Coerce dates and booleans to numeric primitive values. Dates are compared by their
// millisecond representations. Note that invalid dates with millisecond representations
// of `NaN` are not equivalent.
result = +a == +b;
if (!result) {
diffBuilder.record(a, b);
}
return result;
// RegExps are compared by their source patterns and flags.
case '[object RegExp]':
return a.source == b.source &&
a.global == b.global &&
a.multiline == b.multiline &&
a.ignoreCase == b.ignoreCase;
}
if (typeof a != 'object' || typeof b != 'object') {
diffBuilder.record(a, b);
return false;
}
var aIsDomNode = j$.isDomNode(a);
var bIsDomNode = j$.isDomNode(b);
if (aIsDomNode && bIsDomNode) {
// At first try to use DOM3 method isEqualNode
result = a.isEqualNode(b);
if (!result) {
diffBuilder.record(a, b);
}
return result;
}
if (aIsDomNode || bIsDomNode) {
diffBuilder.record(a, b);
return false;
}
var aIsPromise = j$.isPromise(a);
var bIsPromise = j$.isPromise(b);
if (aIsPromise && bIsPromise) {
return a === b;
}
// Assume equality for cyclic structures. The algorithm for detecting cyclic
// structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`.
var length = aStack.length;
while (length--) {
// Linear search. Performance is inversely proportional to the number of
// unique nested structures.
if (aStack[length] == a) { return bStack[length] == b; }
}
// Add the first object to the stack of traversed objects.
aStack.push(a);
bStack.push(b);
var size = 0;
// Recursively compare objects and arrays.
// Compare array lengths to determine if a deep comparison is necessary.
if (className == '[object Array]') {
var aLength = a.length;
var bLength = b.length;
diffBuilder.withPath('length', function() {
if (aLength !== bLength) {
diffBuilder.record(aLength, bLength);
result = false;
}
});
for (i = 0; i < aLength || i < bLength; i++) {
diffBuilder.withPath(i, function() {
if (i >= bLength) {
diffBuilder.record(a[i], void 0, actualArrayIsLongerFormatter);
result = false;
} else {
result = self.eq_(i < aLength ? a[i] : void 0, i < bLength ? b[i] : void 0, aStack, bStack, customTesters, diffBuilder) && result;
}
});
}
if (!result) {
return false;
}
} else if (j$.isMap(a) && j$.isMap(b)) {
if (a.size != b.size) {
diffBuilder.record(a, b);
return false;
}
var keysA = [];
var keysB = [];
a.forEach( function( valueA, keyA ) {
keysA.push( keyA );
});
b.forEach( function( valueB, keyB ) {
keysB.push( keyB );
});
// For both sets of keys, check they map to equal values in both maps.
// Keep track of corresponding keys (in insertion order) in order to handle asymmetric obj keys.
var mapKeys = [keysA, keysB];
var cmpKeys = [keysB, keysA];
var mapIter, mapKey, mapValueA, mapValueB;
var cmpIter, cmpKey;
for (i = 0; result && i < mapKeys.length; i++) {
mapIter = mapKeys[i];
cmpIter = cmpKeys[i];
for (var j = 0; result && j < mapIter.length; j++) {
mapKey = mapIter[j];
cmpKey = cmpIter[j];
mapValueA = a.get(mapKey);
// Only use the cmpKey when one of the keys is asymmetric and the corresponding key matches,
// otherwise explicitly look up the mapKey in the other Map since we want keys with unique
// obj identity (that are otherwise equal) to not match.
if (isAsymmetric(mapKey) || isAsymmetric(cmpKey) &&
this.eq_(mapKey, cmpKey, aStack, bStack, customTesters, j$.NullDiffBuilder())) {
mapValueB = b.get(cmpKey);
} else {
mapValueB = b.get(mapKey);
}
result = this.eq_(mapValueA, mapValueB, aStack, bStack, customTesters, j$.NullDiffBuilder());
}
}
if (!result) {
diffBuilder.record(a, b);
return false;
}
} else if (j$.isSet(a) && j$.isSet(b)) {
if (a.size != b.size) {
diffBuilder.record(a, b);
return false;
}
var valuesA = [];
a.forEach( function( valueA ) {
valuesA.push( valueA );
});
var valuesB = [];
b.forEach( function( valueB ) {
valuesB.push( valueB );
});
// For both sets, check they are all contained in the other set
var setPairs = [[valuesA, valuesB], [valuesB, valuesA]];
var stackPairs = [[aStack, bStack], [bStack, aStack]];
var baseValues, baseValue, baseStack;
var otherValues, otherValue, otherStack;
var found;
var prevStackSize;
for (i = 0; result && i < setPairs.length; i++) {
baseValues = setPairs[i][0];
otherValues = setPairs[i][1];
baseStack = stackPairs[i][0];
otherStack = stackPairs[i][1];
// For each value in the base set...
for (var k = 0; result && k < baseValues.length; k++) {
baseValue = baseValues[k];
found = false;
// ... test that it is present in the other set
for (var l = 0; !found && l < otherValues.length; l++) {
otherValue = otherValues[l];
prevStackSize = baseStack.length;
// compare by value equality
found = this.eq_(baseValue, otherValue, baseStack, otherStack, customTesters, j$.NullDiffBuilder());
if (!found && prevStackSize !== baseStack.length) {
baseStack.splice(prevStackSize);
otherStack.splice(prevStackSize);
}
}
result = result && found;
}
}
if (!result) {
diffBuilder.record(a, b);
return false;
}
} else {
// Objects with different constructors are not equivalent, but `Object`s
// or `Array`s from different frames are.
var aCtor = a.constructor, bCtor = b.constructor;
if (aCtor !== bCtor &&
isFunction(aCtor) && isFunction(bCtor) &&
a instanceof aCtor && b instanceof bCtor &&
!(aCtor instanceof aCtor && bCtor instanceof bCtor)) {
diffBuilder.record(a, b, constructorsAreDifferentFormatter);
return false;
}
}
// Deep compare objects.
var aKeys = keys(a, className == '[object Array]'), key;
size = aKeys.length;
// Ensure that both objects contain the same number of properties before comparing deep equality.
if (keys(b, className == '[object Array]').length !== size) {
diffBuilder.record(a, b, objectKeysAreDifferentFormatter);
return false;
}
for (i = 0; i < size; i++) {
key = aKeys[i];
// Deep compare each member
if (!j$.util.has(b, key)) {
diffBuilder.record(a, b, objectKeysAreDifferentFormatter);
result = false;
continue;
}
diffBuilder.withPath(key, function() {
if(!self.eq_(a[key], b[key], aStack, bStack, customTesters, diffBuilder)) {
result = false;
}
});
}
if (!result) {
return false;
}
// Remove the first object from the stack of traversed objects.
aStack.pop();
bStack.pop();
return result;
};
function keys(obj, isArray) {
var allKeys = Object.keys ? Object.keys(obj) :
(function(o) {
var keys = [];
for (var key in o) {
if (j$.util.has(o, key)) {
keys.push(key);
}
}
return keys;
})(obj);
if (!isArray) {
return allKeys;
}
if (allKeys.length === 0) {
return allKeys;
}
var extraKeys = [];
for (var i = 0; i < allKeys.length; i++) {
if (!/^[0-9]+$/.test(allKeys[i])) {
extraKeys.push(allKeys[i]);
}
}
return extraKeys;
}
function isFunction(obj) {
return typeof obj === 'function';
}
function objectKeysAreDifferentFormatter(actual, expected, path) {
var missingProperties = j$.util.objectDifference(expected, actual),
extraProperties = j$.util.objectDifference(actual, expected),
missingPropertiesMessage = formatKeyValuePairs(missingProperties),
extraPropertiesMessage = formatKeyValuePairs(extraProperties),
messages = [];
if (!path.depth()) {
path = 'object';
}
if (missingPropertiesMessage.length) {
messages.push('Expected ' + path + ' to have properties' + missingPropertiesMessage);
}
if (extraPropertiesMessage.length) {
messages.push('Expected ' + path + ' not to have properties' + extraPropertiesMessage);
}
return messages.join('\n');
}
function constructorsAreDifferentFormatter(actual, expected, path) {
if (!path.depth()) {
path = 'object';
}
return 'Expected ' +
path + ' to be a kind of ' +
j$.fnNameFor(expected.constructor) +
', but was ' + j$.pp(actual) + '.';
}
function actualArrayIsLongerFormatter(actual, expected, path) {
return 'Unexpected ' +
path + (path.depth() ? ' = ' : '') +
j$.pp(actual) +
' in array.';
}
function formatKeyValuePairs(obj) {
var formatted = '';
for (var key in obj) {
formatted += '\n ' + key + ': ' + j$.pp(obj[key]);
}
return formatted;
}
function isDiffBuilder(obj) {
return obj && typeof obj.record === 'function';
}
return MatchersUtil;
};