From ae51d2290993b41b40c1cc8deb40d9dfc804f9a7 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Sat, 8 Jul 2017 14:48:32 +0100 Subject: [PATCH] Simplify pointer implementation Before this commit, Concordance attempted to determine whether a complex value was reused in the same location on either side of a comparison. It did this by detecting value reuse and emitting a Pointer descriptor instead. This required lookups to be maintained, to map pointers back to previously seen descriptors. It turned out that maintaining these lookups is hard, and there were various code paths where Concordance should have added descriptors to the lookup tables but didn't. Furthermore, pointer indexes are simple integer increments, which means that the same value in either side of the comparison may actually have a different index. This breaks the comparison logic. https://github.com/avajs/ava/issues/1431 shows some of the issues that arose from this approach. This commit simplifies the implementation. When describing, if the same value is encountered then the first descriptor is emitted. The serialization logic tracks complex descriptors by their pointer index and serializes a pointer descriptor if reuse is detected. Deserialization ensures that the first descriptor is emitted, rather than the pointer. Circular references are now tracked for each side of the comparison. A stack is maintained, and if at any stage in the comparison a circular reference is detected, then the result of that comparison is only equal if both sides have the same circular reference. Which is a long way of saying that some circular references are considered equal, and others are not. This shouldn't matter much for real-life uses of Concordance, though it is a breaking change. --- README.md | 3 + lib/Circular.js | 28 +++++++- lib/Registry.js | 11 ++- lib/compare.js | 46 ++++--------- lib/describe.js | 9 ++- lib/diff.js | 110 ++++++++++++------------------ lib/format.js | 19 ++---- lib/metaDescriptors/item.js | 16 +---- lib/metaDescriptors/pointer.js | 21 +++--- lib/metaDescriptors/property.js | 16 +---- lib/serialize.js | 39 ++++++++++- test/lodash-isequal-comparison.js | 8 ++- test/snapshots/diff.js.md | 39 +++++------ test/snapshots/diff.js.snap | Bin 4467 -> 4471 bytes 14 files changed, 176 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index 866cb11..cb66b3a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ means Concordance's behavior is consistent, no matter how you use it. Symbol properties are compared by identity. * `Promise` values are compared by identity only. * `Symbol` values are compared by identity only. +* Recursion stops whenever a circular reference is encountered. If the same + cycle is present in the actual and expected values they're considered equal, + but they're unequal otherwise. ### Formatting details diff --git a/lib/Circular.js b/lib/Circular.js index ae749e6..c5c0202 100644 --- a/lib/Circular.js +++ b/lib/Circular.js @@ -1,11 +1,35 @@ 'use strict' -class Circular extends Set { +class Circular { + constructor () { + this.stack = new Map() + } + add (descriptor) { + if (this.stack.has(descriptor)) throw new Error('Already in stack') + if (descriptor.isItem !== true && descriptor.isMapEntry !== true && descriptor.isProperty !== true) { - super.add(descriptor) + this.stack.set(descriptor, this.stack.size + 1) } return this } + + delete (descriptor) { + if (this.stack.has(descriptor)) { + if (this.stack.get(descriptor) !== this.stack.size) throw new Error('Not on top of stack') + this.stack.delete(descriptor) + } + return this + } + + has (descriptor) { + return this.stack.has(descriptor) + } + + get (descriptor) { + return this.stack.has(descriptor) + ? this.stack.get(descriptor) + : 0 + } } module.exports = Circular diff --git a/lib/Registry.js b/lib/Registry.js index d7b56f1..f4a4b68 100644 --- a/lib/Registry.js +++ b/lib/Registry.js @@ -1,7 +1,5 @@ 'use strict' -const describePointer = require('./metaDescriptors/pointer').describe - class Registry { constructor () { this.counter = 0 @@ -13,12 +11,13 @@ class Registry { } get (value) { - return this.map.get(value) + return this.map.get(value).descriptor } - add (value) { - const pointer = this.counter++ - this.map.set(value, describePointer(pointer)) + alloc (value) { + const index = ++this.counter + const pointer = {descriptor: null, index} + this.map.set(value, pointer) return pointer } } diff --git a/lib/compare.js b/lib/compare.js index 614949f..7e24228 100644 --- a/lib/compare.js +++ b/lib/compare.js @@ -23,9 +23,8 @@ function shortcircuitPrimitive (value) { } function compareDescriptors (lhs, rhs) { - const circular = new Circular() - const lhsLookup = new Map() - const rhsLookup = new Map() + const lhsCircular = new Circular() + const rhsCircular = new Circular() const lhsStack = [] const rhsStack = [] @@ -33,30 +32,13 @@ function compareDescriptors (lhs, rhs) { do { let result - - if (lhs.isComplex === true) { - lhsLookup.set(lhs.pointer, lhs) - } - if (rhs.isComplex === true) { - rhsLookup.set(rhs.pointer, rhs) - } - - if (lhs.isPointer === true) { - if (rhs.isPointer === true && lhs.compare(rhs) === DEEP_EQUAL) { - result = DEEP_EQUAL - } else { - lhs = lhsLookup.get(lhs.pointer) - } - } - if (rhs.isPointer === true) { - rhs = rhsLookup.get(rhs.pointer) - } - - if (circular.has(lhs) && circular.has(rhs)) { + if (lhsCircular.has(lhs)) { + result = lhsCircular.get(lhs) === rhsCircular.get(rhs) + ? DEEP_EQUAL + : UNEQUAL + } else if (rhsCircular.has(rhs)) { result = UNEQUAL - } - - if (!result) { + } else { result = lhs.compare(rhs) } @@ -76,11 +58,11 @@ function compareDescriptors (lhs, rhs) { rhsStack[topIndex].recursor = recursorUtils.unshift(rhsStack[topIndex].recursor, rhs.collectAll()) } - circular.add(lhs) - circular.add(rhs) + lhsCircular.add(lhs) + rhsCircular.add(rhs) - lhsStack.push({ origin: lhs, recursor: lhs.createRecursor() }) - rhsStack.push({ origin: rhs, recursor: rhs.createRecursor() }) + lhsStack.push({ subject: lhs, recursor: lhs.createRecursor() }) + rhsStack.push({ subject: rhs, recursor: rhs.createRecursor() }) topIndex++ } @@ -94,8 +76,8 @@ function compareDescriptors (lhs, rhs) { if (lhs === null && rhs === null) { const lhsRecord = lhsStack.pop() const rhsRecord = rhsStack.pop() - circular.delete(lhsRecord.origin) - circular.delete(rhsRecord.origin) + lhsCircular.delete(lhsRecord.subject) + rhsCircular.delete(rhsRecord.subject) topIndex-- } else { return false diff --git a/lib/describe.js b/lib/describe.js index f1f6f7e..a8aa945 100644 --- a/lib/describe.js +++ b/lib/describe.js @@ -99,7 +99,7 @@ function describeComplex (value, registry, tryPlugins, describeAny, describeItem const stringTag = getStringTag(value) const ctor = getCtor(stringTag, value) - const pointer = registry.add(value) + const pointer = registry.alloc(value) let unboxed let describeValue = tryPlugins(value, stringTag, ctor) @@ -115,17 +115,20 @@ function describeComplex (value, registry, tryPlugins, describeAny, describeItem } } } - return describeValue({ + + const descriptor = describeValue({ ctor, describeAny, describeItem, describeMapEntry, describeProperty, - pointer, + pointer: pointer.index, stringTag, unboxed, value }) + pointer.descriptor = descriptor + return descriptor } function describe (value, options) { diff --git a/lib/diff.js b/lib/diff.js index ce85517..5fe4d22 100644 --- a/lib/diff.js +++ b/lib/diff.js @@ -59,9 +59,8 @@ function diffDescriptors (lhs, rhs, options) { const theme = themeUtils.normalize(options) const invert = options ? options.invert === true : false - const circular = new Circular() - const lhsLookup = new Map() - const rhsLookup = new Map() + const lhsCircular = new Circular() + const rhsCircular = new Circular() const maxDepth = (options && options.maxDepth) || 0 let indent = new Indenter(0, ' ') @@ -74,9 +73,9 @@ function diffDescriptors (lhs, rhs, options) { const diffStack = [] let diffIndex = -1 - const isCircular = descriptor => circular.has(descriptor) + const isCircular = descriptor => lhsCircular.has(descriptor) || rhsCircular.has(descriptor) - const format = (builder, subject, lookup) => { + const format = (builder, subject, circular) => { if (diffIndex >= 0 && !diffStack[diffIndex].shouldFormat(subject)) return if (circular.has(subject)) { @@ -88,17 +87,8 @@ function diffDescriptors (lhs, rhs, options) { let formatIndex = -1 do { - if (subject.isComplex === true) { - lookup.set(subject.pointer, subject) - } - - const origin = subject - if (subject.isPointer === true) { - subject = lookup.get(subject.pointer) - } - if (circular.has(subject)) { - formatStack[formatIndex].formatter.append(builder.single(theme.circular), origin) + formatStack[formatIndex].formatter.append(builder.single(theme.circular), subject) } else { let didFormat = false if (typeof subject.formatDeep === 'function') { @@ -111,10 +101,10 @@ function diffDescriptors (lhs, rhs, options) { if (diffIndex === -1) { buffer.append(formatted) } else { - diffStack[diffIndex].formatter.append(formatted, origin) + diffStack[diffIndex].formatter.append(formatted, subject) } } else { - formatStack[formatIndex].formatter.append(formatted, origin) + formatStack[formatIndex].formatter.append(formatted, subject) } } } @@ -131,14 +121,13 @@ function diffDescriptors (lhs, rhs, options) { if (formatIndex === -1) { formatted = builder.setDefaultGutter(formatted) - diffStack[diffIndex].formatter.append(formatted, origin) + diffStack[diffIndex].formatter.append(formatted, subject) } else { - formatStack[formatIndex].formatter.append(formatted, origin) + formatStack[formatIndex].formatter.append(formatted, subject) } } else { formatStack.push({ formatter, - origin, recursor, decreaseIndent: formatter.increaseIndent, shouldFormat: formatter.shouldFormat || alwaysFormat, @@ -172,37 +161,24 @@ function diffDescriptors (lhs, rhs, options) { if (diffIndex === -1) { buffer.append(formatted) } else { - diffStack[diffIndex].formatter.append(formatted, record.origin) + diffStack[diffIndex].formatter.append(formatted, record.subject) } } else { - formatStack[formatIndex].formatter.append(formatted, record.origin) + formatStack[formatIndex].formatter.append(formatted, record.subject) } } } while (formatIndex >= 0) } do { - if (lhs.isComplex === true) { - lhsLookup.set(lhs.pointer, lhs) - } - if (rhs.isComplex === true) { - rhsLookup.set(rhs.pointer, rhs) - } - - let equalPointers = false - const lhsOrigin = lhs - if (lhs.isPointer === true) { - if (rhs.isPointer === true && lhs.compare(rhs) === DEEP_EQUAL) { - equalPointers = true - } else { - lhs = lhsLookup.get(lhs.pointer) - } - } - if (rhs.isPointer === true) { - rhs = rhsLookup.get(rhs.pointer) - } - let compareResult = NOOP + if (lhsCircular.has(lhs)) { + compareResult = lhsCircular.get(lhs) === rhsCircular.get(rhs) + ? DEEP_EQUAL + : UNEQUAL + } else if (rhsCircular.has(rhs)) { + compareResult = UNEQUAL + } let firstPassSymbolProperty = false if (lhs.isProperty === true) { @@ -215,7 +191,7 @@ function diffDescriptors (lhs, rhs, options) { let didFormat = false let mustRecurse = false - if (!equalPointers && !firstPassSymbolProperty && typeof lhs.prepareDiff === 'function') { + if (compareResult !== DEEP_EQUAL && !firstPassSymbolProperty && typeof lhs.prepareDiff === 'function') { const lhsRecursor = topIndex === -1 ? null : lhsStack[topIndex].recursor const rhsRecursor = topIndex === -1 ? null : rhsStack[topIndex].recursor @@ -243,24 +219,24 @@ function diffDescriptors (lhs, rhs, options) { mustRecurse = true } else { if (instructions.actualIsExtraneous === true) { - format(lineBuilder.actual, lhs, lhsLookup) + format(lineBuilder.actual, lhs, lhsCircular) didFormat = true } else if (instructions.multipleAreExtraneous === true) { for (const extraneous of instructions.descriptors) { - format(lineBuilder.actual, extraneous, lhsLookup) + format(lineBuilder.actual, extraneous, lhsCircular) } didFormat = true } else if (instructions.expectedIsMissing === true) { - format(lineBuilder.expected, rhs, rhsLookup) + format(lineBuilder.expected, rhs, rhsCircular) didFormat = true } else if (instructions.multipleAreMissing === true) { for (const missing of instructions.descriptors) { - format(lineBuilder.expected, missing, rhsLookup) + format(lineBuilder.expected, missing, rhsCircular) } didFormat = true } else if (instructions.isUnequal === true) { - format(lineBuilder.actual, lhs, lhsLookup) - format(lineBuilder.expected, rhs, rhsLookup) + format(lineBuilder.actual, lhs, lhsCircular) + format(lineBuilder.expected, rhs, rhsCircular) didFormat = true } else if (!instructions.compareResult) { // TODO: Throw a useful, custom error @@ -272,9 +248,7 @@ function diffDescriptors (lhs, rhs, options) { if (!didFormat) { if (compareResult === NOOP) { - compareResult = equalPointers - ? DEEP_EQUAL - : lhs.compare(rhs) + compareResult = lhs.compare(rhs) } if (!mustRecurse) { @@ -282,7 +256,7 @@ function diffDescriptors (lhs, rhs, options) { } if (compareResult === DEEP_EQUAL) { - format(lineBuilder, lhs, lhsLookup) + format(lineBuilder, lhs, lhsCircular) } else if (mustRecurse) { if (compareResult === AMBIGUOUS && lhs.isProperty === true) { // Replace both sides by a pseudo-descriptor which collects symbol @@ -300,7 +274,7 @@ function diffDescriptors (lhs, rhs, options) { const formatter = lhs.diffShallow(rhs, themeUtils.applyModifiers(lhs, theme), indent) diffStack.push({ formatter, - origin: lhsOrigin, + origin: lhs, decreaseIndent: formatter.increaseIndent, exceedsMaxDepth: formatter.increaseIndent && maxDepth > 0 && indent.level >= maxDepth, shouldFormat: formatter.shouldFormat || alwaysFormat @@ -312,18 +286,18 @@ function diffDescriptors (lhs, rhs, options) { const formatter = lhs.formatShallow(themeUtils.applyModifiers(lhs, theme), indent) diffStack.push({ formatter, - origin: lhsOrigin, decreaseIndent: formatter.increaseIndent, exceedsMaxDepth: formatter.increaseIndent && maxDepth > 0 && indent.level >= maxDepth, - shouldFormat: formatter.shouldFormat || alwaysFormat + shouldFormat: formatter.shouldFormat || alwaysFormat, + subject: lhs }) diffIndex++ if (formatter.increaseIndent) indent = indent.increase() } - circular.add(lhs) - circular.add(rhs) + lhsCircular.add(lhs) + rhsCircular.add(rhs) lhsStack.push({ diffIndex, subject: lhs, recursor: lhs.createRecursor() }) rhsStack.push({ diffIndex, subject: rhs, recursor: rhs.createRecursor() }) @@ -334,13 +308,13 @@ function diffDescriptors (lhs, rhs, options) { : null if (diffed === null) { - format(lineBuilder.actual, lhs, lhsLookup) - format(lineBuilder.expected, rhs, rhsLookup) + format(lineBuilder.actual, lhs, lhsCircular) + format(lineBuilder.expected, rhs, rhsCircular) } else { if (diffIndex === -1) { buffer.append(diffed) } else { - diffStack[diffIndex].formatter.append(diffed, lhsOrigin) + diffStack[diffIndex].formatter.append(diffed, lhs) } } } @@ -357,8 +331,8 @@ function diffDescriptors (lhs, rhs, options) { if (lhs === null && rhs === null) { const lhsRecord = lhsStack.pop() const rhsRecord = rhsStack.pop() - circular.delete(lhsRecord.subject) - circular.delete(rhsRecord.subject) + lhsCircular.delete(lhsRecord.subject) + rhsCircular.delete(rhsRecord.subject) topIndex-- if (lhsRecord.diffIndex === diffIndex) { @@ -382,25 +356,25 @@ function diffDescriptors (lhs, rhs, options) { if (diffIndex === -1) { buffer.append(formatted) } else { - diffStack[diffIndex].formatter.append(formatted, record.origin) + diffStack[diffIndex].formatter.append(formatted, record.subject) } } } else { - let builder, lookup, stack, subject + let builder, circular, stack, subject if (lhs === null) { builder = lineBuilder.expected - lookup = rhsLookup + circular = rhsCircular stack = rhsStack subject = rhs } else { builder = lineBuilder.actual - lookup = lhsLookup + circular = lhsCircular stack = lhsStack subject = lhs } do { - format(builder, subject, lookup) + format(builder, subject, circular) subject = stack[topIndex].recursor() } while (subject !== null) } diff --git a/lib/format.js b/lib/format.js index 17eb728..7629579 100644 --- a/lib/format.js +++ b/lib/format.js @@ -17,7 +17,6 @@ function formatDescriptor (subject, options) { } const circular = new Circular() - const lookup = new Map() const maxDepth = (options && options.maxDepth) || 0 let indent = fixedIndent @@ -27,17 +26,8 @@ function formatDescriptor (subject, options) { let topIndex = -1 do { - if (subject.isComplex === true) { - lookup.set(subject.pointer, subject) - } - - const origin = subject - if (subject.isPointer === true) { - subject = lookup.get(subject.pointer) - } - if (circular.has(subject)) { - stack[topIndex].formatter.append(lineBuilder.single(theme.circular), origin) + stack[topIndex].formatter.append(lineBuilder.single(theme.circular), subject) } else { let didFormat = false if (typeof subject.formatDeep === 'function') { @@ -47,7 +37,7 @@ function formatDescriptor (subject, options) { if (topIndex === -1) { buffer.append(formatted) } else { - stack[topIndex].formatter.append(formatted, origin) + stack[topIndex].formatter.append(formatted, subject) } } } @@ -61,11 +51,10 @@ function formatDescriptor (subject, options) { const formatted = !isEmpty && typeof formatter.maxDepth === 'function' ? formatter.maxDepth() : formatter.finalize() - stack[topIndex].formatter.append(formatted, origin) + stack[topIndex].formatter.append(formatted, subject) } else { stack.push({ formatter, - origin, recursor, decreaseIndent: formatter.increaseIndent, shouldFormat: formatter.shouldFormat || alwaysFormat, @@ -97,7 +86,7 @@ function formatDescriptor (subject, options) { if (topIndex === -1) { buffer.append(formatted) } else { - stack[topIndex].formatter.append(formatted, record.origin) + stack[topIndex].formatter.append(formatted, record.subject) } } } while (topIndex >= 0) diff --git a/lib/metaDescriptors/item.js b/lib/metaDescriptors/item.js index 8592b4e..f8b8b0c 100644 --- a/lib/metaDescriptors/item.js +++ b/lib/metaDescriptors/item.js @@ -5,7 +5,6 @@ const formatUtils = require('../formatUtils') const recursorUtils = require('../recursorUtils') const DEEP_EQUAL = constants.DEEP_EQUAL -const SHALLOW_EQUAL = constants.SHALLOW_EQUAL const UNEQUAL = constants.UNEQUAL function describeComplex (index, value) { @@ -48,19 +47,8 @@ class ComplexItem { } compare (expected) { - if (expected.tag !== complexTag || this.index !== expected.index) return UNEQUAL - - const result = this.value.compare(expected.value) - if (result !== UNEQUAL) return result - - if (this.value.isPointer === true) { - return expected.value.isPointer === true - ? UNEQUAL - : SHALLOW_EQUAL - } - - return expected.value.isPointer === true - ? SHALLOW_EQUAL + return expected.tag === complexTag && this.index === expected.index + ? this.value.compare(expected.value) : UNEQUAL } diff --git a/lib/metaDescriptors/pointer.js b/lib/metaDescriptors/pointer.js index 63c8b45..f569d28 100644 --- a/lib/metaDescriptors/pointer.js +++ b/lib/metaDescriptors/pointer.js @@ -1,12 +1,9 @@ 'use strict' -const constants = require('../constants') +const UNEQUAL = require('../constants').UNEQUAL -const DEEP_EQUAL = constants.DEEP_EQUAL -const UNEQUAL = constants.UNEQUAL - -function describe (pointer) { - return new Pointer(pointer) +function describe (index) { + return new Pointer(index) } exports.describe = describe @@ -16,18 +13,18 @@ const tag = Symbol('Pointer') exports.tag = tag class Pointer { - constructor (pointer) { - this.pointer = pointer + constructor (index) { + this.index = index } + // Pointers cannot be compared, and are not expected to be part of the + // comparisons. compare (expected) { - return this.tag === expected.tag && this.pointer === expected.pointer - ? DEEP_EQUAL - : UNEQUAL + return UNEQUAL } serialize () { - return this.pointer + return this.index } } Object.defineProperty(Pointer.prototype, 'isPointer', { value: true }) diff --git a/lib/metaDescriptors/property.js b/lib/metaDescriptors/property.js index 6ad61dd..f9641fc 100644 --- a/lib/metaDescriptors/property.js +++ b/lib/metaDescriptors/property.js @@ -7,7 +7,6 @@ const symbolPrimitive = require('../primitiveValues/symbol').tag const AMBIGUOUS = constants.AMBIGUOUS const DEEP_EQUAL = constants.DEEP_EQUAL -const SHALLOW_EQUAL = constants.SHALLOW_EQUAL const UNEQUAL = constants.UNEQUAL function describeComplex (key, value) { @@ -103,19 +102,8 @@ class ComplexProperty extends Property { const keyResult = this.compareKeys(expected) if (keyResult !== DEEP_EQUAL) return keyResult - if (this.tag !== expected.tag) return UNEQUAL - - const result = this.value.compare(expected.value) - if (result !== UNEQUAL) return result - - if (this.value.isPointer === true) { - return expected.value.isPointer === true - ? UNEQUAL - : SHALLOW_EQUAL - } - - return expected.value.isPointer === true - ? SHALLOW_EQUAL + return this.tag === expected.tag + ? this.value.compare(expected.value) : UNEQUAL } diff --git a/lib/serialize.js b/lib/serialize.js index 321308b..07f5684 100644 --- a/lib/serialize.js +++ b/lib/serialize.js @@ -97,6 +97,14 @@ class MissingPluginError extends Error { } } +class PointerLookupError extends Error { + constructor (index) { + super(`Could not deserialize buffer: pointer ${index} could not be resolved`) + this.name = 'PointerLookupError' + this.index = index + } +} + class UnsupportedPluginError extends Error { constructor (pluginName, serializerVersion) { super(`Could not deserialize buffer: plugin ${JSON.stringify(pluginName)} expects a different serialization`) @@ -164,11 +172,21 @@ function serialize (descriptor) { } } + const seen = new Set() + const stack = [] let topIndex = -1 let rootRecord do { + if (descriptor.isComplex === true) { + if (seen.has(descriptor.pointer)) { + descriptor = pointerDescriptor.describe(descriptor.pointer) + } else { + seen.add(descriptor.pointer) + } + } + let id let pluginIndex = 0 if (tag2id.has(descriptor.tag)) { @@ -294,10 +312,27 @@ function deserialize (buffer, options) { const decoded = encoder.decode(buffer) const pluginMap = buildPluginMap(decoded.pluginBuffer, options) + + const descriptorsByPointerIndex = new Map() + const mapPointerDescriptor = descriptor => { + if (descriptor.isPointer === true) { + if (!descriptorsByPointerIndex.has(descriptor.index)) throw new PointerLookupError(descriptor.index) + + return descriptorsByPointerIndex.get(descriptor.index) + } else if (descriptor.isComplex === true) { + descriptorsByPointerIndex.set(descriptor.pointer, descriptor) + } + return descriptor + } + const getDescriptorDeserializer = (pluginIndex, id) => { - if (pluginIndex === 0) return id2deserialize.get(id) + return (state, recursor) => { + const deserializeDescriptor = pluginIndex === 0 + ? id2deserialize.get(id) + : pluginMap.get(pluginIndex).get(id) - return pluginMap.get(pluginIndex).get(id) + return mapPointerDescriptor(deserializeDescriptor(state, recursor)) + } } return deserializeRecord(decoded.rootRecord, getDescriptorDeserializer, buffer) } diff --git a/test/lodash-isequal-comparison.js b/test/lodash-isequal-comparison.js index ddc39f3..862430a 100644 --- a/test/lodash-isequal-comparison.js +++ b/test/lodash-isequal-comparison.js @@ -262,7 +262,9 @@ test('have transitive equivalence for circular references of arrays', t => { t.true(isEqual(array1, array2)) t.true(isEqual(array2, array3)) - t.true(isEqual(array1, array3)) + // Concordance detects a different circular reference in array1 before it does + // in array3, making them unequal. + t.false(isEqual(array1, array3)) }) test('compare objects with circular references', t => { @@ -300,7 +302,9 @@ test('have transitive equivalence for circular references of objects', t => { t.true(isEqual(object1, object2)) t.true(isEqual(object2, object3)) - t.true(isEqual(object1, object3)) + // Concordance detects a different circular reference in object1 before it + // does in object3, making them unequal. + t.false(isEqual(object1, object3)) }) test('compare objects with multiple circular references', t => { diff --git a/test/snapshots/diff.js.md b/test/snapshots/diff.js.md index 468c29f..2fb5de8 100644 --- a/test/snapshots/diff.js.md +++ b/test/snapshots/diff.js.md @@ -545,19 +545,20 @@ Generated by [AVA](https://ava.li). > Snapshot 1 `%diffGutters.padding# %%object.openBracket#{%␊ - %diffGutters.padding# % obj%property.separator#: %%object.openBracket#{%␊ - %diffGutters.padding# % obj%property.separator#: %%circular#[Circular]%%property.after#,%␊ - %diffGutters.padding# % %object.closeBracket#}%%property.after#,%␊ + %diffGutters.actual#- % obj%property.separator#: %%object.openBracket#{%␊ + %diffGutters.actual#- % obj%property.separator#: %%circular#[Circular]%%property.after#,%␊ + %diffGutters.actual#- % %object.closeBracket#}%%property.after#,%␊ + %diffGutters.expected#+ % obj%property.separator#: %%circular#[Circular]%%property.after#,%␊ %diffGutters.padding# %%object.closeBracket#}%` > Snapshot 2 `%diffGutters.padding# %%object.openBracket#{%␊ %diffGutters.padding# % obj%property.separator#: %%object.openBracket#{%␊ - %diffGutters.padding# % obj%property.separator#: %%object.openBracket#{%␊ - %diffGutters.actual#- % obj%property.separator#: %%circular#[Circular]%%property.after#,%␊ + %diffGutters.actual#- % obj%property.separator#: %%circular#[Circular]%%property.after#,%␊ + %diffGutters.expected#+ % obj%property.separator#: %%object.openBracket#{%␊ %diffGutters.expected#+ % obj%property.separator#: %%circular#[Circular]%%property.after#,%␊ - %diffGutters.padding# % %object.closeBracket#}%%property.after#,%␊ + %diffGutters.expected#+ % %object.closeBracket#}%%property.after#,%␊ %diffGutters.padding# % %object.closeBracket#}%%property.after#,%␊ %diffGutters.padding# %%object.closeBracket#}%` @@ -565,10 +566,10 @@ Generated by [AVA](https://ava.li). `%diffGutters.padding# %%list.openBracket#[%␊ %diffGutters.padding# % %list.openBracket#[%␊ - %diffGutters.padding# % %list.openBracket#[%␊ - %diffGutters.actual#- % %circular#[Circular]%%item.after#,%␊ + %diffGutters.actual#- % %circular#[Circular]%%item.after#,%␊ + %diffGutters.expected#+ % %list.openBracket#[%␊ %diffGutters.expected#+ % %circular#[Circular]%%item.after#,%␊ - %diffGutters.padding# % %list.closeBracket#]%%item.after#,%␊ + %diffGutters.expected#+ % %list.closeBracket#]%%item.after#,%␊ %diffGutters.padding# % %list.closeBracket#]%%item.after#,%␊ %diffGutters.padding# %%list.closeBracket#]%` @@ -576,10 +577,10 @@ Generated by [AVA](https://ava.li). `%diffGutters.padding# %%object.ctor.open%Map%object.ctor.close% %object.openBracket#{%␊ %diffGutters.padding# % %string.line.open#'%%string.open%map%string.close%%string.line.close#'%%mapEntry.separator# => %%object.ctor.open%Map%object.ctor.close% %object.openBracket#{%␊ - %diffGutters.padding# % %string.line.open#'%%string.open%map%string.close%%string.line.close#'%%mapEntry.separator# => %%object.ctor.open%Map%object.ctor.close% %object.openBracket#{%␊ - %diffGutters.actual#- % %string.line.open#'%%string.open%map%string.close%%string.line.close#'%%mapEntry.separator# => %%circular#[Circular]%%mapEntry.after#,%␊ + %diffGutters.actual#- % %string.line.open#'%%string.open%map%string.close%%string.line.close#'%%mapEntry.separator# => %%circular#[Circular]%%mapEntry.after#,%␊ + %diffGutters.expected#+ % %string.line.open#'%%string.open%map%string.close%%string.line.close#'%%mapEntry.separator# => %%object.ctor.open%Map%object.ctor.close% %object.openBracket#{%␊ %diffGutters.expected#+ % %string.line.open#'%%string.open%map%string.close%%string.line.close#'%%mapEntry.separator# => %%circular#[Circular]%%mapEntry.after#,%␊ - %diffGutters.padding# % %object.closeBracket#}%%mapEntry.after#,%␊ + %diffGutters.expected#+ % %object.closeBracket#}%%mapEntry.after#,%␊ %diffGutters.padding# % %object.closeBracket#}%%mapEntry.after#,%␊ %diffGutters.padding# %%object.closeBracket#}%` @@ -589,16 +590,16 @@ Generated by [AVA](https://ava.li). %diffGutters.padding# % %object.openBracket#{%␊ %diffGutters.padding# % key%property.separator#: %%boolean.open%true%boolean.close%%property.after#,%␊ %diffGutters.padding# % %object.closeBracket#}%%mapEntry.separator# => %%object.ctor.open%Map%object.ctor.close% %object.openBracket#{%␊ - %diffGutters.padding# % %object.openBracket#{%␊ - %diffGutters.padding# % key%property.separator#: %%boolean.open%true%boolean.close%%property.after#,%␊ - %diffGutters.padding# % %object.closeBracket#}%%mapEntry.separator# => %%object.ctor.open%Map%object.ctor.close% %object.openBracket#{%␊ - %diffGutters.actual#- % %object.openBracket#{%␊ - %diffGutters.actual#- % key%property.separator#: %%boolean.open%true%boolean.close%%property.after#,%␊ - %diffGutters.actual#- % %object.closeBracket#}%%mapEntry.separator# => %%circular#[Circular]%%mapEntry.after#,%␊ + %diffGutters.actual#- % %object.openBracket#{%␊ + %diffGutters.actual#- % key%property.separator#: %%boolean.open%true%boolean.close%%property.after#,%␊ + %diffGutters.actual#- % %object.closeBracket#}%%mapEntry.separator# => %%circular#[Circular]%%mapEntry.after#,%␊ + %diffGutters.expected#+ % %object.openBracket#{%␊ + %diffGutters.expected#+ % key%property.separator#: %%boolean.open%true%boolean.close%%property.after#,%␊ + %diffGutters.expected#+ % %object.closeBracket#}%%mapEntry.separator# => %%object.ctor.open%Map%object.ctor.close% %object.openBracket#{%␊ %diffGutters.expected#+ % %object.openBracket#{%␊ %diffGutters.expected#+ % key%property.separator#: %%boolean.open%true%boolean.close%%property.after#,%␊ %diffGutters.expected#+ % %object.closeBracket#}%%mapEntry.separator# => %%circular#[Circular]%%mapEntry.after#,%␊ - %diffGutters.padding# % %object.closeBracket#}%%mapEntry.after#,%␊ + %diffGutters.expected#+ % %object.closeBracket#}%%mapEntry.after#,%␊ %diffGutters.padding# % %object.closeBracket#}%%mapEntry.after#,%␊ %diffGutters.padding# %%object.closeBracket#}%` diff --git a/test/snapshots/diff.js.snap b/test/snapshots/diff.js.snap index cb57f1e2ef6ef2a251b9949434caf8655a07e350..288ce034ec4808999ab0fd3fd9daaca6f46e73ec 100644 GIT binary patch literal 4471 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 J5^KTx002o@uipRw literal 4467 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 F002lbyU_pu