std/assert/object_match.ts
David Luis ab8f6a5746
fix(assert): assertObjectMatch doesn't print whole object (#5498)
* fix(assert): `assertObjectMatch` doesn't print whole object

* tweak

---------

Co-authored-by: Asher Gomez <ashersaupingomez@gmail.com>
2024-07-22 09:17:33 +10:00

139 lines
4.3 KiB
TypeScript

// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
import { assertEquals } from "./equals.ts";
/**
* Make an assertion that `expected` object is a subset of `actual` object,
* deeply. If not, then throw.
*
* @example Usage
* ```ts no-eval
* import { assertObjectMatch } from "@std/assert";
*
* assertObjectMatch({ foo: "bar" }, { foo: "bar" }); // Doesn't throw
* assertObjectMatch({ foo: "bar" }, { foo: "baz" }); // Throws
* ```
*
* @example Usage with nested objects
* ```ts no-eval
* import { assertObjectMatch } from "@std/assert";
*
* assertObjectMatch({ foo: { bar: 3, baz: 4 } }, { foo: { bar: 3 } }); // Doesn't throw
* assertObjectMatch({ foo: { bar: 3 } }, { foo: { bar: 3, baz: 4 } }); // Throws
* ```
*
* @param actual The actual value to be matched.
* @param expected The expected value to match.
* @param msg The optional message to display if the assertion fails.
*/
export function assertObjectMatch(
// deno-lint-ignore no-explicit-any
actual: Record<PropertyKey, any>,
expected: Record<PropertyKey, unknown>,
msg?: string,
): void {
return assertEquals(
// get the intersection of "actual" and "expected"
// side effect: all the instances' constructor field is "Object" now.
filter(actual, expected),
// set (nested) instances' constructor field to be "Object" without changing expected value.
// see https://github.com/denoland/deno_std/pull/1419
filter(expected, expected),
msg,
);
}
type loose = Record<PropertyKey, unknown>;
function isObject(val: unknown): boolean {
return typeof val === "object" && val !== null;
}
function filter(a: loose, b: loose) {
const seen = new WeakMap();
return filterObject(a, b);
function filterObject(a: loose, b: loose): loose {
// Prevent infinite loop with circular references with same filter
if ((seen.has(a)) && (seen.get(a) === b)) {
return a;
}
try {
seen.set(a, b);
} catch (err) {
if (err instanceof TypeError) {
throw new TypeError(
`Cannot assertObjectMatch ${a === null ? null : `type ${typeof a}`}`,
);
}
}
// Filter keys and symbols which are present in both actual and expected
const filtered = {} as loose;
const keysA = Reflect.ownKeys(a);
const keysB = Reflect.ownKeys(b);
const entries = keysA.filter((key) => keysB.includes(key))
.map((key) => [key, a[key as string]]) as Array<[string, unknown]>;
if (keysA.length && keysB.length && !entries.length) {
// If both objects are not empty but don't have the same keys or symbols,
// returns the entries in object a.
for (const key of keysA) {
filtered[key] = a[key];
}
return filtered;
}
for (const [key, value] of entries) {
// On regexp references, keep value as it to avoid loosing pattern and flags
if (value instanceof RegExp) {
filtered[key] = value;
continue;
}
const subset = (b as loose)[key];
// On array references, build a filtered array and filter nested objects inside
if (Array.isArray(value) && Array.isArray(subset)) {
filtered[key] = filterObject({ ...value }, { ...subset });
continue;
}
// On nested objects references, build a filtered object recursively
if (isObject(value) && isObject(subset)) {
// When both operands are maps, build a filtered map with common keys and filter nested objects inside
if ((value instanceof Map) && (subset instanceof Map)) {
filtered[key] = new Map(
[...value].filter(([k]) => subset.has(k)).map(
([k, v]) => {
const v2 = subset.get(k);
if (isObject(v) && isObject(v2)) {
return [k, filterObject(v as loose, v2 as loose)];
}
return [k, v];
},
),
);
continue;
}
// When both operands are set, build a filtered set with common values
if ((value instanceof Set) && (subset instanceof Set)) {
filtered[key] = value.intersection(subset);
continue;
}
filtered[key] = filterObject(value as loose, subset as loose);
continue;
}
filtered[key] = value;
}
return filtered;
}
}