Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add t.unorderedEqual() assertion #3234

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/03-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions lib/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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();
});
}
}
23 changes: 23 additions & 0 deletions lib/unordered-equal.js
Original file line number Diff line number Diff line change
@@ -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,
};
};
95 changes: 95 additions & 0 deletions test-tap/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
11 changes: 7 additions & 4 deletions test-tap/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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);
}),
Expand Down
1 change: 1 addition & 0 deletions test/assertions/fixtures/happy-path.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
1 change: 1 addition & 0 deletions test/assertions/snapshots/test.js.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified test/assertions/snapshots/test.js.snap
Binary file not shown.
42 changes: 42 additions & 0 deletions types/assertions.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here too let's clarify the differences with deepEqual.

unorderedEqual: UnorderedEqualAssertion;
};

type FalsyValue = false | 0 | 0n | '' | null | undefined; // eslint-disable-line @typescript-eslint/ban-types
Expand Down Expand Up @@ -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, Expected extends Actual>(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 extends Expected, Expected>(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, Expected>(actual: Actual, expected: Expected, message?: string): true;

/** Skip this assertion. */
skip(actual: any, expected: any, message?: string): void;
};