ossplz/alsatian-fluent-assertions

View on GitHub
src/matchers/properties-matcher.ts

Summary

Maintainability
A
3 hrs
Test Coverage
import { MatchError } from "alsatian";

import { IPropertiesMatcher } from "./i-properties-matcher";
import { NestedPropertiesMatchError } from "../errors/nested-properties-match-error";
import {
  SubsetPropertyAssertsDict,
  AllPropertyAssertsDict,
  PropertyLambda,
  LocationMode,
  MatchMode,
  SubsetPropertyDict,
  SubsetPropertyLiteralsDict,
  AllPropertyDict,
  AllPropertyLiteralsDict,
  AssertionContext
} from "../types";
import { IFluentCore } from "./i-fluent-core";
import { INarrowableFluentCore } from "./i-narrowable-fluent-core";
import { FluentMatcherBase } from "./fluent-matcher-base";
import { PropertyAssertsLambda } from "../types/property-asserts-lambda";
import { ElementsMatcher } from "./elements-matcher";

export class PropertiesMatcher<T, TNext, TPrev> extends ElementsMatcher<T, TNext, TPrev>
  implements IPropertiesMatcher<T, TNext, TPrev> {
  constructor(
    actualValue: any,
    nextValue: any,
    initial: boolean,
    prevCore?: IFluentCore<any, any, any>,
    ctxt?: AssertionContext
  ) {
    super(actualValue, nextValue, initial, prevCore, ctxt);
  }

  public hasProperties(
    expected: SubsetPropertyAssertsDict<T> | SubsetPropertyDict<T> | SubsetPropertyLiteralsDict<T>,
    mode: MatchMode = MatchMode.normal
  ): IFluentCore<T, TNext, TPrev> {
    this.setCurrentNode(this.hasProperties.name, null);
    this._properties(this.actualValue, expected, [], mode);

    return this.generateFluentState(this.actualValue, null, false);
  }

  public hasAll(
    expected: AllPropertyDict<T> | AllPropertyAssertsDict<T>,
    mode: MatchMode = MatchMode.normal
  ): IFluentCore<T, TNext, TPrev> {
    this.setCurrentNode(this.hasAll.name, null);
    this._properties(this.actualValue, expected, [], mode);

    return this.generateFluentState(this.actualValue, null, false);
  }

  public hasKeys<K extends keyof T>(expectedKeys: Array<K>): IFluentCore<T, TNext, TPrev> {
    this.setCurrentNode(this.hasKeys.name, null);
    if (this.nullOrUndefined(this.actualValue)) {
      if ( !this.invertedContext ) {
        this.specError(`should be defined`, undefined, undefined);
      }

      return; // .not.hasKeys always passes when target not defined.
    }

    if (!(expectedKeys instanceof Array)) {
      this.specError(
        `param expectedKeys should be an array type`,
        "[an array type]",
        this.id(expectedKeys)
      );
    }

    if (this.maybeInvert(!expectedKeys.every(k => typeof this.actualValue[k] !== "undefined"))) {
      this.specError(
        `should${this.negation}contain all`,
        expectedKeys,
        this.actualValue
      );
    }

    return this.generateFluentState(this.actualValue, null, false);
  }

  // tslint:disable-next-line
  /** Asserts that the actual has the expected properties (recursive). */
  protected _properties(
    actualObject: any,
    expectedObject: any,
    path: Array<string>,
    mode: MatchMode
  ): void {
    const notDefined = !this._assertNestedPropertiesDefined(
      actualObject,
      expectedObject,
      path
    );
    if (this.invertedContext && notDefined) {
      // notDefined when inverted is the same as saying, "it doesn't have these properties,"
      // so return without error.
      return;
    }

    const keys = Object.keys(expectedObject);
    /*tslint:disable:forin*/
    for (const i in keys) {
      /*tslint:enable:forin*/
      const k: keyof T = keys[i] as any;
      const curPath = path.slice(0); // clone
      curPath.push(k);
      const expected = expectedObject[k];
      const actual = actualObject[k];
      this._assertPropertyByType(k, actual, expected, curPath, mode);
    }
  }

  protected _assertNestedPropertiesDefined(
    actualObject: any,
    expectedObject: any,
    path: Array<string>
  ): boolean {
    if (this.nullOrUndefined(actualObject)) {
      this._throwIfUnnecessarilyUndefined(actualObject, expectedObject, path);
      return false;
    }

    return true;
  }

  // tslint:disable-next-line
  /** Only throws if undefined props/obj isn't warranted. */
  protected _throwIfUnnecessarilyUndefined(
    actualObject: any,
    expectedObject: any,
    path: Array<string>
  ): void {
    // throw iff an undefined property isn't simply satisfying a prior negation (e.g., not.has()).
    if (!this.invertedContext) {
      if (path.length > 0) {
        const prop = path[path.length - 1];
        const fpath = this.formatKeyPath(path);
        const msg = `property '${prop}' should be defined at path '${fpath}'`;
        this.specError(msg, undefined, undefined);
      } else {
        this.specError("expected object should be defined", undefined, undefined);        
      }
    }
  }

  protected _assertPropertyByType(
    k: any,
    actual: any,
    expected: any,
    curPath: Array<string>,
    mode: MatchMode
  ) {
    if (mode !== MatchMode.literal && typeof expected === "function") {
      this._assertFnProperty(
        k,
        expected as PropertyLambda<T[keyof T]>,
        actual,
        curPath,
        mode
      );
    } else if (mode !== MatchMode.literal && expected instanceof RegExp) {
      this._assertRegExpProperty(k, expected, actual, curPath);
    } else if (
      expected !== null &&
      typeof expected === "object" &&
      Object.keys(expected as any).length > 0 // not a no-op
    ) {
      this._properties(actual, expected, curPath, mode);
    } else if (this.maybeInvert(expected !== actual)) {
      const fpath = this.formatKeyPath(curPath);
      const msg = `property ${k} at path '${fpath}' should${
        this.negation
      }equal`;
      this.specError(msg, expected, actual);
    }
  }

  protected formatKeyPath(path: Array<string>): string {
    path.unshift("$");
    return path.join(".");
  }

  // tslint:disable-next-line
  /**
   * A properties assertion function can fail by falsy return value, or by
   * throwing an error (perhaps from nested assertions).
   */
  protected _assertFnProperty<TKey extends keyof T>(
    key: TKey,
    assertion: PropertyLambda<T[TKey]> | PropertyAssertsLambda<T[TKey]>,
    actual: T[TKey],
    path: Array<string>,
    matchMode: MatchMode
  ): void {
    let check = null;
    try {
      if (matchMode === MatchMode.asserts) {
        check = (assertion as PropertyAssertsLambda<T[TKey]>)(
          this.wrap(actual)
        );
        if (typeof check === "boolean") {
          this._assertFnBoolean(check, assertion, actual, path);
        }
      } else {
        check = (assertion as PropertyLambda<T[TKey]>)(actual);
        this._assertFnBoolean(check, assertion, actual, path);
      }
    } catch (err) {
      this._failFnError(err, path);
    }
  }

  protected _failFnError(err: Error, path: Array<string>) {
    if (err instanceof MatchError) {
      throw new NestedPropertiesMatchError(
        this,
        "failed nested expectation",
        this.formatKeyPath(path),
        err
      );
    } else {
      throw new NestedPropertiesMatchError(
        this,
        "threw unexpected error",
        this.formatKeyPath(path),
        err
      );
    }
  }

  protected _assertFnBoolean<TKey extends keyof T>(
    check: any,
    assertion: PropertyLambda<T[TKey]> | PropertyAssertsLambda<T[TKey]>,
    actual: T[TKey],
    path: Array<string>
  ) {
    const fpath = this.formatKeyPath(path);
    let msg = `Property at path '${fpath}': `;
    if (typeof check === "boolean" && this.maybeInvert(!check)) {
      (msg = msg + `should${this.negation}satisfy lambda assertion`),
        this.specError(msg, this.getFnString(assertion), actual);
    } else if (this.invert) {
      (msg =
        msg +
        "expected lambda to return false, or yield a failed nested expectation or error"),
        this.specError(msg, this.getFnString(assertion), actual);
    }
  }

  protected _assertRegExpProperty<TProp>(
    key: keyof T,
    regexp: RegExp,
    actual: TProp,
    path: Array<string>
  ): void {
    const kpath: string = this.formatKeyPath(path);
    if (actual instanceof RegExp) {
      if (this.maybeInvert(actual.toString() !== regexp.toString())) {
        const msg = `regular expressions at path '${kpath}' should${
          this.negation
        }be equal`;
        this.specError(msg, this.id(regexp), this.id(actual));
      }
    } else if (typeof actual !== "string") {
      const msg = `expected type 'string' for regexp match at path '${kpath}'`;
      this.specError(msg, "string", typeof actual);
    } else if (this.maybeInvert(!regexp.test(actual))) {
      const msg = `regular expression at path '${kpath}' should${
        this.negation
      }match`;
      this.specError(msg, this.id(regexp), actual);
    }
  }
}