Repository: ViacomInc/data-point
Pull request #381 (2019-08-20 16:58:28)

ReducerFunction and  ReducerPath tests
<!--
Thanks for your interest in the project. We appreciate bugs filed and PRs submitted!

Please make sure that you are familiar with and follow the Code of Conduct for
this project (found in the CODE_OF_CONDUCT.md file).

Also, please make sure you're familiar with and follow the instructions in the
contributing guidelines (found in the CONTRIBUTING.md file).

Please fill out the information below to expedite the review and (hopefully)
merge of your pull request!

BREAKING CHANGES:

If your PR includes a breaking change, please submit it with a codemod under
data-point-codemods that will help users upgrade their codebase.

Breaking changes without a codemod will not be accepted unless a codemod is not
viable or does not apply to the specific situation.
-->

<!-- What changes are being made? (What feature/bug is being fixed here?) -->
**What**:

Adds tests and ports compatibility into reducers: 

- ReducerFunction
- ReducerPath

<!-- Why are these changes necessary? -->
**Why**:

Missing tests and features

<!-- Have you done all of these things?  -->
**Checklist**:
<!-- add "N/A" to the end of each line that's irrelevant to your changes -->
<!-- to check an item, place an "x" in the box like so: "- [x] Documentation" -->
- [ ] Has Breaking changes
- [ ] Documentation
- [x] Tests
- [x] Ready to be merged <!-- In your opinion, is this ready to be merged as soon as it's reviewed? -->
- [ ] Added username to **all-contributors** list

<!-- feel free to add additional comments -->

codecov[bot]: # [Codecov](https://codecov.io/gh/ViacomInc/data-point/pull/381?src=pr&el=h1) Report
> Merging [#381](https://codecov.io/gh/ViacomInc/data-point/pull/381?src=pr&el=desc) into [canary](https://codecov.io/gh/ViacomInc/data-point/commit/2fa168969e01b56e7f0cad6c6d14f220351cba15?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/ViacomInc/data-point/pull/381/graphs/tree.svg?width=650&token=LYxItnrx4i&height=150&src=pr)](https://codecov.io/gh/ViacomInc/data-point/pull/381?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           canary   #381   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files           9     11    +2     
  Lines         184    224   +40     
  Branches       28     35    +7     
=====================================
+ Hits          184    224   +40
```


| [Impacted Files](https://codecov.io/gh/ViacomInc/data-point/pull/381?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/data-point/src/ReducerFunction.js](https://codecov.io/gh/ViacomInc/data-point/pull/381/diff?src=pr&el=tree#diff-cGFja2FnZXMvZGF0YS1wb2ludC9zcmMvUmVkdWNlckZ1bmN0aW9uLmpz) | `100% <100%> (ø)` | |
| [packages/data-point/src/ReducerPath.js](https://codecov.io/gh/ViacomInc/data-point/pull/381/diff?src=pr&el=tree#diff-cGFja2FnZXMvZGF0YS1wb2ludC9zcmMvUmVkdWNlclBhdGguanM=) | `100% <100%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/ViacomInc/data-point/pull/381?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/ViacomInc/data-point/pull/381?src=pr&el=footer). Last update [2fa1689...3df3e06](https://codecov.io/gh/ViacomInc/data-point/pull/381?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/packages/data-point/src/ReducerFunction.js b/packages/data-point/src/ReducerFunction.js
index 281bc16..d5141da 100644
--- a/packages/data-point/src/ReducerFunction.js
+++ b/packages/data-point/src/ReducerFunction.js
@@ -1,8 +1,8 @@
-const { ReducerNative } = require("./ReducerNative");
+const { Reducer } = require("./Reducer");
 
-class ReducerFunction extends ReducerNative {
+class ReducerFunction extends Reducer {
   constructor(spec) {
-    super("function", spec.name, spec);
+    super("function", spec.name || "anonymous", spec);
 
     this.functionBody = spec;
   }
@@ -12,7 +12,7 @@ class ReducerFunction extends ReducerNative {
   }
 
   async resolve(accumulator) {
-    return this.functionBody(accumulator.value, accumulator);
+    return this.functionBody.call(undefined, accumulator.value, accumulator);
   }
 }
 
diff --git a/packages/data-point/src/ReducerPath.js b/packages/data-point/src/ReducerPath.js
index f2165ab..0f14bb8 100644
--- a/packages/data-point/src/ReducerPath.js
+++ b/packages/data-point/src/ReducerPath.js
@@ -1,59 +1,158 @@
-const { ReducerNative } = require("./ReducerNative");
+const { Reducer } = require("./Reducer");
 
 // Credit:
+// path parsing based off of the work on:
 // https://gomakethings.com/how-to-get-the-value-of-an-object-from-a-specific-path-with-vanilla-js/
 // chris@gomakethings.com
 
-const splitToArrayRegEx = /\[([^}]+)\]/g;
+/**
+ * @type {RegExp} matches simple accessor separators `[`, `]` and `.`
+ */
+const splitToArrayRegEx = /[[,.,\]]/g;
 
-function stringToPath(path) {
+/**
+ * @param {string} pathProperty
+ * @returns {number|string} parsed value
+ */
+function parsePathProperty(pathProperty) {
+  const numberValue = Number(pathProperty);
+  return Number.isNaN(numberValue) ? pathProperty : numberValue;
+}
+
+/**
+ * @param {string} pathSource
+ * @returns {Array<string|number>}
+ */
+function stringToPath(pathSource) {
   const output = [];
 
-  // Split to an array with dot notation
-  path.split(".").forEach(item => {
-    // Split to an array with bracket notation
-    item.split(splitToArrayRegEx).forEach(key => {
-      // Push to the new array
-      if (key.length > 0) {
-        output.push(key);
-      }
-    });
-  });
+  const accessors = pathSource.split(splitToArrayRegEx);
+
+  const accessorsLength = accessors.length;
+
+  // using for loop for faster iteration
+  for (let index = 0; index < accessorsLength; index += 1) {
+    const accessor = accessors[index];
+    if (accessor !== "") {
+      output.push(parsePathProperty(accessor));
+    }
+  }
 
   return output;
 }
 
-function getPath(value, path) {
+/**
+ * @param {string} pathSource
+ * @returns {Object} compiled source
+ */
+function parsePath(pathSource) {
+  const compiledSource = {
+    /**
+     * @type {Array<string|number>} parsed object path
+     */
+    path: [],
+    /**
+     * @type {Boolean} true if true then use the entire accumulator as the source
+     */
+    useAccumulatorAsSource: false
+  };
+
+  if (pathSource === "$.") {
+    return compiledSource;
+  }
+
+  let sourceToCompile;
+
+  if (pathSource.startsWith("$..")) {
+    compiledSource.useAccumulatorAsSource = true;
+    // removes characters "$.."
+    sourceToCompile = pathSource.substr(3);
+  } else {
+    // removes character "$"
+    sourceToCompile = pathSource.substr(1);
+  }
+
+  compiledSource.path = stringToPath(sourceToCompile);
+
+  return compiledSource;
+}
+
+/**
+ * @param {any} value value to crawl
+ * @param {Array<string|number>} path object path to crawl
+ * @returns {any} resolved path
+ */
+function crawlPath(value, path) {
   let current = value;
 
   // For each item in the path, dig into the object
   const length = path.length;
+
+  // using for loop for faster iteration
   for (let i = 0; i < length; i += 1) {
-    // If the item isn't found, return undefined
-    if (!current[path[i]]) return undefined;
+    const currentPathValue = current[path[i]];
+
+    if (currentPathValue === undefined || currentPathValue === null) {
+      return currentPathValue;
+    }
 
     // Otherwise, update the current  value
-    current = current[path[i]];
+    current = currentPathValue;
   }
 
   return current;
 }
 
-class ReducerPath extends ReducerNative {
+/**
+ * @param {Accumulator} accumulator
+ * @param {Object} compiledPath
+ * @returns {any} resolved value
+ */
+function resolvePath(accumulator, compiledPath) {
+  const valueToCrawl =
+    compiledPath.useAccumulatorAsSource === true
+      ? accumulator
+      : accumulator.value;
+
+  return crawlPath(valueToCrawl, compiledPath.path);
+}
+
+/**
+ * Reducer that crawls a data path (using dot notation) from the accumulator
+ */
+class ReducerPath extends Reducer {
+  /**
+   * @param {string} spec dot notation object path, expects path to start with $
+   */
   constructor(spec) {
     super("path", spec, spec);
-    this.path = stringToPath(spec.substr(1));
+    this.compiledPath = parsePath(spec);
   }
 
+  /**
+   * @param {string} spec
+   */
   static isType(spec) {
-    return typeof spec === "string" && spec.charAt(0) === "$";
+    return (
+      typeof spec === "string" && spec.charAt(0) === "$" && spec.length > 1
+    );
   }
 
+  /**
+   *
+   * @param {Accumulator} accumulator
+   */
   resolve(accumulator) {
-    return getPath(accumulator.value, this.path);
+    return resolvePath(accumulator, this.compiledPath);
   }
 }
 
 module.exports = {
+  splitToArrayRegEx,
+  parsePathProperty,
+  stringToPath,
+  parsePath,
+  crawlPath,
+  resolvePath,
   ReducerPath
 };


Test patch:
diff --git a/packages/data-point/src/ReducerFunction.test.js b/packages/data-point/src/ReducerFunction.test.js
new file mode 100644
index 0000000..49482e1
--- /dev/null
+++ b/packages/data-point/src/ReducerFunction.test.js
@@ -0,0 +1,59 @@
+const { ReducerFunction } = require("./ReducerFunction");
+const { Accumulator } = require("./Accumulator");
+
+describe("ReducerFunction", () => {
+  describe("constructor", () => {
+    const fn = () => true;
+
+    it("should set functionBody property", () => {
+      const result = new ReducerFunction(fn);
+      expect(result.functionBody).toEqual(fn);
+    });
+
+    it("should use function's name as the reducer's name", () => {
+      const result = new ReducerFunction(fn);
+      expect(result.name).toEqual("fn");
+    });
+
+    it("should use anonymous as the reducer's name if function does not have a name", () => {
+      const result = new ReducerFunction(() => true);
+      expect(result.name).toEqual("anonymous");
+    });
+
+    it("should set reducer's type to 'function'", () => {
+      const result = new ReducerFunction(fn);
+      expect(result.type).toEqual("function");
+    });
+  });
+
+  describe("isType", () => {
+    it("should be accessible as static method", () => {
+      expect(ReducerFunction.isType).toBeInstanceOf(Function);
+    });
+
+    it("should return true for function reference", () => {
+      expect(ReducerFunction.isType(() => true)).toEqual(true);
+      expect(ReducerFunction.isType({})).toEqual(false);
+      expect(ReducerFunction.isType("string")).toEqual(false);
+      expect(ReducerFunction.isType([])).toEqual(false);
+    });
+  });
+
+  describe("resolve", () => {
+    it("should execute function", async () => {
+      const fn = jest.fn(() => "result");
+      const reducer = new ReducerFunction(fn);
+      const acc = new Accumulator({
+        value: "val"
+      });
+      const result = await reducer.resolve(acc);
+
+      // return function's result
+      expect(result).toEqual("result");
+      // pass value and accumulator as parameters
+      expect(fn).toBeCalledWith(acc.value, acc);
+      // to use undefined as the value of "this"
+      expect(fn.mock.instances).toEqual([undefined]);
+    });
+  });
+});
diff --git a/packages/data-point/src/ReducerPath.test.js b/packages/data-point/src/ReducerPath.test.js
new file mode 100644
index 0000000..06435f5
--- /dev/null
+++ b/packages/data-point/src/ReducerPath.test.js
@@ -0,0 +1,215 @@
+const reducerPath = require("./ReducerPath");
+const { Accumulator } = require("./Accumulator");
+
+const value = {
+  a: {
+    b: [
+      [
+        {
+          c: "c"
+        }
+      ]
+    ]
+  },
+  e: ["", false, null, 0]
+};
+
+describe("splitToArrayRegEx", () => {
+  it("should split dots and bracket notation", () => {
+    const testString = "aaa.11.rt.43.[1].a.[8][abc][9]";
+    const result = testString.split(reducerPath.splitToArrayRegEx);
+    expect(result).toEqual([
+      "aaa",
+      "11",
+      "rt",
+      "43",
+      "",
+      "1",
+      "",
+      "a",
+      "",
+      "8",
+      "",
+      "abc",
+      "",
+      "9",
+      ""
+    ]);
+  });
+});
+
+describe("parsePathProperty", () => {
+  it("should leave as string if value cant be converted to number", () => {
+    expect(reducerPath.parsePathProperty("ab2")).toEqual("ab2");
+  });
+
+  it("should parse to number if string can be parsed to number", () => {
+    expect(reducerPath.parsePathProperty("2")).toEqual(2);
+  });
+});
+
+describe("stringToPath", () => {
+  it("should parse dot notation into array of strings and numbers", () => {
+    expect(reducerPath.stringToPath("a.b.c[9][7][bb].r[o]")).toEqual([
+      "a",
+      "b",
+      "c",
+      9,
+      7,
+      "bb",
+      "r",
+      "o"
+    ]);
+  });
+});
+
+describe("parsePath", () => {
+  it("should parse $.", () => {
+    expect(reducerPath.parsePath("$.")).toMatchObject({
+      useAccumulatorAsSource: false,
+      path: []
+    });
+  });
+
+  it("should parse $..", () => {
+    expect(reducerPath.parsePath("$..")).toMatchObject({
+      useAccumulatorAsSource: true,
+      path: []
+    });
+
+    expect(reducerPath.parsePath("$..locals.a")).toMatchObject({
+      useAccumulatorAsSource: true,
+      path: ["locals", "a"]
+    });
+  });
+
+  it("should parse $a.b", () => {
+    expect(reducerPath.parsePath("$a.b")).toMatchObject({
+      useAccumulatorAsSource: false,
+      path: ["a", "b"]
+    });
+  });
+});
+
+describe("crawlPath", () => {
+  it("should get current value if array is empty", () => {
+    expect(reducerPath.crawlPath(value, [])).toEqual(value);
+  });
+
+  it("should get value inside path", () => {
+    expect(reducerPath.crawlPath(value, ["a"])).toEqual(value.a);
+    expect(reducerPath.crawlPath(value, ["a", "b"])).toEqual(value.a.b);
+    expect(reducerPath.crawlPath(value, ["a", "b", 0, 0])).toEqual(
+      value.a.b[0][0]
+    );
+    expect(reducerPath.crawlPath(value, ["a", "b", 0, 0, "c"])).toEqual(
+      value.a.b[0][0].c
+    );
+  });
+
+  it("should resolve properties of values", () => {
+    expect(reducerPath.crawlPath(value, ["e", "length"])).toEqual(
+      value.e.length
+    );
+    expect(reducerPath.crawlPath(value, ["e", 0, "length"])).toEqual(
+      value.e[0].length
+    );
+  });
+
+  it("should resolve falsy values", () => {
+    // empty string
+    expect(reducerPath.crawlPath(value, ["e", 0])).toEqual(value.e[0]);
+    // false
+    expect(reducerPath.crawlPath(value, ["e", 1])).toEqual(value.e[1]);
+    // false.toString === toString (function)
+    expect(reducerPath.crawlPath(value, ["e", 1, "toString"])).toBeInstanceOf(
+      Function
+    );
+    // null
+    expect(reducerPath.crawlPath(value, ["e", 2])).toEqual(value.e[2]);
+    // null.toString === null
+    expect(reducerPath.crawlPath(value, ["e", 2, "toString"])).toEqual(null);
+    // 0
+    expect(reducerPath.crawlPath(value, ["e", 3])).toEqual(value.e[3]);
+  });
+
+  it("should return undefined for invalid values", () => {
+    expect(reducerPath.crawlPath(value, ["a", "invalid"])).toEqual(undefined);
+    // out of range index
+    expect(reducerPath.crawlPath(value, ["e", 5])).toEqual(undefined);
+  });
+});
+
+describe("resolvePath", () => {
+  it("should use accumulator if useAccumulatorAsSource flag is true", () => {
+    const acc = new Accumulator({
+      value,
+      locals: {
+        myLocal: "myLocal"
+      }
+    });
+
+    const compiledPath = {
+      useAccumulatorAsSource: true,
+      path: ["locals", "myLocal"]
+    };
+    expect(reducerPath.resolvePath(acc, compiledPath)).toEqual("myLocal");
+  });
+
+  it("should use accumulator.value if useAccumulatorAsSource flag is false", () => {
+    const acc = new Accumulator({
+      value
+    });
+
+    const compiledPath = {
+      useAccumulatorAsSource: false,
+      path: ["a", "b"]
+    };
+    expect(reducerPath.resolvePath(acc, compiledPath)).toEqual(value.a.b);
+  });
+});
+
+describe("ReducerPath", () => {
+  const ReducerPath = reducerPath.ReducerPath;
+  const pathSrc = "$a.b[1][5].c";
+  describe("constructor", () => {
+    it("should set compiledPath property", () => {
+      const result = new ReducerPath(pathSrc);
+      expect(result.compiledPath).toMatchObject({
+        useAccumulatorAsSource: false,
+        path: ["a", "b", 1, 5, "c"]
+      });
+    });
+
+    it("should use function's name as the reducer's name", () => {
+      const result = new ReducerPath(pathSrc);
+      expect(result.name).toEqual(pathSrc);
+    });
+
+    it("should set reducer's type to 'function'", () => {
+      const result = new ReducerPath(pathSrc);
+      expect(result.type).toEqual("path");
+    });
+  });
+
+  describe("isType", () => {
+    it("should check is path", () => {
+      expect(ReducerPath.isType(0)).toEqual(false);
+      expect(ReducerPath.isType("")).toEqual(false);
+      expect(ReducerPath.isType("$")).toEqual(false);
+      expect(ReducerPath.isType("$.")).toEqual(true);
+      expect(ReducerPath.isType("$1")).toEqual(true);
+      expect(ReducerPath.isType("$a")).toEqual(true);
+    });
+  });
+
+  describe("resolve", () => {
+    it("should resolve compiled string from accumulator's value", () => {
+      const result = new ReducerPath("$a.b");
+      const acc = new Accumulator({
+        value
+      });
+      expect(result.resolve(acc)).toEqual(value.a.b);
+    });
+  });
+});