Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ grunt lint lint-fix # Lint with auto-fix
- Use `@nevware21/ts-utils` for cross-platform utilities
- No non-null assertions (`!` operator) - use proper null checks

### Versioning for New Features
- **@since tags**: Check `core/package.json` version before adding new features
- Current version in package.json is the **last published** version
- Use the **next minor version** for @since tags on new features
- Example: If package.json shows `0.1.4`, use `@since 0.1.5` for new features
- This follows semver: new features increment the minor version

## Testing Patterns

### Test Structure
Expand Down
1 change: 1 addition & 0 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ expect({ a: 1 }).to.deep.equal({ a: 1 });
- **Comparisons** - `above`, `below`, `within`, `atLeast`, `atMost`
- **Properties** - `property`, `ownProperty`, `deepProperty`, `nestedProperty` with value validation
- **Collections** - `include`, `keys`, `members` with deep equality and ordered comparison
- **Deep Keys** - `hasAnyDeepKeys`, `hasAllDeepKeys` for Maps/Sets with object keys using deep equality
- **Size/Length** - `lengthOf`, `sizeOf` for arrays, strings, maps, sets
- **Array Operations** - `subsequence`, `endsWith`, `oneOf` matching
- **Change Tracking** - Monitor value `changes`, `increases`, `decreases` with delta validation
Expand Down
10 changes: 9 additions & 1 deletion core/src/assert/assertClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,15 @@ export function createAssert(): IAssertClass {
subsequence: { scopeFn: subsequenceFunc, nArgs: 2 },
notSubsequence: { scopeFn: createExprAdapter("not", subsequenceFunc), nArgs: 2 },
deepSubsequence: { scopeFn: deepSubsequenceFunc, nArgs: 2 },
notDeepSubsequence: { scopeFn: createExprAdapter("not", deepSubsequenceFunc), nArgs: 2 }
notDeepSubsequence: { scopeFn: createExprAdapter("not", deepSubsequenceFunc), nArgs: 2 },

// Deep keys operations
hasAnyDeepKeys: { scopeFn: createExprAdapter("has.any.deep.keys"), nArgs: 2 },
hasAllDeepKeys: { scopeFn: createExprAdapter("has.all.deep.keys"), nArgs: 2 },
notHaveAnyDeepKeys: { scopeFn: createExprAdapter("not.has.any.deep.keys"), nArgs: 2 },
doesNotHaveAnyDeepKeys: { alias: "notHaveAnyDeepKeys" },
notHaveAllDeepKeys: { scopeFn: createExprAdapter("not.has.all.deep.keys"), nArgs: 2 },
doesNotHaveAllDeepKeys: { alias: "notHaveAllDeepKeys" }
};

addAssertFuncs(assert, assertFuncs);
Expand Down
228 changes: 218 additions & 10 deletions core/src/assert/funcs/keysFunc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
* @nevware21/tripwire
* https://github.com/nevware21/tripwire
*
* Copyright (c) 2024-2025 NevWare21 Solutions LLC
* Copyright (c) 2024-2026 NevWare21 Solutions LLC
* Licensed under the MIT license.
*/

import { arrForEach, arrSlice, asString, isArray, isObject, isStrictNullOrUndefined, isString, objGetOwnPropertyDescriptor, objGetOwnPropertySymbols, objKeys } from "@nevware21/ts-utils";
import { arrForEach, arrFrom, arrSlice, asString, dumpObj, isArray, isMapLike, isObject, isSetLike, isStrictNullOrUndefined, isString, objGetOwnPropertyDescriptor, objGetOwnPropertySymbols, objKeys } from "@nevware21/ts-utils";
import { IAssertScope } from "../interface/IAssertScope";
import { KeysFn } from "../interface/funcs/KeysFn";
import { EMPTY_STRING } from "../internal/const";
import { _deepEqual } from "./equal";
import { _isArrayLikeOrIterable, _iterateForEachItem } from "../internal/_isArrayLikeOrIterable";
import { _isMsgSource } from "../internal/_isMsgSource";

function _objKeys(value: any): any[] {
let keys: any[] = [];
Expand All @@ -27,16 +30,25 @@ function _objKeys(value: any): any[] {
return keys;
}

function _formatKeys(keys: string[]): string {
function _formatKeys(keys: any[]): string {
let formattedKeys: string = EMPTY_STRING;
arrForEach(keys, (key) => {
if (formattedKeys.length > 0) {
formattedKeys += ",";
}
if (key || isStrictNullOrUndefined(key)) {
formattedKeys += asString(key);
try {
formattedKeys += asString(key);
} catch (e) {
// Handle objects that can't be converted to string (e.g., null prototype)
formattedKeys += dumpObj(key);
}
} else if (!isString(key)) {
formattedKeys += asString(key);
try {
formattedKeys += asString(key);
} catch (e) {
formattedKeys += dumpObj(key);
}
} else {
// special case for empty string keys
formattedKeys += "\"\"";
Expand All @@ -46,10 +58,17 @@ function _formatKeys(keys: string[]): string {
return formattedKeys;
}

function _getValueKeys(scope: IAssertScope, value: any): string[] {
let theKeys: string[] = [];
function _getValueKeys(value: any): any[] {
let theKeys: any[] = [];
if (!isStrictNullOrUndefined(value)) {
theKeys = _objKeys(value);
// Check if it's a Map or Set
if (isMapLike(value) || isSetLike(value)) {
// For Maps and Sets, use the keys() iterator
theKeys = arrFrom(value.keys());
} else {
// For regular objects, use _objKeys
theKeys = _objKeys(value);
}
}

return theKeys;
Expand All @@ -73,14 +92,66 @@ function _getArgKeys(scope: IAssertScope, theArgs: any[]): string[] {
return theKeys;
}

/**
* Processes arguments for deep key comparisons, extracting the keys to compare.
* This helper function handles ArrayLikeOrSizedIterable expansion and filters out
* optional initMsg parameters.
*
* @param scope - The assert scope (currently unused but matches signature pattern).
* @param theArgs - The raw arguments array from the calling function.
* @returns An array of keys to compare against the target value.
*
* @remarks
* This function handles three scenarios:
* 1. If the last argument is a MsgSource (custom error message), it's excluded from processing
* 2. If a single ArrayLikeOrSizedIterable is provided (Array, Set, Map, etc.), it's expanded into individual keys
* 3. Otherwise, all arguments are treated as individual keys
*
* This matches Chai behavior where `.keys([a, b, c])` checks for keys a, b, c,
* while also supporting Sets, Maps, and other iterables as keys collections.
*
* @example
* ```typescript
* // With array: _getArgKeysForDeep(scope, [[key1, key2]]) → [key1, key2]
* // With Set: _getArgKeysForDeep(scope, [new Set([key1, key2])]) → [key1, key2]
* // With message: _getArgKeysForDeep(scope, [[key1], "msg"]) → [key1]
* ```
*/
function _getArgKeysForDeep(scope: IAssertScope, theArgs: any[]): any[] {
// Exclude optional initMsg parameter if present (last argument)
let argsToProcess = theArgs;
if (theArgs && theArgs.length >= 2 && _isMsgSource(theArgs[theArgs.length - 1])) {
// Remove the last argument (initMsg) from processing
argsToProcess = arrSlice(theArgs, 0, theArgs.length - 1);
}

// For deep keys, if a single ArrayLikeOrSizedIterable argument is provided, unwrap it to get multiple keys
// This matches Chai behavior: .keys([a, b, c]) checks for keys a, b, c
// Also handles Sets, Maps, and other iterables as keys collections
// Otherwise, treat all arguments as individual keys
if (argsToProcess && argsToProcess.length === 1) {
let firstArg = argsToProcess[0];
if (_isArrayLikeOrIterable(firstArg)) {
// Expand the iterable/array-like into individual keys
let keys: any[] = [];
_iterateForEachItem(firstArg, (key) => {
keys.push(key);
});
return keys;
}
}

return argsToProcess;
}

export function anyKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {

function _keys(this: IAssertScope, _argKeys: Array<string|number|symbol> | Object | readonly any[]): R {
let scope = this;
let context = scope.context;
let args = arrSlice(arguments);
let expectedKeys = _getArgKeys(scope, args);
let valueKeys = _getValueKeys(scope, context.value);
let valueKeys = _getValueKeys(context.value);

if (expectedKeys.length === 0) {
scope.context.set("expectedKeys", expectedKeys);
Expand Down Expand Up @@ -111,7 +182,7 @@ export function allKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {
let context = scope.context;
let args = arrSlice(arguments);
let theKeys = _getArgKeys(scope, args);
let valueKeys = _getValueKeys(scope, context.value);
let valueKeys = _getValueKeys(context.value);

if (theKeys.length === 0) {
scope.context.set("theKeys", theKeys);
Expand All @@ -133,3 +204,140 @@ export function allKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {
return _keys;
}

/**
* Creates a KeysFn function that checks if any of the provided keys exist in the value using deep equality comparison.
* This is used for deep key matching where keys are compared using deep equality instead of strict equality.
* Particularly useful for Maps and Sets where object keys need to be compared by value rather than reference.
*
* @param _scope - The assert scope.
* @returns A KeysFn function that accepts keys as an Array, Set, Map, or other iterable, or a single key.
*
* @remarks
* The function performs deep equality comparison using {@link _deepEqual} which recursively compares
* object properties, array elements, and handles special cases like Date, RegExp, Map, Set, etc.
*
* Keys can be provided in multiple ways:
* - Single key: `anyDeepKeysFunc(scope)({ id: 1 })`
* - Array of keys: `anyDeepKeysFunc(scope)([{ id: 1 }, { id: 2 }])`
* - Set of keys: `anyDeepKeysFunc(scope)(new Set([{ id: 1 }, { id: 2 }]))`
* - Map of keys: `anyDeepKeysFunc(scope)(new Map([[{ id: 1 }, 'val']]))` // Uses Map keys
*
* @example
* ```typescript
* // With Map containing object keys
* const map = new Map();
* map.set({ id: 1 }, 'value1');
* map.set({ id: 2 }, 'value2');
* anyDeepKeysFunc(scope).call({ value: map }, [{ id: 1 }, { id: 3 }]); // Passes - has { id: 1 }
*
* // With regular object
* const obj = { greeting: 'hello', subject: 'friend' };
* anyDeepKeysFunc(scope).call({ value: obj }, ['greeting', 'unknown']); // Passes - has 'greeting'
* ```
*/
export function anyDeepKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {

function _keys(this: IAssertScope, _argKeys: Array<string|number|symbol> | Object | readonly any[]): R {
let scope = this;
let context = scope.context;
let args = arrSlice(arguments);
let expectedKeys = _getArgKeysForDeep(scope, args);
let valueKeys = _getValueKeys(context.value);

if (expectedKeys.length === 0) {
scope.context.set("expectedKeys", expectedKeys);
scope.fatal("expected at least one key to be provided {expectedKeys}");
}

let found = false;
arrForEach(expectedKeys, (expKey) => {
arrForEach(valueKeys, (valKey) => {
if (_deepEqual(valKey, expKey)) {
// Found at least one key using deep equality
found = true;
return -1;
}
});

if (found) {
return -1;
}
});

context.eval(found, `expected any deep key: [${_formatKeys(expectedKeys)}], found: [${_formatKeys(valueKeys)}] (${valueKeys.length} keys)`);

return scope.that;
}

return _keys;
}

/**
* Creates a KeysFn function that checks if all of the provided keys exist in the value using deep equality comparison.
* This is used for deep key matching where keys are compared using deep equality instead of strict equality.
* Particularly useful for Maps and Sets where object keys need to be compared by value rather than reference.
*
* @param _scope - The assert scope.
* @returns A KeysFn function that accepts keys as an Array, Set, Map, or other iterable, or a single key.
*
* @remarks
* The function performs deep equality comparison using {@link _deepEqual} which recursively compares
* object properties, array elements, and handles special cases like Date, RegExp, Map, Set, etc.
* All provided keys must exist in the target for the assertion to pass.
*
* Keys can be provided in multiple ways:
* - Single key: `allDeepKeysFunc(scope)({ id: 1 })`
* - Array of keys: `allDeepKeysFunc(scope)([{ id: 1 }, { id: 2 }])`
* - Set of keys: `allDeepKeysFunc(scope)(new Set([{ id: 1 }, { id: 2 }]))`
* - Map of keys: `allDeepKeysFunc(scope)(new Map([[{ id: 1 }, 'val']]))` // Uses Map keys
*
* @example
* ```typescript
* // With Map containing object keys
* const map = new Map();
* map.set({ id: 1 }, 'value1');
* map.set({ id: 2 }, 'value2');
* allDeepKeysFunc(scope).call({ value: map }, [{ id: 1 }, { id: 2 }]); // Passes - has both
* allDeepKeysFunc(scope).call({ value: map }, [{ id: 1 }, { id: 3 }]); // Fails - missing { id: 3 }
*
* // With Set containing object values
* const set = new Set([{ id: 1 }, { id: 2 }]);
* allDeepKeysFunc(scope).call({ value: set }, [{ id: 1 }, { id: 2 }]); // Passes - has both
* ```
*/
export function allDeepKeysFunc<R>(_scope: IAssertScope): KeysFn<R> {

function _keys(this: IAssertScope, argKeys: string[] | Object | readonly any[]): R {
let scope = this;
let context = scope.context;
let args = arrSlice(arguments);
let theKeys = _getArgKeysForDeep(scope, args);
let valueKeys = _getValueKeys(context.value);

if (theKeys.length === 0) {
scope.context.set("theKeys", theKeys);
scope.fatal("expected at least one key to be provided {theKeys}");
}

let missingKeys: any[] = [];
arrForEach(theKeys, (expKey) => {
let found = false;
arrForEach(valueKeys, (valKey) => {
if (_deepEqual(valKey, expKey)) {
found = true;
return -1;
}
});

if (!found) {
missingKeys.push(expKey);
}
});

context.eval(missingKeys.length === 0, `expected all deep keys: [${_formatKeys(theKeys)}], missing: [${_formatKeys(missingKeys)}], found: [${_formatKeys(valueKeys)}]`);

return scope.that;
}

return _keys;
}
Loading
Loading