Skip to content

Commit ee6f4ee

Browse files
committed
feat: implement deep keys assertions for Maps/Sets
Add deep equality key comparison for Maps and Sets with object keys. Implements anyDeepKeysFunc, allDeepKeysFunc, and six assert methods (hasAnyDeepKeys, hasAllDeepKeys, and their negations). Addresses Chai v5.x compatibility for deep keys operations.
1 parent 45b9b85 commit ee6f4ee

File tree

15 files changed

+672
-58
lines changed

15 files changed

+672
-58
lines changed

.github/copilot-instructions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ grunt lint lint-fix # Lint with auto-fix
9898
- Use `@nevware21/ts-utils` for cross-platform utilities
9999
- No non-null assertions (`!` operator) - use proper null checks
100100

101+
### Versioning for New Features
102+
- **@since tags**: Check `core/package.json` version before adding new features
103+
- Current version in package.json is the **last published** version
104+
- Use the **next minor version** for @since tags on new features
105+
- Example: If package.json shows `0.1.4`, use `@since 0.1.5` for new features
106+
- This follows semver: new features increment the minor version
107+
101108
## Testing Patterns
102109

103110
### Test Structure

core/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ expect({ a: 1 }).to.deep.equal({ a: 1 });
6363
- **Comparisons** - `above`, `below`, `within`, `atLeast`, `atMost`
6464
- **Properties** - `property`, `ownProperty`, `deepProperty`, `nestedProperty` with value validation
6565
- **Collections** - `include`, `keys`, `members` with deep equality and ordered comparison
66+
- **Deep Keys** - `hasAnyDeepKeys`, `hasAllDeepKeys` for Maps/Sets with object keys using deep equality
6667
- **Size/Length** - `lengthOf`, `sizeOf` for arrays, strings, maps, sets
6768
- **Array Operations** - `subsequence`, `endsWith`, `oneOf` matching
6869
- **Change Tracking** - Monitor value `changes`, `increases`, `decreases` with delta validation

core/src/assert/assertClass.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,15 @@ export function createAssert(): IAssertClass {
339339
subsequence: { scopeFn: subsequenceFunc, nArgs: 2 },
340340
notSubsequence: { scopeFn: createExprAdapter("not", subsequenceFunc), nArgs: 2 },
341341
deepSubsequence: { scopeFn: deepSubsequenceFunc, nArgs: 2 },
342-
notDeepSubsequence: { scopeFn: createExprAdapter("not", deepSubsequenceFunc), nArgs: 2 }
342+
notDeepSubsequence: { scopeFn: createExprAdapter("not", deepSubsequenceFunc), nArgs: 2 },
343+
344+
// Deep keys operations
345+
hasAnyDeepKeys: { scopeFn: createExprAdapter("has.any.deep.keys"), nArgs: 2 },
346+
hasAllDeepKeys: { scopeFn: createExprAdapter("has.all.deep.keys"), nArgs: 2 },
347+
notHaveAnyDeepKeys: { scopeFn: createExprAdapter("not.has.any.deep.keys"), nArgs: 2 },
348+
doesNotHaveAnyDeepKeys: { alias: "notHaveAnyDeepKeys" },
349+
notHaveAllDeepKeys: { scopeFn: createExprAdapter("not.has.all.deep.keys"), nArgs: 2 },
350+
doesNotHaveAllDeepKeys: { alias: "notHaveAllDeepKeys" }
343351
};
344352

345353
addAssertFuncs(assert, assertFuncs);

core/src/assert/funcs/keysFunc.ts

Lines changed: 123 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
* @nevware21/tripwire
33
* https://github.com/nevware21/tripwire
44
*
5-
* Copyright (c) 2024-2025 NevWare21 Solutions LLC
5+
* Copyright (c) 2024-2026 NevWare21 Solutions LLC
66
* Licensed under the MIT license.
77
*/
88

9-
import { arrForEach, arrSlice, asString, isArray, isObject, isStrictNullOrUndefined, isString, objGetOwnPropertyDescriptor, objGetOwnPropertySymbols, objKeys } from "@nevware21/ts-utils";
9+
import { arrForEach, arrFrom, arrSlice, asString, dumpObj, isArray, isFunction, isObject, isStrictNullOrUndefined, isString, objGetOwnPropertyDescriptor, objGetOwnPropertySymbols, objKeys } from "@nevware21/ts-utils";
1010
import { IAssertScope } from "../interface/IAssertScope";
1111
import { KeysFn } from "../interface/funcs/KeysFn";
1212
import { EMPTY_STRING } from "../internal/const";
13+
import { _deepEqual } from "./equal";
1314

1415
function _objKeys(value: any): any[] {
1516
let keys: any[] = [];
@@ -27,16 +28,25 @@ function _objKeys(value: any): any[] {
2728
return keys;
2829
}
2930

30-
function _formatKeys(keys: string[]): string {
31+
function _formatKeys(keys: any[]): string {
3132
let formattedKeys: string = EMPTY_STRING;
3233
arrForEach(keys, (key) => {
3334
if (formattedKeys.length > 0) {
3435
formattedKeys += ",";
3536
}
3637
if (key || isStrictNullOrUndefined(key)) {
37-
formattedKeys += asString(key);
38+
try {
39+
formattedKeys += asString(key);
40+
} catch (e) {
41+
// Handle objects that can't be converted to string (e.g., null prototype)
42+
formattedKeys += dumpObj(key);
43+
}
3844
} else if (!isString(key)) {
39-
formattedKeys += asString(key);
45+
try {
46+
formattedKeys += asString(key);
47+
} catch (e) {
48+
formattedKeys += dumpObj(key);
49+
}
4050
} else {
4151
// special case for empty string keys
4252
formattedKeys += "\"\"";
@@ -46,10 +56,17 @@ function _formatKeys(keys: string[]): string {
4656
return formattedKeys;
4757
}
4858

49-
function _getValueKeys(scope: IAssertScope, value: any): string[] {
50-
let theKeys: string[] = [];
59+
function _getValueKeys(value: any): any[] {
60+
let theKeys: any[] = [];
5161
if (!isStrictNullOrUndefined(value)) {
52-
theKeys = _objKeys(value);
62+
// Check if it's a Map or Set
63+
if (isFunction(value.keys)) {
64+
// For Maps and Sets, use the keys() iterator
65+
theKeys = arrFrom(value.keys());
66+
} else {
67+
// For regular objects, use _objKeys
68+
theKeys = _objKeys(value);
69+
}
5370
}
5471

5572
return theKeys;
@@ -73,14 +90,25 @@ function _getArgKeys(scope: IAssertScope, theArgs: any[]): string[] {
7390
return theKeys;
7491
}
7592

93+
function _getArgKeysForDeep(scope: IAssertScope, theArgs: any[]): any[] {
94+
// For deep keys, if a single array argument is provided, unwrap it to get multiple keys
95+
// This matches Chai behavior: .keys([a, b, c]) checks for keys a, b, c
96+
// Otherwise, treat all arguments as individual keys
97+
if (theArgs && theArgs.length === 1 && isArray(theArgs[0])) {
98+
return theArgs[0];
99+
}
100+
101+
return theArgs;
102+
}
103+
76104
export function anyKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {
77105

78106
function _keys(this: IAssertScope, _argKeys: Array<string|number|symbol> | Object | readonly any[]): R {
79107
let scope = this;
80108
let context = scope.context;
81109
let args = arrSlice(arguments);
82110
let expectedKeys = _getArgKeys(scope, args);
83-
let valueKeys = _getValueKeys(scope, context.value);
111+
let valueKeys = _getValueKeys(context.value);
84112

85113
if (expectedKeys.length === 0) {
86114
scope.context.set("expectedKeys", expectedKeys);
@@ -111,7 +139,7 @@ export function allKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {
111139
let context = scope.context;
112140
let args = arrSlice(arguments);
113141
let theKeys = _getArgKeys(scope, args);
114-
let valueKeys = _getValueKeys(scope, context.value);
142+
let valueKeys = _getValueKeys(context.value);
115143

116144
if (theKeys.length === 0) {
117145
scope.context.set("theKeys", theKeys);
@@ -133,3 +161,88 @@ export function allKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {
133161
return _keys;
134162
}
135163

164+
/**
165+
* Creates a KeysFn function that checks if any of the provided keys exist in the value using deep equality comparison.
166+
* This is used for deep key matching where keys are compared using deep equality instead of strict equality.
167+
* @param _scope - The assert scope.
168+
* @returns A KeysFn function.
169+
*/
170+
export function anyDeepKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {
171+
172+
function _keys(this: IAssertScope, _argKeys: Array<string|number|symbol> | Object | readonly any[]): R {
173+
let scope = this;
174+
let context = scope.context;
175+
let args = arrSlice(arguments);
176+
let expectedKeys = _getArgKeysForDeep(scope, args);
177+
let valueKeys = _getValueKeys(context.value);
178+
179+
if (expectedKeys.length === 0) {
180+
scope.context.set("expectedKeys", expectedKeys);
181+
scope.fatal("expected at least one key to be provided {expectedKeys}");
182+
}
183+
184+
let found = false;
185+
arrForEach(expectedKeys, (expKey) => {
186+
arrForEach(valueKeys, (valKey) => {
187+
if (_deepEqual(valKey, expKey)) {
188+
// Found at least one key using deep equality
189+
found = true;
190+
return -1;
191+
}
192+
});
193+
194+
if (found) {
195+
return -1;
196+
}
197+
});
198+
199+
context.eval(found, `expected any deep key: [${_formatKeys(expectedKeys)}], found: [${_formatKeys(valueKeys)}] (${valueKeys.length} keys)`);
200+
201+
return scope.that;
202+
}
203+
204+
return _keys;
205+
}
206+
207+
/**
208+
* Creates a KeysFn function that checks if all of the provided keys exist in the value using deep equality comparison.
209+
* This is used for deep key matching where keys are compared using deep equality instead of strict equality.
210+
* @param _scope - The assert scope.
211+
* @returns A KeysFn function.
212+
*/
213+
export function allDeepKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {
214+
215+
function _keys(this: IAssertScope, argKeys: string[] | Object | readonly any[]): R {
216+
let scope = this;
217+
let context = scope.context;
218+
let args = arrSlice(arguments);
219+
let theKeys = _getArgKeysForDeep(scope, args);
220+
let valueKeys = _getValueKeys(context.value);
221+
222+
if (theKeys.length === 0) {
223+
scope.context.set("theKeys", theKeys);
224+
scope.fatal("expected at least one key to be provided {theKeys}");
225+
}
226+
227+
let missingKeys: any[] = [];
228+
arrForEach(theKeys, (expKey) => {
229+
let found = false;
230+
arrForEach(valueKeys, (valKey) => {
231+
if (_deepEqual(valKey, expKey)) {
232+
found = true;
233+
return -1;
234+
}
235+
});
236+
237+
if (!found) {
238+
missingKeys.push(expKey);
239+
}
240+
});
241+
242+
context.eval(missingKeys.length === 0, `expected all deep keys: [${_formatKeys(theKeys)}], missing: [${_formatKeys(missingKeys)}], found: [${_formatKeys(valueKeys)}]`);
243+
244+
return scope.that;
245+
}
246+
247+
return _keys;
248+
}

core/src/assert/interface/IAssertClass.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3354,6 +3354,147 @@ export interface IAssertClass<AssertInst extends IAssertInst = IAssertInst> {
33543354
* ```
33553355
*/
33563356
decreasesButNotBy<T>(fn: () => void, target: T, prop: keyof T, delta: number, initMsg?: MsgSource): AssertInst;
3357+
3358+
/**
3359+
* Asserts that the target has any of the specified keys using deep equality comparison.
3360+
* This method checks if at least one of the given keys exists in the target using deep equality
3361+
* for key comparison. Particularly useful for Maps and Sets where keys may be objects.
3362+
*
3363+
* @param target - The object, Map, or Set to check for keys.
3364+
* @param keys - The keys to search for (can be individual arguments or an array of keys).
3365+
* @param initMsg - The message to display if the assertion fails.
3366+
* @asserts That the `target` has at least one of the specified keys and throws {@link AssertionFailure} if it does not.
3367+
* @since 0.1.5
3368+
* @example
3369+
* ```typescript
3370+
* const obj = { greeting: "hello", subject: "friend" };
3371+
* assert.hasAnyDeepKeys(obj, "greeting", "message"); // Passes - has "greeting"
3372+
* assert.hasAnyDeepKeys(obj, ["greeting", "message"]); // Passes - has "greeting"
3373+
*
3374+
* const map = new Map([[{ id: 1 }, "value1"], [{ id: 2 }, "value2"]]);
3375+
* assert.hasAnyDeepKeys(map, { id: 1 }); // Passes - deep key match
3376+
* assert.hasAnyDeepKeys(map, { id: 1 }, { id: 3 }); // Passes - has { id: 1 }
3377+
* assert.hasAnyDeepKeys(obj, "unknown", "missing"); // Throws AssertionFailure
3378+
* ```
3379+
*/
3380+
hasAnyDeepKeys(target: any, ...keys: any[]): AssertInst;
3381+
3382+
/**
3383+
* Asserts that the target has all of the specified keys using deep equality comparison.
3384+
* This method checks if all of the given keys exist in the target using deep equality
3385+
* for key comparison. Particularly useful for Maps and Sets where keys may be objects.
3386+
*
3387+
* @param target - The object, Map, or Set to check for keys.
3388+
* @param keys - The keys to search for (can be individual arguments or an array of keys).
3389+
* @param initMsg - The message to display if the assertion fails.
3390+
* @asserts That the `target` has all of the specified keys and throws {@link AssertionFailure} if it does not.
3391+
* @since 0.1.5
3392+
* @example
3393+
* ```typescript
3394+
* const obj = { greeting: "hello", subject: "friend", message: "darkness" };
3395+
* assert.hasAllDeepKeys(obj, "greeting", "subject"); // Passes - has both keys
3396+
* assert.hasAllDeepKeys(obj, ["greeting", "subject"]); // Passes - has both keys
3397+
*
3398+
* const map = new Map([[{ id: 1 }, "value1"], [{ id: 2 }, "value2"]]);
3399+
* assert.hasAllDeepKeys(map, { id: 1 }, { id: 2 }); // Passes - has both keys
3400+
* assert.hasAllDeepKeys(obj, "greeting", "unknown"); // Throws AssertionFailure - missing "unknown"
3401+
* ```
3402+
*/
3403+
hasAllDeepKeys(target: any, ...keys: any[]): AssertInst;
3404+
3405+
/**
3406+
* Asserts that the target does NOT have any of the specified keys using deep equality comparison.
3407+
* This method checks that none of the given keys exist in the target using deep equality
3408+
* for key comparison. This is the inverse of {@link hasAnyDeepKeys}.
3409+
*
3410+
* @param target - The object, Map, or Set to check for keys.
3411+
* @param keys - The keys to search for (can be individual arguments or an array of keys).
3412+
* @param initMsg - The message to display if the assertion fails.
3413+
* @asserts That the `target` does not have any of the specified keys and throws {@link AssertionFailure} if it does.
3414+
* @since 0.1.5
3415+
* @example
3416+
* ```typescript
3417+
* const obj = { greeting: "hello", subject: "friend" };
3418+
* assert.notHaveAnyDeepKeys(obj, "unknown", "missing"); // Passes - has neither key
3419+
* assert.notHaveAnyDeepKeys(obj, ["unknown", "missing"]); // Passes - has neither key
3420+
*
3421+
* const map = new Map([[{ id: 1 }, "value1"]]);
3422+
* assert.notHaveAnyDeepKeys(map, { id: 3 }, { id: 4 }); // Passes - has neither key
3423+
* assert.notHaveAnyDeepKeys(obj, "greeting", "unknown"); // Throws AssertionFailure - has "greeting"
3424+
* ```
3425+
*/
3426+
notHaveAnyDeepKeys(target: any, ...keys: any[]): AssertInst;
3427+
3428+
/**
3429+
* Asserts that the target does NOT have any of the specified keys using deep equality comparison.
3430+
* This method checks that none of the given keys exist in the target using deep equality
3431+
* for key comparison. This is the inverse of {@link hasAnyDeepKeys}.
3432+
*
3433+
* @param target - The object, Map, or Set to check for keys.
3434+
* @param keys - The keys to search for (can be individual arguments or an array of keys).
3435+
* @param initMsg - The message to display if the assertion fails.
3436+
* @asserts That the `target` does not have any of the specified keys and throws {@link AssertionFailure} if it does.
3437+
* @alias notHaveAnyDeepKeys
3438+
* @since 0.1.5
3439+
* @example
3440+
* ```typescript
3441+
* const obj = { greeting: "hello", subject: "friend" };
3442+
* assert.doesNotHaveAnyDeepKeys(obj, "unknown", "missing"); // Passes - has neither key
3443+
* assert.doesNotHaveAnyDeepKeys(obj, ["unknown", "missing"]); // Passes - has neither key
3444+
*
3445+
* const map = new Map([[{ id: 1 }, "value1"]]);
3446+
* assert.doesNotHaveAnyDeepKeys(map, { id: 3 }, { id: 4 }); // Passes - has neither key
3447+
* assert.doesNotHaveAnyDeepKeys(obj, "greeting", "unknown"); // Throws AssertionFailure - has "greeting"
3448+
* ```
3449+
*/
3450+
doesNotHaveAnyDeepKeys(target: any, ...keys: any[]): AssertInst;
3451+
3452+
/**
3453+
* Asserts that the target does NOT have all of the specified keys using deep equality comparison.
3454+
* This method checks that at least one of the given keys does not exist in the target using deep equality
3455+
* for key comparison. This is the inverse of {@link hasAllDeepKeys}.
3456+
*
3457+
* @param target - The object, Map, or Set to check for keys.
3458+
* @param keys - The keys to search for (can be individual arguments or an array of keys).
3459+
* @param initMsg - The message to display if the assertion fails.
3460+
* @asserts That the `target` does not have all of the specified keys and throws {@link AssertionFailure} if it does.
3461+
* @since 0.1.5
3462+
* @example
3463+
* ```typescript
3464+
* const obj = { greeting: "hello", subject: "friend" };
3465+
* assert.notHaveAllDeepKeys(obj, "greeting", "unknown"); // Passes - missing "unknown"
3466+
* assert.notHaveAllDeepKeys(obj, ["greeting", "unknown"]); // Passes - missing "unknown"
3467+
*
3468+
* const map = new Map([[{ id: 1 }, "value1"]]);
3469+
* assert.notHaveAllDeepKeys(map, { id: 1 }, { id: 2 }); // Passes - missing { id: 2 }
3470+
* assert.notHaveAllDeepKeys(obj, "greeting", "subject"); // Throws AssertionFailure - has both keys
3471+
* ```
3472+
*/
3473+
notHaveAllDeepKeys(target: any, ...keys: any[]): AssertInst;
3474+
3475+
/**
3476+
* Asserts that the target does NOT have all of the specified keys using deep equality comparison.
3477+
* This method checks that at least one of the given keys does not exist in the target using deep equality
3478+
* for key comparison. This is the inverse of {@link hasAllDeepKeys}.
3479+
*
3480+
* @param target - The object, Map, or Set to check for keys.
3481+
* @param keys - The keys to search for (can be individual arguments or an array of keys).
3482+
* @param initMsg - The message to display if the assertion fails.
3483+
* @asserts That the `target` does not have all of the specified keys and throws {@link AssertionFailure} if it does.
3484+
* @alias notHaveAllDeepKeys
3485+
* @since 0.1.5
3486+
* @example
3487+
* ```typescript
3488+
* const obj = { greeting: "hello", subject: "friend" };
3489+
* assert.doesNotHaveAllDeepKeys(obj, "greeting", "unknown"); // Passes - missing "unknown"
3490+
* assert.doesNotHaveAllDeepKeys(obj, ["greeting", "unknown"]); // Passes - missing "unknown"
3491+
*
3492+
* const map = new Map([[{ id: 1 }, "value1"]]);
3493+
* assert.doesNotHaveAllDeepKeys(map, { id: 1 }, { id: 2 }); // Passes - missing { id: 2 }
3494+
* assert.doesNotHaveAllDeepKeys(obj, "greeting", "subject"); // Throws AssertionFailure - has both keys
3495+
* ```
3496+
*/
3497+
doesNotHaveAllDeepKeys(target: any, ...keys: any[]): AssertInst;
33573498
}
33583499

33593500
export type IExtendedAssert<T = any> = IAssertClass<IAssertInst & T> & T;

0 commit comments

Comments
 (0)