diff --git a/packages/bupkis/src/assertion/impl/sync-parametric.ts b/packages/bupkis/src/assertion/impl/sync-parametric.ts index 54e274f2..268826fb 100644 --- a/packages/bupkis/src/assertion/impl/sync-parametric.ts +++ b/packages/bupkis/src/assertion/impl/sync-parametric.ts @@ -20,7 +20,6 @@ import { BupkisError, InvalidObjectSchemaError } from '../../error.js'; import { isA, isError, isNonNullObject, isString } from '../../guards.js'; import { AnyObjectSchema, - ArrayLikeSchema, BigintSchema, BooleanSchema, ConstructibleSchema, @@ -39,7 +38,6 @@ import { SymbolSchema, UndefinedSchema, UnknownArraySchema, - UnknownRecordSchema, UnknownSchema, WeakMapSchema, WeakRefSchema, @@ -590,95 +588,36 @@ export const strictEqualityAssertion = createAssertion( ); /** - * Assertion for testing deep equality between objects. + * Assertion for testing deep equality between any values. + * + * Works with primitives, objects, arrays, Maps, Sets, and other types. * * @example * * ```typescript + * // Primitives + * expect(42, 'to deep equal', 42); // passes + * expect('hello', 'to deeply equal', 'world'); // fails + * + * // Objects * expect({ a: 1, b: 2 }, 'to deep equal', { a: 1, b: 2 }); // passes * expect({ a: 1 }, 'to deeply equal', { a: 1, b: 2 }); // fails - * ``` - * - * @group Parametric Assertions (Sync) - */ -export const objectDeepEqualAssertion = createAssertion( - [UnknownRecordSchema, ['to deep equal', 'to deeply equal'], UnknownSchema], - (_, expected) => valueToSchema(expected, valueToSchemaOptionsForDeepEqual), -); - -/** - * Assertion for testing deep equality between array-like structures. * - * @example - * - * ```typescript + * // Arrays * expect([1, 2, 3], 'to deep equal', [1, 2, 3]); // passes - * expect([1, 2], 'to deeply equal', [1, 2, 3]); // fails - * ``` - * - * @group Parametric Assertions (Sync) - */ -export const arrayDeepEqualAssertion = createAssertion( - [ArrayLikeSchema, ['to deep equal', 'to deeply equal'], UnknownSchema], - (_, expected) => { - return valueToSchema(expected, valueToSchemaOptionsForDeepEqual); - }, -); - -/** - * Assertion for testing deep equality between Map instances. - * - * @example - * - * ```typescript - * const map1 = new Map([ - * ['a', 1], - * ['b', 2], - * ]); - * const map2 = new Map([ - * ['a', 1], - * ['b', 2], - * ]); - * expect(map1, 'to deep equal', map2); // passes - * - * const map3 = new Map([['a', 1]]); - * expect(map1, 'to deeply equal', map3); // fails - * ``` - * - * @group Parametric Assertions (Sync) - * @bupkisAnchor map-to-deep-equal - * @bupkisAssertionCategory collections - */ -export const mapDeepEqualAssertion = createAssertion( - [MapSchema, ['to deep equal', 'to deeply equal'], UnknownSchema], - (_, expected) => { - return valueToSchema(expected, valueToSchemaOptionsForDeepEqual); - }, -); - -/** - * Assertion for testing deep equality between Set instances. - * - * @example - * - * ```typescript - * const set1 = new Set([1, 2, 3]); - * const set2 = new Set([1, 2, 3]); - * expect(set1, 'to deep equal', set2); // passes * - * const set3 = new Set([1, 2]); - * expect(set1, 'to deeply equal', set3); // fails + * // Maps and Sets + * expect(new Map([['a', 1]]), 'to deep equal', new Map([['a', 1]])); // passes + * expect(new Set([1, 2]), 'to deeply equal', new Set([1, 2])); // passes * ``` * * @group Parametric Assertions (Sync) - * @bupkisAnchor set-to-deep-equal - * @bupkisAssertionCategory collections + * @bupkisAnchor unknown-to-deep-equal-any + * @bupkisAssertionCategory equality */ -export const setDeepEqualAssertion = createAssertion( - [SetSchema, ['to deep equal', 'to deeply equal'], UnknownSchema], - (_, expected) => { - return valueToSchema(expected, valueToSchemaOptionsForDeepEqual); - }, +export const deepEqualAssertion = createAssertion( + [['to deep equal', 'to deeply equal'], UnknownSchema], + (_, expected) => valueToSchema(expected, valueToSchemaOptionsForDeepEqual), ); /** @@ -975,44 +914,35 @@ export const stringLengthAssertion = createAssertion( ); /** - * Assertion for testing if an object satisfies a pattern or shape. + * Assertion for testing if a value satisfies a pattern or shape. + * + * Works with any value type: primitives, objects, arrays, or cross-type checks. + * Uses partial matching semantics - extra properties are allowed in objects. * * @example * * ```typescript + * // Primitives + * expect(42, 'to satisfy', 42); // passes + * expect('hello', 'satisfies', 'hello'); // passes + * + * // Objects (partial matching) * expect({ name: 'John', age: 30 }, 'to satisfy', { name: 'John' }); // passes * expect({ name: 'John' }, 'to be like', { name: 'John', age: 30 }); // fails - * ``` * - * @group Parametric Assertions (Sync) - * @bupkisAnchor object-to-satisfy-any - * @bupkisAssertionCategory object - * @bupkisRedirect satisfies - */ -export const objectSatisfiesAssertion = createAssertion( - [ - AnyObjectSchema.nonoptional(), - ['to satisfy', 'to be like', 'satisfies'], - UnknownSchema, - ], - (_subject, shape) => valueToSchema(shape, valueToSchemaOptionsForSatisfies), -); - -/** - * Assertion for testing if an array-like structure satisfies a pattern or - * shape. + * // Arrays + * expect([1, 2, 3], 'to satisfy', [1, 2, 3]); // passes * - * @example - * - * ```typescript - * expect([1, 2, 3], 'to satisfy', [1, NumberSchema, 3]); // passes - * expect([1, 'two'], 'to be like', [1, NumberSchema]); // fails + * // Cross-type satisfaction + * expect([1, 2, 3], 'to satisfy', { length: 3 }); // passes * ``` * * @group Parametric Assertions (Sync) + * @bupkisAnchor unknown-to-satisfy-any + * @bupkisAssertionCategory equality */ -export const arraySatisfiesAssertion = createAssertion( - [ArrayLikeSchema, ['to satisfy', 'to be like'], UnknownSchema], +export const satisfiesAssertion = createAssertion( + [['to satisfy', 'to be like', 'satisfies'], UnknownSchema], (_subject, shape) => valueToSchema(shape, valueToSchemaOptionsForSatisfies), ); diff --git a/packages/bupkis/src/assertion/impl/sync.ts b/packages/bupkis/src/assertion/impl/sync.ts index f93eb9dc..53af2b6c 100644 --- a/packages/bupkis/src/assertion/impl/sync.ts +++ b/packages/bupkis/src/assertion/impl/sync.ts @@ -106,8 +106,7 @@ import { } from './sync-esoteric.js'; import { SyncIterableAssertions } from './sync-iterable.js'; import { - arrayDeepEqualAssertion, - arraySatisfiesAssertion, + deepEqualAssertion, errorMessageAssertion, errorMessageMatchingAssertion, functionArityAssertion, @@ -116,17 +115,14 @@ import { functionThrowsTypeAssertion, functionThrowsTypeSatisfyingAssertion, instanceOfAssertion, - mapDeepEqualAssertion, numberCloseToAssertion, numberGreaterThanAssertion, numberGreaterThanOrEqualAssertion, numberLessThanAssertion, numberLessThanOrEqualAssertion, numberWithinRangeAssertion, - objectDeepEqualAssertion, - objectSatisfiesAssertion, oneOfAssertion, - setDeepEqualAssertion, + satisfiesAssertion, strictEqualityAssertion, stringBeginsWithAssertion, stringEndsWithAssertion, @@ -242,10 +238,7 @@ export const SyncParametricAssertions = [ errorMessageAssertion, errorMessageMatchingAssertion, strictEqualityAssertion, - objectDeepEqualAssertion, - arrayDeepEqualAssertion, - mapDeepEqualAssertion, - setDeepEqualAssertion, + deepEqualAssertion, functionThrowsAssertion, functionThrowsTypeAssertion, functionThrowsSatisfyingAssertion, @@ -253,8 +246,7 @@ export const SyncParametricAssertions = [ stringIncludesAssertion, stringLengthAssertion, stringMatchesAssertion, - objectSatisfiesAssertion, - arraySatisfiesAssertion, + satisfiesAssertion, ] as const; /** diff --git a/packages/bupkis/src/value-to-schema.ts b/packages/bupkis/src/value-to-schema.ts index 579869a7..838229b1 100644 --- a/packages/bupkis/src/value-to-schema.ts +++ b/packages/bupkis/src/value-to-schema.ts @@ -61,12 +61,14 @@ export const valueToSchema = ( ): z.ZodType => { const { _currentDepth = 0, + literalEmptyArrays = false, literalEmptyObjects = false, literalPrimitives = false, literalRegExp = false, literalTuples = false, maxDepth = 10, noMixedArrays = false, + permissivePropertyCheck = false, strict = false, } = options; @@ -283,7 +285,11 @@ export const valueToSchema = ( const filteredValue = value; // Always process all elements if (filteredValue.length === 0) { - // For empty arrays, use z.tuple() if literalTuples is enabled + // When literalEmptyArrays is false, empty arrays match any array + if (!literalEmptyArrays) { + return z.array(z.unknown()); + } + // For literal empty arrays, use z.tuple() if literalTuples is enabled if (literalTuples) { return z.tuple([]); } @@ -302,6 +308,10 @@ export const valueToSchema = ( ...options, _currentDepth: _currentDepth + 1, literalPrimitives: itemLiteralPrimitives, + // Disable permissive property check for array elements to avoid + // conflicts in union schemas (permissive z.any() would accept + // array elements meant for other branches) + permissivePropertyCheck: false, }, visited, ); @@ -431,7 +441,103 @@ export const valueToSchema = ( } } - // Create the base object schema + // When permissivePropertyCheck is enabled, use a custom validator that + // checks properties via `in` operator. This allows validating properties + // on any value type (functions, arrays, etc.) not just plain objects. + if (permissivePropertyCheck && !strict) { + const capturedSchemaShape = schemaShape; + const capturedProtoSchema = protoSchema; + const capturedUndefinedKeys = undefinedKeys; + + return z.any().superRefine((val, ctx) => { + // Can't use `in` operator on primitives + if ( + val == null || + (typeof val !== 'object' && typeof val !== 'function') + ) { + ctx.addIssue({ + code: 'custom', + message: `Expected a value with properties, but received ${val === null ? 'null' : typeof val}`, + }); + return; + } + + // Check __proto__ if expected + // Cast is safe - we've verified val is object or function above + const valAsObject = val as object; + if (capturedProtoSchema) { + if (!hasOwn(valAsObject, '__proto__')) { + ctx.addIssue({ + code: 'custom', + message: 'Expected property "__proto__" to exist', + path: ['__proto__'], + }); + } else { + const actualProto = (val as Record)[ + '__proto__' + ]; + const protoResult = capturedProtoSchema.safeParse(actualProto); + if (!protoResult.success) { + for (const issue of protoResult.error.issues) { + ctx.addIssue({ + ...issue, + path: ['__proto__', ...issue.path], + }); + } + } + } + } + + // Check each expected property + for (const [key, schema] of entries(capturedSchemaShape)) { + if (!(key in val)) { + ctx.addIssue({ + code: 'custom', + message: `Expected property "${key}" to exist`, + path: [key], + }); + continue; + } + + // Property access can throw for reserved properties on strict mode + // functions (e.g., 'arguments', 'caller', 'callee') + let propValue: unknown; + try { + propValue = (val as Record)[key]; + } catch { + ctx.addIssue({ + code: 'custom', + message: `Property "${key}" exists but cannot be accessed`, + path: [key], + }); + continue; + } + + const result = schema.safeParse(propValue); + if (!result.success) { + for (const issue of result.error.issues) { + ctx.addIssue({ + ...issue, + path: [key, ...issue.path], + }); + } + } + } + + // Check undefined keys exist with undefined value + for (const key of capturedUndefinedKeys) { + if (!hasOwn(valAsObject, key)) { + ctx.addIssue({ + code: 'custom', + message: `Expected property "${key}" to exist with value undefined`, + path: [key], + }); + } + } + }); + } + + // Create the base object schema (standard Zod object validation) const baseSchema = strict ? z.strictObject(schemaShape) : z.looseObject(schemaShape); @@ -548,6 +654,14 @@ export interface ValueToSchemaOptions { */ _currentDepth?: number; + /** + * If `true`, treat empty arrays `[]` as literal empty arrays that only match + * arrays with zero elements. When `false`, empty arrays match any array. + * + * @defaultValue false + */ + literalEmptyArrays?: boolean; + /** * If `true`, treat empty objects `{}` as literal empty objects that only * match objects with zero own properties @@ -597,6 +711,18 @@ export interface ValueToSchemaOptions { */ noMixedArrays?: boolean; + /** + * If `true`, use property checking that works on any value type (functions, + * arrays, etc.), not just plain objects. Properties are checked using the + * `in` operator which works with non-enumerable and inherited properties. + * + * Only applies when `strict` is `false`. When `strict` is `true`, standard + * Zod object validation is used which requires exact type matching. + * + * @defaultValue false + */ + permissivePropertyCheck?: boolean; + /** * If `true`, will disallow unknown properties in parsed objects * @@ -613,10 +739,12 @@ export interface ValueToSchemaOptions { * properties. */ export const valueToSchemaOptionsForSatisfies = freeze({ + literalEmptyArrays: false, literalEmptyObjects: true, literalPrimitives: true, literalRegExp: false, literalTuples: true, + permissivePropertyCheck: true, strict: false, } as const) satisfies ValueToSchemaOptions; @@ -628,6 +756,7 @@ export const valueToSchemaOptionsForSatisfies = freeze({ * matching. */ export const valueToSchemaOptionsForDeepEqual = freeze({ + literalEmptyArrays: true, literalEmptyObjects: true, literalPrimitives: true, literalRegExp: true, diff --git a/packages/bupkis/test-data/sync-parametric-generators.ts b/packages/bupkis/test-data/sync-parametric-generators.ts index 192ea2ed..8003e085 100644 --- a/packages/bupkis/test-data/sync-parametric-generators.ts +++ b/packages/bupkis/test-data/sync-parametric-generators.ts @@ -14,31 +14,70 @@ import { type AnyAssertion } from '../src/types.js'; export const SyncParametricGenerators = new Map([ [ - assertions.arrayDeepEqualAssertion, + assertions.deepEqualAssertion, fc - .array(filteredAnything, { minLength: 1, size: 'small' }) - .filter(objectFilter) + .oneof( + // Primitives + fc.string(), + fc.integer(), + fc.boolean(), + fc.constant(null), + // Arrays + fc + .array(filteredAnything, { minLength: 1, size: 'small' }) + .filter(objectFilter), + // Objects + fc.object().filter(objectFilter), + // Maps + fc + .array( + fc.tuple( + fc.oneof(fc.string(), fc.integer()), + filteredAnything.filter( + (v) => typeof v !== 'function' && !(v instanceof Map), + ), + ), + { minLength: 1, size: 'small' }, + ) + .map((entries) => new Map(entries)), + // Sets + fc + .array( + filteredAnything.filter( + (v) => typeof v !== 'function' && !(v instanceof Set), + ), + { minLength: 1, size: 'small' }, + ) + .map((values) => new Set(values)), + ) .chain((expected) => fc.tuple( fc.constant(structuredClone(expected)), - fc.constantFrom( - ...extractPhrases(assertions.arrayDeepEqualAssertion), - ), + fc.constantFrom(...extractPhrases(assertions.deepEqualAssertion)), fc.constant(expected), ), ), ], [ - assertions.arraySatisfiesAssertion, + assertions.satisfiesAssertion, fc - .array(filteredAnything, { minLength: 1, size: 'small' }) - .filter(objectFilter) + .oneof( + // Primitives + fc.string(), + fc.integer(), + fc.boolean(), + fc.constant(null), + // Arrays + fc + .array(filteredAnything, { minLength: 1, size: 'small' }) + .filter(objectFilter), + // Objects + fc.object({ depthSize: 'small' }).filter(objectFilter), + ) .chain((expected) => fc.tuple( fc.constant(structuredClone(expected)), - fc.constantFrom( - ...extractPhrases(assertions.arraySatisfiesAssertion), - ), + fc.constantFrom(...extractPhrases(assertions.satisfiesAssertion)), fc.constant(expected), ), ), @@ -248,75 +287,6 @@ export const SyncParametricGenerators = new Map([ ), ), ], - [ - assertions.objectDeepEqualAssertion, - fc - .object() - .filter(objectFilter) - .chain((expected) => - fc.tuple( - fc.constant(structuredClone(expected)), - fc.constantFrom( - ...extractPhrases(assertions.objectDeepEqualAssertion), - ), - fc.constant(expected), - ), - ), - ], - [ - assertions.mapDeepEqualAssertion, - fc - .array( - fc.tuple( - fc.oneof(fc.string(), fc.integer()), - filteredAnything.filter( - (v) => typeof v !== 'function' && !(v instanceof Map), - ), - ), - { minLength: 1, size: 'small' }, - ) - .chain((entries) => { - const expected = new Map(entries); - return fc.tuple( - fc.constant(new Map(entries)), - fc.constantFrom(...extractPhrases(assertions.mapDeepEqualAssertion)), - fc.constant(expected), - ); - }), - ], - [ - assertions.setDeepEqualAssertion, - fc - .array( - filteredAnything.filter( - (v) => typeof v !== 'function' && !(v instanceof Set), - ), - { minLength: 1, size: 'small' }, - ) - .chain((values) => { - const expected = new Set(values); - return fc.tuple( - fc.constant(new Set(values)), - fc.constantFrom(...extractPhrases(assertions.setDeepEqualAssertion)), - fc.constant(expected), - ); - }), - ], - [ - assertions.objectSatisfiesAssertion, - fc - .object({ depthSize: 'small' }) - .filter(objectFilter) - .chain((expected) => - fc.tuple( - fc.constant(structuredClone(expected)), - fc.constantFrom( - ...extractPhrases(assertions.objectSatisfiesAssertion), - ), - fc.constant(expected), - ), - ), - ], [ assertions.oneOfAssertion, [ diff --git a/packages/bupkis/test/assertion-error/assert-error.test.ts.snapshot b/packages/bupkis/test/assertion-error/assert-error.test.ts.snapshot index 389b0480..00fbe37d 100644 --- a/packages/bupkis/test/assertion-error/assert-error.test.ts.snapshot +++ b/packages/bupkis/test/assertion-error/assert-error.test.ts.snapshot @@ -1,6 +1,6 @@ exports[`Comparison with Node.js' assert module > deepStrictEqual / \"to deep equal\" > \"to deep equal\" 1`] = ` { - "message": "Assertion \\"{record} 'to deep equal' / 'to deeply equal' {unknown}\\" failed:\\n- expected - 1\\n+ actual + 1\\n\\n Object {\\n \\"a\\": 1,\\n \\"b\\": Object {\\n- \\"c\\": 3,\\n+ \\"c\\": 2,\\n },\\n }", + "message": "Assertion \\"{unknown} 'to deep equal' / 'to deeply equal' {unknown}\\" failed:\\n- expected - 1\\n+ actual + 1\\n\\n Object {\\n \\"a\\": 1,\\n \\"b\\": Object {\\n- \\"c\\": 3,\\n+ \\"c\\": 2,\\n },\\n }", "generatedMessage": false, "name": "AssertionError", "code": "ERR_ASSERTION", @@ -17,7 +17,7 @@ exports[`Comparison with Node.js' assert module > deepStrictEqual / \"to deep eq } }, "diff": "simple", - "assertionId": "record-to-deep-equal-to-deeply-equal-unknown-3s3p" + "assertionId": "unknown-to-deep-equal-to-deeply-equal-unknown-3s2p" } `; diff --git a/packages/bupkis/test/assertion-error/sync-parametric-error.test.ts b/packages/bupkis/test/assertion-error/sync-parametric-error.test.ts index df17cb1b..9769e1b4 100644 --- a/packages/bupkis/test/assertion-error/sync-parametric-error.test.ts +++ b/packages/bupkis/test/assertion-error/sync-parametric-error.test.ts @@ -15,17 +15,11 @@ import { takeErrorSnapshot } from './error-snapshot-util.js'; const failingAssertions = new Map void>([ [ - assertions.arrayDeepEqualAssertion, + assertions.deepEqualAssertion, () => { expect([1, 2, 3], 'to deeply equal', [1, 2, 4]); }, ], - [ - assertions.arraySatisfiesAssertion, - () => { - expect([1, 2, 3], 'to satisfy', [1, 2, 4]); - }, - ], [ assertions.errorMessageAssertion, () => { @@ -104,12 +98,6 @@ const failingAssertions = new Map void>([ expect('hello', 'to be an instance of', Number); }, ], - [ - assertions.mapDeepEqualAssertion, - () => { - expect(new Map([['a', 1]]), 'to deeply equal', new Map([['a', 2]])); - }, - ], [ assertions.numberCloseToAssertion, () => { @@ -146,18 +134,6 @@ const failingAssertions = new Map void>([ expect(15, 'to be within', 1, 10); }, ], - [ - assertions.objectDeepEqualAssertion, - () => { - expect({ a: 1, b: 2 }, 'to deeply equal', { a: 1, b: 3 }); - }, - ], - [ - assertions.objectSatisfiesAssertion, - () => { - expect({ a: 1, b: 2 }, 'to satisfy', { a: 1, b: 3 }); - }, - ], [ assertions.oneOfAssertion, () => { @@ -165,9 +141,9 @@ const failingAssertions = new Map void>([ }, ], [ - assertions.setDeepEqualAssertion, + assertions.satisfiesAssertion, () => { - expect(new Set([1, 2, 3]), 'to deeply equal', new Set([1, 2, 4])); + expect([1, 2, 3], 'to satisfy', [1, 2, 4]); }, ], [ diff --git a/packages/bupkis/test/assertion-error/sync-parametric-error.test.ts.snapshot b/packages/bupkis/test/assertion-error/sync-parametric-error.test.ts.snapshot index 3d854678..d64ee302 100644 --- a/packages/bupkis/test/assertion-error/sync-parametric-error.test.ts.snapshot +++ b/packages/bupkis/test/assertion-error/sync-parametric-error.test.ts.snapshot @@ -1,45 +1,3 @@ -exports[`Sync Parametric Assertion Error Snapshots > \"{arraylike} 'to deep equal' / 'to deeply equal' {unknown}\" [arraylike-to-deep-equal-to-deeply-equal-unknown-3s3p] > should throw a consistent AssertionError [arraylike-to-deep-equal-to-deeply-equal-unknown-3s3p] 1`] = ` -{ - "message": "Assertion \\"{arraylike} 'to deep equal' / 'to deeply equal' {unknown}\\" failed:\\n- expected - 1\\n+ actual + 1\\n\\n Array [\\n 1,\\n 2,\\n- 4,\\n+ 3,\\n ]", - "generatedMessage": false, - "name": "AssertionError", - "code": "ERR_ASSERTION", - "actual": [ - 1, - 2, - 3 - ], - "expected": [ - 1, - 2, - 4 - ], - "diff": "simple", - "assertionId": "arraylike-to-deep-equal-to-deeply-equal-unknown-3s3p" -} -`; - -exports[`Sync Parametric Assertion Error Snapshots > \"{arraylike} 'to satisfy' / 'to be like' {unknown}\" [arraylike-to-satisfy-to-be-like-unknown-3s3p] > should throw a consistent AssertionError [arraylike-to-satisfy-to-be-like-unknown-3s3p] 1`] = ` -{ - "message": "Assertion \\"{arraylike} 'to satisfy' / 'to be like' {unknown}\\" failed:\\n- expected - 1\\n+ actual + 1\\n\\n Array [\\n 1,\\n 2,\\n- 4,\\n+ 3,\\n ]", - "generatedMessage": false, - "name": "AssertionError", - "code": "ERR_ASSERTION", - "actual": [ - 1, - 2, - 3 - ], - "expected": [ - 1, - 2, - 4 - ], - "diff": "simple", - "assertionId": "arraylike-to-satisfy-to-be-like-unknown-3s3p" -} -`; - exports[`Sync Parametric Assertion Error Snapshots > \"{error} 'to have message matching' {regexp}\" [error-to-have-message-matching-regexp-3s3p] > should throw a consistent AssertionError [error-to-have-message-matching-regexp-3s3p] 1`] = ` { "message": "Expected error message \\"wrong message\\" to match /expected/", @@ -132,19 +90,6 @@ exports[`Sync Parametric Assertion Error Snapshots > \"{function} 'to throw'\" [ } `; -exports[`Sync Parametric Assertion Error Snapshots > \"{map} 'to deep equal' / 'to deeply equal' {unknown}\" [map-to-deep-equal-to-deeply-equal-unknown-3s3p] > should throw a consistent AssertionError [map-to-deep-equal-to-deeply-equal-unknown-3s3p] 1`] = ` -{ - "message": "Assertion \\"{map} 'to deep equal' / 'to deeply equal' {unknown}\\" failed:\\nCompared values have no visual difference.", - "generatedMessage": false, - "name": "AssertionError", - "code": "ERR_ASSERTION", - "actual": {}, - "expected": {}, - "diff": "simple", - "assertionId": "map-to-deep-equal-to-deeply-equal-unknown-3s3p" -} -`; - exports[`Sync Parametric Assertion Error Snapshots > \"{number} 'to be close to' {number} {number?}\" [number-to-be-close-to-number-number-4s4p] > should throw a consistent AssertionError [number-to-be-close-to-number-number-4s4p] 1`] = ` { "message": "Expected 10 to be close to 5 (within 2), but difference was 5\\n- expected - 1\\n+ actual + 1\\n\\n- 5\\n+ 10", @@ -221,57 +166,6 @@ exports[`Sync Parametric Assertion Error Snapshots > \"{number} 'to be within' / } `; -exports[`Sync Parametric Assertion Error Snapshots > \"{object!} 'to satisfy' / 'to be like' / 'satisfies' {unknown}\" [object-to-satisfy-to-be-like-satisfies-unknown-3s3p] > should throw a consistent AssertionError [object-to-satisfy-to-be-like-satisfies-unknown-3s3p] 1`] = ` -{ - "message": "Assertion \\"{object!} 'to satisfy' / 'to be like' / 'satisfies' {unknown}\\" failed:\\n- expected - 1\\n+ actual + 1\\n\\n Object {\\n \\"a\\": 1,\\n- \\"b\\": 3,\\n+ \\"b\\": 2,\\n }", - "generatedMessage": false, - "name": "AssertionError", - "code": "ERR_ASSERTION", - "actual": { - "a": 1, - "b": 2 - }, - "expected": { - "a": 1, - "b": 3 - }, - "diff": "simple", - "assertionId": "object-to-satisfy-to-be-like-satisfies-unknown-3s3p" -} -`; - -exports[`Sync Parametric Assertion Error Snapshots > \"{record} 'to deep equal' / 'to deeply equal' {unknown}\" [record-to-deep-equal-to-deeply-equal-unknown-3s3p] > should throw a consistent AssertionError [record-to-deep-equal-to-deeply-equal-unknown-3s3p] 1`] = ` -{ - "message": "Assertion \\"{record} 'to deep equal' / 'to deeply equal' {unknown}\\" failed:\\n- expected - 1\\n+ actual + 1\\n\\n Object {\\n \\"a\\": 1,\\n- \\"b\\": 3,\\n+ \\"b\\": 2,\\n }", - "generatedMessage": false, - "name": "AssertionError", - "code": "ERR_ASSERTION", - "actual": { - "a": 1, - "b": 2 - }, - "expected": { - "a": 1, - "b": 3 - }, - "diff": "simple", - "assertionId": "record-to-deep-equal-to-deeply-equal-unknown-3s3p" -} -`; - -exports[`Sync Parametric Assertion Error Snapshots > \"{set} 'to deep equal' / 'to deeply equal' {unknown}\" [set-to-deep-equal-to-deeply-equal-unknown-3s3p] > should throw a consistent AssertionError [set-to-deep-equal-to-deeply-equal-unknown-3s3p] 1`] = ` -{ - "message": "Assertion \\"{set} 'to deep equal' / 'to deeply equal' {unknown}\\" failed:\\nCompared values have no visual difference.", - "generatedMessage": false, - "name": "AssertionError", - "code": "ERR_ASSERTION", - "actual": {}, - "expected": {}, - "diff": "simple", - "assertionId": "set-to-deep-equal-to-deeply-equal-unknown-3s3p" -} -`; - exports[`Sync Parametric Assertion Error Snapshots > \"{string} 'includes' / 'contains' / 'to include' / 'to contain' {string}\" [string-includes-contains-to-include-to-contain-string-3s3p] > should throw a consistent AssertionError [string-includes-contains-to-include-to-contain-string-3s3p] 1`] = ` { "message": "Expected \\"hello world\\" to include \\"universe\\"", @@ -428,3 +322,45 @@ exports[`Sync Parametric Assertion Error Snapshots > \"{unknown} 'to be' / 'to e "assertionId": "unknown-to-be-to-equal-equals-is-is-equal-to-to-strictly-equal-is-strictly-equal-to-unknown-3s2p" } `; + +exports[`Sync Parametric Assertion Error Snapshots > \"{unknown} 'to deep equal' / 'to deeply equal' {unknown}\" [unknown-to-deep-equal-to-deeply-equal-unknown-3s2p] > should throw a consistent AssertionError [unknown-to-deep-equal-to-deeply-equal-unknown-3s2p] 1`] = ` +{ + "message": "Assertion \\"{unknown} 'to deep equal' / 'to deeply equal' {unknown}\\" failed:\\n- expected - 1\\n+ actual + 1\\n\\n Array [\\n 1,\\n 2,\\n- 4,\\n+ 3,\\n ]", + "generatedMessage": false, + "name": "AssertionError", + "code": "ERR_ASSERTION", + "actual": [ + 1, + 2, + 3 + ], + "expected": [ + 1, + 2, + 4 + ], + "diff": "simple", + "assertionId": "unknown-to-deep-equal-to-deeply-equal-unknown-3s2p" +} +`; + +exports[`Sync Parametric Assertion Error Snapshots > \"{unknown} 'to satisfy' / 'to be like' / 'satisfies' {unknown}\" [unknown-to-satisfy-to-be-like-satisfies-unknown-3s2p] > should throw a consistent AssertionError [unknown-to-satisfy-to-be-like-satisfies-unknown-3s2p] 1`] = ` +{ + "message": "Assertion \\"{unknown} 'to satisfy' / 'to be like' / 'satisfies' {unknown}\\" failed:\\n- expected - 1\\n+ actual + 1\\n\\n Array [\\n 1,\\n 2,\\n- 4,\\n+ 3,\\n ]", + "generatedMessage": false, + "name": "AssertionError", + "code": "ERR_ASSERTION", + "actual": [ + 1, + 2, + 3 + ], + "expected": [ + 1, + 2, + 4 + ], + "diff": "simple", + "assertionId": "unknown-to-satisfy-to-be-like-satisfies-unknown-3s2p" +} +`; diff --git a/packages/bupkis/test/assertion/assertion-classification.test.ts b/packages/bupkis/test/assertion/assertion-classification.test.ts index 656fcb4f..8f4cd9ea 100644 --- a/packages/bupkis/test/assertion/assertion-classification.test.ts +++ b/packages/bupkis/test/assertion/assertion-classification.test.ts @@ -67,7 +67,7 @@ describe('Assertion Classification', () => { } // This will pass since T010 correctly classifies all assertions - // Updated from 72 to 86 after adding 14 sync iterable assertions - expect(totalClassified, 'to equal', 86); + // Updated from 86 to 82 after consolidating deep equal and satisfy assertions + expect(totalClassified, 'to equal', 82); }); }); diff --git a/packages/bupkis/test/assertion/satisfy-deep-equal.test.ts b/packages/bupkis/test/assertion/satisfy-deep-equal.test.ts new file mode 100644 index 00000000..0cbdd76d --- /dev/null +++ b/packages/bupkis/test/assertion/satisfy-deep-equal.test.ts @@ -0,0 +1,396 @@ +/** + * Tests for the consolidated `satisfiesAssertion` and `deepEqualAssertion` + * assertions. + * + * These tests verify that the consolidated assertions work correctly with: + * + * - Primitives (strings, numbers, booleans, null, undefined, symbols, bigints) + * - Objects and arrays + * - Cross-type satisfaction (e.g., array satisfying object shape) + * - Negated assertions + * - Edge cases (NaN, Infinity, etc.) + */ + +import { describe, it } from 'node:test'; + +import { expect } from '../custom-assertions.js'; + +describe('satisfiesAssertion (consolidated)', () => { + describe('primitives', () => { + it('should satisfy identical numbers', () => { + expect(42, 'to satisfy', 42); + }); + + it('should fail for different numbers', () => { + expect(() => expect(42, 'to satisfy', 43), 'to throw'); + }); + + it('should satisfy identical strings', () => { + expect('hello', 'to satisfy', 'hello'); + }); + + it('should fail for different strings', () => { + expect(() => expect('hello', 'to satisfy', 'world'), 'to throw'); + }); + + it('should satisfy identical booleans', () => { + expect(true, 'to satisfy', true); + expect(false, 'to satisfy', false); + }); + + it('should fail for different booleans', () => { + expect(() => expect(true, 'to satisfy', false), 'to throw'); + }); + + it('should satisfy null', () => { + expect(null, 'to satisfy', null); + }); + + it('should satisfy undefined', () => { + expect(undefined, 'to satisfy', undefined); + }); + + it('should satisfy bigints', () => { + expect(BigInt(42), 'to satisfy', BigInt(42)); + }); + + it('should fail for different bigints', () => { + expect(() => expect(BigInt(42), 'to satisfy', BigInt(43)), 'to throw'); + }); + }); + + describe('objects', () => { + it('should satisfy exact object match', () => { + expect({ a: 1, b: 2 }, 'to satisfy', { a: 1, b: 2 }); + }); + + it('should satisfy partial object match (extra properties allowed)', () => { + expect({ a: 1, b: 2, c: 3 }, 'to satisfy', { a: 1 }); + }); + + it('should fail when expected property is missing', () => { + expect(() => expect({ a: 1 }, 'to satisfy', { a: 1, b: 2 }), 'to throw'); + }); + + it('should fail when property value differs', () => { + expect( + () => expect({ a: 1, b: 2 }, 'to satisfy', { a: 1, b: 3 }), + 'to throw', + ); + }); + + it('should satisfy nested objects', () => { + expect({ a: { b: { c: 1 } } }, 'to satisfy', { a: { b: { c: 1 } } }); + }); + + it('should satisfy partial nested objects', () => { + expect({ a: { b: 1, c: 2 }, d: 3 }, 'to satisfy', { a: { b: 1 } }); + }); + }); + + describe('arrays', () => { + it('should satisfy identical arrays', () => { + expect([1, 2, 3], 'to satisfy', [1, 2, 3]); + }); + + it('should fail for different arrays', () => { + expect(() => expect([1, 2, 3], 'to satisfy', [1, 2, 4]), 'to throw'); + }); + + it('should fail for different length arrays', () => { + expect(() => expect([1, 2], 'to satisfy', [1, 2, 3]), 'to throw'); + }); + + it('should satisfy nested arrays', () => { + expect( + [ + [1, 2], + [3, 4], + ], + 'to satisfy', + [ + [1, 2], + [3, 4], + ], + ); + }); + }); + + describe('arrays with object properties', () => { + it('should satisfy array with index properties', () => { + // Arrays can be checked for specific index values + expect([1, 2, 3], 'to satisfy', [1, 2, 3]); + }); + + it('should fail when array elements differ', () => { + expect(() => expect([1, 2, 3], 'to satisfy', [1, 2, 4]), 'to throw'); + }); + + it('should work with arrays containing objects', () => { + expect([{ a: 1 }, { b: 2 }], 'to satisfy', [{ a: 1 }, { b: 2 }]); + }); + }); + + describe('negated assertions', () => { + it('should pass when not satisfying different primitives', () => { + expect(42, 'not to satisfy', 43); + }); + + it('should pass when not satisfying different objects', () => { + expect({ a: 1 }, 'not to satisfy', { a: 2 }); + }); + + it('should fail when actually satisfying', () => { + expect(() => expect(42, 'not to satisfy', 42), 'to throw'); + }); + }); + + describe('edge cases', () => { + it('should satisfy empty objects', () => { + expect({}, 'to satisfy', {}); + }); + + it('should satisfy empty arrays', () => { + expect([], 'to satisfy', []); + }); + + it('should satisfy Infinity', () => { + expect(Infinity, 'to satisfy', Infinity); + }); + + it('should satisfy -Infinity', () => { + expect(-Infinity, 'to satisfy', -Infinity); + }); + }); + + describe('cross-type satisfaction (permissive property check)', () => { + it('should satisfy array with length property from object shape', () => { + expect([1, 2, 3], 'to satisfy', { length: 3 }); + }); + + it('should fail array with wrong length from object shape', () => { + expect(() => expect([1, 2, 3], 'to satisfy', { length: 5 }), 'to throw'); + }); + + it('should satisfy function with static properties', () => { + expect(Promise, 'to satisfy', { reject: expect.it('to be a function') }); + }); + + it('should satisfy function with multiple static properties', () => { + expect(Promise, 'to satisfy', { + all: expect.it('to be a function'), + reject: expect.it('to be a function'), + resolve: expect.it('to be a function'), + }); + }); + + it('should fail function missing expected property', () => { + expect( + () => + expect(Promise, 'to satisfy', { + nonexistent: expect.it('to be a function'), + }), + 'to throw', + ); + }); + + it('should fail function with wrong property type', () => { + expect( + () => + expect(Promise, 'to satisfy', { + reject: expect.it('to be a string'), + }), + 'to throw', + ); + }); + + it('should satisfy class constructor with prototype property', () => { + class MyClass { + static staticMethod() {} + } + expect(MyClass, 'to satisfy', { + prototype: expect.it('to be an object'), + staticMethod: expect.it('to be a function'), + }); + }); + + it('should fail when subject is primitive and shape is object', () => { + expect(() => expect('hello', 'to satisfy', { length: 5 }), 'to throw'); + }); + + it('should fail when subject is null and shape is object', () => { + expect(() => expect(null, 'to satisfy', { foo: 1 }), 'to throw'); + }); + }); +}); + +describe('deepEqualAssertion (consolidated)', () => { + describe('primitives', () => { + it('should deep equal identical numbers', () => { + expect(42, 'to deep equal', 42); + }); + + it('should fail for different numbers', () => { + expect(() => expect(42, 'to deep equal', 43), 'to throw'); + }); + + it('should deep equal identical strings', () => { + expect('hello', 'to deep equal', 'hello'); + }); + + it('should fail for different strings', () => { + expect(() => expect('hello', 'to deep equal', 'world'), 'to throw'); + }); + + it('should deep equal identical booleans', () => { + expect(true, 'to deep equal', true); + expect(false, 'to deep equal', false); + }); + + it('should fail for different booleans', () => { + expect(() => expect(true, 'to deep equal', false), 'to throw'); + }); + + it('should deep equal null', () => { + expect(null, 'to deep equal', null); + }); + + it('should deep equal undefined', () => { + expect(undefined, 'to deep equal', undefined); + }); + + it('should deep equal bigints', () => { + expect(BigInt(42), 'to deep equal', BigInt(42)); + }); + + it('should fail for different bigints', () => { + expect(() => expect(BigInt(42), 'to deep equal', BigInt(43)), 'to throw'); + }); + }); + + describe('objects', () => { + it('should deep equal identical objects', () => { + expect({ a: 1, b: 2 }, 'to deep equal', { a: 1, b: 2 }); + }); + + it('should fail when object has extra properties (strict mode)', () => { + expect( + () => expect({ a: 1, b: 2, c: 3 }, 'to deep equal', { a: 1, b: 2 }), + 'to throw', + ); + }); + + it('should fail when expected property is missing', () => { + expect( + () => expect({ a: 1 }, 'to deep equal', { a: 1, b: 2 }), + 'to throw', + ); + }); + + it('should deep equal nested objects', () => { + expect({ a: { b: { c: 1 } } }, 'to deep equal', { a: { b: { c: 1 } } }); + }); + }); + + describe('arrays', () => { + it('should deep equal identical arrays', () => { + expect([1, 2, 3], 'to deep equal', [1, 2, 3]); + }); + + it('should fail for different arrays', () => { + expect(() => expect([1, 2, 3], 'to deep equal', [1, 2, 4]), 'to throw'); + }); + + it('should fail for different length arrays', () => { + expect(() => expect([1, 2], 'to deep equal', [1, 2, 3]), 'to throw'); + }); + + it('should deep equal nested arrays', () => { + expect( + [ + [1, 2], + [3, 4], + ], + 'to deep equal', + [ + [1, 2], + [3, 4], + ], + ); + }); + }); + + describe('Maps and Sets', () => { + it('should deep equal identical Maps', () => { + expect( + new Map([ + ['a', 1], + ['b', 2], + ]), + 'to deep equal', + new Map([ + ['a', 1], + ['b', 2], + ]), + ); + }); + + it('should fail for different Maps', () => { + expect( + () => expect(new Map([['a', 1]]), 'to deep equal', new Map([['a', 2]])), + 'to throw', + ); + }); + + it('should deep equal identical Sets', () => { + expect(new Set([1, 2, 3]), 'to deep equal', new Set([1, 2, 3])); + }); + + it('should fail for different Sets', () => { + expect( + () => expect(new Set([1, 2]), 'to deep equal', new Set([1, 2, 3])), + 'to throw', + ); + }); + }); + + describe('negated assertions', () => { + it('should pass when not deep equal to different primitives', () => { + expect(42, 'not to deep equal', 43); + }); + + it('should pass when not deep equal to different objects', () => { + expect({ a: 1 }, 'not to deep equal', { a: 2 }); + }); + + it('should fail when actually deep equal', () => { + expect(() => expect(42, 'not to deep equal', 42), 'to throw'); + }); + }); + + describe('edge cases', () => { + it('should deep equal empty objects', () => { + expect({}, 'to deep equal', {}); + }); + + it('should deep equal empty arrays', () => { + expect([], 'to deep equal', []); + }); + + it('should deep equal Infinity', () => { + expect(Infinity, 'to deep equal', Infinity); + }); + + it('should deep equal -Infinity', () => { + expect(-Infinity, 'to deep equal', -Infinity); + }); + + it('should deep equal empty Maps', () => { + expect(new Map(), 'to deep equal', new Map()); + }); + + it('should deep equal empty Sets', () => { + expect(new Set(), 'to deep equal', new Set()); + }); + }); +}); diff --git a/packages/bupkis/test/property/configs/sync-parametric.ts b/packages/bupkis/test/property/configs/sync-parametric.ts index dd20e4c2..fec1b149 100644 --- a/packages/bupkis/test/property/configs/sync-parametric.ts +++ b/packages/bupkis/test/property/configs/sync-parametric.ts @@ -64,123 +64,103 @@ const objectSatisfies = ( export const testConfigs = new Map([ [ - assertions.arrayDeepEqualAssertion, + assertions.deepEqualAssertion, { invalid: { - examples: [[[[[]], 'to deep equal', [[null]]]]], - generators: fc - .array(filteredAnything, { minLength: 1, size: 'small' }) - .filter(objectFilter) - .chain((expected) => + examples: [ + [[[[]], 'to deep equal', [[null]]]], + [[42, 'to deep equal', 43]], + [['hello', 'to deeply equal', 'world']], + ], + generators: fc.oneof( + // Primitives that don't match + fc.integer().chain((expected) => fc.tuple( - fc - .array(filteredAnything, { - // Same length as expected for meaningful comparison - maxLength: expected.length, - minLength: expected.length, - }) - .filter(objectFilter) - .filter( - (actual) => - JSON.stringify(actual) !== JSON.stringify(expected), - ), - fc.constantFrom( - ...extractPhrases(assertions.arrayDeepEqualAssertion), - ), + fc.integer().filter((actual) => actual !== expected), + fc.constantFrom(...extractPhrases(assertions.deepEqualAssertion)), fc.constant(expected), ), ), - }, - valid: { - generators: SyncParametricGenerators.get( - assertions.arrayDeepEqualAssertion, - )!, - }, - validNegated: { - examples: [[[[[]], 'to deep equal', [[null]]]]], - generators: fc - .array(filteredAnything, { minLength: 1, size: 'small' }) - .filter(objectFilter) - .chain((expected) => - fc.tuple( - fc - .array(filteredAnything, { - // Same length as expected for meaningful comparison - maxLength: expected.length, - minLength: expected.length, - }) - .filter(objectFilter) - .filter( - (actual) => - JSON.stringify(actual) !== JSON.stringify(expected), + // Objects that don't match + fc + .object() + .filter(objectFilter) + .chain((expected) => + fc.tuple( + fc + .object() + .filter(objectFilter) + .filter( + (actual) => + JSON.stringify(actual) !== JSON.stringify(expected), + ), + fc.constantFrom( + ...extractPhrases(assertions.deepEqualAssertion), ), - fc.constantFrom( - ...extractPhrases(assertions.arrayDeepEqualAssertion), + fc.constant(expected), ), - fc.constant(expected), ), - ), - }, - }, - ], - - [ - assertions.arraySatisfiesAssertion, - { - invalid: { - examples: [[[[[]], 'to satisfy', [[], null]]]], - generators: fc - .array(filteredAnything, { minLength: 1, size: 'small' }) - .filter(objectFilter) - .chain((expected) => - fc.tuple( - fc - .array(filteredAnything, { - // Same length as expected for meaningful comparison - maxLength: expected.length, - minLength: expected.length, - }) - .filter(objectFilter) - .filter( - (actual) => - JSON.stringify(actual) !== JSON.stringify(expected), + // Arrays that don't match + fc + .array(filteredAnything, { minLength: 1, size: 'small' }) + .filter(objectFilter) + .chain((expected) => + fc.tuple( + fc + .array(filteredAnything, { + maxLength: expected.length, + minLength: expected.length, + }) + .filter(objectFilter) + .filter( + (actual) => + JSON.stringify(actual) !== JSON.stringify(expected), + ), + fc.constantFrom( + ...extractPhrases(assertions.deepEqualAssertion), ), - fc.constantFrom( - ...extractPhrases(assertions.arraySatisfiesAssertion), + fc.constant(expected), ), - fc.constant(expected), ), - ), + ), }, valid: { generators: SyncParametricGenerators.get( - assertions.arraySatisfiesAssertion, + assertions.deepEqualAssertion, )!, }, validNegated: { - examples: [[[[null], 'to satisfy', [null, null]]]], - generators: fc - .array(filteredAnything, { minLength: 1, size: 'small' }) - .filter(objectFilter) - .chain((expected) => + examples: [ + [[[[]], 'to deep equal', [[null]]]], + [[42, 'to deep equal', 43]], + ], + generators: fc.oneof( + fc.integer().chain((expected) => fc.tuple( - fc - .array(filteredAnything, { - // Same length as expected for meaningful comparison - maxLength: expected.length, - minLength: expected.length, - }) - .filter(objectFilter) - .filter( - (actual) => - JSON.stringify(actual) !== JSON.stringify(expected), - ), - fc.constantFrom( - ...extractPhrases(assertions.arraySatisfiesAssertion), - ), + fc.integer().filter((actual) => actual !== expected), + fc.constantFrom(...extractPhrases(assertions.deepEqualAssertion)), fc.constant(expected), ), ), + fc + .object() + .filter(objectFilter) + .chain((expected) => + fc.tuple( + fc + .object() + .filter(objectFilter) + .filter( + (actual) => + JSON.stringify(actual) !== JSON.stringify(expected), + ), + fc.constantFrom( + ...extractPhrases(assertions.deepEqualAssertion), + ), + fc.constant(expected), + ), + ), + ), }, }, ], @@ -422,33 +402,6 @@ export const testConfigs = new Map([ }, ], - [ - assertions.mapDeepEqualAssertion, - { - invalid: { - generators: fc - .array(fc.tuple(fc.string(), fc.integer()), { - minLength: 1, - size: 'small', - }) - .chain((entries) => - fc.tuple( - fc.constant(new Map(entries)), - fc.constantFrom( - ...extractPhrases(assertions.mapDeepEqualAssertion), - ), - fc.constant(new Map([...entries, ['__different_key__', 999]])), - ), - ), - }, - valid: { - generators: SyncParametricGenerators.get( - assertions.mapDeepEqualAssertion, - )!, - }, - }, - ], - [ assertions.numberCloseToAssertion, { @@ -601,72 +554,6 @@ export const testConfigs = new Map([ }, ], - [ - assertions.objectDeepEqualAssertion, - { - invalid: { - generators: fc - .object() - .filter(objectFilter) - .chain((expected) => - fc.tuple( - fc - .object() - .filter(objectFilter) - .filter( - (actual) => - JSON.stringify(actual) !== JSON.stringify(expected), - ), - fc.constantFrom( - ...extractPhrases(assertions.objectDeepEqualAssertion), - ), - fc.constant(expected), - ), - ), - verbose: true, - }, - valid: { - generators: SyncParametricGenerators.get( - assertions.objectDeepEqualAssertion, - )!, - }, - }, - ], - - [ - assertions.objectSatisfiesAssertion, - { - invalid: { - generators: fc - .object() - .filter(objectFilter) - .chain((expected) => - fc.tuple( - fc - .object({ depthSize: 'medium' }) - .filter(objectFilter) - .filter( - (actual) => - // Must be different AND must NOT satisfy (not a superset) - JSON.stringify(actual) !== JSON.stringify(expected) && - !objectSatisfies(actual, expected), - ), - fc.constantFrom( - ...extractPhrases(assertions.objectSatisfiesAssertion), - ), - fc.constant(expected), - ), - ), - verbose: true, - }, - valid: { - generators: SyncParametricGenerators.get( - assertions.objectSatisfiesAssertion, - )!, - }, - }, - ], - [ assertions.oneOfAssertion, { @@ -684,26 +571,106 @@ export const testConfigs = new Map([ ], [ - assertions.setDeepEqualAssertion, + assertions.satisfiesAssertion, { invalid: { - generators: fc - .array(fc.integer(), { minLength: 1, size: 'small' }) - .chain((values) => + examples: [ + [[[[]], 'to satisfy', [[], null]]], + [[42, 'to satisfy', 43]], + [['hello', 'satisfies', 'world']], + ], + generators: fc.oneof( + // Primitives that don't match + fc.integer().chain((expected) => fc.tuple( - fc.constant(new Set(values)), - fc.constantFrom( - ...extractPhrases(assertions.setDeepEqualAssertion), - ), - fc.constant(new Set(['__different_value__', ...values])), + fc.integer().filter((actual) => actual !== expected), + fc.constantFrom(...extractPhrases(assertions.satisfiesAssertion)), + fc.constant(expected), ), ), + // Objects that don't satisfy + fc + .object() + .filter(objectFilter) + .chain((expected) => + fc.tuple( + fc + .object({ depthSize: 'medium' }) + .filter(objectFilter) + .filter( + (actual) => + JSON.stringify(actual) !== JSON.stringify(expected) && + !objectSatisfies(actual, expected), + ), + fc.constantFrom( + ...extractPhrases(assertions.satisfiesAssertion), + ), + fc.constant(expected), + ), + ), + // Arrays that don't satisfy + fc + .array(filteredAnything, { minLength: 1, size: 'small' }) + .filter(objectFilter) + .chain((expected) => + fc.tuple( + fc + .array(filteredAnything, { + maxLength: expected.length, + minLength: expected.length, + }) + .filter(objectFilter) + .filter( + (actual) => + JSON.stringify(actual) !== JSON.stringify(expected), + ), + fc.constantFrom( + ...extractPhrases(assertions.satisfiesAssertion), + ), + fc.constant(expected), + ), + ), + ), }, valid: { generators: SyncParametricGenerators.get( - assertions.setDeepEqualAssertion, + assertions.satisfiesAssertion, )!, }, + validNegated: { + examples: [ + [[[null], 'to satisfy', [null, null]]], + [[42, 'to satisfy', 43]], + ], + generators: fc.oneof( + fc.integer().chain((expected) => + fc.tuple( + fc.integer().filter((actual) => actual !== expected), + fc.constantFrom(...extractPhrases(assertions.satisfiesAssertion)), + fc.constant(expected), + ), + ), + fc + .object() + .filter(objectFilter) + .chain((expected) => + fc.tuple( + fc + .object({ depthSize: 'medium' }) + .filter(objectFilter) + .filter( + (actual) => + JSON.stringify(actual) !== JSON.stringify(expected) && + !objectSatisfies(actual, expected), + ), + fc.constantFrom( + ...extractPhrases(assertions.satisfiesAssertion), + ), + fc.constant(expected), + ), + ), + ), + }, }, ], diff --git a/packages/bupkis/test/property/value-to-schema.test.ts b/packages/bupkis/test/property/value-to-schema.test.ts index 27659edd..101fe4c6 100644 --- a/packages/bupkis/test/property/value-to-schema.test.ts +++ b/packages/bupkis/test/property/value-to-schema.test.ts @@ -827,4 +827,482 @@ describe('valueToSchema() property tests', () => { ); }); }); + + describe('permissivePropertyCheck option', () => { + /** + * Generator for functions with static properties + */ + const functionWithProps = fc + .record({ + prop1: fc.string(), + prop2: fc.integer(), + }) + .map((props) => { + const fn = () => {}; + Object.assign(fn, props); + return { fn, props }; + }); + + /** + * Generator for class constructors with static methods + */ + const classWithStatics = fc + .record({ + staticValue: fc.integer(), + }) + .map((props) => { + class TestClass { + static staticMethod() { + return 42; + } + } + Object.assign(TestClass, props); + return { cls: TestClass, props }; + }); + + it('should validate object shapes against functions when permissivePropertyCheck is true', () => { + fc.assert( + fc.property(functionWithProps, ({ fn, props }) => { + // Create schema from the props (object shape) + const schema = valueToSchema(props, { + literalPrimitives: true, + permissivePropertyCheck: true, + }); + + // Should validate the function that has those properties + const result = schema.safeParse(fn); + return result.success; + }), + { numRuns }, + ); + }); + + it('should reject functions missing expected properties when permissivePropertyCheck is true', () => { + fc.assert( + fc.property( + fc + .string() + .filter((s) => s.length > 0 && s !== 'length' && s !== 'name'), + (propName) => { + const fn = () => {}; + const expectedShape = { [propName]: 'expectedValue' }; + + const schema = valueToSchema(expectedShape, { + literalPrimitives: true, + permissivePropertyCheck: true, + }); + + // Function doesn't have the property, should fail + const result = schema.safeParse(fn); + return !result.success; + }, + ), + { numRuns }, + ); + }); + + it('should validate array length property via object shape', () => { + fc.assert( + fc.property( + fc.array(filteredAnything, { maxLength: 20, minLength: 0 }), + (arr) => { + const expectedShape = { length: arr.length }; + + const schema = valueToSchema(expectedShape, { + literalPrimitives: true, + permissivePropertyCheck: true, + }); + + const result = schema.safeParse(arr); + return result.success; + }, + ), + { examples: [[[{ '': 0 }, { '': { '': 0 } }]]], numRuns }, + ); + }); + + it('should reject arrays with wrong length via object shape', () => { + fc.assert( + fc.property( + fc.array(filteredAnything, { maxLength: 20, minLength: 1 }), + fc.integer({ max: 100, min: 0 }), + (arr, wrongLength) => { + // Skip if lengths happen to match + if (wrongLength === arr.length) { + return true; + } + + const expectedShape = { length: wrongLength }; + + const schema = valueToSchema(expectedShape, { + literalPrimitives: true, + permissivePropertyCheck: true, + }); + + const result = schema.safeParse(arr); + return !result.success; + }, + ), + { numRuns }, + ); + }); + + it('should validate class constructors with static properties', () => { + fc.assert( + fc.property(classWithStatics, ({ cls, props }) => { + // Schema for checking static properties + const schema = valueToSchema( + { ...props, staticMethod: cls.staticMethod }, + { + permissivePropertyCheck: true, + }, + ); + + const result = schema.safeParse(cls); + return result.success; + }), + { numRuns }, + ); + }); + + it('should reject primitives when object shape is expected', () => { + fc.assert( + fc.property( + fc.oneof( + fc.string(), + fc.integer(), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), + fc.bigInt(), + ), + fc.record({ someProp: fc.string() }), + (primitive, shape) => { + const schema = valueToSchema(shape, { + literalPrimitives: true, + permissivePropertyCheck: true, + }); + + const result = schema.safeParse(primitive); + return !result.success; + }, + ), + { numRuns }, + ); + }); + + it('should validate nested object shapes on functions', () => { + fc.assert( + fc.property( + fc.record({ + nested: fc.record({ + value: fc.integer(), + }), + }), + (nestedShape) => { + const fn = () => {}; + Object.assign(fn, nestedShape); + + const schema = valueToSchema(nestedShape, { + literalPrimitives: true, + permissivePropertyCheck: true, + }); + + const result = schema.safeParse(fn); + return result.success; + }, + ), + { numRuns }, + ); + }); + + it('should reject when nested property types mismatch', () => { + fc.assert( + fc.property(fc.string(), fc.integer(), (expectedValue, actualValue) => { + // Skip if values happen to match type-wise + if (typeof expectedValue === typeof actualValue) { + return true; + } + + const fn = () => {}; + (fn as any).prop = { nested: actualValue }; + + const schema = valueToSchema( + { prop: { nested: expectedValue } }, + { + literalPrimitives: true, + permissivePropertyCheck: true, + }, + ); + + const result = schema.safeParse(fn); + return !result.success; + }), + { numRuns }, + ); + }); + + it('should not use permissive check when strict is true', () => { + fc.assert( + fc.property(functionWithProps, ({ fn, props }) => { + // With strict: true, permissivePropertyCheck should be ignored + // and functions should be rejected (since strictObject expects objects) + const schema = valueToSchema(props, { + literalPrimitives: true, + permissivePropertyCheck: true, + strict: true, + }); + + const result = schema.safeParse(fn); + // Should fail because strict mode uses z.strictObject which rejects functions + return !result.success; + }), + { numRuns }, + ); + }); + + it('should work with empty object shapes in permissive mode', () => { + fc.assert( + fc.property( + fc.oneof( + fc.constant(() => {}), + fc.constant([]), + fc.constant({}), + fc.constant(new Map()), + fc.constant(new Set()), + ), + (value) => { + const schema = valueToSchema( + {}, + { + literalEmptyObjects: false, // Don't require literally empty + permissivePropertyCheck: true, + }, + ); + + const result = schema.safeParse(value); + return result.success; + }, + ), + { numRuns }, + ); + }); + + it('should validate built-in function properties', () => { + // Test that we can check properties on built-in functions like Promise + fc.assert( + fc.property( + fc.constantFrom( + { fn: Promise, prop: 'resolve' }, + { fn: Promise, prop: 'reject' }, + { fn: Promise, prop: 'all' }, + { fn: Array, prop: 'isArray' }, + { fn: Object, prop: 'keys' }, + { fn: JSON, prop: 'parse' }, + ), + ({ fn, prop }) => { + const schema = valueToSchema( + { [prop]: (fn as any)[prop] }, + { permissivePropertyCheck: true }, + ); + + const result = schema.safeParse(fn); + return result.success; + }, + ), + { numRuns: 20 }, + ); + }); + + it('should handle array index properties via object shape', () => { + fc.assert( + fc.property( + fc.array(filteredAnything, { maxLength: 10, minLength: 1 }), + (arr) => { + // Check specific index via object notation + const expectedShape = { 0: arr[0] }; + + const schema = valueToSchema(expectedShape, { + literalPrimitives: true, + permissivePropertyCheck: true, + }); + + const result = schema.safeParse(arr); + return result.success; + }, + ), + { + examples: [ + [[{ '': 0 }, { '': { '': 0 } }]], + [[[{ '': [] }, { '': { '': null } }], { '': [] }]], + ], + numRuns, + }, + ); + }); + + it('should combine permissivePropertyCheck with other options', () => { + fc.assert( + fc.property( + fc.record({ + literalPrimitives: fc.boolean(), + maxDepth: fc.integer({ max: 10, min: 1 }), + }), + fc.record({ prop: fc.string() }), + (options, shape) => { + const fn = () => {}; + Object.assign(fn, shape); + + const schema = valueToSchema(shape, { + ...options, + permissivePropertyCheck: true, + strict: false, // Must be false for permissive to work + }); + + const result = schema.safeParse(fn); + return result.success; + }, + ), + { numRuns }, + ); + }); + + it('should correctly validate prototype property access', () => { + // The `in` operator checks prototype chain, so inherited properties should work + fc.assert( + fc.property( + fc.constantFrom( + { obj: [], prop: 'map' }, + { obj: [], prop: 'filter' }, + { obj: [], prop: 'reduce' }, + { obj: {}, prop: 'hasOwnProperty' }, + { obj: {}, prop: 'toString' }, + { obj: '', prop: 'charAt' }, + ), + ({ obj, prop }) => { + // Skip string because it's a primitive and will fail the object check + if (typeof obj === 'string') { + return true; + } + + // Check that inherited method exists + const schema = valueToSchema( + { [prop]: (obj as any)[prop] }, + { permissivePropertyCheck: true }, + ); + + const result = schema.safeParse(obj); + return result.success; + }, + ), + { numRuns: 20 }, + ); + }); + + it('should handle symbol properties on objects', () => { + const testSymbol = Symbol('test'); + + fc.assert( + fc.property(fc.integer(), (value) => { + const obj = { regularProp: 'hello', [testSymbol]: value }; + const fn = () => {}; + Object.assign(fn, obj); + + // Check regular property (symbols can't be in object shape keys easily) + const schema = valueToSchema( + { regularProp: 'hello' }, + { + literalPrimitives: true, + permissivePropertyCheck: true, + }, + ); + + const result = schema.safeParse(fn); + return result.success; + }), + { numRuns }, + ); + }); + + it('should validate getters via property access', () => { + fc.assert( + fc.property(fc.integer(), (expectedValue) => { + const obj = { + get computed() { + return expectedValue; + }, + }; + + const fn = Object.create(null, { + computed: { + enumerable: true, + get() { + return expectedValue; + }, + }, + }) as { computed: number }; + + const schema = valueToSchema( + { computed: expectedValue }, + { + literalPrimitives: true, + permissivePropertyCheck: true, + }, + ); + + // Test with regular object with getter + const result1 = schema.safeParse(obj); + if (!result1.success) { + return false; + } + + // Test with object created via Object.create + const result2 = schema.safeParse(fn); + return result2.success; + }), + { numRuns }, + ); + }); + + it('should correctly report missing properties in error', () => { + // Reserved properties that throw when accessed on strict mode functions + const reservedProps = new Set(['arguments', 'callee', 'caller']); + + fc.assert( + fc.property( + fc + .string() + .filter( + (s) => + s.length > 0 && + !/[^a-zA-Z0-9_]/.test(s) && + !reservedProps.has(s), + ), + (propName) => { + const fn = () => {}; + const expectedShape = { [propName]: 'value' }; + + const schema = valueToSchema(expectedShape, { + literalPrimitives: true, + permissivePropertyCheck: true, + }); + + const result = schema.safeParse(fn); + if (result.success) { + return false; // Should have failed + } + + // Check that the error mentions the missing property + const hasPropertyError = result.error.issues.some( + (issue) => + issue.path.includes(propName) || + issue.message.includes(propName), + ); + return hasPropertyError; + }, + ), + { numRuns }, + ); + }); + }); }); diff --git a/packages/events/test/property.test.ts b/packages/events/test/property.test.ts index 302ad31e..df00358a 100644 --- a/packages/events/test/property.test.ts +++ b/packages/events/test/property.test.ts @@ -33,7 +33,7 @@ const errorArbitrary = fc .tuple(fc.string(), fc.constantFrom(Error, TypeError, RangeError)) .map(([msg, ErrorClass]) => new ErrorClass(msg)); -// Helper: Diverse event args +// Helper: Diverse event args (can be empty) const eventArgsArbitrary = fc.array( fc.oneof( fc.string(), @@ -45,6 +45,20 @@ const eventArgsArbitrary = fc.array( { maxLength: 5, minLength: 0 }, ); +// Helper: Non-empty event args (for invalid tests where we need actual args to mismatch) +// Empty arrays [] now mean "any array" in satisfaction mode, so invalid tests +// must use non-empty expected args to actually test argument mismatches. +const nonEmptyEventArgsArbitrary = fc.array( + fc.oneof( + fc.string(), + fc.integer(), + fc.boolean(), + fc.double({ noNaN: true }), + fc.constant(null), + ), + { maxLength: 5, minLength: 1 }, +); + // ───────────────────────────────────────────────────────────── // SYNC ASSERTION CONFIGS // ───────────────────────────────────────────────────────────── @@ -566,7 +580,9 @@ const asyncTestConfigs = new Map< invalid: { async: true, generators: fc - .tuple(fc.string({ minLength: 1 }), eventArgsArbitrary) + // Use nonEmptyEventArgsArbitrary because [] means "any array" in + // satisfaction mode, so we need actual args to test mismatches + .tuple(fc.string({ minLength: 1 }), nonEmptyEventArgsArbitrary) .chain(([eventName, expectedArgs]) => { const emitter = new EventEmitter(); return fc.tuple( @@ -611,7 +627,9 @@ const asyncTestConfigs = new Map< invalid: { async: true, generators: fc - .tuple(fc.string({ minLength: 1 }), eventArgsArbitrary) + // Use nonEmptyEventArgsArbitrary because [] means "any array" in + // satisfaction mode, so we need actual args to test mismatches + .tuple(fc.string({ minLength: 1 }), nonEmptyEventArgsArbitrary) .chain(([eventName, expectedArgs]) => { const emitter = new EventEmitter(); return fc.tuple( diff --git a/site/assertions/collection.md b/site/assertions/collection.md index 2176f266..ccf0fd96 100644 --- a/site/assertions/collection.md +++ b/site/assertions/collection.md @@ -133,6 +133,14 @@ expect([1, 2, 3], 'to contain', 5); expect([1, 2, 3], 'not to contain', 5); ``` +### {array} to deep equal {array} + +See [{unknown} to deep equal {any}](equality.md#unknown-to-deep-equal-any) in Equality & Comparison Assertions. + +### {array} to satisfy {any} + +See [{unknown} to satisfy {any}](equality.md#unknown-to-satisfy-any) in Equality & Comparison Assertions. + ### {Map} to contain {any} > ✏️ Aliases: @@ -215,49 +223,11 @@ expect(map, 'not to be empty'); ### {Map} to deep equal {Map} -> ✏️ Aliases: -> -> {Map} to deep equal {Map} -> {Map} to deeply equal {Map} +See [{unknown} to deep equal {any}](equality.md#unknown-to-deep-equal-any) in Equality & Comparison Assertions. -Tests that two Maps have the same keys and values using deep equality comparison. +### {Map} to satisfy {any} -**Success**: - -```js -const map1 = new Map([ - ['a', 1], - ['b', { nested: 'value' }], -]); -const map2 = new Map([ - ['a', 1], - ['b', { nested: 'value' }], -]); -expect(map1, 'to deep equal', map2); -``` - -**Failure**: - -```js -const map1 = new Map([ - ['a', 1], - ['b', 2], -]); -const map2 = new Map([ - ['a', 1], - ['b', 3], -]); -expect(map1, 'to deep equal', map2); -// AssertionError: Expected Map to deep equal Map -``` - -**Negation**: - -```js -const map1 = new Map([['a', 1]]); -const map2 = new Map([['a', 2]]); -expect(map1, 'not to deep equal', map2); -``` +See [{unknown} to satisfy {any}](equality.md#unknown-to-satisfy-any) in Equality & Comparison Assertions. ### {Set} to contain {any} @@ -335,37 +305,11 @@ expect(set, 'not to be empty'); ### {Set} to deep equal {Set} -> ✏️ Aliases: -> -> {Set} to deep equal {Set} -> {Set} to deeply equal {Set} - -Tests that two Sets have the same values using deep equality comparison. - -**Success**: - -```js -const set1 = new Set([1, 2, { nested: 'value' }]); -const set2 = new Set([1, 2, { nested: 'value' }]); -expect(set1, 'to deep equal', set2); -``` +See [{unknown} to deep equal {any}](equality.md#unknown-to-deep-equal-any) in Equality & Comparison Assertions. -**Failure**: +### {Set} to satisfy {any} -```js -const set1 = new Set([1, 2, 3]); -const set2 = new Set([1, 2, 4]); -expect(set1, 'to deep equal', set2); -// AssertionError: Expected Set to deep equal Set -``` - -**Negation**: - -```js -const set1 = new Set([1, 2, 3]); -const set2 = new Set([1, 2, 4]); -expect(set1, 'not to deep equal', set2); -``` +See [{unknown} to satisfy {any}](equality.md#unknown-to-satisfy-any) in Equality & Comparison Assertions. ### {unknown} to be a Set diff --git a/site/assertions/equality.md b/site/assertions/equality.md index 819a16b5..387e1e78 100644 --- a/site/assertions/equality.md +++ b/site/assertions/equality.md @@ -49,14 +49,39 @@ expect({}, 'not to equal', {}); > ✏️ Aliases: > > {unknown} to deep equal {any} -> {unknown} to deep equal {any} +> {unknown} to deeply equal {any} + +Tests structural equality between any two values. Works with primitives, objects, arrays, Maps, Sets, and nested structures. **Success**: ```js +// Primitives +expect(42, 'to deep equal', 42); +expect('hello', 'to deeply equal', 'hello'); + +// Objects expect({ a: 1, b: 2 }, 'to deep equal', { a: 1, b: 2 }); -expect([1, 2, 3], 'to deeply equal', [1, 2, 3]); expect({ nested: { value: 42 } }, 'to deep equal', { nested: { value: 42 } }); + +// Arrays +expect([1, 2, 3], 'to deeply equal', [1, 2, 3]); + +// Maps +expect( + new Map([ + ['a', 1], + ['b', 2], + ]), + 'to deep equal', + new Map([ + ['a', 1], + ['b', 2], + ]), +); + +// Sets +expect(new Set([1, 2, 3]), 'to deeply equal', new Set([1, 2, 3])); ``` **Failure**: @@ -72,6 +97,73 @@ expect({ a: 1 }, 'to deep equal', { a: 1, b: 2 }); expect({ a: 1 }, 'not to deep equal', { a: 1, b: 2 }); ``` +### {unknown} to satisfy {any} + +> ✏️ Aliases: +> +> {unknown} to satisfy {any} +> {unknown} to be like {any} +> {unknown} satisfies {any} + +A loose "deep equal" assertion similar to AVA's `t.like()` or Jest's `expect.objectContaining()`. It checks that the actual value contains _at least_ the properties and values specified in the expected pattern, ignoring additional properties. + +**Cross-Type Satisfaction**: This assertion also supports validating properties on any value that has them—including arrays (which have `length`), functions (which have `name`), and constructors (which have static properties). + +Any _regular expression_ in a property value position will be used to match the corresponding actual value (which will be coerced into a string). This makes it easy to assert that a string property contains a substring, starts with a prefix, or matches some other pattern. + +> Note: The parameter in this assertion is not strongly typed, even though regular expressions and `expect.it()` have special meaning. This is because the parameter can accept _literally any value_. + +**Success**: + +```js +// Objects satisfying object shapes +expect({ a: 1, b: 2, c: 3 }, 'to satisfy', { a: 1, b: 2 }); +expect({ name: 'John', age: 30 }, 'to be like', { name: 'John' }); + +// Arrays satisfying array shapes +expect([1, 2, 3], 'to satisfy', [1, 2, 3]); + +// Arrays satisfying object shapes (cross-type satisfaction) +expect([1, 2, 3], 'to satisfy', { length: 3 }); + +// Functions satisfying object shapes +expect(function myFn() {}, 'to satisfy', { name: 'myFn' }); + +// Constructors satisfying object shapes +expect(Promise, 'to satisfy', { + reject: expect.it('to be a function'), + resolve: expect.it('to be a function'), +}); + +// Using regular expressions in property values +expect( + { + email: 'user@example.com', + phone: '+1-555-0123', + id: 12345, + }, + 'to satisfy', + { + email: /^user@/, + phone: /^\+1-555/, + id: /123/, + }, +); +``` + +**Failure**: + +```js +expect({ a: 1 }, 'to satisfy', { a: 1, b: 2 }); +// AssertionError: Expected { a: 1 } to satisfy { a: 1, b: 2 } +``` + +**Negation**: + +```js +expect({ a: 1 }, 'not to satisfy', { a: 1, b: 2 }); +``` + ### {unknown} to be one of {array} **Success**: diff --git a/site/assertions/object.md b/site/assertions/object.md index 7520b8e4..6a9c9d6f 100644 --- a/site/assertions/object.md +++ b/site/assertions/object.md @@ -358,48 +358,8 @@ expect(obj, 'not to be extensible'); ### {object} to satisfy {any} -> ✏️ Aliases: -> -> {object} to satisfy {any} -> {object} to be like {any} - -"To satisfy" is a ~~wonky~~ _special_ loose "deep equal" assertion. It is similar to AVA's `t.like()` or Jest's `expect.objectContaining()`. It checks that the actual object contains _at least_ the properties and values specified in the expected object. It ignores any additional properties. - -In addition, any _regular expression_ in a property value position will be used to match the corresponding actual value (which will be coerced into a string). This makes it easy to assert that a string property contains a substring, starts with a prefix, or matches some other pattern. - -> Note: The parameter in this assertion and others supporting the "to satisfy" semantics are not strongly typed, even though regular expressions and `expect.it()` have special meaning. This is because the parameter can accept _literally any value_. +See [{unknown} to satisfy {any}](equality.md#unknown-to-satisfy-any) in Equality & Comparison Assertions. -**Success**: - -```js -expect({ a: 1, b: 2, c: 3 }, 'to satisfy', { a: 1, b: 2 }); -expect({ name: 'John', age: 30 }, 'to be like', { name: 'John' }); - -// Using regular expressions in property values -expect( - { - email: 'user@example.com', - phone: '+1-555-0123', - id: 12345, - }, - 'to satisfy', - { - email: /^user@/, - phone: /^\+1-555/, - id: /123/, - }, -); -``` +### {object} to deep equal {any} -**Failure**: - -```js -expect({ a: 1 }, 'to satisfy', { a: 1, b: 2 }); -// AssertionError: Expected { a: 1 } to satisfy { a: 1, b: 2 } -``` - -**Negation**: - -```js -expect({ a: 1 }, 'not to satisfy', { a: 1, b: 2 }); -``` +See [{unknown} to deep equal {any}](equality.md#unknown-to-deep-equal-any) in Equality & Comparison Assertions.