diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 9f7e3106..2616482f 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2008-2019 Pivotal Labs +Copyright (c) 2008-2020 Pivotal Labs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -106,6 +106,7 @@ var getJasmineRequireObj = (function(jasmineGlobal) { j$.DiffBuilder = jRequire.DiffBuilder(j$); j$.NullDiffBuilder = jRequire.NullDiffBuilder(j$); j$.ObjectPath = jRequire.ObjectPath(j$); + j$.MismatchTree = jRequire.MismatchTree(j$); j$.GlobalErrors = jRequire.GlobalErrors(j$); j$.Truthy = jRequire.Truthy(j$); @@ -4187,20 +4188,54 @@ getJasmineRequireObj().toBeResolvedTo = function(j$) { }; }; -getJasmineRequireObj().DiffBuilder = function(j$) { +getJasmineRequireObj().DiffBuilder = function (j$) { return function DiffBuilder(config) { - var path = new j$.ObjectPath(), - mismatches = [], - prettyPrinter = (config || {}).prettyPrinter || j$.makePrettyPrinter(); + var prettyPrinter = (config || {}).prettyPrinter || j$.makePrettyPrinter(), + mismatches = new j$.MismatchTree(), + path = new j$.ObjectPath(), + actualRoot = undefined, + expectedRoot = undefined; return { - record: function (actual, expected, formatter) { - formatter = formatter || defaultFormatter; - mismatches.push(formatter(actual, expected, path, prettyPrinter)); + setRoots: function (actual, expected) { + actualRoot = actual; + expectedRoot = expected; + }, + + recordMismatch: function (formatter) { + mismatches.add(path, formatter); }, getMessage: function () { - return mismatches.join('\n'); + var messages = []; + + mismatches.traverse(function (path, isLeaf, formatter) { + var actualCustom, expectedCustom, useCustom, + actual = path.dereference(actualRoot), + expected = path.dereference(expectedRoot); + + if (formatter) { + messages.push(formatter(actual, expected, path, prettyPrinter)); + return true; + } + + actualCustom = prettyPrinter.customFormat_(actual); + expectedCustom = prettyPrinter.customFormat_(expected); + useCustom = !(j$.util.isUndefined(actualCustom) && j$.util.isUndefined(expectedCustom)); + + if (useCustom) { + messages.push(wrapPrettyPrinted(actualCustom, expectedCustom, path)); + return false; // don't recurse further + } + + if (isLeaf) { + messages.push(defaultFormatter(actual, expected, path, prettyPrinter)); + } + + return true; + }); + + return messages.join('\n'); }, withPath: function (pathComponent, block) { @@ -4211,12 +4246,16 @@ getJasmineRequireObj().DiffBuilder = function(j$) { } }; - function defaultFormatter (actual, expected, path, prettyPrinter) { + function defaultFormatter(actual, expected, path, prettyPrinter) { + return wrapPrettyPrinted(prettyPrinter(actual), prettyPrinter(expected), path); + } + + function wrapPrettyPrinted(actual, expected, path) { return 'Expected ' + path + (path.depth() ? ' = ' : '') + - prettyPrinter(actual) + + actual + ' to equal ' + - prettyPrinter(expected) + + expected + '.'; } }; @@ -4293,7 +4332,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (asymmetricA) { result = a.asymmetricMatch(b, shim); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } @@ -4301,7 +4340,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (asymmetricB) { result = b.asymmetricMatch(a, shim); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } @@ -4319,6 +4358,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { customTesters = customTesters || this.customTesters_; diffBuilder = diffBuilder || j$.NullDiffBuilder(); + diffBuilder.setRoots(a, b); return this.eq_(a, b, [], [], customTesters, diffBuilder); }; @@ -4337,7 +4377,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { var customTesterResult = customTesters[i](a, b); if (!j$.util.isUndefined(customTesterResult)) { if (!customTesterResult) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return customTesterResult; } @@ -4346,7 +4386,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (a instanceof Error && b instanceof Error) { result = a.message == b.message; if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } @@ -4356,7 +4396,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (a === b) { result = a !== 0 || 1 / a == 1 / b; if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } @@ -4364,13 +4404,13 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (a === null || b === null) { result = a === b; if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } var className = Object.prototype.toString.call(a); if (className != Object.prototype.toString.call(b)) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } switch (className) { @@ -4380,7 +4420,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // equivalent to `new String("5")`. result = a == String(b); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; case '[object Number]': @@ -4388,7 +4428,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // other numeric values. result = a != +a ? b != +b : (a === 0 ? 1 / a == 1 / b : a == +b); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; case '[object Date]': @@ -4398,7 +4438,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // of `NaN` are not equivalent. result = +a == +b; if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; // RegExps are compared by their source patterns and flags. @@ -4409,7 +4449,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { a.ignoreCase == b.ignoreCase; } if (typeof a != 'object' || typeof b != 'object') { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } @@ -4419,12 +4459,12 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // At first try to use DOM3 method isEqualNode result = a.isEqualNode(b); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } if (aIsDomNode || bIsDomNode) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } @@ -4454,7 +4494,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { diffBuilder.withPath('length', function() { if (aLength !== bLength) { - diffBuilder.record(aLength, bLength); + diffBuilder.recordMismatch(); result = false; } }); @@ -4462,7 +4502,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { for (i = 0; i < aLength || i < bLength; i++) { diffBuilder.withPath(i, function() { if (i >= bLength) { - diffBuilder.record(a[i], void 0, actualArrayIsLongerFormatter.bind(null, self.pp)); + diffBuilder.recordMismatch(actualArrayIsLongerFormatter.bind(null, self.pp)); result = false; } else { result = self.eq_(i < aLength ? a[i] : void 0, i < bLength ? b[i] : void 0, aStack, bStack, customTesters, diffBuilder) && result; @@ -4474,7 +4514,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { } } else if (j$.isMap(a) && j$.isMap(b)) { if (a.size != b.size) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } @@ -4516,12 +4556,12 @@ getJasmineRequireObj().MatchersUtil = function(j$) { } if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } } else if (j$.isSet(a) && j$.isSet(b)) { if (a.size != b.size) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } @@ -4566,7 +4606,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { } if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } } else { @@ -4579,7 +4619,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { a instanceof aCtor && b instanceof bCtor && !(aCtor instanceof aCtor && bCtor instanceof bCtor)) { - diffBuilder.record(a, b, constructorsAreDifferentFormatter.bind(null, this.pp)); + diffBuilder.recordMismatch(constructorsAreDifferentFormatter.bind(null, this.pp)); return false; } } @@ -4590,7 +4630,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // 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.bind(null, this.pp)); + diffBuilder.recordMismatch(objectKeysAreDifferentFormatter.bind(null, this.pp)); return false; } @@ -4598,7 +4638,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { key = aKeys[i]; // Deep compare each member if (!j$.util.has(b, key)) { - diffBuilder.record(a, b, objectKeysAreDifferentFormatter.bind(null, this.pp)); + diffBuilder.recordMismatch(objectKeysAreDifferentFormatter.bind(null, this.pp)); result = false; continue; } @@ -4704,12 +4744,75 @@ getJasmineRequireObj().MatchersUtil = function(j$) { } function isDiffBuilder(obj) { - return obj && typeof obj.record === 'function'; + return obj && typeof obj.recordMismatch === 'function'; } return MatchersUtil; }; +getJasmineRequireObj().MismatchTree = function (j$) { + + /* + To be able to apply custom object formatters at all possible levels of an + object graph, DiffBuilder needs to be able to know not just where the + mismatch occurred but also all ancestors of the mismatched value in both + the expected and actual object graphs. MismatchTree maintains that context + and provides it via the traverse method. + */ + function MismatchTree(path) { + this.path = path || new j$.ObjectPath([]); + this.formatter = undefined; + this.children = []; + this.isMismatch = false; + } + + MismatchTree.prototype.add = function (path, formatter) { + var key, child; + + if (path.depth() === 0) { + this.formatter = formatter; + this.isMismatch = true; + } else { + key = path.components[0]; + path = path.shift(); + child = this.child(key); + + if (!child) { + child = new MismatchTree(this.path.add(key)); + this.children.push(child); + } + + child.add(path, formatter); + } + }; + + MismatchTree.prototype.traverse = function (visit) { + var i, hasChildren = this.children.length > 0; + + if (this.isMismatch || hasChildren) { + if (visit(this.path, !hasChildren, this.formatter)) { + for (i = 0; i < this.children.length; i++) { + this.children[i].traverse(visit); + } + } + } + }; + + MismatchTree.prototype.child = function(key) { + var i, pathEls; + + for (i = 0; i < this.children.length; i++) { + pathEls = this.children[i].path.components; + if (pathEls[pathEls.length - 1] === key) { + return this.children[i]; + } + } + }; + + return MismatchTree; +}; + + getJasmineRequireObj().nothing = function() { /** * {@link expect} nothing explicitly. @@ -4738,7 +4841,8 @@ getJasmineRequireObj().NullDiffBuilder = function(j$) { withPath: function(_, block) { block(); }, - record: function() {} + setRoots: function() {}, + recordMismatch: function() {} }; }; }; @@ -4756,10 +4860,24 @@ getJasmineRequireObj().ObjectPath = function(j$) { } }; + ObjectPath.prototype.dereference = function(obj) { + var i; + + for (i = 0; i < this.components.length; i++) { + obj = obj[this.components[i]]; + } + + return obj; + }; + ObjectPath.prototype.add = function(component) { return new ObjectPath(this.components.concat([component])); }; + ObjectPath.prototype.shift = function() { + return new ObjectPath(this.components.slice(1)); + }; + ObjectPath.prototype.depth = function() { return this.components.length; }; @@ -6109,15 +6227,7 @@ getJasmineRequireObj().makePrettyPrinter = function(j$) { }; SinglePrettyPrintRun.prototype.applyCustomFormatters_ = function(value) { - var i, result; - - for (i = 0; i < this.customObjectFormatters_.length; i++) { - result = this.customObjectFormatters_[i](value); - - if (result !== undefined) { - return result; - } - } + return customFormat(value, this.customObjectFormatters_); }; SinglePrettyPrintRun.prototype.iterateObject = function(obj, fn) { @@ -6390,16 +6500,31 @@ getJasmineRequireObj().makePrettyPrinter = function(j$) { return extraKeys; } + function customFormat(value, customObjectFormatters) { + var i, result; + + for (i = 0; i < customObjectFormatters.length; i++) { + result = customObjectFormatters[i](value); + + if (result !== undefined) { + return result; + } + } + } + return function(customObjectFormatters) { + customObjectFormatters = customObjectFormatters || []; + var pp = function(value) { - var prettyPrinter = new SinglePrettyPrintRun( - customObjectFormatters || [], - pp - ); + var prettyPrinter = new SinglePrettyPrintRun(customObjectFormatters, pp); prettyPrinter.format(value); return prettyPrinter.stringParts.join(''); }; + pp.customFormat_ = function(value) { + return customFormat(value, customObjectFormatters); + }; + return pp; }; }; diff --git a/spec/core/PrettyPrintSpec.js b/spec/core/PrettyPrintSpec.js index 4aafefcd..f12469b7 100644 --- a/spec/core/PrettyPrintSpec.js +++ b/spec/core/PrettyPrintSpec.js @@ -505,7 +505,7 @@ describe('PrettyPrinter', function() { pp = jasmineUnderTest.makePrettyPrinter(customObjectFormatters), obj = { foo: 'bar' }; - expect(pp(obj, customObjectFormatters)).toEqual('2nd: bar'); + expect(pp(obj)).toEqual('2nd: bar'); }); it('should fall back to built in logic if all custom object formatters return undefined', function() { @@ -517,7 +517,39 @@ describe('PrettyPrinter', function() { pp = jasmineUnderTest.makePrettyPrinter(customObjectFormatters), obj = { foo: 'bar' }; - expect(pp(obj, customObjectFormatters)).toEqual("Object({ foo: 'bar' })"); + expect(pp(obj)).toEqual("Object({ foo: 'bar' })"); + }); + }); + + describe('#customFormat_', function() { + it('should use the first custom object formatter that does not return undefined', function() { + var customObjectFormatters = [ + function(obj) { + return undefined; + }, + function(obj) { + return '2nd: ' + obj.foo; + }, + function(obj) { + return '3rd: ' + obj.foo; + } + ], + pp = jasmineUnderTest.makePrettyPrinter(customObjectFormatters), + obj = { foo: 'bar' }; + + expect(pp.customFormat_(obj)).toEqual('2nd: bar'); + }); + + it('should return undefined if all custom object formatters return undefined', function() { + var customObjectFormatters = [ + function(obj) { + return undefined; + } + ], + pp = jasmineUnderTest.makePrettyPrinter(customObjectFormatters), + obj = { foo: 'bar' }; + + expect(pp.customFormat_(obj)).toBeUndefined(); }); }); }); diff --git a/spec/core/matchers/DiffBuilderSpec.js b/spec/core/matchers/DiffBuilderSpec.js index 3b68207d..6d82c3f8 100644 --- a/spec/core/matchers/DiffBuilderSpec.js +++ b/spec/core/matchers/DiffBuilderSpec.js @@ -1,73 +1,136 @@ -describe("DiffBuilder", function() { - it("records the actual and expected objects", function() { +describe("DiffBuilder", function () { + it("records the actual and expected objects", function () { var diffBuilder = jasmineUnderTest.DiffBuilder(); - diffBuilder.record({x: 'actual'}, {x: 'expected'}); + diffBuilder.setRoots({x: 'actual'}, {x: 'expected'}); + diffBuilder.recordMismatch(); expect(diffBuilder.getMessage()).toEqual("Expected Object({ x: 'actual' }) to equal Object({ x: 'expected' })."); }); - it("prints the path at which the difference was found", function() { + it("prints the path at which the difference was found", function () { var diffBuilder = jasmineUnderTest.DiffBuilder(); + diffBuilder.setRoots({foo: {x: 'actual'}}, {foo: {x: 'expected'}}); - diffBuilder.withPath('foo', function() { - diffBuilder.record({x: 'actual'}, {x: 'expected'}); + diffBuilder.withPath('foo', function () { + diffBuilder.recordMismatch(); }); expect(diffBuilder.getMessage()).toEqual("Expected $.foo = Object({ x: 'actual' }) to equal Object({ x: 'expected' })."); }); - it("prints multiple messages, separated by newlines", function() { + it("prints multiple messages, separated by newlines", function () { var diffBuilder = jasmineUnderTest.DiffBuilder(); + diffBuilder.setRoots({foo: 1, bar: 3}, {foo: 2, bar: 4}); - diffBuilder.withPath('foo', function() { - diffBuilder.record(1, 2); + diffBuilder.withPath('foo', function () { + diffBuilder.recordMismatch(); + }); + diffBuilder.withPath('bar', function () { + diffBuilder.recordMismatch(); }); var message = "Expected $.foo = 1 to equal 2.\n" + - "Expected 3 to equal 4."; + "Expected $.bar = 3 to equal 4."; - diffBuilder.record(3, 4); expect(diffBuilder.getMessage()).toEqual(message); }); - it("allows customization of the message", function() { + it("allows customization of the message", function () { var diffBuilder = jasmineUnderTest.DiffBuilder(); + diffBuilder.setRoots({x: 'bar'}, {x: 'foo'}); function darthVaderFormatter(actual, expected, path) { return "I find your lack of " + expected + " disturbing. (was " + actual + ", at " + path + ")" } - diffBuilder.withPath('x', function() { - diffBuilder.record('bar', 'foo', darthVaderFormatter); + diffBuilder.withPath('x', function () { + diffBuilder.recordMismatch(darthVaderFormatter); }); expect(diffBuilder.getMessage()).toEqual("I find your lack of foo disturbing. (was bar, at $.x)"); }); - it("uses the injected pretty-printer", function() { - var prettyPrinter = function(val) { + it("uses the injected pretty-printer", function () { + var prettyPrinter = function (val) { return '|' + val + '|'; }, diffBuilder = jasmineUnderTest.DiffBuilder({prettyPrinter: prettyPrinter}); + prettyPrinter.customFormat_ = function () { + }; - diffBuilder.withPath('foo', function() { - diffBuilder.record('actual', 'expected'); + diffBuilder.setRoots({foo: 'actual'}, {foo: 'expected'}); + diffBuilder.withPath('foo', function () { + diffBuilder.recordMismatch(); }); expect(diffBuilder.getMessage()).toEqual("Expected $.foo = |actual| to equal |expected|."); }); - - it("passes the injected pretty-printer to the diff formatter", function() { + it("passes the injected pretty-printer to the diff formatter", function () { var diffFormatter = jasmine.createSpy('diffFormatter'), - prettyPrinter = function() {}, + prettyPrinter = function () { + }, diffBuilder = jasmineUnderTest.DiffBuilder({prettyPrinter: prettyPrinter}); + prettyPrinter.customFormat_ = function () { + }; - diffBuilder.withPath('x', function() { - diffBuilder.record('bar', 'foo', diffFormatter); + diffBuilder.setRoots({x: 'bar'}, {x: 'foo'}); + diffBuilder.withPath('x', function () { + diffBuilder.recordMismatch(diffFormatter); }); + diffBuilder.getMessage(); + expect(diffFormatter).toHaveBeenCalledWith('bar', 'foo', jasmine.anything(), prettyPrinter); }); + + it("uses custom object formatters on leaf nodes", function() { + var formatter = function(x) { + if (typeof x === 'number') { + return '[number:' + x + ']'; + } + }; + prettyPrinter = jasmineUnderTest.makePrettyPrinter([formatter]); + var diffBuilder = new jasmineUnderTest.DiffBuilder({prettyPrinter: prettyPrinter}); + + diffBuilder.setRoots(5, 4); + diffBuilder.recordMismatch(); + + expect(diffBuilder.getMessage()).toEqual('Expected [number:5] to equal [number:4].'); + }); + + + it("uses custom object formatters on non leaf nodes", function () { + var formatter = function (x) { + if (x.hasOwnProperty('a')) { + return '[thing with a=' + x.a + ', b=' + JSON.stringify(x.b) + ']'; + } + }; + prettyPrinter = jasmineUnderTest.makePrettyPrinter([formatter]); + var diffBuilder = new jasmineUnderTest.DiffBuilder({prettyPrinter: prettyPrinter}); + var expectedMsg = 'Expected $[0].foo = [thing with a=1, b={"x":42}] to equal [thing with a=1, b={"x":43}].\n' + + "Expected $[0].bar = 'yes' to equal 'no'."; + + diffBuilder.setRoots( + [{foo: {a: 1, b: {x: 42}}, bar: 'yes'}], + [{foo: {a: 1, b: {x: 43}}, bar: 'no'}] + ); + + diffBuilder.withPath(0, function () { + diffBuilder.withPath('foo', function () { + diffBuilder.withPath('b', function () { + diffBuilder.withPath('x', function () { + diffBuilder.recordMismatch(); + }); + }); + }); + + diffBuilder.withPath('bar', function () { + diffBuilder.recordMismatch(); + }); + }); + + expect(diffBuilder.getMessage()).toEqual(expectedMsg); + }); }); diff --git a/spec/core/matchers/MismatchTreeSpec.js b/spec/core/matchers/MismatchTreeSpec.js new file mode 100644 index 00000000..ea053eb7 --- /dev/null +++ b/spec/core/matchers/MismatchTreeSpec.js @@ -0,0 +1,136 @@ +describe('MismatchTree', function () { + describe('#add', function () { + describe('When the path is empty', function () { + it('flags the root node as mismatched', function () { + var tree = new jasmineUnderTest.MismatchTree(); + tree.add(new jasmineUnderTest.ObjectPath([])); + expect(tree.isMismatch).toBe(true); + }); + }); + + describe('When the path is not empty', function () { + it('flags the node as mismatched', function () { + var tree = new jasmineUnderTest.MismatchTree(); + + tree.add(new jasmineUnderTest.ObjectPath(['a', 'b'])); + + expect(tree.child('a').child('b').isMismatch).toBe(true); + }); + + it('does not flag ancestors as mismatched', function () { + var tree = new jasmineUnderTest.MismatchTree(); + + tree.add(new jasmineUnderTest.ObjectPath(['a', 'b'])); + + expect(tree.isMismatch).toBe(false); + expect(tree.child('a').isMismatch).toBe(false); + }); + }); + + it('stores the formatter on only the target node', function () { + var tree = new jasmineUnderTest.MismatchTree(); + + tree.add(new jasmineUnderTest.ObjectPath(['a', 'b']), formatter); + + expect(tree.formatter).toBeFalsy(); + expect(tree.child('a').formatter).toBeFalsy(); + expect(tree.child('a').child('b').formatter).toBe(formatter); + }); + + it('stores the path to the node', function () { + var tree = new jasmineUnderTest.MismatchTree(); + + tree.add(new jasmineUnderTest.ObjectPath(['a', 'b']), formatter); + + expect(tree.child('a').child('b').path.components).toEqual(['a', 'b']); + }); + }); + + describe('#traverse', function () { + it('calls the callback for all nodes that are or contain mismatches', function () { + var tree = new jasmineUnderTest.MismatchTree(); + tree.add(new jasmineUnderTest.ObjectPath(['a', 'b']), formatter); + tree.add(new jasmineUnderTest.ObjectPath(['c'])); + var visit = jasmine.createSpy('visit').and.returnValue(true); + + tree.traverse(visit); + + expect(visit).toHaveBeenCalledWith( + new jasmineUnderTest.ObjectPath([]), false, undefined + ); + expect(visit).toHaveBeenCalledWith( + new jasmineUnderTest.ObjectPath(['a']), false, undefined + ); + expect(visit).toHaveBeenCalledWith( + new jasmineUnderTest.ObjectPath(['a', 'b']), true, formatter + ); + expect(visit).toHaveBeenCalledWith( + new jasmineUnderTest.ObjectPath(['c']), true, undefined + ); + }); + + it('does not call the callback if there are no mismatches', function () { + var tree = new jasmineUnderTest.MismatchTree(); + var visit = jasmine.createSpy('visit'); + + tree.traverse(visit); + + expect(visit).not.toHaveBeenCalled(); + }); + + it('visits parents before children', function () { + var tree = new jasmineUnderTest.MismatchTree(); + tree.add(new jasmineUnderTest.ObjectPath(['a', 'b'])); + var visited = []; + + tree.traverse(function (path) { + visited.push(path); + return true; + }); + + expect(visited).toEqual([ + new jasmineUnderTest.ObjectPath([]), + new jasmineUnderTest.ObjectPath(['a']), + new jasmineUnderTest.ObjectPath(['a', 'b']) + ]); + }); + + it('visits children in the order they were recorded', function() { + var tree = new jasmineUnderTest.MismatchTree(); + tree.add(new jasmineUnderTest.ObjectPath(['length'])); + tree.add(new jasmineUnderTest.ObjectPath([1])); + var visited = []; + + tree.traverse(function (path) { + visited.push(path); + return true; + }); + + expect(visited).toEqual([ + new jasmineUnderTest.ObjectPath([]), + new jasmineUnderTest.ObjectPath(['length']), + new jasmineUnderTest.ObjectPath([1]) + ]); + }); + + it('does not visit children if the callback returns falsy', function() { + var tree = new jasmineUnderTest.MismatchTree(); + tree.add(new jasmineUnderTest.ObjectPath(['a', 'b'])); + var visited = []; + + tree.traverse(function (path) { + visited.push(path); + return path.depth() === 0; + }); + + expect(visited).toEqual([ + new jasmineUnderTest.ObjectPath([]), + new jasmineUnderTest.ObjectPath(['a']) + ]); + }); + }); + + function formatter() { + } + +}); diff --git a/spec/core/matchers/NullDiffBuilderSpec.js b/spec/core/matchers/NullDiffBuilderSpec.js index a9aac3db..3333776b 100644 --- a/spec/core/matchers/NullDiffBuilderSpec.js +++ b/spec/core/matchers/NullDiffBuilderSpec.js @@ -1,13 +1,8 @@ -describe('NullDiffBuilder', function() { - it('responds to withPath() by calling the passed function', function() { +describe('NullDiffBuilder', function () { + it('responds to withPath() by calling the passed function', function () { var spy = jasmine.createSpy('callback'); jasmineUnderTest.NullDiffBuilder().withPath('does not matter', spy); expect(spy).toHaveBeenCalled(); }); - it('responds to record()', function() { - expect(function() { - jasmineUnderTest.NullDiffBuilder().record('does not matter'); - }).not.toThrow(); - }) }); diff --git a/spec/core/matchers/ObjectPathSpec.js b/spec/core/matchers/ObjectPathSpec.js index 88b023d1..9750272e 100644 --- a/spec/core/matchers/ObjectPathSpec.js +++ b/spec/core/matchers/ObjectPathSpec.js @@ -39,5 +39,13 @@ describe('ObjectPath', function() { expect(path.toString()).toEqual('$.foo'); expect(root.toString()).toEqual(''); - }) + }); + + describe('#dereference', function() { + it('returns the value corresponding to the path', function () { + var path = new ObjectPath().add('foo').add(1).add('bar'); + var obj = {foo: ['', {bar: 42}]}; + expect(path.dereference(obj)).toEqual(42); + }); + }); }); diff --git a/spec/core/matchers/matchersUtilSpec.js b/spec/core/matchers/matchersUtilSpec.js index 1c0903ba..48f4781a 100644 --- a/spec/core/matchers/matchersUtilSpec.js +++ b/spec/core/matchers/matchersUtilSpec.js @@ -730,26 +730,26 @@ describe("matchersUtil", function() { var diffBuilder = new jasmineUnderTest.DiffBuilder(), matchersUtil = new jasmineUnderTest.MatchersUtil(); - spyOn(diffBuilder, 'record'); + spyOn(diffBuilder, 'recordMismatch'); spyOn(diffBuilder, 'withPath').and.callThrough(); matchersUtil.equals([1], [2], [], diffBuilder); expect(diffBuilder.withPath).toHaveBeenCalledWith('length', jasmine.any(Function)); expect(diffBuilder.withPath).toHaveBeenCalledWith(0, jasmine.any(Function)); - expect(diffBuilder.record).toHaveBeenCalledWith(1, 2); + expect(diffBuilder.recordMismatch).toHaveBeenCalledWith(); }); it('uses a diffBuilder if one is provided as the third argument', function() { var diffBuilder = new jasmineUnderTest.DiffBuilder(), matchersUtil = new jasmineUnderTest.MatchersUtil(); - spyOn(diffBuilder, 'record'); + spyOn(diffBuilder, 'recordMismatch'); spyOn(diffBuilder, 'withPath').and.callThrough(); matchersUtil.equals([1], [2], diffBuilder); expect(diffBuilder.withPath).toHaveBeenCalledWith('length', jasmine.any(Function)); expect(diffBuilder.withPath).toHaveBeenCalledWith(0, jasmine.any(Function)); - expect(diffBuilder.record).toHaveBeenCalledWith(1, 2); + expect(diffBuilder.recordMismatch).toHaveBeenCalled(); }); }); diff --git a/spec/core/matchers/toEqualSpec.js b/spec/core/matchers/toEqualSpec.js index 5bfc7715..78b40ef0 100644 --- a/spec/core/matchers/toEqualSpec.js +++ b/spec/core/matchers/toEqualSpec.js @@ -97,8 +97,12 @@ describe("toEqual", function() { expect(compareEquals(actual, expected).message).toEqual(message); }); - it("uses custom object formatters to pretty-print properties", function() { - function formatter(x) { return '|' + x + '|'; } + it("uses custom object formatters to pretty-print simple properties", function() { + function formatter(x) { + if (typeof x === 'number') { + return '|' + x + '|'; + } + } var actual = {x: {y: 1, z: 2, f: 4}}, expected = {x: {y: 1, z: 2, g: 3}}, @@ -114,8 +118,12 @@ describe("toEqual", function() { expect(matcher.compare(actual, expected).message).toEqual(message); }); - it("uses custom object formatters to build diffs", function() { - function formatter(x) { return '|' + x + '|'; } + it("uses custom object formatters to show simple values in diffs", function() { + function formatter(x) { + if (typeof x === 'number') { + return '|' + x + '|'; + } + } var actual = [{foo: 4}], expected = [{foo: 5}], @@ -127,6 +135,30 @@ describe("toEqual", function() { expect(matcher.compare(actual, expected).message).toEqual(message); }); + it("uses custom object formatters to show more complex objects diffs", function() { + function formatter(x) { + if (x.hasOwnProperty('a')) { + return '[thing with a=' + x.a + ', b=' + x.b + ']'; + } + } + + var actual = [{ + foo: {a: 1, b: 2}, + bar: 'should not be pretty printed' + }], + expected = [{ + foo: {a: 5, b: 2}, + bar: "shouldn't be pretty printed" + }], + prettyPrinter = jasmineUnderTest.makePrettyPrinter([formatter]), + util = new jasmineUnderTest.MatchersUtil({pp: prettyPrinter}), + matcher = jasmineUnderTest.matchers.toEqual(util), + message = "Expected $[0].foo = [thing with a=1, b=2] to equal [thing with a=5, b=2].\n" + + "Expected $[0].bar = 'should not be pretty printed' to equal 'shouldn't be pretty printed'."; + + expect(matcher.compare(actual, expected).message).toEqual(message); + }); + it("reports extra and missing properties of the root-level object", function() { var actual = {x: 1}, expected = {a: 1}, @@ -303,10 +335,14 @@ describe("toEqual", function() { expect(compareEquals(actual, expected).message).toEqual(message); }); - it("uses custom object formatters to report objects with different constructors", function () { + it("uses custom object formatters for the value but not the type when reporting objects with different constructors", function () { function Foo() {} function Bar() {} - function formatter(x) { return '|' + x + '|'; } + function formatter(x) { + if (x instanceof Foo || x instanceof Bar) { + return '|' + x + '|'; + } + } var actual = {x: new Foo()}, expected = {x: new Bar()}, @@ -329,6 +365,11 @@ describe("toEqual", function() { expect(compareEquals(actual, expected).message).toEqual(message); }); + it("reports value mismatches at the root level", function() { + expect(compareEquals(1, 2).message).toEqual("Expected 1 to equal 2."); + }); + + it("reports mismatches between objects with their own constructor property", function () { function Foo() {} function Bar() {} @@ -839,7 +880,11 @@ describe("toEqual", function() { }); it("uses custom object formatters when the actual array is longer", function() { - function formatter(x) { return '|' + x + '|'; } + function formatter(x) { + if (typeof x === 'number') { + return '|' + x + '|'; + } + } var actual = [1, 1, 2, 3, 5], expected = [1, 1, 2, 3], diff --git a/src/core/PrettyPrinter.js b/src/core/PrettyPrinter.js index a15177ce..5faa3bbf 100644 --- a/src/core/PrettyPrinter.js +++ b/src/core/PrettyPrinter.js @@ -102,15 +102,7 @@ getJasmineRequireObj().makePrettyPrinter = function(j$) { }; SinglePrettyPrintRun.prototype.applyCustomFormatters_ = function(value) { - var i, result; - - for (i = 0; i < this.customObjectFormatters_.length; i++) { - result = this.customObjectFormatters_[i](value); - - if (result !== undefined) { - return result; - } - } + return customFormat(value, this.customObjectFormatters_); }; SinglePrettyPrintRun.prototype.iterateObject = function(obj, fn) { @@ -383,16 +375,31 @@ getJasmineRequireObj().makePrettyPrinter = function(j$) { return extraKeys; } + function customFormat(value, customObjectFormatters) { + var i, result; + + for (i = 0; i < customObjectFormatters.length; i++) { + result = customObjectFormatters[i](value); + + if (result !== undefined) { + return result; + } + } + } + return function(customObjectFormatters) { + customObjectFormatters = customObjectFormatters || []; + var pp = function(value) { - var prettyPrinter = new SinglePrettyPrintRun( - customObjectFormatters || [], - pp - ); + var prettyPrinter = new SinglePrettyPrintRun(customObjectFormatters, pp); prettyPrinter.format(value); return prettyPrinter.stringParts.join(''); }; + pp.customFormat_ = function(value) { + return customFormat(value, customObjectFormatters); + }; + return pp; }; }; diff --git a/src/core/matchers/DiffBuilder.js b/src/core/matchers/DiffBuilder.js index f5517e17..197e4c71 100644 --- a/src/core/matchers/DiffBuilder.js +++ b/src/core/matchers/DiffBuilder.js @@ -1,17 +1,51 @@ -getJasmineRequireObj().DiffBuilder = function(j$) { +getJasmineRequireObj().DiffBuilder = function (j$) { return function DiffBuilder(config) { - var path = new j$.ObjectPath(), - mismatches = [], - prettyPrinter = (config || {}).prettyPrinter || j$.makePrettyPrinter(); + var prettyPrinter = (config || {}).prettyPrinter || j$.makePrettyPrinter(), + mismatches = new j$.MismatchTree(), + path = new j$.ObjectPath(), + actualRoot = undefined, + expectedRoot = undefined; return { - record: function (actual, expected, formatter) { - formatter = formatter || defaultFormatter; - mismatches.push(formatter(actual, expected, path, prettyPrinter)); + setRoots: function (actual, expected) { + actualRoot = actual; + expectedRoot = expected; + }, + + recordMismatch: function (formatter) { + mismatches.add(path, formatter); }, getMessage: function () { - return mismatches.join('\n'); + var messages = []; + + mismatches.traverse(function (path, isLeaf, formatter) { + var actualCustom, expectedCustom, useCustom, + actual = path.dereference(actualRoot), + expected = path.dereference(expectedRoot); + + if (formatter) { + messages.push(formatter(actual, expected, path, prettyPrinter)); + return true; + } + + actualCustom = prettyPrinter.customFormat_(actual); + expectedCustom = prettyPrinter.customFormat_(expected); + useCustom = !(j$.util.isUndefined(actualCustom) && j$.util.isUndefined(expectedCustom)); + + if (useCustom) { + messages.push(wrapPrettyPrinted(actualCustom, expectedCustom, path)); + return false; // don't recurse further + } + + if (isLeaf) { + messages.push(defaultFormatter(actual, expected, path, prettyPrinter)); + } + + return true; + }); + + return messages.join('\n'); }, withPath: function (pathComponent, block) { @@ -22,12 +56,16 @@ getJasmineRequireObj().DiffBuilder = function(j$) { } }; - function defaultFormatter (actual, expected, path, prettyPrinter) { + function defaultFormatter(actual, expected, path, prettyPrinter) { + return wrapPrettyPrinted(prettyPrinter(actual), prettyPrinter(expected), path); + } + + function wrapPrettyPrinted(actual, expected, path) { return 'Expected ' + path + (path.depth() ? ' = ' : '') + - prettyPrinter(actual) + + actual + ' to equal ' + - prettyPrinter(expected) + + expected + '.'; } }; diff --git a/src/core/matchers/MismatchTree.js b/src/core/matchers/MismatchTree.js new file mode 100644 index 00000000..1ce356de --- /dev/null +++ b/src/core/matchers/MismatchTree.js @@ -0,0 +1,62 @@ +getJasmineRequireObj().MismatchTree = function (j$) { + + /* + To be able to apply custom object formatters at all possible levels of an + object graph, DiffBuilder needs to be able to know not just where the + mismatch occurred but also all ancestors of the mismatched value in both + the expected and actual object graphs. MismatchTree maintains that context + and provides it via the traverse method. + */ + function MismatchTree(path) { + this.path = path || new j$.ObjectPath([]); + this.formatter = undefined; + this.children = []; + this.isMismatch = false; + } + + MismatchTree.prototype.add = function (path, formatter) { + var key, child; + + if (path.depth() === 0) { + this.formatter = formatter; + this.isMismatch = true; + } else { + key = path.components[0]; + path = path.shift(); + child = this.child(key); + + if (!child) { + child = new MismatchTree(this.path.add(key)); + this.children.push(child); + } + + child.add(path, formatter); + } + }; + + MismatchTree.prototype.traverse = function (visit) { + var i, hasChildren = this.children.length > 0; + + if (this.isMismatch || hasChildren) { + if (visit(this.path, !hasChildren, this.formatter)) { + for (i = 0; i < this.children.length; i++) { + this.children[i].traverse(visit); + } + } + } + }; + + MismatchTree.prototype.child = function(key) { + var i, pathEls; + + for (i = 0; i < this.children.length; i++) { + pathEls = this.children[i].path.components; + if (pathEls[pathEls.length - 1] === key) { + return this.children[i]; + } + } + }; + + return MismatchTree; +}; + diff --git a/src/core/matchers/NullDiffBuilder.js b/src/core/matchers/NullDiffBuilder.js index de7d3464..cb6672e4 100644 --- a/src/core/matchers/NullDiffBuilder.js +++ b/src/core/matchers/NullDiffBuilder.js @@ -4,7 +4,8 @@ getJasmineRequireObj().NullDiffBuilder = function(j$) { withPath: function(_, block) { block(); }, - record: function() {} + setRoots: function() {}, + recordMismatch: function() {} }; }; }; diff --git a/src/core/matchers/ObjectPath.js b/src/core/matchers/ObjectPath.js index cd1629e2..266e8e62 100644 --- a/src/core/matchers/ObjectPath.js +++ b/src/core/matchers/ObjectPath.js @@ -11,10 +11,24 @@ getJasmineRequireObj().ObjectPath = function(j$) { } }; + ObjectPath.prototype.dereference = function(obj) { + var i; + + for (i = 0; i < this.components.length; i++) { + obj = obj[this.components[i]]; + } + + return obj; + }; + ObjectPath.prototype.add = function(component) { return new ObjectPath(this.components.concat([component])); }; + ObjectPath.prototype.shift = function() { + return new ObjectPath(this.components.slice(1)); + }; + ObjectPath.prototype.depth = function() { return this.components.length; }; diff --git a/src/core/matchers/matchersUtil.js b/src/core/matchers/matchersUtil.js index f38b9185..bd7664a0 100644 --- a/src/core/matchers/matchersUtil.js +++ b/src/core/matchers/matchersUtil.js @@ -69,7 +69,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (asymmetricA) { result = a.asymmetricMatch(b, shim); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } @@ -77,7 +77,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (asymmetricB) { result = b.asymmetricMatch(a, shim); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } @@ -95,6 +95,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { customTesters = customTesters || this.customTesters_; diffBuilder = diffBuilder || j$.NullDiffBuilder(); + diffBuilder.setRoots(a, b); return this.eq_(a, b, [], [], customTesters, diffBuilder); }; @@ -113,7 +114,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { var customTesterResult = customTesters[i](a, b); if (!j$.util.isUndefined(customTesterResult)) { if (!customTesterResult) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return customTesterResult; } @@ -122,7 +123,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (a instanceof Error && b instanceof Error) { result = a.message == b.message; if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } @@ -132,7 +133,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (a === b) { result = a !== 0 || 1 / a == 1 / b; if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } @@ -140,13 +141,13 @@ getJasmineRequireObj().MatchersUtil = function(j$) { if (a === null || b === null) { result = a === b; if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } var className = Object.prototype.toString.call(a); if (className != Object.prototype.toString.call(b)) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } switch (className) { @@ -156,7 +157,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // equivalent to `new String("5")`. result = a == String(b); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; case '[object Number]': @@ -164,7 +165,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // other numeric values. result = a != +a ? b != +b : (a === 0 ? 1 / a == 1 / b : a == +b); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; case '[object Date]': @@ -174,7 +175,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // of `NaN` are not equivalent. result = +a == +b; if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; // RegExps are compared by their source patterns and flags. @@ -185,7 +186,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { a.ignoreCase == b.ignoreCase; } if (typeof a != 'object' || typeof b != 'object') { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } @@ -195,12 +196,12 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // At first try to use DOM3 method isEqualNode result = a.isEqualNode(b); if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); } return result; } if (aIsDomNode || bIsDomNode) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } @@ -230,7 +231,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { diffBuilder.withPath('length', function() { if (aLength !== bLength) { - diffBuilder.record(aLength, bLength); + diffBuilder.recordMismatch(); result = false; } }); @@ -238,7 +239,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { for (i = 0; i < aLength || i < bLength; i++) { diffBuilder.withPath(i, function() { if (i >= bLength) { - diffBuilder.record(a[i], void 0, actualArrayIsLongerFormatter.bind(null, self.pp)); + diffBuilder.recordMismatch(actualArrayIsLongerFormatter.bind(null, self.pp)); result = false; } else { result = self.eq_(i < aLength ? a[i] : void 0, i < bLength ? b[i] : void 0, aStack, bStack, customTesters, diffBuilder) && result; @@ -250,7 +251,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { } } else if (j$.isMap(a) && j$.isMap(b)) { if (a.size != b.size) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } @@ -292,12 +293,12 @@ getJasmineRequireObj().MatchersUtil = function(j$) { } if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } } else if (j$.isSet(a) && j$.isSet(b)) { if (a.size != b.size) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } @@ -342,7 +343,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { } if (!result) { - diffBuilder.record(a, b); + diffBuilder.recordMismatch(); return false; } } else { @@ -355,7 +356,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { a instanceof aCtor && b instanceof bCtor && !(aCtor instanceof aCtor && bCtor instanceof bCtor)) { - diffBuilder.record(a, b, constructorsAreDifferentFormatter.bind(null, this.pp)); + diffBuilder.recordMismatch(constructorsAreDifferentFormatter.bind(null, this.pp)); return false; } } @@ -366,7 +367,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { // 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.bind(null, this.pp)); + diffBuilder.recordMismatch(objectKeysAreDifferentFormatter.bind(null, this.pp)); return false; } @@ -374,7 +375,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { key = aKeys[i]; // Deep compare each member if (!j$.util.has(b, key)) { - diffBuilder.record(a, b, objectKeysAreDifferentFormatter.bind(null, this.pp)); + diffBuilder.recordMismatch(objectKeysAreDifferentFormatter.bind(null, this.pp)); result = false; continue; } @@ -480,7 +481,7 @@ getJasmineRequireObj().MatchersUtil = function(j$) { } function isDiffBuilder(obj) { - return obj && typeof obj.record === 'function'; + return obj && typeof obj.recordMismatch === 'function'; } return MatchersUtil; diff --git a/src/core/requireCore.js b/src/core/requireCore.js index a21044ff..0a30f424 100644 --- a/src/core/requireCore.js +++ b/src/core/requireCore.js @@ -84,6 +84,7 @@ var getJasmineRequireObj = (function(jasmineGlobal) { j$.DiffBuilder = jRequire.DiffBuilder(j$); j$.NullDiffBuilder = jRequire.NullDiffBuilder(j$); j$.ObjectPath = jRequire.ObjectPath(j$); + j$.MismatchTree = jRequire.MismatchTree(j$); j$.GlobalErrors = jRequire.GlobalErrors(j$); j$.Truthy = jRequire.Truthy(j$);