lib/subject/subject.js

/** @module certainty */
var diff = require('../compare').diff;
var compare = require('../compare').compare;
var format = require('../format/format');

/** Represents the value being checked, and provides assertion methods. Can be subclassed to
    provide assertion methods that are type-specific.
    @param {FailureStrategy} failureStrategy The failure strategy to use when an assertion fails.
    @param {*} value The value being checked.
    @constructor
*/
function Subject(failureStrategy, value) {
  this.failureStrategy = failureStrategy;
  this.value = value;
  this.name = null;
  this.format = format;
  this.failureMessage = null;
}

/** Return a string description of the subject. */
Subject.prototype.describe = function () {
  return this.name ? this.name : this.format(this.value, { clip: 128 }) + '';
};

/** Generic failure method that takes a messasge.
    @param {string} msg The failure message.
    @protected
*/
Subject.prototype.fail = function (msg) {
  this.failureStrategy.fail(this.failureMessage || msg);
};

/** Failure method for simple comparison assertions.
    @param {*} expected The expected value, which is different from the actual value.
    @protected
*/
Subject.prototype.failEqual = function (expected) {
  if (this.name) {
    this.fail(
      'Expected ' + this.name + ' to be ' + expected + ', actual value was ' +
        this.format(this.value, { clip: 128 }) + '.');
  } else {
    this.fail(
      'Expected ' + this.describe() + ' to be ' + expected + '.');
  }
};

/** Failure method for simple negated comparison assertions.
    @param {*} expected The expected value, which is the same as the actual value.
    @protected
*/
Subject.prototype.failNotEqual = function (expected) {
  this.fail(
    'Expected ' + this.describe() + ' to not be ' + expected + '.');
};

/** Failure method for simple comparison assertions.
    @param {string} verb The name of the comparison verb, such has 'greater than'.
    @param {*} expected The value that we're comparing against.
    @protected
*/
Subject.prototype.failComparison = function (verb, expected) {
  if (this.name) {
    this.fail(
      'Expected ' + this.name + ' to be ' + verb + ' ' + expected +
        ', actual value was ' + this.format(this.value) + '.');
  } else {
    this.fail(
      'Expected ' + this.describe() + ' to be ' + verb + ' ' + expected + '.');
  }
};

/** Assign a descriptive name to the subject.
    @param {string} name The name to assign to this subject.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.named = function (name) {
  this.name = name;
  return this;
};

/** Override the normal failure message.
    @param {string} msg The failure message to use on failure.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.withFailureMessage = function (msg) {
  this.failureMessage = msg;
  return this;
};

/** Ensure that the subject value is `true`.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isTrue = function () {
  if (this.value !== true) {
    this.failEqual('true');
  }
  return this;
};

/** Ensure that the subject value is `false`.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isFalse = function () {
  if (this.value !== false) {
    this.failEqual('false');
  }
  return this;
};

/** Ensure that the subject value is true when coerced to a boolean.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isTruthy = function () {
  if (!this.value) {
    this.failEqual('truthy');
  }
  return this;
};

/** Ensure that the subject value is false when coerced to a boolean.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isFalsey = function () {
  if (this.value) {
    this.failEqual('falsey');
  }
  return this;
};

/** Ensure that the subject value is `null`.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isNull = function () {
  if (this.value !== null) {
    this.failEqual('null');
  }
  return this;
};

/** Ensure that the subject value is not `null`.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isNotNull = function () {
  if (this.value === null) {
    this.failNotEqual('null');
  }
  return this;
};

/** Ensure that the subject value is `undefined`.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isUndefined = function () {
  if (this.value !== undefined) {
    this.failEqual('undefined');
  }
  return this;
};

/** Ensure that the subject value is not `undefined`.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isNotUndefined = function () {
  if (this.value === undefined) {
    this.failNotEqual('undefined');
  }
  return this;
};

/** Ensure that the subject value is `null` or `undefined`.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isNullOrUndefined = function () {
  if (this.value !== null && this.value !== undefined) {
    this.failEqual('null or undefined');
  }
  return this;
};

/** Ensure that the subject value is not `null` or `undefined`.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isNotNullOrUndefined = function () {
  if (this.value === null || this.value === undefined) {
    this.failNotEqual('null or undefined');
  }
  return this;
};

/** Synonym for isNotNullOrUndefined.
    @function
    @return {Subject} `this` for chaining.
*/
Subject.prototype.exists = Subject.prototype.isNotNullOrUndefined;

/** Ensure that the value is equal to the expected value.
    @param {*} expected The expected value.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isEqualTo = function (expected) {
  if (!(this.value == expected)) {
    var d = diff(expected, this.value, this.name, false);
    this.fail(d.join('\n'));
  }
  return this;
};

/** Synonym for isEqualTo().
    @function
    @param {*} expected The expected value.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.equals = Subject.prototype.isEqualTo;

/** Ensure that the subject value is not equal to the expected value.
    @param {*} expected The expected value.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isNotEqualTo = function (expected) {
  if (!(this.value != expected)) {
    this.failNotEqual(this.format(expected));
  }
  return this;
};

/** Ensure that the subject value is exactly equal (===) to the expected value.
    @param {*} expected The expected value.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isExactly = function (expected) {
  if (!(this.value === expected)) {
    this.failComparison('exactly', this.format(expected));
  }
  return this;
};

/** Ensure that the subject value is equal to the expected value using a deep comparison.
    @param {*} expected The expected value.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isDeeplyEqualTo = function (expected) {
  var d = diff(expected, this.value, this.name, true);
  if (d.length > 0) {
    if (d.length > 4) {
      // If there are more than 4 differences, just display the first 4.
      var msg = '...' + d.length - 4 + ' additional differences not shown.';
      d = d.slice(0, 4);
      d.push(msg);
    }
    this.fail(d.join('\n'));
  }
  return this;
};

/** Ensure that the subject value is equal to the expected value using a deep comparison. */
Subject.prototype.isNotDeeplyEqualTo = function (expected) {
  if (compare(expected, this.value, this.name, true)) {
    this.fail(
      'Expected ' + this.describe() + ' to not be deeply equal to ' + expected + '.');
  }
  return this;
}

/** Ensure that the subject value is greater than to the expected value.
    @param {*} expected The expected value.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isGreaterThan = function (expected) {
  if (!(this.value > expected)) {
    this.failComparison('greater than', this.format(expected));
  }
  return this;
};

/** Ensure that the subject value is not greater than to the expected value.
    @param {*} expected The expected value.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isNotGreaterThan = function (expected) {
  if (this.value > expected) {
    this.failComparison('not greater than', this.format(expected));
  }
  return this;
};

/** Ensure that the subject value is less than to the expected value.
    @param {*} expected The expected value.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isLessThan = function (expected) {
  if (!(this.value < expected)) {
    this.failComparison('less than', this.format(expected));
  }
  return this;
};

/** Ensure that the subject value is not less than to the expected value.
    @param {*} expected The expected value.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isNotLessThan = function (expected) {
  if (this.value < expected) {
    this.failComparison('not less than', this.format(expected));
  }
  return this;
};

/** Ensure that the subject value is an instance of the expected type.
    @param {Type} expected The expected type.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isInstanceOf = function (expected) {
  if (!(this.value instanceof expected)) {
    var value = this.value;
    if (typeof value !== 'object') {
      // Convert primitive to wrapper object
      value = ({}).valueOf.call(value);
    }
    this.fail(
      'Expected ' + this.describe() + ' to be instance of ' + expected.name +
        ', actual prototype was ' + Object.getPrototypeOf(value).constructor.name + '.');
  }
  return this;
};

/** Ensure that the subject value is not an instance of the expected type.
    @param {Type} expected The expected type.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isNotInstanceOf = function (expected) {
  if (this.value instanceof expected) {
    this.fail(
      'Expected ' + this.describe() + ' to not be instance of ' + expected.name + '.');
  }
  return this;
};

/** Ensure that the subject value has the expected type.
    @param {string} expected The name of the expected type.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.hasType = function (expected) {
  var ty = typeof(this.value);
  if (ty !== expected) {
    this.fail(
      'Expected ' + this.describe() + ' to have type ' + this.format(expected) +
        ', actual type was "' + ty + '".');
  }
  return this;
};

/** Ensure that the subject is in the specified collection.
    @param {Collection} collection An array or set containing matchable elements.
    @return {Subject} `this` for chaining.
*/
Subject.prototype.isIn = function (collection) {
  /* global Set */
  if (Array.isArray(collection)) {
    if (collection.indexOf(this.value) >= 0) {
      return this;
    }
  } else if (typeof Set !== 'undefined' && collection instanceof Set) {
    if (collection.has(this.value)) {
      return this;
    }
  }
  this.fail(
    'Expected ' + this.describe() + ' to be in ' + this.format(collection) + '.');
  return this;
};

module.exports = Subject;