Skip to content

Commit

Permalink
feat: Improve isObject diff
Browse files Browse the repository at this point in the history
  • Loading branch information
NiGhTTraX committed Sep 24, 2023
1 parent 87cb768 commit 5533bcf
Show file tree
Hide file tree
Showing 3 changed files with 182 additions and 9 deletions.
70 changes: 62 additions & 8 deletions src/expectation/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isUndefined,
omitBy,
} from 'lodash';
import type { Property } from '../proxy';
import type { Matcher, TypeMatcher } from './matcher';
import { isMatcher, MATCHER_SYMBOL } from './matcher';

Expand Down Expand Up @@ -67,7 +68,8 @@ const removeUndefined = (object: any): any => {
* non `Object` instances with different constructors as not equal. Setting
* this to `false` will consider the objects in both cases as equal.
*
* @see It.is A matcher that uses strict equality.
* @see {@link It.isObject} or {@link It.isArray} if you want to nest matchers.
* @see {@link It.is} if you want to use strict equality.
*/
const deepEquals = <T>(
expected: T,
Expand Down Expand Up @@ -111,16 +113,55 @@ const is = <T = unknown>(expected: T): TypeMatcher<T> =>
const isAny = (): TypeMatcher<any> =>
matches(() => true, { toJSON: () => 'anything' });

type DeepPartial<T> = T extends object
type ObjectType = Record<Property, unknown>;

type DeepPartial<T> = T extends ObjectType
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;

const isMatch = (actual: object, expected: object): boolean =>
const looksLikeObject = (value: unknown): value is ObjectType =>
isPlainObject(value);

const getExpectedObjectDiff = (actual: unknown, expected: ObjectType): object =>
Object.fromEntries(
Reflect.ownKeys(expected).map((key) => {
const right = expected[key];
const left = looksLikeObject(actual) ? actual[key] : actual;

if (isMatcher(right)) {
return [key, right.getDiff(left).expected];
}

if (looksLikeObject(right)) {
return [key, getExpectedObjectDiff(left, right)];
}

return [key, right];
})
);

const getActualObjectDiff = (actual: unknown, expected: ObjectType): object =>
Object.fromEntries(
Reflect.ownKeys(expected).map((key) => {
const right = expected[key];
const left = looksLikeObject(actual) ? actual[key] : actual;

if (isMatcher(right)) {
return [key, right.getDiff(left).actual];
}

if (looksLikeObject(right)) {
return [key, getActualObjectDiff(left, right)];
}

return [key, left];
})
);

const isMatch = (actual: unknown, expected: ObjectType): boolean =>
Reflect.ownKeys(expected).every((key) => {
// @ts-expect-error
const right = expected[key];
// @ts-expect-error
const left = actual?.[key];
const left = looksLikeObject(actual) ? actual[key] : actual;

if (!left) {
return false;
Expand All @@ -130,7 +171,7 @@ const isMatch = (actual: object, expected: object): boolean =>
return right.matches(left);
}

if (isPlainObject(right)) {
if (looksLikeObject(right)) {
return isMatch(left, right);
}

Expand All @@ -155,7 +196,7 @@ const isMatch = (actual: object, expected: object): boolean =>
* @example
* It.isObject({ foo: It.isString() })
*/
const isObject = <T extends object, K extends DeepPartial<T>>(
const isObject = <T extends ObjectType, K extends DeepPartial<T>>(
partial?: K
): TypeMatcher<T> =>
matches(
Expand All @@ -172,6 +213,19 @@ const isObject = <T extends object, K extends DeepPartial<T>>(
},
{
toJSON: () => (partial ? `object(${printExpected(partial)})` : 'object'),
getDiff: (actual) => {
if (!partial) {
return {
expected: 'object',
actual: isPlainObject(actual) ? 'object' : 'not object',
};
}

return {
actual: getActualObjectDiff(actual, partial),
expected: getExpectedObjectDiff(actual, partial),
};
},
}
);

Expand Down
118 changes: 118 additions & 0 deletions src/expectation/matcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,30 @@ describe('It', () => {
'{"foo": {"bar": [1, 2, 3]}}'
);
});

it("should get diff when there's a match", () => {
expect(It.deepEquals(1).getDiff(1)).toEqual({
actual: 1,
expected: 1,
});

expect(It.deepEquals({ foo: 'bar' }).getDiff({ foo: 'bar' })).toEqual({
actual: { foo: 'bar' },
expected: { foo: 'bar' },
});
});

it("should get diff when there's a mismatch", () => {
expect(It.deepEquals(1).getDiff(2)).toEqual({
actual: 2,
expected: 1,
});

expect(It.deepEquals({ foo: 'bar' }).getDiff({ foo: 'baz' })).toEqual({
actual: { foo: 'baz' },
expected: { foo: 'bar' },
});
});
});

describe('is', () => {
Expand Down Expand Up @@ -622,9 +646,13 @@ describe('It', () => {
expect(
It.isObject({ [foo]: 'bar' }).matches({ [foo]: 'bar' })
).toBeTruthy();
expect(It.isObject({ 100: 'bar' }).matches({ 100: 'bar' })).toBeTruthy();

expect(
It.isObject({ [foo]: 'bar' }).matches({ [foo]: 'baz' })
).toBeFalsy();
expect(It.isObject({ 100: 'bar' }).matches({ 100: 'baz' })).toBeFalsy();
expect(It.isObject({ 100: 'bar' }).matches({ 101: 'bar' })).toBeFalsy();
});

it('should deep match nested objects', () => {
Expand Down Expand Up @@ -685,6 +713,17 @@ describe('It', () => {
).toBeFalsy();
});

it('should handle non string keys when matching nested matchers', () => {
const matcher = It.matches(() => false, {
getDiff: () => ({ actual: 'a', expected: 'e' }),
});
const foo = Symbol('foo');

expect(
It.isObject({ [foo]: matcher }).matches({ [foo]: 'actual' })
).toBeFalsy();
});

it('should pretty print', () => {
expectAnsilessEqual(It.isObject().toJSON(), `object`);
});
Expand All @@ -695,6 +734,85 @@ describe('It', () => {
`object({"foo": "bar"})`
);
});

it("should return diff when there's a match", () => {
expect(It.isObject().getDiff({})).toEqual({
expected: 'object',
actual: 'object',
});

expect(It.isObject().getDiff({ foo: 'bar' })).toEqual({
actual: 'object',
expected: 'object',
});

expect(It.isObject({ foo: 'bar' }).getDiff({ foo: 'bar' })).toEqual({
expected: { foo: 'bar' },
actual: { foo: 'bar' },
});
});

it("should return diff when there's a mismatch", () => {
expect(It.isObject().getDiff('not object')).toEqual({
expected: 'object',
actual: 'not object',
});

expect(It.isObject({ foo: 'bar' }).getDiff({ foo: 'baz' })).toEqual({
actual: { foo: 'baz' },
expected: { foo: 'bar' },
});
});

it('should collect diffs from nested matchers', () => {
const matcher = It.matches(() => false, {
getDiff: () => ({ actual: 'a', expected: 'e' }),
});

expect(It.isObject({ foo: matcher }).getDiff({ foo: 'actual' })).toEqual({
actual: { foo: 'a' },
expected: { foo: 'e' },
});

expect(
It.isObject({ foo: { bar: matcher } }).getDiff({
foo: { bar: 'actual' },
})
).toEqual({
actual: { foo: { bar: 'a' } },
expected: { foo: { bar: 'e' } },
});
});

it('should handle missing keys when collecting diffs', () => {
const matcher = It.matches(() => false, {
getDiff: () => ({ actual: 'a', expected: 'e' }),
});

expect(It.isObject({ foo: matcher }).getDiff({})).toEqual({
actual: { foo: 'a' },
expected: { foo: 'e' },
});

expect(It.isObject({}).getDiff({ foo: 'bar' })).toEqual({
actual: {},
expected: {},
});
});

it('should handle non string keys when collecting diffs', () => {
const matcher = It.matches(() => false, {
getDiff: () => ({ actual: 'a', expected: 'e' }),
});
const foo = Symbol('foo');

expect(
It.isObject({ [foo]: matcher }).getDiff({ [foo]: 'actual' })
).toEqual({
actual: { [foo]: 'a' },
expected: { [foo]: 'e' },
});
});
});

describe('willCapture', () => {
Expand Down
3 changes: 2 additions & 1 deletion tests/types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ it('type safety', () => {
// @ts-expect-error wrong matcher type
number(It.isString());

const nestedObject = (x: { foo: { bar: number; baz: string } }) => x;
const nestedObject = (x: { foo: { bar: number; 42: string } }) => x;
nestedObject(It.isObject());
nestedObject(It.isObject({ foo: { bar: 23 } }));
nestedObject(It.isObject({ foo: { 42: 'baz' } }));
nestedObject(
// @ts-expect-error wrong nested property type
It.isObject({ foo: { bar: 'boo' } })
Expand Down

0 comments on commit 5533bcf

Please sign in to comment.