From 60b8c2e81ba7bf2084e6a6e0afbf4417c6678993 Mon Sep 17 00:00:00 2001 From: Stella <353917373+Stella050408@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:22:41 +0800 Subject: [PATCH] assert: fix partial matching of Set and Map entries Retry overlapping partial collection matches with augmenting paths when greedy matching fails, while retaining the existing success paths Handle the alternate pairing for two-element Sets and cover reordered entries, shared references, Map values and cycles Fixes: https://github.com/nodejs/node/issues/67317 Assisted-by: Codex Signed-off-by: Stella <353917373+Stella050408@users.noreply.github.com> --- lib/internal/util/comparisons.js | 91 ++++++++++++++++++- test/parallel/test-assert-deep.js | 31 +++++++ .../test-util-isPartialDeepStrictEqual.js | 18 ++++ 3 files changed, 137 insertions(+), 3 deletions(-) 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 }),