From 32b4fb62d1f1bcabc08edf856eb9e384e63c69ef Mon Sep 17 00:00:00 2001 From: eryue0220 Date: Fri, 1 Nov 2024 18:19:57 +0800 Subject: [PATCH] fix(expect): re-align `expect.toMatchObject` api (#6160) --- expect/_equal.ts | 17 +++---- expect/_matchers.ts | 62 +++++++++++++------------ expect/_to_match_object_test.ts | 18 ++++++++ expect/_utils.ts | 82 +++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 37 deletions(-) diff --git a/expect/_equal.ts b/expect/_equal.ts index 15b0351fa..5bb80e32c 100644 --- a/expect/_equal.ts +++ b/expect/_equal.ts @@ -44,6 +44,11 @@ export function equal(c: unknown, d: unknown, options?: EqualOptions): boolean { const seen = new Map(); return (function compare(a: unknown, b: unknown): boolean { + const asymmetric = asymmetricEqual(a, b); + if (asymmetric !== undefined) { + return asymmetric; + } + if (customTesters?.length) { for (const customTester of customTesters) { const testContext = { @@ -67,11 +72,6 @@ export function equal(c: unknown, d: unknown, options?: EqualOptions): boolean { return String(a) === String(b); } - const asymmetric = asymmetricEqual(a, b); - if (asymmetric !== undefined) { - return asymmetric; - } - if (a instanceof Date && b instanceof Date) { const aTime = a.getTime(); const bTime = b.getTime(); @@ -119,6 +119,10 @@ export function equal(c: unknown, d: unknown, options?: EqualOptions): boolean { let aLen = aKeys.length; let bLen = bKeys.length; + if (strictCheck && aLen !== bLen) { + return false; + } + if (!strictCheck) { if (aLen > 0) { for (let i = 0; i < aKeys.length; i += 1) { @@ -145,9 +149,6 @@ export function equal(c: unknown, d: unknown, options?: EqualOptions): boolean { } } - if (aLen !== bLen) { - return false; - } seen.set(a, b); if (isKeyedCollection(a) && isKeyedCollection(b)) { if (a.size !== b.size) { diff --git a/expect/_matchers.ts b/expect/_matchers.ts index c06489f3d..7a9abd543 100644 --- a/expect/_matchers.ts +++ b/expect/_matchers.ts @@ -6,7 +6,6 @@ import { assertInstanceOf } from "@std/assert/instance-of"; import { assertIsError } from "@std/assert/is-error"; import { assertNotInstanceOf } from "@std/assert/not-instance-of"; import { assertMatch } from "@std/assert/match"; -import { assertObjectMatch } from "@std/assert/object-match"; import { assertNotMatch } from "@std/assert/not-match"; import { AssertionError } from "@std/assert/assertion-error"; @@ -17,7 +16,11 @@ import { format } from "@std/internal/format"; import type { AnyConstructor, MatcherContext, MatchResult } from "./_types.ts"; import { getMockCalls } from "./_mock_util.ts"; import { inspectArg, inspectArgs } from "./_inspect_args.ts"; -import { buildEqualOptions, iterableEquality } from "./_utils.ts"; +import { + buildEqualOptions, + iterableEquality, + subsetEquality, +} from "./_utils.ts"; export function toBe(context: MatcherContext, expect: unknown): MatchResult { if (context.isNot) { @@ -462,34 +465,35 @@ export function toMatchObject( context: MatcherContext, expected: Record | Record[], ): MatchResult { - if (context.isNot) { - let objectMatch = false; - try { - assertObjectMatch( - // deno-lint-ignore no-explicit-any - context.value as Record, - expected as Record, - context.customMessage, - ); - objectMatch = true; - const actualString = format(context.value); - const expectedString = format(expected); - throw new AssertionError( - `Expected ${actualString} to NOT match ${expectedString}`, - ); - } catch (e) { - if (objectMatch) { - throw e; - } - return; - } - } else { - assertObjectMatch( - // deno-lint-ignore no-explicit-any - context.value as Record, - expected as Record, - context.customMessage, + const received = context.value; + + if (typeof received !== "object" || received === null) { + throw new AssertionError("Received value must be an object"); + } + + if (typeof expected !== "object" || expected === null) { + throw new AssertionError("Received value must be an object"); + } + + const pass = equal(context.value, expected, { + strictCheck: false, + customTesters: [ + ...context.customTesters, + iterableEquality, + subsetEquality, + ], + }); + + const triggerError = () => { + const actualString = format(context.value); + const expectedString = format(expected); + throw new AssertionError( + `Expected ${actualString} to NOT match ${expectedString}`, ); + }; + + if (context.isNot && pass || !context.isNot && !pass) { + triggerError(); } } diff --git a/expect/_to_match_object_test.ts b/expect/_to_match_object_test.ts index 225a9b0f9..144e51b92 100644 --- a/expect/_to_match_object_test.ts +++ b/expect/_to_match_object_test.ts @@ -50,3 +50,21 @@ Deno.test("expect().toMatchObject()", () => { expect([house0]).not.toMatchObject([desiredHouse]); }, AssertionError); }); + +Deno.test("expect().toMatchObject() with array", () => { + const fixedPriorityQueue = Array.from({ length: 5 }); + fixedPriorityQueue[0] = { data: 1, priority: 0 }; + + expect(fixedPriorityQueue).toMatchObject([ + { data: 1, priority: 0 }, + ]); +}); + +Deno.test("expect(),toMatchObject() with asyAsymmetric matcher", () => { + expect({ position: { x: 0, y: 0 } }).toMatchObject({ + position: { + x: expect.any(Number), + y: expect.any(Number), + }, + }); +}); diff --git a/expect/_utils.ts b/expect/_utils.ts index 0411e155d..8e3e707be 100644 --- a/expect/_utils.ts +++ b/expect/_utils.ts @@ -40,6 +40,40 @@ function isObject(a: unknown) { return a !== null && typeof a === "object"; } +function isObjectWithKeys(a: unknown) { + return ( + isObject(a) && + !(a instanceof Error) && + !Array.isArray(a) && + !(a instanceof Date) && + !(a instanceof Set) && + !(a instanceof Map) + ); +} + +function getObjectKeys(object: object): Array { + return [ + ...Object.keys(object), + ...Object.getOwnPropertySymbols(object).filter( + (s) => Object.getOwnPropertyDescriptor(object, s)?.enumerable, + ), + ]; +} + +function hasPropertyInObject(object: object, key: string | symbol): boolean { + const shouldTerminate = !object || typeof object !== "object" || + object === Object.prototype; + + if (shouldTerminate) { + return false; + } + + return ( + Object.prototype.hasOwnProperty.call(object, key) || + hasPropertyInObject(Object.getPrototypeOf(object), key) + ); +} + // deno-lint-ignore no-explicit-any function entries(obj: any) { if (!isObject(obj)) return []; @@ -199,3 +233,51 @@ export function iterableEquality( bStack.pop(); return true; } + +// Ported from https://github.com/jestjs/jest/blob/442c7f692e3a92f14a2fb56c1737b26fc663a0ef/packages/expect-utils/src/utils.ts#L341 +export function subsetEquality( + object: unknown, + subset: unknown, + customTesters: Tester[] = [], +): boolean | undefined { + const filteredCustomTesters = customTesters.filter((t) => + t !== subsetEquality + ); + + const subsetEqualityWithContext = + (seenReferences: WeakMap = new WeakMap()) => + // deno-lint-ignore no-explicit-any + (object: any, subset: any): boolean | undefined => { + if (!isObjectWithKeys(subset)) { + return undefined; + } + + if (seenReferences.has(subset)) return undefined; + seenReferences.set(subset, true); + + const matchResult = getObjectKeys(subset).every((key) => { + if (isObjectWithKeys(subset[key])) { + if (seenReferences.has(subset[key])) { + return equal(object[key], subset[key], { + customTesters: filteredCustomTesters, + }); + } + } + const result = object != null && + hasPropertyInObject(object, key) && + equal(object[key], subset[key], { + customTesters: [ + ...filteredCustomTesters, + subsetEqualityWithContext(seenReferences), + ], + }); + seenReferences.delete(subset[key]); + return result; + }); + seenReferences.delete(subset); + + return matchResult; + }; + + return subsetEqualityWithContext()(object, subset); +}