core.js

// eslint-disable-next-line no-unused-vars
import { LoggerBase } from "./logClasses/LoggerBase.js";
import { InternalUtils } from "./utilClasses/_InternalUtils.js";
import { MultiLevelAutoNumbering } from "./utilClasses/textAutomation.js";

/**
 * 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; 
        }
    }
}

export {
    TestInternalResult,
    TestBase,
    Assert,
    ThrowsBase
};