You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ma...@apache.org on 2018/12/20 21:47:21 UTC

[airavata-django-portal] 02/02: AIRAVATA-2761 Unit tests for boolean expression evaluation

This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch airavata-2761
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit a8d663700ee4c6b85cdec6c78b17a237ad4584c0
Author: Marcus Christie <ma...@iu.edu>
AuthorDate: Thu Dec 20 16:47:00 2018 -0500

    AIRAVATA-2761 Unit tests for boolean expression evaluation
---
 .travis.yml                                        |   1 +
 django_airavata/apps/api/package.json              |   7 +-
 .../js/models/InputDataObjectType.js               |  13 +-
 .../dependencies/BooleanExpressionEvaluator.js     | 101 ++++++++++
 .../js/models/dependencies/DependencyEvaluator.js  |  94 ---------
 .../BooleanExpressionEvaluator.test.js             | 213 +++++++++++++++++++++
 6 files changed, 326 insertions(+), 103 deletions(-)

diff --git a/.travis.yml b/.travis.yml
index c628f90..2939209 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -10,3 +10,4 @@ script:
   # For now ignore long line endings
   - flake8 --ignore=E501 .
   - ./build_js.sh
+  - cd django_airavata/apps/api && npm run test
diff --git a/django_airavata/apps/api/package.json b/django_airavata/apps/api/package.json
index a595c69..6009c29 100644
--- a/django_airavata/apps/api/package.json
+++ b/django_airavata/apps/api/package.json
@@ -9,7 +9,9 @@
   "repository": "https://github.com/apache/airavata-django-portal",
   "scripts": {
     "build": "babel static/django_airavata_api/js -d static/django_airavata_api/dist",
-    "watch": "babel static/django_airavata_api/js --watch -d static/django_airavata_api/dist"
+    "watch": "babel static/django_airavata_api/js --watch -d static/django_airavata_api/dist",
+    "test": "jest",
+    "test:watch": "jest --watch"
   },
   "dependencies": {
     "babel-runtime": "^6.26.0",
@@ -19,6 +21,7 @@
   "devDependencies": {
     "babel-cli": "^6.26.0",
     "babel-plugin-transform-runtime": "^6.23.0",
-    "babel-preset-env": "^1.7.0"
+    "babel-preset-env": "^1.7.0",
+    "jest": "^23.6.0"
   }
 }
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/InputDataObjectType.js b/django_airavata/apps/api/static/django_airavata_api/js/models/InputDataObjectType.js
index f3b9867..2a15279 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/InputDataObjectType.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/InputDataObjectType.js
@@ -1,6 +1,6 @@
 import BaseModel from "./BaseModel";
 import DataType from "./DataType";
-import DependencyEvaluator from "./dependencies/DependencyEvaluator";
+import BooleanExpressionEvaluator from "./dependencies/BooleanExpressionEvaluator";
 import uuidv4 from "uuid/v4";
 import ValidatorFactory from "./validators/ValidatorFactory";
 
@@ -219,14 +219,13 @@ export default class InputDataObjectType extends BaseModel {
    */
   evaluateDependencies(inputValues) {
     if (Object.keys(this.editorDependencies).length > 0) {
-      const dependencyEvaluator = new DependencyEvaluator();
-      const dependencyEvaluations = dependencyEvaluator.evaluateDependencies(
-        inputValues,
-        this.editorDependencies
-      );
+      const booleanExpressionEvaluator = new BooleanExpressionEvaluator();
       // TODO: if show changes to false, set value to null? Save off current value to restore when shown again?
       if ("show" in dependencyEvaluations) {
-        this.show = dependencyEvaluations["show"];
+        this.show = booleanExpressionEvaluator.evaluate(
+          dependencyEvaluations.show,
+          inputValues
+        );
       }
     }
   }
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/dependencies/BooleanExpressionEvaluator.js b/django_airavata/apps/api/static/django_airavata_api/js/models/dependencies/BooleanExpressionEvaluator.js
new file mode 100644
index 0000000..1962d80
--- /dev/null
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/dependencies/BooleanExpressionEvaluator.js
@@ -0,0 +1,101 @@
+export default class BooleanExpressionEvaluator {
+
+  /**
+   * Context to use for looking up values of variables in expressions.
+   * @param {object} context
+   */
+  constructor(context) {
+    this.context = context;
+  }
+  /**
+   * Evaluates boolean expression and returns boolean result.
+   * @param {object} expression
+   */
+  evaluate(expression) {
+    const keys = Object.keys(expression);
+    if (keys.length > 1) {
+      // Implicitly AND together several expressions
+      return this.evaluate(
+        {
+          AND: keys.map(k => {
+            const exp = {};
+            exp[k] = expression[k];
+            return exp;
+          })
+        }
+      );
+    }
+    if (keys.length < 1) {
+      throw new Error(
+        "Expression does not contain a key: " + JSON.stringify(expression)
+      );
+    }
+
+    const key = keys[0];
+    const value = expression[key];
+    if (key === "AND") {
+      if (value instanceof Array) {
+        const evaluations = value.map(exp => this.evaluate(exp));
+        return evaluations.reduce((acc, curr) => acc && curr);
+      } else {
+        throw new Error(
+          "Unrecognized operand value for AND: " + JSON.stringify(value)
+        );
+      }
+    } else if (key === "OR") {
+      if (value instanceof Array) {
+        const evaluations = value.map(exp => this.evaluate(exp));
+        return evaluations.reduce((acc, curr) => acc || curr);
+      } else {
+        throw new Error(
+          "Unrecognized operand value for OR: " + JSON.stringify(value)
+        );
+      }
+    } else if (key === "NOT") {
+      if (typeof value === "object" && !(value instanceof Array)) {
+        return !this.evaluate(value);
+      } else {
+        throw new Error(
+          "Unrecognized operand value for NOT: " + JSON.stringify(value)
+        );
+      }
+    }
+
+    if (typeof value === "object") {
+      if (!(key in this.context)) {
+        throw new Error(
+          "Missing context value for expression " +
+            JSON.stringify(expression) +
+            " in context " +
+            JSON.stringify(this.context)
+        );
+      }
+      const contextValue = this.context[key];
+      return this._evaluateComparison(contextValue, value);
+    }
+  }
+
+  _evaluateComparison(value, comparisonDefinition) {
+    const comparison = comparisonDefinition["comparison"];
+    if (!comparison) {
+      throw new Error(
+        "Expression definition is missing 'comparison' property: " +
+          JSON.stringify(comparisonDefinition)
+      );
+    }
+    if (comparison === "equals") {
+      return value === this._getComparisonValue(comparisonDefinition);
+    }
+    throw new Error("Unrecognized comparison " + JSON.stringify(comparison));
+  }
+
+  _getComparisonValue(comparisonDefinition) {
+    if (!("value" in comparisonDefinition)) {
+      throw new Error(
+        "Missing required 'value' property in comparison definition: " +
+          JSON.stringify(comparisonDefinition)
+      );
+    }
+    return comparisonDefinition["value"];
+  }
+}
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/dependencies/DependencyEvaluator.js b/django_airavata/apps/api/static/django_airavata_api/js/models/dependencies/DependencyEvaluator.js
deleted file mode 100644
index 0a4f04c..0000000
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/dependencies/DependencyEvaluator.js
+++ /dev/null
@@ -1,94 +0,0 @@
-
-// TODO: rename: BooleanExpressionEvaluator? There's really nothing related to "app input dependencies" in here.
-export default class DependencyEvaluator {
-
-  /**
-   * Evaluate all dependency expressions and return an object with the same
-   * keys as dependenciesConfig but with the result of each expression.
-   *
-   * @param inputValues
-   * @param dependenciesConfig
-   */
-  evaluateDependencies(inputValues, dependenciesConfig) {
-
-    const result = {};
-    for (const k of Object.keys(dependenciesConfig)) {
-      result[k] = this._evaluateExpression(dependenciesConfig[k], inputValues);
-    }
-    return result;
-  }
-
-  /**
-   * Evaluates boolean expression for given context and returns boolean result.
-   */
-  _evaluateExpression(expression, context) {
-    console.log("expression: " + JSON.stringify(expression));
-    console.log("context: " + JSON.stringify(context));
-    const keys = Object.keys(expression);
-    if (keys.length > 1) {
-      throw new Error("Expression contains more than one key: " + JSON.stringify(expression));
-    }
-    if (keys.length < 1) {
-      throw new Error("Expression does not contain a key: " + JSON.stringify(expression));
-    }
-
-    const key = keys[0];
-    const value = expression[key];
-    if (key === "AND") {
-      if (value instanceof Array) {
-        const evaluations = value.map(exp => this._evaluateExpression(exp, context));
-        return evaluations.reduce((acc, curr) => acc && curr);
-      } else if (typeof value === 'object') {
-        return this._evaluateExpression(exp, context);
-      } else {
-        throw new Error("Unrecognized operand value for AND: " + JSON.stringify(value));
-      }
-    } else if (key === "OR") {
-      if (value instanceof Array) {
-        const evaluations = value.map(exp => this._evaluateExpression(exp, context));
-        return evaluations.reduce((acc, curr) => acc || curr);
-      } else if (typeof value === 'object') {
-        return this._evaluateExpression(exp, context);
-      } else {
-        throw new Error("Unrecognized operand value for OR: " + JSON.stringify(value));
-      }
-    } else if (key === "NOT") {
-      if (typeof value === 'object') {
-        return !this._evaluateExpression(value, context);
-      } else {
-        throw new Error("Unrecognized operand value for NOT: " + JSON.stringify(value));
-      }
-    }
-
-    if (typeof value === 'object') {
-      if (!(key in context)) {
-        throw new Error("Missing context value for expression " + JSON.stringify(expression) + " in context " + JSON.stringify(context));
-      }
-      const contextValue = context[key];
-      return this._evaluateExpressionType(contextValue, value);
-    }
-  }
-
-  // TODO: rename: _evaluateExpressionDefinition
-  // TODO: rename: _evaluateComparison
-  _evaluateExpressionType(value, expressionType) {
-    const type = expressionType["type"];
-    if (!type) {
-      throw new Error("Expression definition is missing 'type' property: " + JSON.stringify(expressionType));
-    }
-    if (type === "equals") {
-      return value === this._getExpressionTypeValue(expressionType);
-    }
-    throw new Error("Unrecognized expression type " + JSON.stringify(expressionType));
-  }
-
-  // TODO: rename: _getExpressionDefinitionValue(expressionDefiniton)
-  // TODO: rename: _getComparisonTarget
-  _getExpressionTypeValue(expressionType) {
-
-    if (!("value" in expressionType)) {
-      throw new Error("Missing required 'value' property in expression definition: " + JSON.stringify(expressionType));
-    }
-    return expressionType["value"];
-  }
-}
diff --git a/django_airavata/apps/api/static/django_airavata_api/tests/models/dependencies/BooleanExpressionEvaluator.test.js b/django_airavata/apps/api/static/django_airavata_api/tests/models/dependencies/BooleanExpressionEvaluator.test.js
new file mode 100644
index 0000000..f1d37eb
--- /dev/null
+++ b/django_airavata/apps/api/static/django_airavata_api/tests/models/dependencies/BooleanExpressionEvaluator.test.js
@@ -0,0 +1,213 @@
+import BooleanExpressionEvaluator from "../../../js/models/dependencies/BooleanExpressionEvaluator";
+
+const context = {
+  INPUT1: "1",
+  INPUT2: "2",
+  INPUT3: "3"
+};
+const booleanExpressionEvaluator = new BooleanExpressionEvaluator(context);
+
+test("throws error when expression is empty", () => {
+  expect(() => booleanExpressionEvaluator.evaluate({})).toThrow();
+});
+
+test("INPUT1 == 1 AND INPUT2 == 2 is TRUE", () => {
+  const result = booleanExpressionEvaluator.evaluate({
+    AND: [
+      {
+        INPUT1: {
+          comparison: "equals",
+          value: "1"
+        }
+      },
+      {
+        INPUT2: {
+          comparison: "equals",
+          value: "2"
+        }
+      }
+    ]
+  });
+  expect(result).toBe(true);
+});
+
+test("INPUT1 == 2 AND INPUT2 == 2 is FALSE", () => {
+  const result = booleanExpressionEvaluator.evaluate({
+    AND: [
+      {
+        INPUT1: {
+          comparison: "equals",
+          value: "2" // "1" !== "2"
+        }
+      },
+      {
+        INPUT2: {
+          comparison: "equals",
+          value: "2"
+        }
+      }
+    ]
+  });
+  expect(result).toBe(false);
+});
+
+test("INPUT1 == 2 OR INPUT2 == 2 is TRUE", () => {
+  const result = booleanExpressionEvaluator.evaluate({
+    OR: [
+      {
+        INPUT1: {
+          comparison: "equals",
+          value: "2"
+        }
+      },
+      {
+        INPUT2: {
+          comparison: "equals",
+          value: "2"
+        }
+      }
+    ]
+  });
+  expect(result).toBe(true);
+});
+
+test("INPUT1 == 2 OR INPUT2 == 1 is FALSE", () => {
+  const result = booleanExpressionEvaluator.evaluate({
+    OR: [
+      {
+        INPUT1: {
+          comparison: "equals",
+          value: "2"
+        }
+      },
+      {
+        INPUT2: {
+          comparison: "equals",
+          value: "1"
+        }
+      }
+    ]
+  });
+  expect(result).toBe(false);
+});
+
+test("(NOT INPUT1 == 2) AND INPUT2 == 2 is TRUE", () => {
+  const result = booleanExpressionEvaluator.evaluate({
+    AND: [
+      {
+        NOT: {
+          INPUT1: {
+            comparison: "equals",
+            value: "2"
+          }
+        }
+      },
+      {
+        INPUT2: {
+          comparison: "equals",
+          value: "2"
+        }
+      }
+    ]
+  });
+  expect(result).toBe(true);
+});
+
+// single comparison
+test("INPUT1 == 1 is TRUE", () => {
+  const result = booleanExpressionEvaluator.evaluate({
+    INPUT1: {
+      comparison: "equals",
+      value: "1"
+    }
+  });
+  expect(result).toBe(true);
+});
+
+// single comparison
+test("INPUT1 == 2 is FALSE", () => {
+  const result = booleanExpressionEvaluator.evaluate({
+    INPUT1: {
+      comparison: "equals",
+      value: "2"
+    }
+  });
+  expect(result).toBe(false);
+});
+
+test("non-array given for AND throws Error", () => {
+  expect(() =>
+    booleanExpressionEvaluator.evaluate({
+      AND: "a"
+    })
+  ).toThrow();
+});
+
+test("non-array given for OR throws Error", () => {
+  expect(() =>
+    booleanExpressionEvaluator.evaluate({
+      OR: "a"
+    })
+  ).toThrow();
+});
+
+test("non-object given for NOT throws Error", () => {
+  expect(() =>
+    booleanExpressionEvaluator.evaluate({
+      NOT: [
+        {
+          INPUT1: {
+            comparison: "equals",
+            value: 1
+          }
+        }
+      ]
+    })
+  ).toThrow();
+});
+
+test("referenced variable not in context throws Error", () => {
+  expect(() =>
+    booleanExpressionEvaluator.evaluate({
+      ZINPUT1: {
+        comparison: "equals",
+        value: 1
+      }
+    })
+  ).toThrow(/missing context value/i);
+});
+
+test("missing 'comparison' property throws Error", () => {
+  expect(() =>
+    booleanExpressionEvaluator.evaluate({
+      INPUT1: {
+        value: 1
+      }
+    })
+  ).toThrow(/missing 'comparison' property/i);
+});
+
+test("unrecognized 'comparison' property throws Error", () => {
+  expect(() =>
+    booleanExpressionEvaluator.evaluate({
+      INPUT1: {
+        comparison: "foo",
+        value: 1
+      }
+    })
+  ).toThrow(/unrecognized comparison/i);
+});
+
+test("Implicitly ANDed INPUT1 == 1 AND INPUT2 == 2 is TRUE", () => {
+  const result = booleanExpressionEvaluator.evaluate({
+    INPUT1: {
+      comparison: "equals",
+      value: "1"
+    },
+    INPUT2: {
+      comparison: "equals",
+      value: "2"
+    }
+  });
+  expect(result).toBe(true);
+});