From 3f705c2fc746b47bc262c54e620b033635c56fe2 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Thu, 15 Jan 2026 14:01:28 -0800 Subject: [PATCH 1/6] feat(bupkis): consolidate satisfy and deep equal assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 6 type-specific assertions with 2 generic versions: - objectSatisfiesAssertion + arraySatisfiesAssertion → satisfiesAssertion - object/array/map/setDeepEqualAssertion → deepEqualAssertion Both now accept unknown subjects and parameters, delegating type validation to valueToSchema. This simplifies the API while maintaining equivalent behavior for same-type comparisons. Includes updated property tests, snapshot tests, and new unit tests covering primitive equality cases. --- .../src/assertion/impl/sync-parametric.ts | 139 ++----- packages/bupkis/src/assertion/impl/sync.ts | 16 +- .../test-data/sync-parametric-generators.ts | 132 +++---- .../assert-error.test.ts.snapshot | 4 +- .../sync-parametric-error.test.ts | 30 +- .../sync-parametric-error.test.ts.snapshot | 148 ++----- .../assertion-classification.test.ts | 4 +- .../test/assertion/satisfy-deep-equal.test.ts | 336 ++++++++++++++++ .../test/property/configs/sync-parametric.ts | 363 ++++++++---------- 9 files changed, 640 insertions(+), 532 deletions(-) create mode 100644 packages/bupkis/test/assertion/satisfy-deep-equal.test.ts diff --git a/packages/bupkis/src/assertion/impl/sync-parametric.ts b/packages/bupkis/src/assertion/impl/sync-parametric.ts index 54e274f2..2d0df1e1 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 to-deep-equal + * @bupkisAssertionCategory comparison */ -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,36 @@ 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 to-satisfy + * @bupkisAssertionCategory comparison + * @bupkisRedirect satisfies */ -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/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..075a6922 --- /dev/null +++ b/packages/bupkis/test/assertion/satisfy-deep-equal.test.ts @@ -0,0 +1,336 @@ +/** + * 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('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), + ), + ), + ), + }, }, ], From 493ecb47ef73fabfb4817b7b0f1703235d90795a Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Thu, 15 Jan 2026 14:20:41 -0800 Subject: [PATCH 2/6] feat(bupkis): add permissive property checking for cross-type satisfaction Adds a new 'permissivePropertyCheck' option to valueToSchema that allows checking object properties 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. This enables use cases like: - expect([1,2,3], 'to satisfy', {length: 3}) - expect(Promise, 'to satisfy', {reject: expect.it('to be a function')}) The option is automatically enabled for 'to satisfy' assertions via valueToSchemaOptionsForSatisfies. Includes comprehensive tests for cross-type satisfaction scenarios. --- packages/bupkis/src/value-to-schema.ts | 99 ++++++++++++++++++- .../test/assertion/satisfy-deep-equal.test.ts | 60 +++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/packages/bupkis/src/value-to-schema.ts b/packages/bupkis/src/value-to-schema.ts index 579869a7..47ab7c42 100644 --- a/packages/bupkis/src/value-to-schema.ts +++ b/packages/bupkis/src/value-to-schema.ts @@ -67,6 +67,7 @@ export const valueToSchema = ( literalTuples = false, maxDepth = 10, noMixedArrays = false, + permissivePropertyCheck = false, strict = false, } = options; @@ -431,7 +432,90 @@ 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; + } + + const propValue = (val as Record)[key]; + 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); @@ -597,6 +681,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 * @@ -617,6 +713,7 @@ export const valueToSchemaOptionsForSatisfies = freeze({ literalPrimitives: true, literalRegExp: false, literalTuples: true, + permissivePropertyCheck: true, strict: false, } as const) satisfies ValueToSchemaOptions; diff --git a/packages/bupkis/test/assertion/satisfy-deep-equal.test.ts b/packages/bupkis/test/assertion/satisfy-deep-equal.test.ts index 075a6922..0cbdd76d 100644 --- a/packages/bupkis/test/assertion/satisfy-deep-equal.test.ts +++ b/packages/bupkis/test/assertion/satisfy-deep-equal.test.ts @@ -162,6 +162,66 @@ describe('satisfiesAssertion (consolidated)', () => { 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)', () => { From ab0efe9a2a18463f7303ede030bf8d4390d74d3e Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Thu, 15 Jan 2026 14:44:32 -0800 Subject: [PATCH 3/6] feat(bupkis): add literalEmptyArrays option and fix permissive edge cases - Add literalEmptyArrays option: when false (default), empty arrays [] match any array (like literalEmptyObjects for objects) - Fix permissive property check for array elements: disable permissive mode inside arrays to avoid union conflicts with z.any() - Handle inaccessible properties (arguments/caller/callee on strict mode functions) gracefully with try/catch - Add comprehensive property tests for permissivePropertyCheck option covering functions, arrays, classes, getters, and edge cases --- packages/bupkis/src/value-to-schema.ts | 36 +- .../test/property/value-to-schema.test.ts | 478 ++++++++++++++++++ 2 files changed, 512 insertions(+), 2 deletions(-) diff --git a/packages/bupkis/src/value-to-schema.ts b/packages/bupkis/src/value-to-schema.ts index 47ab7c42..838229b1 100644 --- a/packages/bupkis/src/value-to-schema.ts +++ b/packages/bupkis/src/value-to-schema.ts @@ -61,6 +61,7 @@ export const valueToSchema = ( ): z.ZodType => { const { _currentDepth = 0, + literalEmptyArrays = false, literalEmptyObjects = false, literalPrimitives = false, literalRegExp = false, @@ -284,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([]); } @@ -303,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, ); @@ -490,7 +499,20 @@ export const valueToSchema = ( continue; } - const propValue = (val as Record)[key]; + // 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) { @@ -632,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 @@ -709,6 +739,7 @@ export interface ValueToSchemaOptions { * properties. */ export const valueToSchemaOptionsForSatisfies = freeze({ + literalEmptyArrays: false, literalEmptyObjects: true, literalPrimitives: true, literalRegExp: false, @@ -725,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/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 }, + ); + }); + }); }); From acc64779ec9baeb6923bc2dae436161a16e55125 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Thu, 15 Jan 2026 15:32:13 -0800 Subject: [PATCH 4/6] docs(bupkis): update assertion docs for satisfy/deep-equal consolidation - Move 'to satisfy' from object.md to equality.md - Expand 'to deep equal' examples to show primitives, Map, Set - Add cross-type satisfaction examples (arrays with length, Promise) - Replace redundant Map/Set deep equal sections with redirects - Add redirects in object.md and collection.md pointing to equality.md --- site/assertions/collection.md | 84 +++++------------------------- site/assertions/equality.md | 96 ++++++++++++++++++++++++++++++++++- site/assertions/object.md | 46 ++--------------- 3 files changed, 111 insertions(+), 115 deletions(-) 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. From dfb5658a6c12173c496c87adfbda14db2c9fac82 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Thu, 15 Jan 2026 15:41:39 -0800 Subject: [PATCH 5/6] fix(bupkis): correct JSDoc tags for satisfy/deep-equal assertions Update @bupkisAssertionCategory from 'comparison' to 'equality' and fix @bupkisAnchor to match documentation anchors. --- packages/bupkis/src/assertion/impl/sync-parametric.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/bupkis/src/assertion/impl/sync-parametric.ts b/packages/bupkis/src/assertion/impl/sync-parametric.ts index 2d0df1e1..268826fb 100644 --- a/packages/bupkis/src/assertion/impl/sync-parametric.ts +++ b/packages/bupkis/src/assertion/impl/sync-parametric.ts @@ -612,8 +612,8 @@ export const strictEqualityAssertion = createAssertion( * ``` * * @group Parametric Assertions (Sync) - * @bupkisAnchor to-deep-equal - * @bupkisAssertionCategory comparison + * @bupkisAnchor unknown-to-deep-equal-any + * @bupkisAssertionCategory equality */ export const deepEqualAssertion = createAssertion( [['to deep equal', 'to deeply equal'], UnknownSchema], @@ -938,9 +938,8 @@ export const stringLengthAssertion = createAssertion( * ``` * * @group Parametric Assertions (Sync) - * @bupkisAnchor to-satisfy - * @bupkisAssertionCategory comparison - * @bupkisRedirect satisfies + * @bupkisAnchor unknown-to-satisfy-any + * @bupkisAssertionCategory equality */ export const satisfiesAssertion = createAssertion( [['to satisfy', 'to be like', 'satisfies'], UnknownSchema], From 83eae5490ab447f09d04cb212434192a1a655787 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Thu, 15 Jan 2026 15:49:07 -0800 Subject: [PATCH 6/6] fix(events): update property tests for new empty array satisfaction semantics Empty arrays [] now mean 'any array' in satisfaction mode, so invalid tests must use non-empty expected args to actually test argument mismatches in event emission assertions. --- packages/events/test/property.test.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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(