Skip to content

Commit 9743555

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 9743555

File tree

15 files changed

+678
-53
lines changed

15 files changed

+678
-53
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: -1 },
346+
hasAllDeepKeys: { scopeFn: createExprAdapter("has.all.deep.keys"), nArgs: -1 },
347+
notHaveAnyDeepKeys: { scopeFn: createExprAdapter("not.has.any.deep.keys"), nArgs: -1 },
348+
doesNotHaveAnyDeepKeys: { alias: "notHaveAnyDeepKeys" },
349+
notHaveAllDeepKeys: { scopeFn: createExprAdapter("not.has.all.deep.keys"), nArgs: -1 },
350+
doesNotHaveAllDeepKeys: { alias: "notHaveAllDeepKeys" }
343351
};
344352

345353
addAssertFuncs(assert, assertFuncs);

core/src/assert/funcs/keysFunc.ts

Lines changed: 127 additions & 5 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, 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 += "\"\"";
@@ -55,6 +65,22 @@ function _getValueKeys(scope: IAssertScope, value: any): string[] {
5565
return theKeys;
5666
}
5767

68+
function _getValueKeysForMap(value: any): any[] {
69+
let theKeys: any[] = [];
70+
if (!isStrictNullOrUndefined(value)) {
71+
// Check if it's a Map or Set
72+
if (value instanceof Map || value instanceof Set) {
73+
// For Maps and Sets, use the keys() iterator
74+
theKeys = arrFrom(value.keys());
75+
} else {
76+
// For regular objects, use _objKeys
77+
theKeys = _objKeys(value);
78+
}
79+
}
80+
81+
return theKeys;
82+
}
83+
5884
function _getArgKeys(scope: IAssertScope, theArgs: any[]): string[] {
5985
let theKeys: string[] = theArgs;
6086
if (theArgs && (isArray(theArgs[0]) || isObject(theArgs[0])) && !isString(theArgs[0])) {
@@ -73,6 +99,17 @@ function _getArgKeys(scope: IAssertScope, theArgs: any[]): string[] {
7399
return theKeys;
74100
}
75101

102+
function _getArgKeysForDeep(scope: IAssertScope, theArgs: any[]): any[] {
103+
// For deep keys, if a single array argument is provided, unwrap it to get multiple keys
104+
// This matches Chai behavior: .keys([a, b, c]) checks for keys a, b, c
105+
// Otherwise, treat all arguments as individual keys
106+
if (theArgs && theArgs.length === 1 && isArray(theArgs[0])) {
107+
return theArgs[0];
108+
}
109+
110+
return theArgs;
111+
}
112+
76113
export function anyKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {
77114

78115
function _keys(this: IAssertScope, _argKeys: Array<string|number|symbol> | Object | readonly any[]): R {
@@ -133,3 +170,88 @@ export function allKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {
133170
return _keys;
134171
}
135172

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

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;

core/src/assert/interface/ops/IAllOp.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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

@@ -14,4 +14,14 @@ import { IKeysOp } from "./IKeysOp";
1414
* @template R - The type of the result of the operation.
1515
*/
1616
export interface IAllOp<R> extends ValuesFn<R>, IKeysOp<R> {
17+
/**
18+
* Provides deep equality operations for keys.
19+
* @example
20+
* ```typescript
21+
* const map = new Map();
22+
* map.set({ a: 1 }, "value");
23+
* expect(map).has.all.deep.keys({ a: 1 });
24+
* ```
25+
*/
26+
deep: IKeysOp<R>;
1727
}

0 commit comments

Comments
 (0)