diff --git a/docs/03-assertions.md b/docs/03-assertions.md index f5800b063..4c1cfa73c 100644 --- a/docs/03-assertions.md +++ b/docs/03-assertions.md @@ -141,6 +141,14 @@ Assert that `actual` is deeply equal to `expected`. See [Concordance](https://gi Assert that `actual` is not deeply equal to `expected`. The inverse of `.deepEqual()`. +### `.unorderedEqual(actual, expected, message?)` + +Assert that all values in `actual` are in `expected`. This is a variant of `.deepEqual()` that does not depend on the order of `actual`/`expected` and only compares instances of `Map`s or `Set`s/arrays. + +The size of `actual` and `expected` must be equal. For `Map`s, each key-value pair in `actual` must be in `expected`, and vice-versa. For `Set`s/arrays, each value in `actual` must be in `expected`. + +Returns a boolean indicating whether the assertion passed. + ### `.like(actual, selector, message?)` Assert that `actual` is like `selector`. This is a variant of `.deepEqual()`, however `selector` does not need to have the same enumerable properties as `actual` does. diff --git a/lib/assert.js b/lib/assert.js index 9e93d31fa..60d79ec9d 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -6,6 +6,7 @@ import isPromise from 'is-promise'; import concordanceOptions from './concordance-options.js'; import {CIRCULAR_SELECTOR, isLikeSelector, selectComparable} from './like-selector.js'; import {SnapshotError, VersionMismatchError} from './snapshot-manager.js'; +import {checkValueForUnorderedEqual} from './unordered-equal.js'; function formatDescriptorDiff(actualDescriptor, expectedDescriptor, options) { options = {...options, ...concordanceOptions}; @@ -796,5 +797,69 @@ export class Assertions { return pass(); }); + + this.unorderedEqual = withSkip((actual, expected, message) => { + assertMessage(message, 't.unorderedEqual()'); + + const actualInfo = checkValueForUnorderedEqual(actual); + const expectedInfo = checkValueForUnorderedEqual(expected); + + if (!actualInfo.isValid || !expectedInfo.isValid) { + throw fail(new AssertionError('`t.unorderedEqual()` only compares arrays, maps, and sets', { + assertion: 't.unorderedEqual()', + improperUsage: true, + values: [ + !actualInfo.isValid && formatWithLabel('Called with:', actual), + !expectedInfo.isValid && formatWithLabel('Called with:', expected), + ].filter(Boolean), + })); + } + + if ( + actualInfo.type !== expectedInfo.type + && (actualInfo.type === 'map' || expectedInfo.type === 'map') + ) { + throw fail(new AssertionError('types of actual and expected must be comparable', { + assertion: 't.unorderedEqual()', + improperUsage: true, + })); + } + + if (actualInfo.size !== expectedInfo.size) { + throw fail(new AssertionError('size must be equal', { + assertion: 't.unorderedEqual()', + })); + } + + if (actualInfo.type === 'map') { + // Keys are unique - if actual and expected are the same size, + // and expected has a value for every key in actual, then the two are equal. + + for (const [key, value] of actual.entries()) { + const result = concordance.compare(value, expected.get(key), concordanceOptions); + if (!result.pass) { + // TODO: allow passing custom messages + throw fail(new AssertionError('all values must be equal - map', { + assertion: 't.unorderedEqual()', + })); + } + } + + return pass(); + } + + const setActual = actualInfo.type === 'set' ? actual : new Set(actual); + const setExpected = expectedInfo.type === 'set' ? expected : new Set(expected); + + for (const value of setActual) { + if (!setExpected.has(value)) { + throw fail(new AssertionError('all values must be equal - array/set', { + assertion: 't.unorderedEqual()', + })); + } + } + + return pass(); + }); } } diff --git a/lib/unordered-equal.js b/lib/unordered-equal.js new file mode 100644 index 000000000..796371209 --- /dev/null +++ b/lib/unordered-equal.js @@ -0,0 +1,23 @@ +export const checkValueForUnorderedEqual = value => { + let type = 'invalid'; + + if (value instanceof Map) { + type = 'map'; + } else if (value instanceof Set) { + type = 'set'; + } else if (Array.isArray(value)) { + type = 'array'; + } + + if (type === 'invalid') { + return {isValid: false}; + } + + return { + isValid: true, + type, + size: type === 'array' + ? value.length + : value.size, + }; +}; diff --git a/test-tap/assert.js b/test-tap/assert.js index 633814a8b..da89982cf 100644 --- a/test-tap/assert.js +++ b/test-tap/assert.js @@ -1822,3 +1822,98 @@ test('.assert()', t => { t.end(); }); + +test('.unorderedEqual()', t => { + passes(t, () => assertions.unorderedEqual([1, 2, 3], [2, 3, 1])); + + passes(t, () => assertions.unorderedEqual(new Set([1, 2, 3]), new Set([2, 3, 1]))); + + passes(t, () => assertions.unorderedEqual([1, 2, 3], new Set([2, 3, 1]))); + + passes(t, () => assertions.unorderedEqual(new Set([1, 2, 3]), [2, 3, 1])); + + passes(t, () => assertions.unorderedEqual( + new Map([['a', 1], ['b', 2], ['c', 3]]), + new Map([['b', 2], ['c', 3], ['a', 1]]), + )); + + // Types must match + + fails(t, () => assertions.unorderedEqual('foo', [1, 2, 3])); + + fails(t, () => assertions.unorderedEqual([1, 2, 3], 'foo')); + + // Sizes must match + + fails(t, () => assertions.unorderedEqual([1, 2, 3], [1, 2, 3, 4])); + + fails(t, () => assertions.unorderedEqual([1, 2, 3, 4], [1, 2, 3])); + + fails(t, () => assertions.unorderedEqual(new Set([1, 2, 3]), new Set([1, 2, 3, 4]))); + + fails(t, () => assertions.unorderedEqual(new Set([1, 2, 3, 4]), new Set([1, 2, 3]))); + + fails(t, () => assertions.unorderedEqual(new Set([1, 2, 3]), [1, 2, 3, 4])); + + fails(t, () => assertions.unorderedEqual(new Set([1, 2, 3, 4]), [1, 2, 3])); + + fails(t, () => assertions.unorderedEqual([1, 2, 3], new Set([1, 2, 3, 4]))); + + fails(t, () => assertions.unorderedEqual([1, 2, 3, 4], new Set([1, 2, 3]))); + + fails(t, () => assertions.unorderedEqual( + new Map([['a', 1], ['b', 2], ['c', 3]]), + new Map([['a', 1], ['b', 2]])), + ); + + fails(t, () => assertions.unorderedEqual( + new Map([['a', 1], ['b', 2]]), + new Map([['a', 1], ['b', 2], ['c', 3]])), + ); + + // Keys must match - maps + + fails(t, () => assertions.unorderedEqual( + new Map([['a', 1], ['b', 2], ['c', 3]]), + new Map([['a', 1], ['d', 2], ['c', 3]])), + ); + + fails(t, () => assertions.unorderedEqual( + new Map([['a', 1], ['d', 2], ['c', 3]]), + new Map([['a', 1], ['b', 2], ['c', 3]])), + ); + + // Values must match - maps + + fails(t, () => assertions.unorderedEqual( + new Map([['a', 1], ['b', 2], ['c', 3]]), + new Map([['a', 1], ['b', 4], ['c', 3]])), + ); + + fails(t, () => assertions.unorderedEqual( + new Map([['a', 1], ['b', 4], ['c', 3]]), + new Map([['a', 1], ['b', 2], ['c', 3]])), + ); + + // Values must match - sets + + fails(t, () => assertions.unorderedEqual([1, 2, 3], [1, 2, 4])); + + fails(t, () => assertions.unorderedEqual([1, 2, 4], [1, 2, 3])); + + fails(t, () => assertions.unorderedEqual(new Set([1, 2, 3]), new Set([1, 2, 4]))); + + fails(t, () => assertions.unorderedEqual(new Set([1, 2, 4]), new Set([1, 2, 3]))); + + fails(t, () => assertions.unorderedEqual(new Set([1, 2, 3]), [1, 2, 4])); + + fails(t, () => assertions.unorderedEqual(new Set([1, 2, 4]), [1, 2, 3])); + + fails(t, () => assertions.unorderedEqual([1, 2, 3], new Set([1, 2, 4]))); + + fails(t, () => assertions.unorderedEqual([1, 2, 4], new Set([1, 2, 3]))); + + // TODO: check error messages + + t.end(); +}); diff --git a/test-tap/test.js b/test-tap/test.js index 17f4bc19e..37c94d85e 100644 --- a/test-tap/test.js +++ b/test-tap/test.js @@ -270,11 +270,12 @@ test('skipped assertions count towards the plan', t => { a.false.skip(false); a.regex.skip('foo', /foo/); a.notRegex.skip('bar', /foo/); + a.unorderedEqual.skip([1, 2, 3], [2, 3, 1]); }); return instance.run().then(result => { t.equal(result.passed, true); - t.equal(instance.planCount, 16); - t.equal(instance.assertCount, 16); + t.equal(instance.planCount, 17); + t.equal(instance.assertCount, 17); }); }); @@ -299,11 +300,12 @@ test('assertion.skip() is bound', t => { (a.false.skip)(false); (a.regex.skip)('foo', /foo/); (a.notRegex.skip)('bar', /foo/); + (a.unorderedEqual.skip)([1, 2, 3], [2, 3, 1]); }); return instance.run().then(result => { t.equal(result.passed, true); - t.equal(instance.planCount, 16); - t.equal(instance.assertCount, 16); + t.equal(instance.planCount, 17); + t.equal(instance.assertCount, 17); }); }); @@ -464,6 +466,7 @@ test('assertions are bound', t => (a.false)(false); (a.regex)('foo', /foo/); (a.notRegex)('bar', /foo/); + (a.unorderedEquals)([1, 2, 3], [2, 3, 1]); }).run().then(result => { t.ok(result.passed); }), diff --git a/test/assertions/fixtures/happy-path.js b/test/assertions/fixtures/happy-path.js index 8b2c6020a..08901d705 100644 --- a/test/assertions/fixtures/happy-path.js +++ b/test/assertions/fixtures/happy-path.js @@ -43,3 +43,4 @@ test(passes, 'false', false); test(passes, 'regex', 'foo', /foo/); test(passes, 'notRegex', 'bar', /foo/); test(passes, 'assert', 1); +test(passes, 'unorderedEqual', [1, 2, 3], [2, 3, 1]); diff --git a/test/assertions/snapshots/test.js.md b/test/assertions/snapshots/test.js.md index 610eee926..151ae2ba0 100644 --- a/test/assertions/snapshots/test.js.md +++ b/test/assertions/snapshots/test.js.md @@ -27,6 +27,7 @@ Generated by [AVA](https://avajs.dev). 't.throwsAsync() passes', 't.true(true) passes', 't.truthy(1) passes', + 't.unorderedEqual([1,2,3], [2,3,1]) passes', ] ## throws requires native errors diff --git a/test/assertions/snapshots/test.js.snap b/test/assertions/snapshots/test.js.snap index a1a251bd9..bc4064cf2 100644 Binary files a/test/assertions/snapshots/test.js.snap and b/test/assertions/snapshots/test.js.snap differ diff --git a/types/assertions.d.cts b/types/assertions.d.cts index 6f5ccddd6..2df1b2281 100644 --- a/types/assertions.d.cts +++ b/types/assertions.d.cts @@ -141,6 +141,9 @@ export type Assertions = { * Note: An `else` clause using this as a type guard will be subtly incorrect for `string` and `number` types and will not give `0` or `''` as a potential value in an `else` clause. */ truthy: TruthyAssertion; + + /** Assert that all values in `actual` are in `expected`, returning a boolean indicating whether the assertion passed. */ + unorderedEqual: UnorderedEqualAssertion; }; type FalsyValue = false | 0 | 0n | '' | null | undefined; // eslint-disable-line @typescript-eslint/ban-types @@ -400,3 +403,42 @@ export type TruthyAssertion = { /** Skip this assertion. */ skip(actual: any, message?: string): void; }; + +// TODO: limit to Map | Set | Array +export type UnorderedEqualAssertion = { + /** + * Assert that all values in `actual` are in `expected`. This is a variant of `.deepEqual()` that does not depend + * on the order of `actual`/`expected` and only compares instances of `Map`s or `Set`s/arrays. + * + * The size of `actual` and `expected` must be equal. For `Map`s, each key-value pair in `actual` must be in + * `expected`, and vice-versa. For `Set`s/arrays, each value in `actual` must be in `expected`. + * + * Returns `true` if the assertion passed and throws otherwise. + */ + (actual: Actual, expected: Expected, message?: string): actual is Expected; + + /** + * Assert that all values in `actual` are in `expected`. This is a variant of `.deepEqual()` that does not depend + * on the order of `actual`/`expected` and only compares instances of `Map`s or `Set`s/arrays. + * + * The size of `actual` and `expected` must be equal. For `Map`s, each key-value pair in `actual` must be in + * `expected`, and vice-versa. For `Set`s/arrays, each value in `actual` must be in `expected`. + * + * Returns `true` if the assertion passed and throws otherwise. + */ + (actual: Actual, expected: Expected, message?: string): expected is Actual; + + /** + * Assert that all values in `actual` are in `expected`. This is a variant of `.deepEqual()` that does not depend + * on the order of `actual`/`expected` and only compares instances of `Map`s or `Set`s/arrays. + * + * The size of `actual` and `expected` must be equal. For `Map`s, each key-value pair in `actual` must be in + * `expected`, and vice-versa. For `Set`s/arrays, each value in `actual` must be in `expected`. + * + * Returns `true` if the assertion passed and throws otherwise. + */ + (actual: Actual, expected: Expected, message?: string): true; + + /** Skip this assertion. */ + skip(actual: any, expected: any, message?: string): void; +};