assert.js

/**
 * Assert API
 *
 * Javascript, battle tested, simple assertion library with no dependencies.
 *
 * @see {@link https://assert-js.norbert.tech/}
 *
 * @module Assert
 */

const VALUE_NAME_REGEXP = /\${(.*?)}/g;

class MessageFactory {
  /**
   * @param {string} template
   * @param {object} [data]
   */
  static create(template, data = {}) {
    if (typeof template !== 'string') {
      throw new Error(
        `Expected string but got "${ValueConverter.toString(template)}".`
      );
    }

    if (typeof data !== 'object') {
      throw new Error(
        `Expected string but got "${ValueConverter.toString(data)}".`
      );
    }

    return template.replace(
      VALUE_NAME_REGEXP,
      function (placeholder, propertyName) {
        if (data.hasOwnProperty(propertyName)) {
          return data[propertyName];
        }

        return placeholder;
      }
    );
  }
}

class ValueConverter {
  /**
   * @param {*} value
   * @returns {string}
   */
  static toString(value) {
    if (typeof value === 'string') {
      return `string["${value}"]`;
    }

    if (typeof value === 'number') {
      if (Number.isInteger(value)) {
        return `int[${value}]`;
      }

      return `float[${value}]`;
    }

    if (typeof value === 'boolean') {
      return `boolean[${value ? 'true' : 'false'}]`;
    }

    if (typeof value === 'function') {
      return `function[${value.toString()}]`;
    }

    if (typeof value === 'object') {
      if (Array.isArray(value)) {
        return `array[length: ${value.length}]`;
      }

      if (value instanceof Map) {
        return `Map[size: ${value.size}]`;
      }

      if (value instanceof WeakMap) {
        return `WeakMap[]`;
      }

      if (value instanceof Set) {
        return `Set[size: ${value.size}]`;
      }

      if (value instanceof WeakSet) {
        return `WeakSet[]`;
      }

      if (value instanceof String) {
        return `String["${value}"]`;
      }

      if (value instanceof Number) {
        let source = value.valueOf();

        if (Number.isInteger(source)) {
          return `Number:int[${source}]`;
        }

        return `Number:float[${source}]`;
      }

      if (value instanceof Boolean) {
        return `Boolean[${value.valueOf() ? 'true' : 'false'}]`;
      }

      if (value instanceof Date) {
        return `Date["${value.toUTCString()}"]`;
      }

      if (value instanceof RegExp) {
        return `RegExp[${value.toString()}]`;
      }

      return `object[${JSON.stringify(value)}]`;
    }

    if (typeof value === 'undefined') {
      return 'undefined';
    }

    throw `Unhandled type ${typeof value}`;
  }
}

class InvalidValueException {
  /**
   * @param {string} type
   * @param {*} value
   * @param {string} [message]
   * @returns {Error}
   */
  static expected(type, value, message = '') {
    if (typeof message !== 'string') {
      throw new Error(
        `Expected string but got "${ValueConverter.toString(message)}".`
      );
    }

    if (message.length) {
      return new Error(
        MessageFactory.create(message, {
          expected: type,
          received: ValueConverter.toString(value),
        })
      );
    }

    return new Error(
      `Expected ${type} but got "${ValueConverter.toString(value)}".`
    );
  }
}

/**
 * A class that exposes static methods for assertions.
 */
class Assert {
  /**
   * Asserts that a given object is an instance of a specified class.
   *
   * @param {object} objectValue - The object to be tested against the expected instance.
   * @param {function} expectedInstance - The constructor function that the value is expected to be an instance of.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static instanceOf(objectValue, expectedInstance, message = '') {
    this.string(
      message,
      'Custom error message passed to Assert.instanceOf needs to be a valid string.'
    );

    if (typeof objectValue !== 'object') {
      throw InvalidValueException.expected('object', objectValue, message);
    }

    if (!(objectValue instanceof expectedInstance)) {
      throw InvalidValueException.expected(
        expectedInstance.name,
        objectValue,
        message.length
          ? message
          : 'Expected instance of "${expected}" but got "${received}".'
      );
    }
  }

  /**
   * Validates that a given value is an integer.
   *
   * @param {int} integerValue - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static integer(integerValue, message = '') {
    this.string(
      message,
      'Custom error message passed to Assert.integer needs to be a valid string.'
    );

    if (!Number.isInteger(integerValue)) {
      throw InvalidValueException.expected('integer', integerValue, message);
    }
  }

  /**
   * Validates that a given value is a number.
   *
   * @param {number} numberValue - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static number(numberValue, message = '') {
    this.string(
      message,
      'Custom error message passed to Assert.number needs to be a valid string.'
    );

    if (typeof numberValue !== 'number') {
      throw InvalidValueException.expected('number', numberValue);
    }
  }

  /**
   * Validates that a given value is a string.
   *
   * @param {string} stringValue - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static string(stringValue, message = '') {
    if (typeof message !== 'string') {
      throw new Error(
        'Custom error message passed to Assert.string needs to be a valid string.'
      );
    }

    if (typeof stringValue !== 'string') {
      throw InvalidValueException.expected('string', stringValue, message);
    }
  }

  /**
   * Validates that a given value is a boolean.
   *
   * @param {boolean} booleanValue - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static boolean(booleanValue, message = '') {
    this.string(
      message,
      'Custom error message passed to Assert.boolean needs to be a valid string.'
    );

    if (typeof booleanValue !== 'boolean') {
      throw InvalidValueException.expected('boolean', booleanValue, message);
    }
  }

  /**
   * Validates that a given value is true.
   *
   * @param {boolean} value - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static true(value, message = '') {
    this.boolean(value);
    this.string(
      message,
      'Custom error message passed to Assert.true needs to be a valid string.'
    );

    if (value !== true) {
      throw InvalidValueException.expected('true', value, message);
    }
  }

  /**
   * Validates that a given value is false.
   *
   * @param {boolean} value - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static false(value, message = '') {
    this.boolean(value);
    this.string(
      message,
      'Custom error message passed to Assert.false needs to be a valid string.'
    );

    if (value !== false) {
      throw InvalidValueException.expected('false', value, message);
    }
  }

  /**
   * Asserts that a given value is equal to an expected value.
   *
   * @param {*} value - The value to be compared. This can be of any type.
   * @param {*} expectedValue - The value against which the first parameter is compared.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static equal(value, expectedValue, message = '') {
    if (typeof value !== 'object') {
      this.true(
        value === expectedValue,
        message
          ? message
          : `Expected value ${ValueConverter.toString(
              value
            )} to be equals ${ValueConverter.toString(
              expectedValue
            )} but it's not.`
      );
    } else {
      this.objectEqual(
        value,
        expectedValue,
        message
          ? message
          : `Expected value ${ValueConverter.toString(
              value
            )} to be equals ${ValueConverter.toString(
              expectedValue
            )} but it's not.`
      );
    }
  }

  /**
   * Asserts that two objects are equal by comparing their properties.
   *
   * @param {object} object - The object to be compared.
   * @param {object} expectedObject - The object expected to be equal to the first object.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static objectEqual(object, expectedObject, message = '') {
    this.object(object, message);
    this.object(expectedObject, message);

    let objectProperties = Object.getOwnPropertyNames(object);
    let expectedObjectProperties = Object.getOwnPropertyNames(expectedObject);

    this.true(
      objectProperties.length === expectedObjectProperties.length,
      message
        ? message
        : `Expected object ${ValueConverter.toString(
            object
          )} to be equals ${ValueConverter.toString(
            expectedObject
          )} but it's not.`
    );

    objectProperties.forEach((objectProperty) => {
      this.equal(
        object[objectProperty],
        expectedObject[objectProperty],
        message
          ? message
          : `Expected object ${ValueConverter.toString(
              object
            )} to be equals ${ValueConverter.toString(
              expectedObject
            )} but it's not.`
      );
    });
  }

  /**
   * Asserts that a given value is of type 'object'.
   *
   * @param {object} objectValue - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static object(objectValue, message = '') {
    this.string(
      message,
      'Custom error message passed to Assert.object needs to be a valid string.'
    );

    if (typeof objectValue !== 'object') {
      throw InvalidValueException.expected('object', objectValue, message);
    }
  }

  /**
   * Asserts that a given object has a function of a specified name.
   *
   * @param {string} expectedFunctionName - The name of the function expected to be present in the object.
   * @param {object} objectValue - The object to be checked for the presence of the specified function.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static hasFunction(expectedFunctionName, objectValue, message = '') {
    this.string(expectedFunctionName);
    this.object(objectValue);
    this.string(
      message,
      'Custom error message passed to Assert.hasFunction needs to be a valid string.'
    );

    if (typeof objectValue[expectedFunctionName] !== 'function') {
      throw InvalidValueException.expected(
        `object to has function "${expectedFunctionName}"`,
        objectValue,
        message
      );
    }
  }

  /**
   * Asserts that a given object has a specific property.
   *
   * @param {string} expectedPropertyName - The name of the property expected to be present in the object.
   * @param {object} objectValue - The object to be checked for the presence of the specified property.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static hasProperty(expectedPropertyName, objectValue, message = '') {
    this.string(expectedPropertyName);
    this.object(objectValue);
    this.string(
      message,
      'Custom error message passed to Assert.hasProperty needs to be a valid string.'
    );

    if (typeof objectValue[expectedPropertyName] === 'undefined') {
      throw InvalidValueException.expected(
        `object to has property "${expectedPropertyName}"`,
        objectValue,
        message
      );
    }
  }

  /**
   * Asserts that a given value is an array.
   *
   * @param {array} arrayValue - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static array(arrayValue, message = '') {
    this.string(
      message,
      'Custom error message passed to Assert.array needs to be a valid string.'
    );

    if (!Array.isArray(arrayValue)) {
      throw InvalidValueException.expected('array', arrayValue, message);
    }
  }

  /**
   * Asserts that a given value is a function.
   *
   * @param {function} functionValue - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static isFunction(functionValue, message = '') {
    this.string(
      message,
      'Custom error message passed to Assert.isFunction needs to be a valid string.'
    );

    if (typeof functionValue !== 'function') {
      throw InvalidValueException.expected('function', functionValue, message);
    }
  }

  /**
   * Asserts that a given integer value is greater than an expected integer value.
   *
   * @param {int} expected - The integer value that the integerValue is expected to be greater than.
   * @param {int} integerValue - The integer value to be tested against the expected value.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static greaterThan(expected, integerValue, message = '') {
    this.number(expected);
    this.number(integerValue);
    this.string(
      message,
      'Custom error message passed to Assert.greaterThan needs to be a valid string.'
    );

    if (integerValue <= expected) {
      throw new Error(
        message.length > 0
          ? message
          : `Expected value ${integerValue} to be greater than ${expected}`
      );
    }
  }

  /**
   * Asserts that a given integer value is greater or equal than an expected integer value.
   *
   * @param {int} expected - The integer value that the integerValue is expected to be greater or equal than.
   * @param {int} integerValue - The integer value to be tested against the expected value.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static greaterThanOrEqual(expected, integerValue, message = '') {
    this.number(expected);
    this.number(integerValue);
    this.string(
      message,
      'Custom error message passed to Assert.greaterThanOrEqual needs to be a valid string.'
    );

    if (integerValue < expected) {
      throw new Error(
        message.length > 0
          ? message
          : `Expected value ${integerValue} to be greater than ${expected} or equal`
      );
    }
  }

  /**
   * Asserts that a given integer value is less than an expected integer value.
   *
   * @param {int} expected - The integer value that the integerValue is expected to be less than.
   * @param {int} integerValue - The integer value to be tested to ensure it is less than the expected value.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static lessThan(expected, integerValue, message = '') {
    this.number(expected);
    this.number(integerValue);
    this.string(
      message,
      'Custom error message passed to Assert.lessThan needs to be a valid string.'
    );

    if (integerValue >= expected) {
      throw new Error(
        message.length > 0
          ? message
          : `Expected value ${integerValue} to be less than ${expected}`
      );
    }
  }

  /**
   * Asserts that a given integer value is less or equal than an expected integer value.
   *
   * @param {int} expected - The integer value that the integerValue is expected to be less or equal than.
   * @param {int} integerValue - The integer value to be tested against the expected value.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static lessThanOrEqual(expected, integerValue, message = '') {
    this.number(expected);
    this.number(integerValue);
    this.string(
      message,
      'Custom error message passed to Assert.lessThanOrEqual needs to be a valid string.'
    );

    if (integerValue > expected) {
      throw new Error(
        message.length > 0
          ? message
          : `Expected value ${integerValue} to be less than ${expected} or equal`
      );
    }
  }

  /**
   * Asserts that the length of a given array matches an expected count.
   *
   * @param {int} expectedCount - The number that the length of the array is expected to match.
   * @param {array} arrayValue - The array whose length is being checked against the expected count.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static count(expectedCount, arrayValue, message = '') {
    this.integer(expectedCount);
    this.array(arrayValue);
    this.string(
      message,
      'Custom error message passed to Assert.count needs to be a valid string.'
    );

    if (arrayValue.length !== expectedCount) {
      throw new Error(
        message.length
          ? message
          : `Expected count ${expectedCount}, got ${arrayValue.length}`
      );
    }
  }

  /**
   * Asserts that a given value is not empty.
   *
   * @param {*} value - The value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static notEmpty(value, message = '') {
    this.string(
      message,
      'Custom error message passed to Assert.empty needs to be a valid string.'
    );

    if (value.length === 0) {
      throw InvalidValueException.expected('not empty value', value, message);
    }
  }

  /**
   * Asserts that a given integer value is an odd number.
   *
   * @param {int} integerValue - The integer value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static oddNumber(integerValue, message = '') {
    this.integer(integerValue);
    this.string(
      message,
      'Custom error message passed to Assert.oddNumber needs to be a valid string.'
    );

    if (integerValue % 2 !== 1) {
      throw InvalidValueException.expected('odd number', integerValue, message);
    }
  }

  /**
   * Asserts that a given integer value is an even number.
   *
   * @param {int} integerValue - The integer value to be checked.
   * @param {string} [message] - A custom error message to be used if the assertion fails.
   */
  static evenNumber(integerValue, message = '') {
    this.integer(integerValue);
    this.string(
      message,
      'Custom error message passed to Assert.evenNumber needs to be a valid string.'
    );

    if (integerValue % 2 !== 0) {
      throw InvalidValueException.expected(
        'even number',
        integerValue,
        message
      );
    }
  }

  /**
   * Asserts that a function throws an error.
   *
   * @param {function} callback - The function expected to throw an error when invoked.
   * @param {object} [expectedError] - An Error object representing the expected error.
   */
  static throws(callback, expectedError = new Error()) {
    this.isFunction(callback);

    try {
      callback();
    } catch (error) {
      if (
        typeof error === 'object' &&
        error instanceof Error &&
        typeof expectedError === 'object' &&
        expectedError instanceof Error
      ) {
        if (expectedError.message.length) {
          this.equal(
            error.message,
            expectedError.message,
            `Expected exception message "${error.message}" to be equals "${expectedError.message}" but it's not.`
          );
        }

        return;
      }

      this.equal(
        error,
        expectedError,
        `Expected error of type ${ValueConverter.toString(
          error
        )} to be equals ${ValueConverter.toString(expectedError)} but it's not.`
      );

      return;
    }

    throw InvalidValueException.expected(
      ValueConverter.toString(expectedError),
      null,
      'Expected from callback to throw an Error "${expected}" but it didn\'t.'
    );
  }
}

export default Assert;