diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index 535c3484bfea..7176c319e3bc 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -4,6 +4,7 @@ const { Array, ArrayBuffer, ArrayIsArray, + ArrayPrototypeFill, ArrayPrototypePush, BigInt, BigInt64Array, @@ -638,6 +639,82 @@ function mapMightHaveLoosePrim(a, b, prim, item2, memo) { return !b.has(altValue) && innerDeepEqual(item1, item2, kLoose, memo); } +function partialCollectionEquiv(a, b, memo, iterationType) { + const valuesA = []; + const valuesB = []; + // Primitive keys and members were handled by the caller. Shared Set members + // are already matched, but shared Map keys may have different values. + for (const value of a) { + const key = iterationType === kIsMap ? value[0] : value; + if (typeof key === 'object' && key !== null && + (iterationType === kIsMap || !b.has(key))) { + ArrayPrototypePush(valuesA, value); + } + } + for (const value of iterationType === kIsMap ? b : b.values()) { + const key = iterationType === kIsMap ? value[0] : value; + if (typeof key === 'object' && key !== null && + (iterationType === kIsMap || !a.has(key))) { + ArrayPrototypePush(valuesB, value); + } + } + if (valuesA.length < valuesB.length) { + return false; + } + const matched = new Uint32Array(valuesA.length); + const visited = new Uint32Array(valuesA.length); + const parents = new Uint32Array(valuesB.length); + const paths = new Uint32Array(valuesB.length); + const comparisons = ArrayPrototypeFill(new Array(valuesB.length), undefined); + + // Partial matches can overlap. If greedy matching fails, find augmenting + // paths to reassign earlier matches, comparing each pair at most once. + for (let i = 0; i < valuesB.length; i++) { + const queue = [i]; + let found = false; + for (let j = 0; j < queue.length && !found; j++) { + const current = queue[j]; + const val2 = valuesB[current]; + const results = comparisons[current] ??= new Uint8Array(valuesA.length); + for (let k = 0; k < valuesA.length; k++) { + if (visited[k] === i + 1) { + continue; + } + if (results[k] === 0) { + const val1 = valuesA[k]; + const equal = iterationType === kIsMap ? + innerDeepEqual(val1[0], val2[0], kPartial, memo) && + innerDeepEqual(val1[1], val2[1], kPartial, memo) : + innerDeepEqual(val1, val2, kPartial, memo); + results[k] = equal ? 2 : 1; + } + if (results[k] === 1) { + continue; + } + visited[k] = i + 1; + if (matched[k] === 0) { + matched[k] = current + 1; + let previous = current; + while (previous !== i) { + matched[paths[previous]] = parents[previous] + 1; + previous = parents[previous]; + } + found = true; + break; + } + const next = matched[k] - 1; + parents[next] = current; + paths[next] = k; + ArrayPrototypePush(queue, next); + } + } + if (!found) { + return false; + } + } + return true; +} + function partialObjectSetEquiv(array, a, b, mode, memo) { let aPos = 0; let direction = 1; @@ -745,7 +822,13 @@ function compareSmallSets(a, b, val, iteratorB, mode, memo) { return true; } const secondA = iteratorA.next().value; - return b.has(secondA) || innerDeepEqual(secondA, iteratorB.next().value, mode, memo); + if (b.has(secondA)) { + return true; + } + const secondB = iteratorB.next().value; + return innerDeepEqual(secondA, secondB, mode, memo) || + (mode === kPartial && innerDeepEqual(secondA, val, mode, memo) && + (b.has(firstA) || innerDeepEqual(firstA, secondB, mode, memo))); } return a.size !== 1 && innerDeepEqual(iteratorA.next().value, val, mode, memo) && ( b.size === 1 || // Partial mode @@ -784,7 +867,8 @@ function setEquiv(a, b, mode, memo) { return true; } if (mode === kPartial) { - return partialObjectSetEquiv(array, a, b, mode, memo); + return partialObjectSetEquiv(array, a, b, mode, memo) || + partialCollectionEquiv(a, b, memo, kIsSet); } return setObjectEquiv(array, a, b, mode, memo); } @@ -932,7 +1016,8 @@ function mapEquiv(a, b, mode, memo) { } if (mode === kPartial) { - return partialObjectMapEquiv(array, a, b, mode, memo); + return partialObjectMapEquiv(array, a, b, mode, memo) || + partialCollectionEquiv(a, b, memo, kIsMap); } return mapObjectEquiv(array, a, b, mode, memo); diff --git a/test/parallel/test-assert-deep.js b/test/parallel/test-assert-deep.js index 91faa7839854..7f5eeec93a6b 100644 --- a/test/parallel/test-assert-deep.js +++ b/test/parallel/test-assert-deep.js @@ -591,6 +591,37 @@ test('es6 Maps and Sets', () => { } }); +test('partial comparison of unordered Sets and Maps with overlapping entries', () => { + assertDeepAndStrictEqual( + new Set([[1, 2], [1]]), + new Set([[1], [1, 2]]) + ); + assertDeepAndStrictEqual( + new Set([[1, 2], [1], [3]]), + new Set([[1], [1, 2], [3]]) + ); + assertDeepAndStrictEqual( + new Set([{ a: 1, b: 1 }, { a: 1 }]), + new Set([{ a: 1 }, { a: 1, b: 1 }]) + ); + assertDeepAndStrictEqual( + new Map([[{ a: 1, b: 1 }, 1], [{ a: 1 }, 1]]), + new Map([[{ a: 1 }, 1], [{ a: 1, b: 1 }, 1]]) + ); +}); + +test('partial comparison of unordered Sets with circular entries', () => { + const actual = new Set([{ a: 1, b: 1 }, { a: 1 }, { a: 2 }]); + const expected = new Set([{ a: 1 }, { a: 1, b: 1 }, { a: 2 }]); + for (const value of actual) { + value.self = actual; + } + for (const value of expected) { + value.self = expected; + } + assertDeepAndStrictEqual(actual, expected); +}); + test('GH-6416. Make sure circular refs do not throw', () => { const b = {}; b.b = b; diff --git a/test/parallel/test-util-isPartialDeepStrictEqual.js b/test/parallel/test-util-isPartialDeepStrictEqual.js index 3b1ae08eb57f..3caa197c7d6a 100644 --- a/test/parallel/test-util-isPartialDeepStrictEqual.js +++ b/test/parallel/test-util-isPartialDeepStrictEqual.js @@ -38,6 +38,24 @@ test('util.isPartialDeepStrictEqual returns true with expected properties', () = ); }); +test('util.isPartialDeepStrictEqual matches unordered collections with overlapping entries', () => { + const shared = { c: 3 }; + assert.strictEqual( + util.isPartialDeepStrictEqual( + new Set([null, shared, 1, [1, 2, 3], [1, 3], [4]]), + new Set([null, shared, 1, [1], [1, 2]]), + ), + true, + ); + assert.strictEqual( + util.isPartialDeepStrictEqual( + new Map([[null, 0], [1, 1], [shared, [1, 2, 3]], [{}, [1, 3]]]), + new Map([[null, 0], [1, 1], [{}, [1]], [shared, [1, 2]]]), + ), + true, + ); +}); + test('util.isPartialDeepStrictEqual returns false with unexpected properties', () => { assert.strictEqual( util.isPartialDeepStrictEqual({ a: 1 }, { a: 2 }),