diff --git a/lib/Unexpected.js b/lib/Unexpected.js index 02e72e29b..c5ed973fd 100644 --- a/lib/Unexpected.js +++ b/lib/Unexpected.js @@ -502,7 +502,7 @@ Unexpected.prototype.fail = function (arg) { var match = placeholderRegexp.exec(token); if (match) { var index = match[1]; - if (placeholderArgs && index in placeholderArgs) { + if (index in placeholderArgs) { var placeholderArg = placeholderArgs[index]; if (placeholderArg && placeholderArg.isMagicPen) { output.append(placeholderArg); diff --git a/lib/assertions.js b/lib/assertions.js index 91d210ab0..547378ca1 100644 --- a/lib/assertions.js +++ b/lib/assertions.js @@ -1,5 +1,8 @@ /*global setTimeout*/ var utils = require('./utils'); +var arrayChanges = require('array-changes'); +var arrayChangesAsync = require('array-changes-async'); +var throwIfNonUnexpectedError = require('./throwIfNonUnexpectedError'); var objectIs = utils.objectIs; var isRegExp = utils.isRegExp; var isArray = utils.isArray; @@ -632,7 +635,7 @@ module.exports = function (expect) { expect.errorMode = 'bubble'; var keys = expect.subjectType.getKeys(subject); - var expected = Array.isArray(subject) ? [] : {}; + var expected = {}; keys.forEach(function (key, index) { if (typeof nextArg === 'string') { expected[key] = function (s) { @@ -892,7 +895,178 @@ module.exports = function (expect) { expect(subject, 'to equal', value); }); - expect.addAssertion(' to [exhaustively] satisfy ', function (expect, subject, value) { + expect.addAssertion(' to [exhaustively] satisfy ', function (expect, subject, value) { + expect.errorMode = 'bubble'; + var keyPromises = new Array(value.length); + var i; + var valueKeys = new Array(value.length); + for (i = 0 ; i < value.length ; i += 1) { + valueKeys[i] = i; + keyPromises[i] = expect.promise(function () { + var valueKeyType = expect.findTypeOf(value[i]); + if (valueKeyType.is('function')) { + return value[i](subject[i]); + } else { + return expect(subject[i], 'to [exhaustively] satisfy', value[i]); + } + }); + } + return expect.promise.all([ + expect.promise(function () { + expect(subject, 'to only have keys', valueKeys); + }), + expect.promise.all(keyPromises) + ]).caught(function () { + var subjectType = expect.subjectType; + return expect.promise.settle(keyPromises).then(function () { + var toSatisfyMatrix = new Array(subject.length); + for (i = 0 ; i < subject.length ; i += 1) { + toSatisfyMatrix[i] = new Array(value.length); + if (i < value.length) { + toSatisfyMatrix[i][i] = keyPromises[i].isFulfilled() || keyPromises[i].reason(); + } + } + if (subject.length > 10 || value.length > 10) { + var indexByIndexChanges = []; + for (i = 0 ; i < subject.length ; i += 1) { + var promise = keyPromises[i]; + if (i < value.length) { + indexByIndexChanges.push({ + type: promise.isFulfilled() ? 'equal' : 'similar', + value: subject[i], + expected: value[i], + actualIndex: i, + expectedIndex: i, + last: i === Math.max(subject.length, value.length) - 1 + }); + } else { + indexByIndexChanges.push({ + type: 'remove', + value: subject[i], + actualIndex: i, + last: i === subject.length - 1 + }); + } + } + for (i = subject.length ; i < value.length ; i += 1) { + indexByIndexChanges.push({ + type: 'insert', + value: value[i], + expectedIndex: i + }); + } + return failWithChanges(indexByIndexChanges); + } + + var isAsync = false; + var changes = arrayChanges(subject, value, function equal(a, b, aIndex, bIndex) { + var existingResult = aIndex < subject.length && toSatisfyMatrix[aIndex][bIndex]; + if (typeof existingResult !== 'undefined') { + return existingResult === true; + } + var result; + try { + result = expect(a, 'to [exhaustively] satisfy', b); + } catch (err) { + throwIfNonUnexpectedError(err); + toSatisfyMatrix[aIndex][bIndex] = err; + return false; + } + result.then(function () {}, function () {}); + if (result.isPending()) { + isAsync = true; + return false; + } + toSatisfyMatrix[aIndex][bIndex] = true; + return true; + }, function (a, b) { + return subjectType.similar(a, b); + }); + if (isAsync) { + return expect.promise(function (resolve, reject) { + arrayChangesAsync(subject, value, function equal(a, b, aIndex, bIndex, cb) { + var existingResult = aIndex < subject.length && toSatisfyMatrix[aIndex][bIndex]; + if (typeof existingResult !== 'undefined') { + return cb(existingResult === true); + } + expect.promise(function () { + return expect(a, 'to [exhaustively] satisfy', b); + }).then(function () { + toSatisfyMatrix[aIndex][bIndex] = true; + cb(true); + }, function (err) { + toSatisfyMatrix[aIndex][bIndex] = err; + cb(false); + }); + }, function (a, b, aIndex, bIndex, cb) { + cb(subjectType.similar(a, b)); + }, resolve); + }).then(failWithChanges); + } else { + return failWithChanges(changes); + } + + function failWithChanges(changes) { + expect.errorMode = 'default'; + expect.fail({ + diff: function (output, diff, inspect, equal) { + var result = { + diff: output, + inline: true + }; + var indexOfLastNonInsert = changes.reduce(function (previousValue, diffItem, index) { + return (diffItem.type === 'insert') ? previousValue : index; + }, -1); + output.append(subjectType.prefix(output.clone(), subject)).nl().indentLines(); + changes.forEach(function (diffItem, index) { + var delimiterOutput = subjectType.delimiter(output.clone(), index, indexOfLastNonInsert + 1); + output.i().block(function () { + var type = diffItem.type; + if (type === 'insert') { + this.annotationBlock(function () { + if (expect.findTypeOf(diffItem.value).is('function')) { + this.error('missing: should satisfy ').block(inspect(diffItem.value)); + } else { + this.error('missing ').block(inspect(diffItem.value)); + } + }); + } else if (type === 'remove') { + this.block(inspect(diffItem.value).amend(delimiterOutput.sp()).error('// should be removed')); + } else if (type === 'equal') { + this.block(inspect(diffItem.value).amend(delimiterOutput)); + } else { + var toSatisfyResult = toSatisfyMatrix[diffItem.actualIndex][diffItem.expectedIndex]; + var valueDiff = toSatisfyResult && toSatisfyResult !== true && toSatisfyResult.getDiff({ output: output.clone() }); + if (valueDiff && valueDiff.inline) { + this.block(valueDiff.diff.amend(delimiterOutput)); + } else { + this.block(inspect(diffItem.value).amend(delimiterOutput)).sp().annotationBlock(function () { + this.omitSubject = diffItem.value; + if (toSatisfyResult.getLabel()) { + this.error(toSatisfyResult.getLabel() || 'should satisfy').sp() + .block(inspect(diffItem.expected)); + + if (valueDiff) { + this.nl().append(valueDiff.diff); + } + } else { + this.appendErrorMessage(toSatisfyResult); + } + }); + } + } + }).nl(); + }); + output.outdentLines().append(subjectType.suffix(output.clone(), subject)); + return result; + } + }); + } + }); + }); + }); + + expect.addAssertion(' to [exhaustively] satisfy ', function (expect, subject, value) { var valueType = expect.argTypes[0]; var subjectType = expect.subjectType; if (subject === value) { @@ -1229,7 +1403,7 @@ module.exports = function (expect) { return subject; }).then(function (fulfillmentValue) { if (expect.findTypeOf(nextAssertion).is('expect.it')) { - // Force a failing expect.it error message to be property nested instead of replacing the default error message: + // Force a failing expect.it error message to be properly nested instead of replacing the default error message: return expect.promise(function () { return expect.shift(fulfillmentValue, 0); }).caught(function (err) { diff --git a/lib/types.js b/lib/types.js index 3b2bdb836..647c4e449 100644 --- a/lib/types.js +++ b/lib/types.js @@ -300,6 +300,38 @@ module.exports = function (expect) { this.suffix(output, actual); return result; + }, + + similar: function (a, b) { + var typeA = typeof a; + var typeB = typeof b; + + if (typeA !== typeB) { + return false; + } + + if (typeA === 'string') { + return leven(a, b) < a.length / 2; + } + + if (typeA !== 'object' || !a) { + return false; + } + + if (utils.isArray(a) && utils.isArray(b)) { + return true; + } + + var aKeys = expect.findTypeOf(a).getKeys(a); + var bKeys = expect.findTypeOf(b).getKeys(b); + var numberOfSimilarKeys = 0; + var requiredSimilarKeys = Math.round(Math.max(aKeys.length, bKeys.length) / 2); + return aKeys.concat(bKeys).some(function (key) { + if (key in a && key in b) { + numberOfSimilarKeys += 1; + } + return numberOfSimilarKeys >= requiredSimilarKeys; + }); } }); @@ -314,39 +346,6 @@ module.exports = function (expect) { } }); - function structurallySimilar(a, b) { - var typeA = typeof a; - var typeB = typeof b; - - if (typeA !== typeB) { - return false; - } - - if (typeA === 'string') { - return leven(a, b) < a.length / 2; - } - - if (typeA !== 'object' || !a) { - return false; - } - - if (utils.isArray(a) && utils.isArray(b)) { - return true; - } - - var aKeys = expect.findTypeOf(a).getKeys(a); - var bKeys = expect.findTypeOf(b).getKeys(b); - var numberOfSimilarKeys = 0; - var requiredSimilarKeys = Math.round(Math.max(aKeys.length, bKeys.length) / 2); - return aKeys.concat(bKeys).some(function (key) { - if (key in a && key in b) { - numberOfSimilarKeys += 1; - } - - return numberOfSimilarKeys >= requiredSimilarKeys; - }); - } - expect.addType({ name: 'magicpen', identify: function (obj) { @@ -472,11 +471,12 @@ module.exports = function (expect) { return this.baseType.diff(actual, expected, output); } - var changes = arrayChanges(actual, expected, equal, structurallySimilar); - output.append(this.prefix(output.clone(), actual)).nl().indentLines(); var type = this; + var changes = arrayChanges(actual, expected, equal, function (a, b) { + return type.similar(a, b); + }); var indexOfLastNonInsert = changes.reduce(function (previousValue, diffItem, index) { return (diffItem.type === 'insert') ? previousValue : index; }, -1); diff --git a/package.json b/package.json index 9076b1707..c63e69e1b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ }, "main": "./lib/index.js", "dependencies": { - "array-changes": "1.0.3", + "array-changes": "1.2.0", + "array-changes-async": "2.0.2", "bluebird": "2.9.34", "detect-indent": "3.0.1", "diff": "1.1.0", diff --git a/test/unexpected.spec.js b/test/unexpected.spec.js index 711120629..13c4e647d 100644 --- a/test/unexpected.spec.js +++ b/test/unexpected.spec.js @@ -3108,11 +3108,214 @@ describe('unexpected', function () { "expected [] to satisfy [ 1, 2 ]\n" + "\n" + "[\n" + - " // missing: should equal 1\n" + - " // missing: should equal 2\n" + + " // missing 1\n" + + " // missing 2\n" + "]" ); }); + + it('should fall back to comparing index-by-index if one of the arrays has more than 10 entries', function () { + expect(function () { + expect([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ], 'to satisfy', [0, 2, 3, 4, 5, 6, 7, 8, 9 ]); + }, 'to throw', + "expected [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]\n" + + "to satisfy [ 0, 2, 3, 4, 5, 6, 7, 8, 9 ]\n" + + "\n" + + "[\n" + + " 0,\n" + + " 1, // should equal 2\n" + + " 2, // should equal 3\n" + + " 3, // should equal 4\n" + + " 4, // should equal 5\n" + + " 5, // should equal 6\n" + + " 6, // should equal 7\n" + + " 7, // should equal 8\n" + + " 8, // should equal 9\n" + + " 9, // should be removed\n" + + " 10 // should be removed\n" + + "]" + ); + + expect(function () { + expect([1, 2, 3, 4, 5, 6, 7, 8], 'to satisfy', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + }, 'to throw', + "expected [ 1, 2, 3, 4, 5, 6, 7, 8 ]\n" + + "to satisfy [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 ]\n" + + "\n" + + "[\n" + + " 1,\n" + + " 2,\n" + + " 3,\n" + + " 4,\n" + + " 5,\n" + + " 6,\n" + + " 7,\n" + + " 8\n" + + " // missing 9\n" + + " // missing 10\n" + + " // missing 11\n" + + "]" + ); + }); + + describe('with sync expect.it entries in the value', function () { + it('should render missing entries', function () { + expect(function () { + expect([1, 2], 'to satisfy', [expect.it('to be a number'), 2, expect.it('to be a string')]); + }, 'to throw', + "expected [ 1, 2 ]\n" + + "to satisfy [ expect.it('to be a number'), 2, expect.it('to be a string') ]\n" + + "\n" + + "[\n" + + " 1,\n" + + " 2\n" + + " // missing: should satisfy expect.it('to be a string')\n" + + "]" + ); + }); + + it('should render moved entries', function () { + return expect(function () { + expect(['a', 'b'], 'to satisfy', [expect.it('to equal', 'b')]); + }, 'to throw', + "expected [ 'a', 'b' ] to satisfy [ expect.it('to equal', 'b') ]\n" + + "\n" + + "[\n" + + " 'a', // should be removed\n" + + " 'b'\n" + + "]" + ); + }); + + it('should render entries that do not satisfy the RHS entry', function () { + return expect(function () { + expect(['a', 'b'], 'to satisfy', ['a', expect.it('to equal', 'c')]); + }, 'to throw', + "expected [ 'a', 'b' ] to satisfy [ 'a', expect.it('to equal', 'c') ]\n" + + "\n" + + "[\n" + + " 'a',\n" + + " 'b' // should equal 'c'\n" + + " //\n" + + " // -b\n" + + " // +c\n" + + "]" + ); + }); + + it('should render extraneous entries', function () { + expect(function () { + expect([1, 2, 3], 'to satisfy', [1, 2]); + }, 'to throw', + "expected [ 1, 2, 3 ] to satisfy [ 1, 2 ]\n" + + "\n" + + "[\n" + + " 1,\n" + + " 2,\n" + + " 3 // should be removed\n" + + "]" + ); + }); + }); + + describe('with async expect.it entries in the value', function () { + it('should render missing entries', function () { + return expect(function () { + return expect([1, 2], 'to satisfy', [expect.it('when delayed a little bit', 'to be a number'), 2, expect.it('when delayed a little bit', 'to be a string')]); + }, 'to error', + "expected [ 1, 2 ] to satisfy\n" + + "[\n" + + " expect.it('when delayed a little bit', 'to be a number'),\n" + + " 2,\n" + + " expect.it('when delayed a little bit', 'to be a string')\n" + + "]\n" + + "\n" + + "[\n" + + " 1,\n" + + " 2\n" + + " // missing: should satisfy expect.it('when delayed a little bit', 'to be a string')\n" + + "]" + ); + }); + + it('should render unsatisfied entries', function () { + return expect(function () { + return expect([1, 2, 3, 4, 5, 6], 'to satisfy', [ + expect.it('when delayed a little bit', 'to be a number'), + 2, + expect.it('when delayed a little bit', 'to be a string'), + expect.it('when delayed a little bit', 'to be a boolean'), + expect.it('when delayed a little bit', 'to be a regular expression'), + expect.it('when delayed a little bit', 'to be a function') + ]); + }, 'to error', + "expected [ 1, 2, 3, 4, 5, 6 ] to satisfy\n" + + "[\n" + + " expect.it('when delayed a little bit', 'to be a number'),\n" + + " 2,\n" + + " expect.it('when delayed a little bit', 'to be a string'),\n" + + " expect.it('when delayed a little bit', 'to be a boolean'),\n" + + " expect.it('when delayed a little bit', 'to be a regular expression'),\n" + + " expect.it('when delayed a little bit', 'to be a function')\n" + + "]\n" + + "\n" + + "[\n" + + " 1,\n" + + " 2,\n" + + " 3, // expected: when delayed a little bit to be a string\n" + + " 4, // expected: when delayed a little bit to be a boolean\n" + + " 5, // expected: when delayed a little bit to be a regular expression\n" + + " 6 // expected: when delayed a little bit to be a function\n" + + "]" + ); + }); + + it('should render moved entries', function () { + return expect(function () { + return expect(['a', 'b'], 'to satisfy', [expect.it('when delayed a little bit', 'to equal', 'b')]); + }, 'to error', + "expected [ 'a', 'b' ]\n" + + "to satisfy [ expect.it('when delayed a little bit', 'to equal', 'b') ]\n" + + "\n" + + "[\n" + + " 'a', // should be removed\n" + + " 'b'\n" + + "]" + ); + }); + + it('should render entries that do not satisfy the RHS entry', function () { + return expect(function () { + return expect(['a', 'b'], 'to satisfy', ['a', expect.it('when delayed a little bit', 'to equal', 'c')]); + }, 'to error', + "expected [ 'a', 'b' ]\n" + + "to satisfy [ 'a', expect.it('when delayed a little bit', 'to equal', 'c') ]\n" + + "\n" + + "[\n" + + " 'a',\n" + + " 'b' // expected: when delayed a little bit to equal 'c'\n" + + " //\n" + + " // -b\n" + + " // +c\n" + + "]" + ); + }); + + it('should render extraneous entries', function () { + return expect(function () { + return expect([1, 2, 3], 'to satisfy', [expect.it('when delayed a little bit', 'to be a number'), 2]); + }, 'to error', + "expected [ 1, 2, 3 ]\n" + + "to satisfy [ expect.it('when delayed a little bit', 'to be a number'), 2 ]\n" + + "\n" + + "[\n" + + " 1,\n" + + " 2,\n" + + " 3 // should be removed\n" + + "]" + ); + }); + }); }); describe('with an array satisfied against an object', function () { @@ -3658,7 +3861,7 @@ describe('unexpected', function () { 'expected [] to satisfy [ undefined ]\n' + '\n' + '[\n' + - ' // missing: should satisfy undefined\n' + + ' // missing undefined\n' + ']' ); }); @@ -3686,7 +3889,7 @@ describe('unexpected', function () { ' 1,\n' + ' 2,\n' + ' 3\n' + - ' // missing: should equal 4\n' + + ' // missing 4\n' + ']'); }); });