Skip to content

Commit 6481c93

Browse files
josephgaddaleax
authored andcommitted
assert: add support for Map and Set in deepEqual
assert.deepEqual and assert.deepStrictEqual currently return true for any pair of Maps and Sets regardless of content. This patch adds support in deepEqual and deepStrictEqual to verify the contents of Maps and Sets. Deeo equivalence checking is currently an O(n^2) operation, and worse, it gets slower exponentially if maps and sets were nested. Note that this change breaks compatibility with previous versions of deepEqual and deepStrictEqual if consumers were depending on all maps and sets to be seen as equivalent. The old behaviour was never documented, but nevertheless there are certainly some tests out there which depend on it. Support has stalled because the assert API was frozen, but was recently unfrozen in CTC#63. --- Later squashed in: This change updates the checks for deep equality checking on Map and Set to check all set values / all map keys to see if any of them match the expected result. This change is much slower, but based on the conversation in the pull request its probably the right approach. Fixes: #2309 Refs: tape-testing/tape#342 Refs: #2315 Refs: nodejs/CTC#63 PR-URL: #12142 Reviewed-By: Anna Henningsen <anna@addaleax.net> Reviewed-By: Rich Trott <rtrott@gmail.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
1 parent 3cc3e09 commit 6481c93

File tree

3 files changed

+287
-4
lines changed

3 files changed

+287
-4
lines changed

doc/api/assert.md

+15-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ An alias of [`assert.ok()`][].
1818
<!-- YAML
1919
added: v0.1.21
2020
changes:
21+
- version: REPLACEME
22+
pr-url: https://github.com/nodejs/node/pull/12142
23+
description: Set and Map content is also compared
2124
- version: v6.4.0, v4.7.1
2225
pr-url: https://github.com/nodejs/node/pull/8002
2326
description: Typed array slices are handled correctly now.
@@ -40,7 +43,7 @@ Only [enumerable "own" properties][] are considered. The
4043
[`assert.deepEqual()`][] implementation does not test the
4144
[`[[Prototype]]`][prototype-spec] of objects, attached symbols, or
4245
non-enumerable properties — for such checks, consider using
43-
[assert.deepStrictEqual()][] instead. This can lead to some
46+
[`assert.deepStrictEqual()`][] instead. This can lead to some
4447
potentially surprising results. For example, the following example does not
4548
throw an `AssertionError` because the properties on the [`Error`][] object are
4649
not enumerable:
@@ -50,6 +53,9 @@ not enumerable:
5053
assert.deepEqual(Error('a'), Error('b'));
5154
```
5255

56+
An exception is made for [`Map`][] and [`Set`][]. Maps and Sets have their
57+
contained items compared too, as expected.
58+
5359
"Deep" equality means that the enumerable "own" properties of child objects
5460
are evaluated also:
5561

@@ -96,6 +102,9 @@ parameter is undefined, a default error message is assigned.
96102
<!-- YAML
97103
added: v1.2.0
98104
changes:
105+
- version: REPLACEME
106+
pr-url: https://github.com/nodejs/node/pull/12142
107+
description: Set and Map content is also compared
99108
- version: v6.4.0, v4.7.1
100109
pr-url: https://github.com/nodejs/node/pull/8002
101110
description: Typed array slices are handled correctly now.
@@ -113,7 +122,8 @@ changes:
113122
Generally identical to `assert.deepEqual()` with three exceptions:
114123

115124
1. Primitive values are compared using the [Strict Equality Comparison][]
116-
( `===` ).
125+
( `===` ). Set values and Map keys are compared using the [SameValueZero][]
126+
comparison. (Which means they are free of the [caveats][]).
117127
2. [`[[Prototype]]`][prototype-spec] of objects are compared using
118128
the [Strict Equality Comparison][] too.
119129
3. [Type tags][Object.prototype.toString()] of objects should be the same.
@@ -576,10 +586,13 @@ For more information, see
576586
[`assert.ok()`]: #assert_assert_ok_value_message
577587
[`assert.throws()`]: #assert_assert_throws_block_error_message
578588
[`Error`]: errors.html#errors_class_error
589+
[caveats]: #assert_caveats
579590
[`RegExp`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
580591
[`TypeError`]: errors.html#errors_class_typeerror
581592
[Abstract Equality Comparison]: https://tc39.github.io/ecma262/#sec-abstract-equality-comparison
582593
[Strict Equality Comparison]: https://tc39.github.io/ecma262/#sec-strict-equality-comparison
594+
[`Map`]: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Map
595+
[`Set`]: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Set
583596
[`Object.is()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
584597
[SameValueZero]: https://tc39.github.io/ecma262/#sec-samevaluezero
585598
[prototype-spec]: https://tc39.github.io/ecma262/#sec-ordinary-object-internal-methods-and-internal-slots

lib/assert.js

+110-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
// UTILITY
2424
const compare = process.binding('buffer').compare;
2525
const util = require('util');
26+
const { isSet, isMap } = process.binding('util');
2627
const objectToString = require('internal/util').objectToString;
2728
const Buffer = require('buffer').Buffer;
2829

@@ -262,11 +263,12 @@ function _deepEqual(actual, expected, strict, memos) {
262263
}
263264
}
264265

265-
// For all other Object pairs, including Array objects,
266+
// For all other Object pairs, including Array objects and Maps,
266267
// equivalence is determined by having:
267268
// a) The same number of owned enumerable properties
268269
// b) The same set of keys/indexes (although not necessarily the same order)
269270
// c) Equivalent values for every corresponding key/index
271+
// d) For Sets and Maps, equal contents
270272
// Note: this accounts for both named and indexed properties on Arrays.
271273

272274
// Use memos to handle cycles.
@@ -283,6 +285,97 @@ function _deepEqual(actual, expected, strict, memos) {
283285
return objEquiv(actual, expected, strict, memos);
284286
}
285287

288+
function setHasSimilarElement(set, val1, strict, memo) {
289+
if (set.has(val1))
290+
return true;
291+
292+
// In strict mode the only things which can match a primitive or a function
293+
// will already be detected by set.has(val1).
294+
if (strict && (util.isPrimitive(val1) || util.isFunction(val1)))
295+
return false;
296+
297+
// Otherwise go looking.
298+
for (const val2 of set) {
299+
if (_deepEqual(val1, val2, strict, memo))
300+
return true;
301+
}
302+
303+
return false;
304+
}
305+
306+
function setEquiv(a, b, strict, memo) {
307+
// This code currently returns false for this pair of sets:
308+
// assert.deepEqual(new Set(['1', 1]), new Set([1]))
309+
//
310+
// In theory, all the items in the first set have a corresponding == value in
311+
// the second set, but the sets have different sizes. Its a silly case,
312+
// and more evidence that deepStrictEqual should always be preferred over
313+
// deepEqual.
314+
if (a.size !== b.size)
315+
return false;
316+
317+
for (const val1 of a) {
318+
// If the value doesn't exist in the second set by reference, and its an
319+
// object or an array we'll need to go hunting for something thats
320+
// deep-equal to it. Note that this is O(n^2) complexity, and will get
321+
// slower if large, very similar sets / maps are nested inside.
322+
// Unfortunately there's no real way around this.
323+
if (!setHasSimilarElement(b, val1, strict, memo)) {
324+
return false;
325+
}
326+
}
327+
328+
return true;
329+
}
330+
331+
function mapHasSimilarEntry(map, key1, item1, strict, memo) {
332+
// To be able to handle cases like:
333+
// Map([[1, 'a'], ['1', 'b']]) vs Map([['1', 'a'], [1, 'b']])
334+
// or:
335+
// Map([[{}, 'a'], [{}, 'b']]) vs Map([[{}, 'b'], [{}, 'a']])
336+
// ... we need to consider *all* matching keys, not just the first we find.
337+
338+
// This check is not strictly necessary. The loop performs this check, but
339+
// doing it here improves performance of the common case when reference-equal
340+
// keys exist (which includes all primitive-valued keys).
341+
if (map.has(key1) && _deepEqual(item1, map.get(key1), strict, memo))
342+
return true;
343+
344+
if (strict && (util.isPrimitive(key1) || util.isFunction(key1)))
345+
return false;
346+
347+
for (const [key2, item2] of map) {
348+
// This case is checked above.
349+
if (key2 === key1)
350+
continue;
351+
352+
if (_deepEqual(key1, key2, strict, memo) &&
353+
_deepEqual(item1, item2, strict, memo)) {
354+
return true;
355+
}
356+
}
357+
358+
return false;
359+
}
360+
361+
function mapEquiv(a, b, strict, memo) {
362+
// Caveat: In non-strict mode, this implementation does not handle cases
363+
// where maps contain two equivalent-but-not-reference-equal keys.
364+
//
365+
// For example, maps like this are currently considered not equivalent:
366+
if (a.size !== b.size)
367+
return false;
368+
369+
for (const [key1, item1] of a) {
370+
// Just like setEquiv above, this hunt makes this function O(n^2) when
371+
// using objects and lists as keys
372+
if (!mapHasSimilarEntry(b, key1, item1, strict, memo))
373+
return false;
374+
}
375+
376+
return true;
377+
}
378+
286379
function objEquiv(a, b, strict, actualVisitedObjects) {
287380
// If one of them is a primitive, the other must be the same.
288381
if (util.isPrimitive(a) || util.isPrimitive(b))
@@ -307,6 +400,22 @@ function objEquiv(a, b, strict, actualVisitedObjects) {
307400
return false;
308401
}
309402

403+
// Sets and maps don't have their entries accessible via normal object
404+
// properties.
405+
if (isSet(a)) {
406+
if (!isSet(b) || !setEquiv(a, b, strict, actualVisitedObjects))
407+
return false;
408+
} else if (isSet(b)) {
409+
return false;
410+
}
411+
412+
if (isMap(a)) {
413+
if (!isMap(b) || !mapEquiv(a, b, strict, actualVisitedObjects))
414+
return false;
415+
} else if (isMap(b)) {
416+
return false;
417+
}
418+
310419
// The pair must have equivalent values for every corresponding key.
311420
// Possibly expensive deep test:
312421
for (i = aKeys.length - 1; i >= 0; i--) {

test/parallel/test-assert-deep.js

+162-1
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,172 @@ const similar = new Set([
100100
for (const a of similar) {
101101
for (const b of similar) {
102102
if (a !== b) {
103-
assert.doesNotThrow(() => assert.deepEqual(a, b));
103+
assert.deepEqual(a, b);
104104
assert.throws(() => assert.deepStrictEqual(a, b),
105105
re`${a} deepStrictEqual ${b}`);
106106
}
107107
}
108108
}
109109

110+
function assertDeepAndStrictEqual(a, b) {
111+
assert.deepEqual(a, b);
112+
assert.deepStrictEqual(a, b);
113+
114+
assert.deepEqual(b, a);
115+
assert.deepStrictEqual(b, a);
116+
}
117+
118+
function assertNotDeepOrStrict(a, b) {
119+
assert.throws(() => assert.deepEqual(a, b));
120+
assert.throws(() => assert.deepStrictEqual(a, b));
121+
122+
assert.throws(() => assert.deepEqual(b, a));
123+
assert.throws(() => assert.deepStrictEqual(b, a));
124+
}
125+
126+
function assertOnlyDeepEqual(a, b) {
127+
assert.doesNotThrow(() => assert.deepEqual(a, b));
128+
assert.throws(() => assert.deepStrictEqual(a, b));
129+
130+
assert.doesNotThrow(() => assert.deepEqual(b, a));
131+
assert.throws(() => assert.deepStrictEqual(b, a));
132+
}
133+
134+
// es6 Maps and Sets
135+
assertDeepAndStrictEqual(new Set(), new Set());
136+
assertDeepAndStrictEqual(new Map(), new Map());
137+
138+
assertDeepAndStrictEqual(new Set([1, 2, 3]), new Set([1, 2, 3]));
139+
assertNotDeepOrStrict(new Set([1, 2, 3]), new Set([1, 2, 3, 4]));
140+
assertNotDeepOrStrict(new Set([1, 2, 3, 4]), new Set([1, 2, 3]));
141+
assertDeepAndStrictEqual(new Set(['1', '2', '3']), new Set(['1', '2', '3']));
142+
assertDeepAndStrictEqual(new Set([[1, 2], [3, 4]]), new Set([[3, 4], [1, 2]]));
143+
144+
assertDeepAndStrictEqual(new Map([[1, 1], [2, 2]]), new Map([[1, 1], [2, 2]]));
145+
assertDeepAndStrictEqual(new Map([[1, 1], [2, 2]]), new Map([[2, 2], [1, 1]]));
146+
assertNotDeepOrStrict(new Map([[1, 1], [2, 2]]), new Map([[1, 2], [2, 1]]));
147+
148+
assertNotDeepOrStrict(new Set([1]), [1]);
149+
assertNotDeepOrStrict(new Set(), []);
150+
assertNotDeepOrStrict(new Set(), {});
151+
152+
assertNotDeepOrStrict(new Map([['a', 1]]), {a: 1});
153+
assertNotDeepOrStrict(new Map(), []);
154+
assertNotDeepOrStrict(new Map(), {});
155+
156+
assertOnlyDeepEqual(new Set(['1']), new Set([1]));
157+
158+
assertOnlyDeepEqual(new Map([['1', 'a']]), new Map([[1, 'a']]));
159+
assertOnlyDeepEqual(new Map([['a', '1']]), new Map([['a', 1]]));
160+
161+
assertDeepAndStrictEqual(new Set([{}]), new Set([{}]));
162+
163+
// This is an awful case, where a map contains multiple equivalent keys:
164+
assertOnlyDeepEqual(
165+
new Map([[1, 'a'], ['1', 'b']]),
166+
new Map([['1', 'a'], [1, 'b']])
167+
);
168+
assertDeepAndStrictEqual(
169+
new Map([[{}, 'a'], [{}, 'b']]),
170+
new Map([[{}, 'b'], [{}, 'a']])
171+
);
172+
173+
{
174+
const values = [
175+
123,
176+
Infinity,
177+
0,
178+
null,
179+
undefined,
180+
false,
181+
true,
182+
{},
183+
[],
184+
() => {},
185+
];
186+
assertDeepAndStrictEqual(new Set(values), new Set(values));
187+
assertDeepAndStrictEqual(new Set(values), new Set(values.reverse()));
188+
189+
const mapValues = values.map((v) => [v, {a: 5}]);
190+
assertDeepAndStrictEqual(new Map(mapValues), new Map(mapValues));
191+
assertDeepAndStrictEqual(new Map(mapValues), new Map(mapValues.reverse()));
192+
}
193+
194+
{
195+
const s1 = new Set();
196+
const s2 = new Set();
197+
s1.add(1);
198+
s1.add(2);
199+
s2.add(2);
200+
s2.add(1);
201+
assertDeepAndStrictEqual(s1, s2);
202+
}
203+
204+
{
205+
const m1 = new Map();
206+
const m2 = new Map();
207+
const obj = {a: 5, b: 6};
208+
m1.set(1, obj);
209+
m1.set(2, 'hi');
210+
m1.set(3, [1, 2, 3]);
211+
212+
m2.set(2, 'hi'); // different order
213+
m2.set(1, obj);
214+
m2.set(3, [1, 2, 3]); // deep equal, but not reference equal.
215+
216+
assertDeepAndStrictEqual(m1, m2);
217+
}
218+
219+
{
220+
const m1 = new Map();
221+
const m2 = new Map();
222+
223+
// m1 contains itself.
224+
m1.set(1, m1);
225+
m2.set(1, new Map());
226+
227+
assertNotDeepOrStrict(m1, m2);
228+
}
229+
230+
assert.deepEqual(new Map([[1, 1]]), new Map([[1, '1']]));
231+
assert.throws(() =>
232+
assert.deepStrictEqual(new Map([[1, 1]]), new Map([[1, '1']]))
233+
);
234+
235+
{
236+
// Two equivalent sets / maps with different key/values applied shouldn't be
237+
// the same. This is a terrible idea to do in practice, but deepEqual should
238+
// still check for it.
239+
const s1 = new Set();
240+
const s2 = new Set();
241+
s1.x = 5;
242+
assertNotDeepOrStrict(s1, s2);
243+
244+
const m1 = new Map();
245+
const m2 = new Map();
246+
m1.x = 5;
247+
assertNotDeepOrStrict(m1, m2);
248+
}
249+
250+
{
251+
// Circular references.
252+
const s1 = new Set();
253+
s1.add(s1);
254+
const s2 = new Set();
255+
s2.add(s2);
256+
assertDeepAndStrictEqual(s1, s2);
257+
258+
const m1 = new Map();
259+
m1.set(2, m1);
260+
const m2 = new Map();
261+
m2.set(2, m2);
262+
assertDeepAndStrictEqual(m1, m2);
263+
264+
const m3 = new Map();
265+
m3.set(m3, 2);
266+
const m4 = new Map();
267+
m4.set(m4, 2);
268+
assertDeepAndStrictEqual(m3, m4);
269+
}
270+
110271
/* eslint-enable */

0 commit comments

Comments
 (0)