/*eslint max-lines: 'off'*/
import { TestResultReporter } from './test_result_reporter.js';
import { EqualityAssertionStrategy } from './equality_assertion_strategy.js';
import { IdentityAssertionStrategy } from './identity_assertion_strategy.js';
import { I18nMessage } from '../i18n/i18n_messages.js';
import { InvalidAssertionError } from '../errors.js';
import {
convertToArray,
isFunction,
isRegex,
isNumber,
isUndefined,
notNullOrUndefined,
numberOfElements,
prettyPrint,
asFloat,
} from '../utils.js';
/**
* I represent an assertion we want to make on a specific object (called the `actual`), against an expectation, in the
* context of a {@link TestRunner}.
*
* I have multiple ways to write expectations, represented by my public instance methods.
*
* When the expectation is evaluated, it reports the results to the {@link TestRunner}.
*/
export class Assertion extends TestResultReporter {
#actual;
constructor(runner, actual) {
super(runner);
this.#actual = actual;
}
// Boolean assertions
/**
* Expects the actual object to be strictly equal to `true`. Other "truthy" values according to Javascript rules
* will be considered not true.
* Another way of writing this assertion is to use the [isTrue]{@link Asserter#isTrue} method.
*
* @example
* assert.that(3 < 4).isTrue()
*
* @example equivalent version
* assert.isTrue(3 < 4)
*
* @returns {void}
*/
isTrue() {
this.#reportAssertionResult(
this.#actual === true,
() => I18nMessage.of('expectation_be_true', this.#actualResultAsString()),
);
}
/**
* Expects the actual object to be strictly equal to `false`. Other "falsy" values according to Javascript rules
* will be considered not false.
* Another way of writing this assertion is to use the [isFalse]{@link Asserter#isFalse} method.
*
* @example
* assert.that(3 >= 4).isFalse()
*
* @example equivalent version
* assert.isFalse(3 >= 4)
*
* @returns {void}
*/
isFalse() {
this.#reportAssertionResult(
this.#actual === false,
() => I18nMessage.of('expectation_be_false', this.#actualResultAsString()),
);
}
// Undefined value assertions
/**
* Expects the actual object to be strictly equal to `undefined`.
* Another way of writing this assertion is to use the [isUndefined]{@link Asserter#isUndefined} method.
*
* @example
* assert.that(object.missingProperty).isUndefined()
*
* @example equivalent version
* assert.isUndefined(object.missingProperty)
*
* @returns {void}
*/
isUndefined() {
this.#reportAssertionResult(
isUndefined(this.#actual),
() => I18nMessage.of('expectation_be_undefined', this.#actualResultAsString()),
);
}
/**
* Expects the actual object to be not strictly equal to `undefined`.
* Another way of writing this assertion is to use the [isNotUndefined]{@link Asserter#isNotUndefined} method.
*
* @example
* assert.that("hello".length).isNotUndefined()
*
* @example equivalent version
* assert.isNotUndefined("hello".length)
*
* @returns {void}
*/
isNotUndefined() {
this.#reportAssertionResult(
!isUndefined(this.#actual),
() => I18nMessage.of('expectation_be_defined', this.#actualResultAsString()),
);
}
// Null value assertions
/**
* Expects the actual object to be strictly equal to `null`.
* Another way of writing this assertion is to use the [isNull]{@link Asserter#isNull} method.
*
* @example
* assert.that(null).isNull()
*
* @example equivalent version
* assert.isNull(null)
*
* @returns {void}
*/
isNull() {
this.#reportAssertionResult(
this.#actual === null,
() => I18nMessage.of('expectation_be_null', this.#actualResultAsString()),
);
}
/**
* Expects the actual object to be different from `null`.
* Another way of writing this assertion is to use the [isNotNull]{@link Asserter#isNotNull} method.
*
* @example
* assert.that('something').isNotNull()
*
* @example equivalent version
* assert.isNotNull('something')
*
* @returns {void}
*/
isNotNull() {
this.#reportAssertionResult(
this.#actual !== null,
() => I18nMessage.of('expectation_be_not_null', this.#actualResultAsString()),
);
}
// Equality assertions
/**
* Expects the actual object to be equal to an expected object, according to a default or custom criteria.
* Another way of writing this assertion is to use the {@link areEqual} method.
*
* @example
* assert.that('3' + '4').isEqualTo('34')
*
* @example equivalent version
* assert.areEqual(3 + 4, 7)
*
* @example custom criteria
* assert.that([2, 3]).isEqualTo(['x', 'y'], (a, b) => a.length === b.length)
*
* @param {*} expected the object that you are expecting the `actual` to be.
* @param {Function} [criteria] a two-argument function to be used to compare `actual` and `expected`. Optional.
*
* @returns {void}
*/
isEqualTo(expected, criteria) {
this.#equalityAssertion(expected, criteria, true);
}
/**
* Expects the actual object to be not equal to an expected object, according to a default or custom criteria.
* Another way of writing this assertion is to use the {@link areNotEqual} method.
*
* @example
* assert.that('3' + '4').isNotEqualTo('7')
*
* @example equivalent version
* assert.areNotEqual(3 + 4, 8)
*
* @example custom criteria
* assert.that([2, 3]).isNotEqualTo(['x'], (a, b) => a.length === b.length)
*
* @param {*} expected the object that you are expecting the `actual` to be not equal.
* @param {Function} [criteria] a two-argument function to be used to compare `actual` and `expected`. Optional.
*
* @returns {void}
*/
isNotEqualTo(expected, criteria) {
this.#equalityAssertion(expected, criteria, false);
}
// Identity assertions
/**
* Expects the actual object to be identical (be the same reference) to an expected one.
* Another way of writing this assertion is to use the {@link areIdentical} method.
*
* @example literals
* assert.that(3).isIdenticalTo(3)
*
* @example equivalent version
* assert.areIdentical(3, 3)
*
* @example same reference
* const object = { my: "object" }
* assert.that(object).isIdenticalTo(object)
*
* @param {*} expected the object that you are expecting the `actual` to be.
*
* @returns {void}
*/
isIdenticalTo(expected) {
this.#identityAssertion(expected, true);
}
isNotIdenticalTo(expected) {
this.#identityAssertion(expected, false);
}
// Collection assertions
/**
* Expects the actual collection object to include an expected object. Works for Array, Strings, Set and Maps.
* It works in the same way as {@link isIncludedIn} but swapping actual and expected objects.
*
* @example array
* assert.that([1, 2, 3]).includes(2)
*
* @example set
* assert.that(new Set([1, 2, 3])).includes(3)
*
* @example string
* assert.that('42').includes('4')
*
* @param {*} expectedObject the object that you are expecting to be included.
* @param {Function} [equalityCriteria] a two-argument function to be used to compare elements from the `actual` collection and `expectedObject`. Optional.
*
* @returns {void}
*/
includes(expectedObject, equalityCriteria) {
const resultIsSuccessful = convertToArray(this.#actual).find(element =>
this.#areConsideredEqual(element, expectedObject, equalityCriteria));
const failureMessage = () => I18nMessage.of('expectation_include', this.#actualResultAsString(), prettyPrint(expectedObject));
this.#reportAssertionResult(resultIsSuccessful, failureMessage);
}
/**
* Expects the actual object to be included on an expected collection. Works for Array, Strings, Set and Maps.
* It works in the same way as {@link includes} but swapping actual and expected objects.
*
* @example array
* assert.that(2).isIncludedIn([1, 2, 3])
*
* @example set
* assert.that(3).isIncludedIn(new Set([1, 2, 3]))
*
* @example string
* assert.that('lo').isIncludedIn('hello')
*
* @param {*} expectedCollection the collection that you are expecting the `actual` to be included in.
* @param {Function} [equalityCriteria] a two-argument function to be used to compare elements from the `expectedCollection` and your `actual` object. Optional.
*
* @returns {void}
*/
isIncludedIn(expectedCollection, equalityCriteria) {
const resultIsSuccessful = expectedCollection.find(element =>
this.#areConsideredEqual(element, this.#actual, equalityCriteria));
const failureMessage = () => I18nMessage.of('expectation_be_included_in', this.#actualResultAsString(), prettyPrint(expectedCollection));
this.#reportAssertionResult(resultIsSuccessful, failureMessage);
}
doesNotInclude(expectedObject, equalityCriteria) {
const resultIsSuccessful = !convertToArray(this.#actual).find(element =>
this.#areConsideredEqual(element, expectedObject, equalityCriteria));
const failureMessage = () => I18nMessage.of('expectation_not_include', this.#actualResultAsString(), prettyPrint(expectedObject));
this.#reportAssertionResult(resultIsSuccessful, failureMessage);
}
isNotIncludedIn(expectedCollection, equalityCriteria) {
const resultIsSuccessful = !expectedCollection.find(element =>
this.#areConsideredEqual(element, this.#actual, equalityCriteria));
const failureMessage = () => I18nMessage.of('expectation_be_not_included_in', this.#actualResultAsString(), prettyPrint(expectedCollection));
this.#reportAssertionResult(resultIsSuccessful, failureMessage);
}
includesExactly(...objects) {
const resultIsSuccessful = this.#haveElementsConsideredEqual(this.#actual, objects);
const failureMessage = () => I18nMessage.of('expectation_include_exactly', this.#actualResultAsString(), prettyPrint(objects));
this.#reportAssertionResult(resultIsSuccessful, failureMessage);
}
/**
* Expects the actual object to be an empty collection (arrays, strings, sets and maps).
* Another way of writing this assertion is to use the [isEmpty]{@link Asserter#isEmpty} method.
*
* @example
* assert.that([]).isEmpty()
* assert.that('').isEmpty()
* assert.that(new Set()).isEmpty()
* assert.that(new Map()).isEmpty()
*
* @example equivalent version
* assert.isEmpty('')
*
* @returns {void}
*/
isEmpty() {
const resultIsSuccessful = numberOfElements(this.#actual || {}) === 0 && notNullOrUndefined(this.#actual);
const failureMessage = () => I18nMessage.of('expectation_be_empty', this.#actualResultAsString());
this.#reportAssertionResult(resultIsSuccessful, failureMessage);
}
/**
* Expects the actual object to be a non-empty collection (arrays, strings, sets and maps).
* Another way of writing this assertion is to use the [isNotEmpty]{@link Asserter#isNotEmpty} method.
*
* @example
* assert.that([42]).isNotEmpty()
* assert.that('hello').isNotEmpty()
* assert.that(new Set([42])).isNotEmpty()
* assert.that(new Map([['key', 42]])).isNotEmpty()
*
* @example equivalent version
* assert.isNotEmpty('hello')
*
* @returns {void}
*/
isNotEmpty() {
const setValueWhenUndefined = this.#actual || {};
const resultIsSuccessful = numberOfElements(setValueWhenUndefined) > 0;
const failureMessage = () => I18nMessage.of('expectation_be_not_empty', this.#actualResultAsString());
this.#reportAssertionResult(resultIsSuccessful, failureMessage);
}
// Exception assertions
/**
* Expects the actual object (in this case, a function) to raise an exception that matches the given expectation.
*
* @example exact error object
* assert.that(() => throw new Error("oops")).raises(new Error("oops"))
*
* @example regular expression
* assert.that(() => throw new Error("oops I did it again")).raises(/oops/)
*
* @param {any|RegExp} errorExpectation the error object expected to be thrown or a Regex that matches with the actual error message.
*
* @returns {void}
*/
raises(errorExpectation) {
this.#ensureActualObjectIsAFunction();
try {
this.#evaluateActualObjectAsFunction();
this.reportFailure(I18nMessage.of('expectation_error', prettyPrint(errorExpectation)));
} catch (actualError) {
const assertionPassed = this.#checkIfErrorMatchesExpectation(errorExpectation, actualError);
const errorMessage = () => I18nMessage.of('expectation_different_error', prettyPrint(errorExpectation), prettyPrint(actualError));
this.#reportAssertionResult(assertionPassed, errorMessage);
}
}
/**
* Expects the actual object (in this case, a function) to not raise an exception that matches the given criteria.
*
* @example exact error object
* assert.that(() => throw new Error("oops")).doesNotRaise(new Error("ay!"))
*
* @example regular expression
* assert.that(() => throw new Error("oops")).doesNotRaise(/ay/)
*
* @param {any|RegExp} notExpectedError the error object expected not to be thrown or a Regex that should not match
* with the actual error message.
*
* @returns {void}
*/
doesNotRaise(notExpectedError) {
this.#ensureActualObjectIsAFunction();
try {
this.#evaluateActualObjectAsFunction();
this.reportSuccess();
} catch (actualError) {
const errorCheck = this.#checkIfErrorMatchesExpectation(notExpectedError, actualError);
const failureMessage = () => I18nMessage.of('expectation_no_error', prettyPrint(actualError));
this.#reportAssertionResult(!errorCheck, failureMessage);
}
}
/**
* Expects the actual object (in this case, a function) to not raise any exception at all.
* This is the most accurate way to ensure that a piece of code does not fail.
*
* @example
* assert.that(() => 42).doesNotRaiseAnyErrors()
*
* @returns {void}
*/
doesNotRaiseAnyErrors() {
this.#ensureActualObjectIsAFunction();
try {
this.#evaluateActualObjectAsFunction();
this.reportSuccess();
} catch (error) {
this.reportFailure(I18nMessage.of('expectation_no_errors', prettyPrint(error)));
}
}
// Numeric assertions
isNearTo(number, precisionDigits = 4) {
const result = asFloat(this.#actual.toFixed(precisionDigits)) === number;
const failureMessage = () => I18nMessage.of('expectation_be_near_to', this.#actualResultAsString(), number.toString(), precisionDigits.toString());
this.#reportAssertionResult(result, failureMessage);
}
/**
* Expects the actual object (in this case, a number) to be strictly greater than the given one.
*
* @example
* assert.that(4).isGreaterThan(3)
*
* @param {Number} number number to compare against the actual value.
* @returns {void}
*/
isGreaterThan(number) {
const comparator = (number1, number2) => number1 > number2;
this.#runNumericalComparisonAssertion(number, comparator, 'expectation_be_greater_than');
}
/**
* Expects the actual object (in this case, a number) to be greater or equal than the given one.
*
* @example
* assert.that(4).isGreaterThanOrEqualTo(4)
*
* @param {Number} number number to compare against the actual value.
* @returns {void}
*/
isGreaterThanOrEqualTo(number) {
const comparator = (number1, number2) => number1 >= number2;
this.#runNumericalComparisonAssertion(number, comparator, 'expectation_be_greater_than_or_equal');
}
/**
* Expects the actual object (in this case, a number) to be strictly less than the given one.
*
* @example
* assert.that(3).isLessThan(4)
*
* @param {Number} number number to compare against the actual value.
* @returns {void}
*/
isLessThan(number) {
const comparator = (number1, number2) => number1 < number2;
this.#runNumericalComparisonAssertion(number, comparator, 'expectation_be_less_than');
}
/**
* Expects the actual object (in this case, a number) to be less or equal than the given one.
*
* @example
* assert.that(4).isLessThanOrEqualTo(4)
*
* @param {Number} number number to compare against the actual value.
* @returns {void}
*/
isLessThanOrEqualTo(number) {
const comparator = (number1, number2) => number1 <= number2;
this.#runNumericalComparisonAssertion(number, comparator, 'expectation_be_less_than_or_equal');
}
// String assertions
matches(regex) {
const result = this.#actual.match(regex);
const failureMessage = () => I18nMessage.of('expectation_match_regex', this.#actualResultAsString(), regex);
this.#reportAssertionResult(result, failureMessage);
}
// Private
#identityAssertion(expected, shouldBeIdentical) {
const { comparisonResult, overrideFailureMessage } =
IdentityAssertionStrategy.evaluate(this.#actual, expected);
const resultIsSuccessful = shouldBeIdentical ? comparisonResult : !comparisonResult;
if (isUndefined(comparisonResult)) {
this.#reportAssertionResult(false, overrideFailureMessage);
} else {
const expectationMessageKey = shouldBeIdentical ? 'identity_assertion_be_identical_to' : 'identity_assertion_be_not_identical_to';
const expectationMessage = () => I18nMessage.of(expectationMessageKey, this.#actualResultAsString(), prettyPrint(expected));
this.#reportAssertionResult(resultIsSuccessful, expectationMessage);
}
}
#equalityAssertion(expected, criteria, shouldBeEqual) {
const { comparisonResult, additionalFailureMessage, overrideFailureMessage } =
EqualityAssertionStrategy.evaluate(this.#actual, expected, criteria);
const resultIsSuccessful = shouldBeEqual ? comparisonResult : !comparisonResult;
if (isUndefined(comparisonResult)) {
this.#reportAssertionResult(false, overrideFailureMessage);
} else {
const expectationMessageKey = shouldBeEqual ? 'equality_assertion_be_equal_to' : 'equality_assertion_be_not_equal_to';
const expectationMessage = () => I18nMessage.of(expectationMessageKey, this.#actualResultAsString(), prettyPrint(expected));
const finalMessage = () => I18nMessage.joined([expectationMessage.call(), additionalFailureMessage.call()], ' ');
this.#reportAssertionResult(resultIsSuccessful, finalMessage);
}
}
#areConsideredEqual(objectOne, objectTwo, equalityCriteria) {
return EqualityAssertionStrategy.evaluate(objectOne, objectTwo, equalityCriteria).comparisonResult;
}
#checkIfErrorMatchesExpectation(errorExpectation, actualError) {
if (isRegex(errorExpectation)) {
return errorExpectation.test(actualError);
} else {
return this.#areConsideredEqual(actualError, errorExpectation);
}
}
#reportAssertionResult(wasSuccess, failureMessage) {
if (wasSuccess) {
this.reportSuccess();
} else {
this.reportFailure(failureMessage.call());
}
}
#actualResultAsString() {
return prettyPrint(this.#actual);
}
#haveElementsConsideredEqual(collectionOne, collectionTwo) {
const collectionOneArray = Array.from(collectionOne);
const collectionTwoArray = Array.from(collectionTwo);
if (collectionOneArray.length !== collectionTwoArray.length) {
return false;
}
for (let index = 0; index < collectionOne.length; index += 1) {
const includedInOne = collectionOne.find(element =>
this.#areConsideredEqual(element, collectionTwoArray[index]));
const includedInTwo = collectionTwo.find(element =>
this.#areConsideredEqual(element, collectionOneArray[index]));
if (!includedInOne || !includedInTwo) {
return false;
}
}
return true;
}
#ensureActualObjectIsAFunction() {
if (!isFunction(this.#actual)) {
throw new InvalidAssertionError(I18nMessage.of('invalid_actual_object_in_exception_assertion', this.#actualResultAsString()));
}
}
#evaluateActualObjectAsFunction() {
this.#actual.call();
}
#runNumericalComparisonAssertion(number, comparator, errorMessage) {
this.#validateNumericalComparison(number);
const number1 = asFloat(this.#actual);
const number2 = asFloat(number);
const result = comparator(number1, number2);
const failureMessage = () => I18nMessage.of(errorMessage, number1.toString(), number2.toString());
this.#reportAssertionResult(result, failureMessage);
}
#validateNumericalComparison(number) {
if (!isNumber(this.#actual)) {
throw new InvalidAssertionError(I18nMessage.of('invalid_actual_object_in_numerical_comparison', this.#actualResultAsString(), typeof this.#actual));
}
if (!isNumber(number)) {
throw new InvalidAssertionError(I18nMessage.of('invalid_object_in_numerical_comparison', this.#actualResultAsString(), typeof number));
}
}
}