UNPKG

@moyal/js-test

Version:

A lightweight, dependency-free JavaScript testing utility. This project is framework-agnostic and usable in both browser and Node.js environments.

1,620 lines (1,440 loc) 71.9 kB
let SimpleLogger$1; let BrowserLogger$1; let NodeLogger$1; /** * Base class for logger. * * @abstract */ class LoggerBase { /** @type {LoggerBase} */ static #_defaultLogger = null; /** * * @param {LoggerBase} simpleLogger * @param {LoggerBase} browserLogger * @param {LoggerBase} nodeLogger * @ignore */ static __setup(simpleLogger, browserLogger, nodeLogger) { SimpleLogger$1 = simpleLogger; BrowserLogger$1 = browserLogger; NodeLogger$1 = nodeLogger; } /** * Detects the current environment and returns the appropriate console printer adapter. * @returns {LoggerBase} */ static getDefaultLogger() { if (this.#_defaultLogger === null) { try { if ((typeof window !== "undefined" && typeof window.document !== "undefined"/* Browser */) || (typeof importScripts === "function" && typeof self !== "undefined" /* Web worker */)) { this.#_defaultLogger = new BrowserLogger$1(); } else if (typeof process !== "undefined" && process.versions?.node) { this.#_defaultLogger = new NodeLogger$1(); } else { this.#_defaultLogger = new SimpleLogger$1(); } } catch { this.#_defaultLogger = new SimpleLogger$1(); } } return this.#_defaultLogger; } /** * Checks if obj is an instance of a subclass of LoggerBase. * @param {any} logger - Object to test. * @returns {boolean} True if obj is derived from LoggerBase. */ static isLogger(logger) { return logger instanceof LoggerBase && Object.getPrototypeOf(logger.constructor) === LoggerBase; } /** * Support only colors that supported on all systems. */ #_supportedColors = new Set([ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "lightred", "lightgreen", "lightyellow", "lightblue", "lightmagenta", "lightcyan", "lightgray" ]); /** * Returns true if the specified color is supported by the logger. * @param {string} color - The color. * @returns {boolean} - true if the specified color is supported by the logger; otherwise, flase. */ isSupportedColor(color) { return this.#_supportedColors.has(color); } /** * Normalizes the specified color name, or return an empty string if it is not supported. * * @param {string} color - The color. * @returns {string} - The normalized color. */ normalizeColor(color) { return typeof color === "string" ? color.toLowerCase() : ""; } /** * Logs a message. * @param {string} message - The message. * @param {string} [color] - The color to be used. * @param {...any} args - Additional arguments. * @returns {this} - The current instance for chaining. * @abstract */ log(message, color, ...args){ /* eslint-disable-line no-unused-vars */ } /** * Logs information message. * @param {string} message - The message. * @param {string} [color] - The color to be used. * @param {...any} args - Additional arguments. * @returns {this} The current instance for chaining. * @abstract */ info(message, color, ...args){ /* eslint-disable-line no-unused-vars */ } /** * Logs warning message. * @param {string} message - The message. * @param {string} [color] - The color to be used. * @param {...any} args - Additional arguments. * @returns {this} The current instance for chaining. * @abstract */ warn(message, color, ...args){ /* eslint-disable-line no-unused-vars */ } /** * Logs error message. * @param {string} message - The message. * @param {string} [color] - The color to be used. * @param {...any} args - Additional arguments. * @returns {this} The current instance for chaining. * @abstract */ error(message, color, ...args){ /* eslint-disable-line no-unused-vars */ } /** * Starts grouped output. * @param {string} label - The group label. * @param {string} [color] - The color to be used. * @returns {this} The current instance for chaining. * @abstract */ group(label, color){ /* eslint-disable-line no-unused-vars */ } /** * Starts grouped output (collapsed by default). * @param {string} label - The group label. * @param {string} [color] - The color to be used. * @returns {this} The current instance for chaining. * @abstract */ groupCollapsed(label, color){ /* eslint-disable-line no-unused-vars */ } /** * Ends group output. * @returns {this} The current instance for chaining. * @abstract */ groupEnd(){} } /*! * File: src/utilClasses/_InternalUtils.js */ /** * A set of internal utility functions for type checking. * @ignore */ class InternalUtils { static #_now; static { // Safe timer that always exists this.#_now = (typeof performance !== "undefined" && typeof performance.now === "function") ? () => performance.now() : () => Date.now(); } /** * * @returns {Number} * @ignore */ static now() { return this.#_now(); } /** * Checks if an object is a string. * @param {*} obj - The object to test. * @returns {boolean} True if the object is a string. * @ignore */ static isString(obj) { return typeof obj === "string" || Object.prototype.toString.call(obj) === "[object String]"; } /** * Checks if an object is iterable (i.e., supports Symbol.iterator). * @param {*} obj - The object to check. * @returns {boolean} True if it's iterable. * @ignore */ static isIterable(obj) { return InternalUtils.isFunctionOrGeneratorFunction(obj?.[Symbol.iterator]); } /** * Checks if an object is a function. * @param {*} obj - The object to check. * @returns {boolean} True if the object is a function. * @ignore */ static isFunction(obj) { let too = typeof obj; return (too === "object" || too === "function") && Object.prototype.toString.call(obj) === "[object Function]"; } /** * Checks if an object is either a normal function or a generator function. * @param {*} obj - The object to check. * @returns {boolean} True if the object is a function or generator function. * @ignore */ static isFunctionOrGeneratorFunction(obj) { const typeOfObj = typeof obj; if (typeOfObj !== "function" && typeOfObj !== "object") return false; const tag = Object.prototype.toString.call(obj); return tag === "[object Function]" || tag === "[object GeneratorFunction]"; } } /** * File: src/utilClasses/SequentialText.js */ /** * @class SequentialText * * A utility class that generates a sequence of formatted strings like `"1"`, `"2"`, etc., using a * text template such as `"{0}"` or `"Step {0}"`. * * Supports resetting and iteration with `for...of`. * * Example: * ```js * const st = new SequentialText("Item {0}", 1); * st.next(); // "Item 1" * st.next(); // "Item 2" * ``` */ class SequentialText { /** * Generator that produces an infinite sequence of formatted strings using a number. * Example: "{0}" → "1", "2", "3", ... * @param {string} textFormat - A string template, e.g., "{0}" or "Step {0}". * @param {number} startValue - The initial numeric value. * @yields {string} Formatted strings. */ static *#_sequentialTextGen(textFormat, startValue) { while (true) { yield textFormat.replace("{0}", startValue++); } } #_textFormat = null; #_startValue = null; #_gen = null; /** * Constructs a sequential text generator instance. * @param {string} textFormat - The format string, default is "{0}". * @param {number} startValue - The starting number, default is 1. */ constructor(textFormat, startValue) { if (startValue != null && (!Number.isInteger(startValue) || startValue < 1)) { throw new Error("startValue must be a positive integer"); } this.#_textFormat = textFormat ?? "{0}"; this.#_startValue = startValue ?? 1; } /** * Resets the generator state so iteration starts over from startValue. */ reset() { this.#_gen = SequentialText.#_sequentialTextGen(this.#_textFormat, this.#_startValue); } /** * Returns the next generated formatted string. * * @returns {string} */ next() { if (this.#_gen == null) this.reset(); return this.#_gen.next().value; } /** * Closes the generator and cleans up internal state. */ close() { if (this.#_gen?.return) this.#_gen.return(); this.#_gen = null; } /** * Enables iteration using for...of syntax on the class. * Each call to the iterator returns a fresh generator. */ *[Symbol.iterator]() { let gen = SequentialText.#_sequentialTextGen(this.#_textFormat, this.#_startValue); for (let item of gen) { yield item; } } } /** * File: src/utilClasses/AutoNumbering.js */ /** * @class AutoNumbering * * Extends {@link SequentialText} to support formatted auto-numbered items like `"1. Step A"`. * * Useful for numbering tests, documentation sections, or steps in a procedure. * * Example: * ```js * const an = new AutoNumbering(); * an.next("Initialize DB"); // "1. Initialize DB" * an.next("Check Schema"); // "2. Check Schema" * ``` */ class AutoNumbering extends SequentialText { /** * Constructs an auto-numbering generator that prefixes a number to each item. * * This is a convenience wrapper around `SequentialText` for cases where you want * numbered outputs like "1. Item A", "2. Item B", etc. * * @param {number} [startValue=1] - Starting number for the sequence. * @param {string} [numberingTextFormat="{0}. "] - Format for the numeric prefix. * The string must contain "{0}" as a placeholder. */ constructor(startValue = 1, numberingTextFormat = "{0}. ") { numberingTextFormat ??= "{0}. "; if(!InternalUtils.isString(numberingTextFormat) || numberingTextFormat.indexOf("{0}") < 0) throw new Error("Automatic numbering format must include {0}"); super(numberingTextFormat, startValue ?? 1); } /** * Generates the next string in the sequence by prefixing a number to the given text. * * This method calls the base `next()` to get the current number * and appends the optional string after it. * * @param {string} [text=""] - Optional content to append after the number. * @returns {string} Numbered string like "1. Hello" */ next(text) { return super.next() + (text ?? ""); } } /** * File: src/utilClasses/MultiLevelAutoNumbering.js */ /** * @class MultiLevelAutoNumbering * * A hierarchical auto-numbering utility supporting nested sequences like: * ``` * 1. * 1.1. * 1.2. * 2. * 2.1.1. * ``` * * Internally uses a stack of {@link AutoNumbering} instances, one for each level. * Supports `nest()` to go deeper and `unnest()` to go back. * * Example: * ```js * const ml = new MultiLevelAutoNumbering(); * ml.next("Root A"); // "1. Root A" * ml.nest().next("Child A"); // "1.1. Child A" * ml.next("Child B"); // "1.2. Child B" * ml.unnest().next("Root B"); // "2. Root B" * ml.next("Root C"); // "3. Root B" * ``` */ class MultiLevelAutoNumbering { /** * Stores the most recent result to calculate the nested prefix * * @type {string} * */ #_current = ""; /** * Stack of AutoNumbering generators, one per nesting level * * @type {AutoNumbering[]} * */ #_an = []; /** * The start value of the auto numbering * * @type {number} */ #_startValue = 1; /** * Creates a new multi-level auto-numbering generator. * * Only the default format `"{0}. "` is supported — other formats are not allowed. * * @param {number} [startValue=1] - The starting number for the top-level counter. * * @throws {Error} If a custom numbering format is provided. */ constructor(startValue) { this.#_startValue = startValue ?? 1; this.reset(); } /** * Resets this instance of {@link MultiLevelAutoNumbering}. */ reset(){ this.#_current = ""; this.#_an.length = 0; this.#_an.push(new AutoNumbering(this.#_startValue, null)); } /** * Gets the current nesting level (1 = root). * * @returns {number} Current nesting level (1 = root) * */ get level() { return this.#_an.length; } /** * Returns the next string in the current nesting level. * * @param {string} [text] - Optional content to append after the number (e.g., a title). * @returns {string} Formatted numbered string like `1. Title` or `2.3. Another`. */ next(text) { this.#_current = this.#_an[this.#_an.length - 1].next(); return this.#_current + (text ?? ""); } /** * Increases the nesting level (e.g., goes from `2.` to `2.1.`, or from `1.2.` to `1.2.1.`). * * The new level resets its own counter, while prefixing the last generated parent string. * * @param {number} [startValue=1] - Starting number for the new level. * @returns {MultiLevelAutoNumbering} The current instance (for chaining). */ nest(startValue) { let nxtFormat = this.#_current.trim(); nxtFormat += "{0}. "; this.#_an.push(new AutoNumbering(startValue ?? 1, nxtFormat)); return this; } /** * Decreases the nesting level (e.g., goes from `1.1.1.` to `1.1.`). * * Does nothing if already at the top-level (level 1). * * @returns {MultiLevelAutoNumbering} The current instance (for chaining). */ unnest() { if(this.#_an.length > 1) this.#_an.pop(); return this; } } // eslint-disable-next-line no-unused-vars /** * Internal class used by assertions to carry both the result of a test evaluation * and any associated metadata (such as expected/actual values) for logging. * * @private * @class */ class TestInternalResult{ /** * Indicates if the test passed or failed. * * @type {boolean} */ _result = null; /** * Additional context (e.g., expected/actual values) to display with the result. * * @type {any} */ _additionalData = null; /** * Constructs a new result object for use in lazy assertions. * * @param {boolean} result - The outcome of the test (true/false). * @param {any} additionalData - Extra metadata to assist in diagnostics (optional). */ constructor(result, additionalData) { this._result = result; this._additionalData = additionalData; } } /** * @class BaseTest * * Abstract base class for all test types. * * Provides a unified interface for managing test name, success/failure status, timing, and output. * Subclasses must override the `runImpl()` method to implement test logic. * @abstract */ class TestBase { /** @type {string} */ #_testName = null; /** @type {boolean} */ #_succeeded = true; /** @type {string} */ #_successMessage = null; /** @type {string} */ #_failureMessage = null; /** @type {any} */ #_additionalData = null; /** @type {Array<Error>} */ #_errors = []; /** @type {number} */ #_elapsed = 0; /** @type {{logger: LoggerBase}} */ static #_test = null; /** * @param {{{logger: LoggerBase}}} test * @ignore */ static __setup(test) { this.#_test = test; } /** * Base class for all test types. * * This class defines the common interface for test name, result summary, * success/failure messages, optional data, and a way to log results. * * @param {string} testName - The name of the test (must be string). * @param {string} [successMessage="success"] - Message when the test passes. * @param {string} [failureMessage="failure"] - Message when the test fails. * @param {any} [additionalData] - Arbitrary data to show with test output. */ constructor(testName, successMessage, failureMessage, additionalData) { if (!InternalUtils.isString(testName)) { throw new Error("testName must be string"); } if (successMessage != null && !InternalUtils.isString(successMessage)) { throw new Error("successMessage must be string, null or undefined"); } if (failureMessage != null && !InternalUtils.isString(failureMessage)) { throw new Error("failureMessage must be string, null or undefined"); } this.#_testName = testName; this.#_successMessage = successMessage ?? "success"; this.#_failureMessage = failureMessage ?? "failure"; this.#_additionalData = additionalData; } /** * Returns the name of the test. * * @returns {string} - The name of the test. */ get name() { return this.#_testName; } /** * Returns the message to display on test success. * * @returns {string} - The message to display on test success. */ get successMessage() { return this.#_successMessage; } /** * Returns the message to display on test failure. * * @returns {string} - The message to display on test failure. */ get failureMessage() { return this.#_failureMessage; } /** * Gets the duration in milliseconds. * * @returns {number} The duration in milliseconds. */ get elapsed() { return this.#_elapsed; } /* milliseconds*/ /** * Sets the time elapsed. * * @param {number} value - Duration in milliseconds */ set elapsed(value) { this.#_elapsed = value; } /* milliseconds*/ /** * Returns whether the test passed — overridden in derived classes. * * @returns {boolean} - Whether the test passed — overridden in derived classes. */ get succeeded() { return this.#_succeeded;} /** * Sets a value indicating whether the test passed. * * @param {boolean} value - A value indicating whether the test passed. */ set succeeded(value) { this.#_succeeded = (value === true);} /** * Returns whether the test failed (inverse of succeeded). * * @returns {boolean} - Whether the test failed (inverse of succeeded). */ get failed() { return this.succeeded !== true; } /** * Returns the list of errors associated with the test. * * @returns {Array<Error>} - The list of errors associated with the test. */ get errors() { return this.#_errors; } /** * Returns the count of errors (possibly from child tests) * * @returns {number} - The count of errors (possibly from child tests). */ get errorCount() { return this.errors.length; } /* might be the count of inner tests' errors, so in derived class might be positive even though the errors collection is null! */ /** * Gets extra information to log with the test. * * @returns {any} Extra information to log with the test */ get additionalData() { return this.#_additionalData; } /** * Sets extra information to log with the test. * * @param {any} value The additional data. */ set additionalData(value) { this.#_additionalData = value; } /** * Returns the logger used to log test results. * * @returns {LoggerBase} - The logger used to log test results. */ get logger() { return TestBase.#_test.logger; } /** * Runs the test and optionally writes its result. * * @param {boolean} write - If true, writes the result to the console; * If false doesn't write the result to the console; * Otherwise writes only failures to the console. * @param {MultiLevelAutoNumbering} [mlAutoNumber] - Optional multi-level automatic numbering to automatically prefix messages with numbers. * @returns {boolean} Whether the test passed. */ run(write, mlAutoNumber) { this.runImpl(); if (write === true || (write !== false && !this.succeeded)) { this.write(mlAutoNumber); } return this.succeeded; } /** * Runs the test without printing, just settings succeeded to the test result. * * @abstract */ runImpl() { throw new Error("Method 'runImpl()' must be implemented by subclass"); } /** * Pushes the specified error to the error list. * * @param {Error} e - The error. * @ignore */ _push_error(e){ this.#_errors.push(e); } /** * Logs the result of the test to the console. * * If the test passes with no errors, it uses a flat `console.log`. * If there are errors or additional data, it uses a collapsed group for clarity. * @param {MultiLevelAutoNumbering} [mlAutoNumber] - Optional multi-level automatic numbering to automatically prefix messages with numbers. */ write(mlAutoNumber) { if(mlAutoNumber == null || !(mlAutoNumber instanceof MultiLevelAutoNumbering)) mlAutoNumber = null; const labelName = this.name?.trim() || "(unnamed test)"; let label = `${(mlAutoNumber?.next() ?? "")}${labelName}: ${(this.succeeded ? this.successMessage : this.failureMessage)} (${this.elapsed} ms`; let color = this.succeeded ? "green" : "red"; if (this.errorCount === 0) { label += ")"; } else { let errorStr = (this.succeeded ? "" : "un") + "expected " + (this.errorCount > 1 ? "errors" : "error"); label += `, ${this.errorCount} ${errorStr})`; } if (this.errorCount == 0 && this.additionalData == null) { // Simple success case this.logger.log(label, color); return; } // Grouped output with errors or extra info this.logger.groupCollapsed(label, color); // Show errors if available if (this.errorCount > 0) { if (this.additionalData != null) { this.logger.groupCollapsed("errors"); } /* * Available colors: * "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "lightred", * "lightgreen", "lightyellow", "lightblue", "lightmagenta", "lightcyan", "lightgray" */ for (let err of this.errors) { if(this.succeeded) this.logger.error(err, "black"); else this.logger.error(err); } if (this.additionalData != null) { this.logger.groupEnd(); } } // Show additional data if available if (this.additionalData != null) { if (this.errorCount > 0) { this.logger.groupCollapsed("additional data"); } this.logger.log(JSON.stringify(this.additionalData)); if (this.errorCount > 0) { this.logger.groupEnd(); } } this.logger.groupEnd(); } } /** * @class Assert * * A generic assertion test class that evaluates either a boolean or a function returning boolean. * * Inherits from {@link TestBase}. * Typically used for boolean tests or custom logic. */ class Assert extends TestBase { // Holds the test logic, result, context, error and timing info /** @type {(function|boolean)} */ #_test = null; /** @type {any} */ #_thisArg = null; /** * A test that evaluates a function or boolean and tracks its result. * * If the test value is a function, it's called and timed. * If the function throws, it fails and captures the error. * * @param {string} testName - Name of the test. * @param {(function|boolean)} test - Test logic (function or static boolean). * @param {string} [successMessage] - Custom message on success. * @param {string} [failureMessage] - Custom message on failure. * @param {any} [additionalData] - Extra data to log. * @param {any} [thisArg=globalThis] - `this` context to bind when calling the function. */ constructor(testName, test, successMessage, failureMessage, additionalData, thisArg) { super(testName, successMessage, failureMessage, additionalData); this.#_test = test; this.#_thisArg = thisArg ?? globalThis; } /** * Runs the test without printing, just set `succeeded` property to the test result. * * @override * @ignore */ runImpl() { if (this.#_test === true) { this.succeeded = true; } else if (!InternalUtils.isFunction(this.#_test)) { this.succeeded = false; // Test is neither true nor a function } else { let res; const t0 = InternalUtils.now(); try { let tmp = this.#_test.call(this.#_thisArg); if(tmp instanceof TestInternalResult){ this.additionalData = tmp._additionalData; res = tmp._result === true; } else { res = tmp; } } catch (e) { this._push_error(e); res = false; } const t1 = InternalUtils.now(); this.elapsed = t1 - t0; this.succeeded = res; } return this.succeeded; } } /** * @class ThrowsBase * * Base class for tests that evaluate whether a function throws or not. * * Supports optional error validation via predicate functions. * * Inherits from {@link Assert}. * Not used directly — use {@link Throws} or {@link NoThrows} instead. */ class ThrowsBase extends Assert { /** @type {function} */ #_checkErrorFn = null; /** @type {any} */ #_thisArg = null; /** @type {boolean} */ #_expected = true; /** * Base class to test whether a function throws (or not), and optionally validate the error thrown. * * @param {string} testName - Name of the test. * @param {boolean} expectingError - Whether an error is expected (`true` = should throw). * @param {function} fn - Function to test. * @param {function(any):boolean} [checkErrorFn] - Optional error predicate to validate the thrown error. * @param {any} [thisArg] - Optional `this` context for invoking the test/check function. */ constructor(testName, expectingError, fn, checkErrorFn, thisArg) { expectingError = (expectingError === true); const errWasThrownAsExpected = "An error was thrown (as expected)!"; const errWasNotThrownAsExpected = "An error was NOT thrown (as expected)."; const errExpectedFail = "Expected an error, but none was thrown or it did not satisfy the predicate."; const errWasThrownAsUnexpectedly = "An error was not thrown (unexpectedly)."; super(testName, fn, expectingError ? errWasThrownAsExpected : errWasNotThrownAsExpected, expectingError ? errExpectedFail : errWasThrownAsUnexpectedly, thisArg); if (!InternalUtils.isFunction(fn)) { throw new Error("fn parameter must be a function"); } if (expectingError && checkErrorFn != null && !InternalUtils.isFunction(checkErrorFn)) { throw new Error("checkErrorFn parameter must be a function, null or undefined"); } this.#_expected = expectingError; this.#_thisArg = thisArg; this.#_checkErrorFn = checkErrorFn; } /** * Executes the test, checking if an error was thrown and optionally applying a predicate on the error. * * @override */ runImpl() { super.runImpl(); const basePassed = super.succeeded; if (!basePassed && this.errors.length === 1) { this.succeeded = this.#_expected && (this.#_checkErrorFn == null || this.#_checkErrorFn.call(this.#_thisArg, this.errors[0]) === true); } else { this.succeeded = !this.#_expected; } } } /** * File: src/testClasses/AreEqual.js */ /** * @class AreEqual * * A test that compares two values using strict equality (`===`) or a custom comparer function. * * Inherits from {@link Assert}. */ class AreEqual extends Assert { /** * Compares two values using `===` or a custom comparer function. * Values can be passed directly or as functions for deferred evaluation. * * @param {string} testName - Name of the test. * @param {any|Function} expected - Expected value or function returning it. * @param {any|Function} actual - Actual value or function returning it. * @param {function(any, any):boolean} [comparer] - Optional custom comparison function ((expected, actual) => boolean). * @param {any} [thisArg] - Optional context for invoking deferred or comparison functions. */ constructor(testName, expected, actual, comparer, thisArg) { // Use the comparer if provided, otherwise compare using strict equality const needsDelayedExecution = InternalUtils.isFunction(expected) || InternalUtils.isFunction(actual) || InternalUtils.isFunction(comparer); let test; let ad = null; if(needsDelayedExecution){ test = () => { let expectedVal = InternalUtils.isFunction(expected) ? expected.call(thisArg ?? globalThis) : expected; let actualVal = InternalUtils.isFunction(actual) ? actual.call(thisArg ?? globalThis) : actual; return new TestInternalResult( InternalUtils.isFunction(comparer) ? comparer.call(thisArg ?? globalThis, expectedVal, actualVal) : expectedVal === actualVal, { "expected": expectedVal, "actual": actualVal } ); }; } else { test = expected === actual; ad = { "expected": expected, "actual": actual }; } super(testName, test, "Expected and actual values matched", "Expected and actual values did not match", ad, thisArg); } } /** * File: src/testClasses/AreNotNull.js */ /** * @class AreNotEqual * * A test that verifies two values are **not equal** using strict inequality (`!==`) * or a custom comparer function that is expected to return `false`. * * This test passes when `actual !== not_expected`, or when the `comparer` returns `false`. * * Inherits from {@link Assert}. * * Example: * ```js * new AreNotEqual("Should be different", 42, value); * new AreNotEqual("Custom inequality", a, b, (a, b) => deepCompare(a, b)); * ``` */ class AreNotEqual extends Assert { /** * Constructs a new inequality assertion. * * @param {string} testName - Name of the test. * @param {any|Function} not_expected - Value the actual result must NOT match. * @param {any|Function} actual - Actual value or function returning it. * @param {function(any, any):boolean} [comparer] - Optional custom comparison function ((not_expected, actual) => boolean). * @param {any} [thisArg] - Optional context for invoking deferred or comparison functions. */ constructor(testName, not_expected, actual, comparer, thisArg) { // Use the comparer if provided, otherwise compare using strict equality const needsDelayedExecution = InternalUtils.isFunction(not_expected) || InternalUtils.isFunction(actual) || InternalUtils.isFunction(comparer); let test; let ad = null; if(needsDelayedExecution){ test = () => { let not_expectedVal = InternalUtils.isFunction(not_expected) ? not_expected.call(thisArg ?? globalThis) : not_expected; let actualVal = InternalUtils.isFunction(actual) ? actual.call(thisArg ?? globalThis) : actual; return new TestInternalResult( InternalUtils.isFunction(comparer) ? !comparer.call(thisArg ?? globalThis, not_expectedVal, actualVal) : not_expectedVal !== actualVal, { "not_expected": not_expectedVal, "actual": actualVal } ); }; } else { test = not_expected !== actual; ad = { "not_expected": not_expected, "actual": actual }; } super(testName, test, "Actual value did not match the disallowed value (as expected!)", "Actual value matched the disallowed value (not as expected!)", ad, thisArg); } } /** * File: src/testClasses/IsDefined.js */ /** * @class IsDefined * * A test that asserts the actual value is **not** `undefined`. * * Inherits from {@link Assert}. * * Example: * ```js * isDefined("Value should be defined", myValue); * ``` */ class IsDefined extends AreNotEqual { /** * Constructs a defined-check assertion. * * @param {string} testName - Descriptive name of the test. * @param {any|Function} actual - Value to evaluate or function to call. * @param {any} [thisArg] - Optional context for evaluation. */ constructor(testName, actual, thisArg) { super(testName, undefined, actual, null, thisArg); } } /** * File: src/testClasses/IsFalse.js */ /** * @class IsFalse * * A test that asserts the actual value is strictly `false`. * * Can accept a boolean value or a function returning boolean. * * Inherits from {@link Assert}. */ class IsFalse extends AreEqual { /** * Asserts that a value is strictly `false`. * * @param {string} testName - The name of the test. * @param {any|Function} actual - The value or function to evaluate. * @param {any} [thisArg] - Optional context for evaluation. */ constructor(testName, actual, thisArg) { super(testName, false, actual, null, thisArg); } } /** * File: src/testClasses/IsNotNull.js */ /** * @class IsNotNull * * A test that asserts the actual value is **not** `null`. * * Inherits from {@link Assert}. * * Example: * ```js * isNotNull("Should not be null", myValue); * ``` */ class IsNotNull extends AreNotEqual { /** * Constructs a not-null assertion. * * @param {string} testName - Descriptive name of the test. * @param {any|Function} actual - Value to check. * @param {any} [thisArg] - Optional context for invocation. */ constructor(testName, actual, thisArg) { super(testName, null, actual, null, thisArg); } } /** * File: src/testClasses/IsNull.js */ /** * @class IsNull * * A test that asserts the actual value is strictly `null`. * * Inherits from {@link Assert}. * * Example: * ```js * isNull("Should be null", myValue); * ``` */ class IsNull extends AreEqual { /** * Constructs a null-check assertion. * * @param {string} testName - Descriptive name of the test. * @param {any|Function} actual - Value to test or function that returns it. * @param {any} [thisArg] - Optional context for function calls. */ constructor(testName, actual, thisArg) { super(testName, null, actual, null, thisArg); } } /** * File: src/testClasses/IsTrue.js */ /** * @class IsTrue * * A test that asserts the actual value is strictly `true`. * * Can accept a boolean value or a function returning boolean. * * Inherits from {@link Assert}. */ class IsTrue extends AreEqual { /** * Asserts that a value is strictly `true`. * * @param {string} testName - The name of the test. * @param {any|Function} actual - The value or function to evaluate. * @param {any} [thisArg] - Optional context in which to invoke deferred evaluation. */ constructor(testName, actual, thisArg) { super(testName, true, actual, null, thisArg); } } /** * File: src/testClasses/IsUndefined.js */ /** * @class IsUndefined * * A test that asserts the actual value is strictly `undefined`. * * Inherits from {@link Assert}. * * Example: * ```js * isUndefined("Should be undefined", maybeMissing); * ``` */ class IsUndefined extends AreEqual { /** * Constructs an undefined-check assertion. * * @param {string} testName - Descriptive name of the test. * @param {any|Function} actual - Value to test or a function that returns it. * @param {any} [thisArg] - Optional context for deferred invocation. */ constructor(testName, actual, thisArg) { super(testName, undefined, actual, null, thisArg); } } /** * File: src/testClasses/NoThrows.js */ /** * @class NoThrows * * A test that verifies a function does not throw any error. * * Inherits from {@link ThrowsBase}. */ class NoThrows extends ThrowsBase { /** * Tests that a function does NOT throw. * * @param {string} testName - Name of the test. * @param {?function} fn - The function to test. * @param {any} [thisArg] - Optional `this` context. */ constructor(testName, fn, thisArg) { super(testName, false, fn, null, thisArg); } } /** * File: src/testClasses/SequenceAreEqual.js */ /** * @class SequencesAreEqual * * A test that compares two iterable sequences element-by-element for equality. * * You can supply a custom item comparison function. Results include index mismatches. * * Inherits from {@link TestBase}. */ class SequencesAreEqual extends TestBase { /** @type {Iterable<any>} */ #_expected = null; /** @type {Iterable<any>} */ #_actual = null; /** @type {function} */ #_itemComparer = null; /** @type {boolean} */ #_validIterables = true; /** @type {any} */ #_thisArg = null; /** * Compares two iterable sequences element by element. * * @param {string} testName - Name of the test. * @param {Iterable<any>} expected - Expected sequence. * @param {Iterable<any>} actual - Actual sequence. * @param {function(any, any):boolean} [itemComparer] - Optional custom comparison function to compare individual items ((expected, actual) => boolean). * @param {any} [thisArg] - Optional `this` binding for the itemComparer. */ constructor(testName, expected, actual, itemComparer, thisArg) { // Call Assert constructor with all info super(testName, `Actual sequence equals to the expected sequence`, `Actual sequence does not equal to the expected sequence)`, null, thisArg); this.additionalData = {}; this.#_expected = expected; this.#_actual = actual; this.#_itemComparer = itemComparer; this.#_thisArg = thisArg; // Validate that expected is iterable if (!InternalUtils.isIterable(expected)) { this.additionalData["expected"] = "ERROR: 'expected' argument is not iterable!"; this.#_validIterables = false; } // Validate that actual is iterable if (!InternalUtils.isIterable(actual)) { this.additionalData["actual"] = "ERROR: 'actual' argument is not iterable!"; this.#_validIterables = false; } } /** * Runs the test without printing. * * @returns {boolean} Whether the test passed. * @override */ runImpl() { if(!this.#_validIterables) { this.succeeded = false; } else { const t0 = InternalUtils.now(); let expectedArr = this.additionalData["expected"] = Array.from(this.#_expected); let actualArr = this.additionalData["actual"] = Array.from(this.#_actual); let res; // Check lengths if (expectedArr.length === actualArr.length) { // Check individual items let indicesDifferent = []; for (var i = 0; i < expectedArr.length; i++) { let res = this.#_itemComparer != null ? this.#_itemComparer.call(this.#_thisArg ?? undefined, expectedArr[i], actualArr[i]) : expectedArr[i] === actualArr[i]; if (res !== true) { indicesDifferent.push(i); } } if (indicesDifferent.length > 0) this.additionalData["Mismatch at indices"] = "Different element indices are: {" + indicesDifferent.join(", ") + "}"; res = indicesDifferent.length === 0; } else { this.additionalData["Mismatch at indices"] = "expected.length !== actual.length"; res = false; } const t1 = InternalUtils.now(); this.elapsed = t1 - t0; this.succeeded = res; } } } /** * File: src/testClasses/Throws.js */ /** * @class Throws * * A test that expects a function to throw an exception. * * You may optionally provide a predicate to verify the thrown error. * * Inherits from {@link ThrowsBase}. */ class Throws extends ThrowsBase { /** * Tests that a function throws, and optionally that the thrown error satisfies a condition. * * @param {string} testName - Name of the test. * @param {function} fn - The function that should throw. * @param {function(any):boolean} [checkErrorFn] - Optional error predicate. * @param {any} [thisArg] - Optional `this` context. */ constructor(testName, fn, checkErrorFn, thisArg) { super(testName, true, fn, checkErrorFn, thisArg); } } /** * File: src/testClasses/TestGroup.js */ /** * @class TestGroup * * A container for managing and executing multiple tests (or nested groups of tests). * * Automatically aggregates success/failure counts and outputs structured logs. * * Supports fluent-style chaining: * ```js * group.isTrue("A", true) * .areEqual("Compare", 1, 1) * .throws("Expect error", () => { throw new Error(); }); * .groupStart("another group") * .areEqual("Compare", 3, 3) * .throws("Expect error", () => { throw new Error(); }); * .groupClose() * .run(); * ``` * * Inherits from {@link TestBase}. */ class TestGroup extends TestBase { /** @type {Array<TestBase>} */ #_tests = []; /** @type {number} */ #_directFailureCount = 0; /** @type {number} */ #_totalFailureCount = 0; /** @type {number} */ #_totalErrorCount = 0; /** @type {number} */ #_unexpectedErrorCount = 0; /** @type {TestGroup|null} */ #_parentGroup = null; /** @type {boolean|null} */ #_write = null; // Controls output policy for child test writes (true, false, or conditional) /** * Creates a new test group to encapsulate multiple tests or nested groups. * * @param {string} testName - The name/title of this group. * @param {...TestBase} tests - Optional tests or nested groups to immediately add. */ constructor(testName, ...tests) { super(testName); this.add(...tests); } /** * Clears all tests in this group. */ clear() { this.#_tests.length = 0; this.#_directFailureCount = 0; this.#_totalFailureCount = 0; this.#_totalErrorCount = 0; this.#_unexpectedErrorCount = 0; } /** * The total number of errors found (including in nested groups). * * @returns {number} Total number of errors found (including in nested groups). * */ get errorCount() { return this.#_totalErrorCount; } /** * Runs the test and optionally writes its result. * * @param {boolean} write - If true, writes the result to the console; * If false doesn't write the result to the console; * Otherwise writes only failures to the console. * @param {MultiLevelAutoNumbering} [mlAutoNumber] - Optional multi-level automatic numbering to automatically prefix messages with numbers. * @returns {boolean} Whether the test passed. * @override */ run(write, mlAutoNumber) { this.#_write = write; return super.run(write, mlAutoNumber); } /** * Executes all tests/groups in this group without printing. * Aggregates error and timing info, but delays output if `write` is false.* * @returns {boolean} True if all direct tests succeeded. * @override */ runImpl() { this.#_directFailureCount = 0; this.#_totalErrorCount = 0; this.#_unexpectedErrorCount = 0; const t0 = InternalUtils.now(); for (let t of this.#_tests) { t.runImpl(); if (t instanceof TestGroup) { // Accumulate from nested groups this.#_totalFailureCount += t.totalFailureCount; this.#_unexpectedErrorCount += t.unexpectedErrorCount; } else { // Leaf tests this.#_directFailureCount += t.succeeded ? 0 : 1; this.#_unexpectedErrorCount += t.succeeded ? 0 : t.errorCount; this.#_totalFailureCount += t.succeeded ? 0 : 1; } this.#_totalErrorCount += t.errorCount; } const t1 = InternalUtils.now(); this.elapsed = t1 - t0; return this.#_directFailureCount === 0; } /** * Outputs a summary line and recursively logs all child test results. * Uses collapsed group for passed tests and expanded group for failed ones. * * @param {MultiLevelAutoNumbering} [mlAutoNumber] - Optional multi-level automatic numbering to automatically prefix messages with numbers. * @override */ write(mlAutoNumber, parentWriteMode) { if(mlAutoNumber == null || !(mlAutoNumber instanceof MultiLevelAutoNumbering)) mlAutoNumber = null; const writeMode = parentWriteMode == null ? this.#_write : parentWriteMode; let label = `${(mlAutoNumber?.next() ?? "")}${this.name}: (${this.elapsed}ms, `; let color; if (this.succeeded) { color = "green"; label += "all passed"; } else { color = "red"; label += `${(this.#_directFailureCount == 0 ? "no" : this.#_directFailureCount.toString())} direct failure${(this.#_directFailureCount === 1 ? "" : "s")}, ${this.#_totalFailureCount} total failure${(this.#_totalFailureCount === 1 ? "" : "s")}`; } if (this.#_unexpectedErrorCount> 0) { label += `, ${this.#_unexpectedErrorCount} unexpected error${(this.#_unexpectedErrorCount > 1 ? "s" : "")}`; } label += ")"; if (this.succeeded) { this.logger.groupCollapsed(label, color); } else { this.logger.group(label, color); } if(this.#_tests?.length > 0) { mlAutoNumber?.nest(); for (let t of this.#_tests) { if(writeMode === true || (writeMode == null && !t.succeeded)) { t.write(mlAutoNumber, this.#_write); } } mlAutoNumber?.unnest(); } this.logger.groupEnd(); } /** * Returns the number of unexpected errors that were thrown. * * @returns {number} - The number of unexpected errors that were thrown. */ get unexpectedErrorCount() { return this.#_unexpectedErrorCount; } /** * Returns the number of direct failures of tests within the group. Note that a failure of the group itself is not counted. * * @returns {number} - The number of direct failures of tests within the group. Note that a failure of the group itself is not counted. */ get directFailureCount() { return this.#_directFailureCount; } /** * Returns the number of total failure count, including in inner groups. Note that a failure of the group itself is not counted. * * @returns {number} - The number of total failure count, including in inner groups. Note that a failure of the group itself is not counted. */ get totalFailureCount() { return this.#_totalFailureCount; } /** * Returns `true` if the test scucceeded (that is the value of `totalFailureCount` equals 0); otherwise, `false`. * * @returns {boolean} - `true` if the test scucceeded (that is the value of `totalFailureCount` equals 0); otherwise, `false`. */ get succeeded() { return this.#_totalFailureCount === 0; } /** * Adds tests or groups to this group. * * @param {...TestBase} tests - One or more test/group instances. */ add(...tests) { this.#_tests.push(...tests); for (let t of tests) { if (t instanceof TestGroup) { t.#_parentGroup = this; } } } /** * Begins a new nested group and automatically adds it to this group. * * @param {string} testName - The name of the nested group. * @returns {TestGroup} The new nested group. */ groupStart(testName) { let grp = new TestGroup(testName); this.add(grp); return grp; } /** * Ends the current group and returns its parent, if any. * Enables fluid chaining of group nesting. * * @returns {TestGroup} - The parent group or `this` if already root. */ groupClose() { return this.#_parentGroup ?? this; } /** * Adds an equality assertion to the group. * Checks if `actual === expected`, or uses a custom comparer if provided. * * @param {string} testName - Descriptive test title. * @param {*} expected - Expected value. * @param {*} actual - Actual value to compare. * @param {function(any, any):boolean} [comparer] - Optional custom comparison function ((expected, actual) => boolean). * @param {any} [thisArg] - Optional context for evaluation. * @returns {TestGroup} The current test group (for chaining). */ areEqual(testNam