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
3 changes: 3 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ expect(() => { dosomething(); }).to.not.throw();
expect(() => { throw new Error("failed")}).to.throw();
```

For detailed documentation on specific assertion types, see:
- [Change/Increase/Decrease Assertions](https://nevware21.github.io/tripwire/change-assertions) - Testing value changes over function execution

## API Documentation

The API documentation is generated from the source code via typedoc and is located [here](https://nevware21.github.io/tripwire/index.html)
Expand Down
2 changes: 1 addition & 1 deletion core/src/assert/adapters/evalAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function createEvalAdapter(evalFn: EvalFn, evalMsg?: MsgSource, funcName?
if (context.opts.isVerbose) {
let theFuncName = funcName || evalFn.name || (evalFn as any)["displayName"] || "anonymous";

context.setOp("[[" + theFuncName + "]]");
context.setOp("[[ev:" + theFuncName + "]]");
}

theArgs.unshift(context.value);
Expand Down
18 changes: 9 additions & 9 deletions core/src/assert/adapters/exprAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ function _processFn(scope: IAssertScope, scopeFn: IScopeFn, theArgs: any[], theR
// Track the operation path and set the stack start position
if (scope.context.opts.isVerbose) {
let theScopeName = scopeFn.name || (scopeFn as any)["displayName"] || "anonymous";
scope.context.setOp("[[" + theScopeName + "]]");
scope.context.setOp("[[p:" + theScopeName + "]]");
}

theResult = scopeFn;
Expand All @@ -190,6 +190,9 @@ function _processFn(scope: IAssertScope, scopeFn: IScopeFn, theArgs: any[], theR
if (handleResultFunc && isFunction(theResult)) {
// The last step is a function, so we call it
theResult = scope.exec(theResult, theArgs);
if (scope.context.opts.isVerbose) {
scope.context.setOp("=>[[r:" + _formatValue(scope.context, theResult) + "]]");
}
}

return theResult;
Expand Down Expand Up @@ -225,18 +228,15 @@ export function createExprAdapter(theExpr: string | string[], scopeFn?: IScopeFn

// Track the operation path and set the stack start position
if (context.opts.isVerbose) {
let theFuncName: string;
if (isArray(theExpr)) {
theFuncName = theExpr.join(".");
let theFuncName = theExpr;
if (isArray(theFuncName)) {
// Convert to a string
theFuncName = theFuncName.join(".");
}

context.setOp("[[\"" + theFuncName + "\"]]");
}

return doAwait(_runExpr(scope.that, scope, steps, scopeFn, theArgs), (result) => {
scope.that = result;

return _processFn(scope, scopeFn, theArgs, result, false);
});
return _runExpr(scope.that, scope, steps, scopeFn, theArgs);
};
}
19 changes: 19 additions & 0 deletions core/src/assert/assertClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import {
sameMembersFunc, sameDeepMembersFunc, sameOrderedMembersFunc, sameDeepOrderedMembersFunc, startsWithMembersFunc,
startsWithDeepMembersFunc, endsWithMembersFunc, endsWithDeepMembersFunc, subsequenceFunc, deepSubsequenceFunc
} from "./funcs/members";
import { changesFunc, increasesFunc, decreasesFunc } from "./funcs/changes";
import { changesByFunc, increasesByFunc, decreasesByFunc, changesButNotByFunc, increasesButNotByFunc, decreasesButNotByFunc } from "./funcs/changesBy";

/**
* @internal
Expand Down Expand Up @@ -279,6 +281,23 @@ export function createAssert(): IAssertClass {
approximately: { alias: "closeTo" },
notApproximately: { alias: "notCloseTo" },

// Change/increase/decrease detection
changes: { scopeFn: changesFunc, nArgs: 3 },
doesNotChange: { scopeFn: createExprAdapter("not", changesFunc), nArgs: 3 },
changesBy: { scopeFn: changesByFunc, nArgs: 4 },
notChangesBy: { scopeFn: createExprAdapter("not", changesByFunc), nArgs: 4 },
changesButNotBy: { scopeFn: changesButNotByFunc, nArgs: 4 },
increases: { scopeFn: increasesFunc, nArgs: 3 },
doesNotIncrease: { scopeFn: createExprAdapter("not", increasesFunc), nArgs: 3 },
increasesBy: { scopeFn: increasesByFunc, nArgs: 4 },
notIncreasesBy: { scopeFn: createExprAdapter("not", increasesByFunc), nArgs: 4 },
increasesButNotBy: { scopeFn: increasesButNotByFunc, nArgs: 4 },
decreases: { scopeFn: decreasesFunc, nArgs: 3 },
doesNotDecrease: { scopeFn: createExprAdapter("not", decreasesFunc), nArgs: 3 },
decreasesBy: { scopeFn: decreasesByFunc, nArgs: 4 },
notDecreasesBy: { scopeFn: createExprAdapter("not", decreasesByFunc), nArgs: 4 },
decreasesButNotBy: { scopeFn: decreasesButNotByFunc, nArgs: 4 },

// Value membership (oneOf)
oneOf: { scopeFn: oneOfFunc, nArgs: 2 },
notOneOf: { scopeFn: createExprAdapter("not", oneOfFunc), nArgs: 2 },
Expand Down
2 changes: 1 addition & 1 deletion core/src/assert/assertScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export function createAssertScope(context: IScopeContext, handlerCreator?: Asser
function _exec<F extends IScopeFn, R = ReturnType<F>>(fn: F, args: Parameters<F>, funcName?: string): R {
let theFuncName = funcName || fn.name || (fn as any)["displayName"] || "anonymous";
if (_context.opts.isVerbose) {
theScope.context.setOp("[[" + theFuncName + "]]");
theScope.context.setOp("[[ex:" + theFuncName + "]]");
}

_context.set(EXEC, theFuncName);
Expand Down
239 changes: 239 additions & 0 deletions core/src/assert/funcs/changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/*
* @nevware21/tripwire
* https://github.com/nevware21/tripwire
*
* Copyright (c) 2026 NevWare21 Solutions LLC
* Licensed under the MIT license.
*/

import { arrSlice, isFunction, isNumber, isObject, isString, isSymbol } from "@nevware21/ts-utils";
import { IAssertScope } from "../interface/IAssertScope";
import { MsgSource } from "../type/MsgSource";
import { _isMsgSource } from "../internal/_isMsgSource";
import { IScopeContext } from "../interface/IScopeContext";
import { changeResultOp } from "../ops/changeResultOp";
import { IChangeResultOp } from "../interface/ops/IChangeResultOp";
import { IChangeResultValue } from "../interface/ops/IChangeResultValue";

/**
* @internal
* Internal helper to get the value to monitor.
* Returns either the result of calling a getter function or the property value from an object.
*/
export function _getTargetValue<T = any>(context: IScopeContext, name: string, target: (() => any) | T, prop?: keyof T): any {
let result: any;

if (isFunction(target)) {
result = target();
} else if (isObject(target as any)) {
if (!(prop in (target as any))) {
context.set("target", target);
context.fail("expected {target} to have {property} property");
}

result = (target as any)[prop];
} else {
result = (target as any)[prop];
}

context.set(name, result);

return result;
}

function _handleChangeFunc<V>(scope: IAssertScope, theArgs: unknown[], callback: (context: IScopeContext, resultValue: IChangeResultValue<V>, evalMsg?: MsgSource) => IChangeResultOp): IChangeResultOp {

let context = scope.context;
let fn = context.value;
let targetOrFn = theArgs[0] as ((() => any) | any);
let property: (string | symbol | number) = null;
let theMsg: MsgSource = null;

if (!targetOrFn) {
context.set("targetOrFn", targetOrFn);
scope.fatal("expected {targetOrFn} to be function or an object");
}

// Parse arguments
if (isFunction(targetOrFn)) {
// Form: changes(func, msg?)
if (theArgs.length >= 2 && _isMsgSource(theArgs[1])) {
theMsg = theArgs[1] as MsgSource;
}
} else {
// Form: changes(obj, prop, msg?)
property = theArgs[1] as (string | symbol | number);
context.set("property", property);

if (theArgs.length >= 3 && _isMsgSource(theArgs[2])) {
theMsg = theArgs[2] as MsgSource;
}

if (!(isString(property) || isSymbol(property) || isNumber(property))) {
scope.fatal("expected property name ({property}) to be a string, symbol or number");
}
}

if (!isFunction(fn)) {
scope.fatal("expected {value} to be a function");
}

// Get the initial value
let initialValue = _getTargetValue(context, "initial", targetOrFn, property);

// Execute the function
fn();

// Get the final value
let finalValue = _getTargetValue(context, "final", targetOrFn, property);

let result: IChangeResultValue<V> = {
property: property,
initial: initialValue,
value: finalValue
};

if (isNumber(initialValue) && isNumber(finalValue)) {
result.delta = finalValue - initialValue;
context.set("delta", result.delta);
}

return callback(context, result, theMsg);
}

/**
* Asserts that executing the target function changes the monitored value.
* @param this - The current {@link IAssertScope} object
* @param func - A function that returns a value to monitor
* @param evalMsg - Optional message to display if the assertion fails
* @returns The current {@link IAssertScope.that} object with optional .by() chaining
*/
export function changesFunc(this: IAssertScope, func: () => any, evalMsg?: MsgSource): IChangeResultOp;

/**
* Asserts that executing the target function changes the monitored value.
* @param this - The current {@link IAssertScope} object
* @param target - An object containing the property to monitor
* @param prop - The property name to monitor
* @param evalMsg - Optional message to display if the assertion fails
* @returns The current {@link IAssertScope.that} object with optional .by() chaining
*/
export function changesFunc<T = any>(this: IAssertScope, target: T, prop: keyof T, evalMsg?: MsgSource): IChangeResultOp;

/**
* Asserts that executing the target function changes the monitored value.
* @param this - The current {@link IAssertScope} object
* @returns The current {@link IAssertScope.that} object with optional .by() chaining
*/
export function changesFunc(this: IAssertScope): IChangeResultOp {
let scope = this;

return _handleChangeFunc(scope, arrSlice(arguments), (context, resultValue, theMsg) => {
// Check if the value changed
let changed = resultValue.initial !== resultValue.value;

context.eval(changed, theMsg || (resultValue.property
? "expected {value} to change {property} from {initial} to a different value"
: "expected {value} to change the monitored value from {initial} to a different value"));

// Create a new scope with the delta for potential .by() chaining
return changeResultOp(scope, resultValue);
});
}

/**
* Asserts that executing the target function increases the monitored value.
* @param this - The current {@link IAssertScope} object
* @param getter - A function that returns a value to monitor, or an object containing the property to monitor
* @param prop - The property name to monitor (only used when first argument is an object)
* @param evalMsg - Optional message to display if the assertion fails
* @returns The current {@link IAssertScope.that} object with optional .by() chaining
*/
export function increasesFunc(this: IAssertScope, func: () => any, evalMsg?: MsgSource): IChangeResultOp;

/**
* Asserts that executing the target function increases the monitored value.
* @param this - The current {@link IAssertScope} object
* @param target - An object containing the property to monitor
* @param prop - The property name to monitor
* @param evalMsg - Optional message to display if the assertion fails
* @returns The current {@link IAssertScope.that} object with optional .by() chaining
*/
export function increasesFunc<T = any>(this: IAssertScope, target: T, prop: keyof T, evalMsg?: MsgSource): IChangeResultOp;

/**
* Asserts that executing the target function increases the monitored value.
* @param this - The current {@link IAssertScope} object
* @returns The current {@link IAssertScope.that} object with optional .by() chaining
*/
export function increasesFunc(this: IAssertScope): IChangeResultOp {
let scope = this;

return _handleChangeFunc(scope, arrSlice(arguments), (context, resultValue, theMsg) => {
// Final sanity checks
if (!isNumber(resultValue.initial)) {
scope.fatal("expected initial value ({initial}) to be a number");
}

if (!isNumber(resultValue.value)) {
scope.fatal("expected final value ({final}) to be a number");
}

// Assert if increased
context.eval(resultValue.delta > 0, theMsg || (resultValue.property
? "expected {value} to increase {property} from {initial} but it changed by {delta}"
: "expected {value} to increase the monitored value from {initial} but it changed by {delta}"));

// Create a new scope with the delta for potential .by() chaining
return changeResultOp(scope, resultValue);
});
}

/**
* Asserts that executing the target function decreases the monitored value.
* @param this - The current {@link IAssertScope} object
* @param func - A function that returns a value to monitor
* @param evalMsg - Optional message to display if the assertion fails
* @returns The current {@link IAssertScope.that} object with optional .by() chaining
*/
export function decreasesFunc(this: IAssertScope, func: () => any, evalMsg?: MsgSource): IChangeResultOp;

/**
* Asserts that executing the target function decreases the monitored value.
* @param this - The current {@link IAssertScope} object
* @param target - An object containing the property to monitor
* @param prop - The property name to monitor
* @param evalMsg - Optional message to display if the assertion fails
* @returns The current {@link IAssertScope.that} object with optional .by() chaining
*/
export function decreasesFunc<T = any>(this: IAssertScope, target: T, prop: keyof T, evalMsg?: MsgSource): IChangeResultOp;

/**
* Asserts that executing the target function decreases the monitored value.
* @param this - The current {@link IAssertScope} object
* @returns The current {@link IAssertScope.that} object with optional .by() chaining
*/
export function decreasesFunc(this: IAssertScope): IChangeResultOp {
let scope = this;

return _handleChangeFunc(scope, arrSlice(arguments), (context, resultValue, theMsg) => {
if (!isNumber(resultValue.initial)) {
scope.fatal("expected initial value ({initial}) to be a number");
}

if (!isNumber(resultValue.value)) {
scope.fatal("expected final value ({final}) to be a number");
}

// Assert delta (will be negative for decrease)
context.eval(resultValue.delta < 0, theMsg || (resultValue.property
? "expected {value} to decrease {property} from {initial} but it changed by {delta}"
: "expected {value} to decrease the monitored value from {initial} but it changed by {delta}"));

// Invert the delta to be positive for potential .by() chaining
resultValue.delta = -resultValue.delta;

// Create a new scope with the absolute delta for potential .by() chaining
return changeResultOp(scope, resultValue);
});
}
Loading
Loading