unit-testinggoogle-apps-scriptclosuressearch-tree

Search tree node via closure in Google Apps Script


General problem I'm trying to solve

I'm trying to implement a search tree in Google Apps Script, sorted by pkgName attribute, with the end purpose of comparing imported metadata on a software project against a Sheet containing similar data.

To keep the namespace of the constructor function from being polluted with "private" properties, I used closures.

Implementation

The implementation I have thus far is thus:

SheetDataNode.gs

/**
 *  Constructor for a SheetDataNode. Takes one, three, or four arguments.
 *  @param { { package : string, files : { complexity : number, name : string, testingStatus : string }[], rowNumber : number } | string } line data or package name
 *  @param { string } filename : the files contained in package
 *  @param { number } complexity : the total number of branches in the file 
 *  @param { number } rowNumber : the row number as this appears in the spreadsheet it is being created from
 *  @param { string } [ testingStatus ] : the status on the testing of this file. Should be one of the following: NOT_TESTED, FULLY_TESTED, IN_PROGRESS, or PARTIAL
 *  @returns { SheetDataNode }
 *  @deprecated This function is not working right now 
 **/
function SheetDataNode(data, filename, complexity, rowNumber, testingStatus) { 
    var _pkgName = '';
    var _leftChild = null;
    var _rightChild = null;
    var _beenFound = false;
    var _rowNumber = rowNumber;
    var _files = [];

    // if there's only one argument, it better be an object, having the required fields
    if (arguments.length === 1) { 
      // it should have package field
      if ((data.package === undefined) || (data.package !== data.package.toString())) { 
        throw ReferenceError('only one argument was specified, but it is not an object that contains package');
      }
      // it should have files field
      if ((data.files === undefined) || (!Array.isArray(data.files))) { 
        throw ReferenceError('Called from the one-arg constructor, so files should be Array');
      }
      // that files field should itself be an object with the following fields: complexity and name
      for (var idx in data.files) { 
        if (data.files[idx].complexity !== parseInt(data.files[idx].complexity)) { 
          throw TypeError("complexity should be an integer");
        }
        if (data.files[idx].name !== data.files[idx].name.toString()) { 
          throw TypeError("name of file should be a string");
        }
      }

      // sort the array of files
      data.files.sort(fileSorter)

      // call the initialization function
      return SheetDataNode._init(data.package, data.files, parseInt(data.rowNumber));
    }
    // performing argument checking
    if (filename !== filename.toString()) throw TypeError("filename is supposed to be a String")
    if ((complexity !== undefined) && (complexity !== parseInt(complexity))) { 
      throw TypeError("complexity must be a number, or undefined")
    }

  // call the initialization function, constructing a single file object
  return SheetDataNode._init(data.toString(), [{
    complexity : complexity,
    name: filename, 
    testingStatus : testingStatus
  }])
}

// Helper private function that performs initialization
SheetDataNode._init = function(package, files, rowNumber) { 
  // bring in the variables
  var _pkgName = package;
  var _files = files;
  var _leftChild = null;
  var _rightChild = null;
  var _beenFound = false;
  var _rowNumber = rowNumber;

  // providing a function to add file
  _addFile = function(file) { 
    for (var f in _files) { 
      if (file.name < _files[f].name) { 
        _files.splice(f, 0, file)
        return 
      }
    }
    _files.push(file)
  }


  return {
    getRowNumber : function() { return _rowNumber; },
    getPackageName : function () { return _pkgName; },
    getFiles: function() { return _files; },
    addFile : _addFile,
    addFiles : function(files) { 
      if (!Array.isArray(files)) throw TypeError("files should be an Array")
      for (var idx in files) { 
        _addFile(files[idx])
      }
    },
    getLeftChild : function() { return _leftChild; },
    setLeftChild : function(node) { 
        _leftChild = node;
    },
    getRightChild : function() { return _rightChild; },
    setRightChild : function(node) { 
        _rightChild = node;
    },
    insertNode : function(node) { 
      // set the current node as the head node
      var currentNode = this;
      // while we are on a non-null node
      while (currentNode) { 
        // if the package of node is the same as that of currentNode
        if (currentNode.getPackageName() === node.getPackageName()) { 
          // simply add the files of node to currentNode._files
          currentNode.addFiles(node.getFiles())
          return
        }
        // if the package of node "comes before" that of currentNode, move to the left
        if (currentNode.getPackageName() > node.getPackageName()) { 
          // if the left child of node is defined, that becomes the current node
          if (currentNode.getLeftChild()) currentNode = currentNode.getLeftChild()
          // else construct it, and we're done
          else { 
            currentNode.setLeftChild(node)
            return
          }
        }
        // if the package of node "comes after" that of currentNode, move to the right
        if (currentNode.getPackageName() < node.getPackageName()) {
          // if the right child of node is defined, that becomes the current node
          if (currentNode.getRightChild()) currentNode = currentNode.getRightChild()
          // else construct it, and we're done
          else {
            currentNode.setRightChild(node)
            return 
          }
        }
        throw Error("Whoa, some infinite looping was about to happen!")
      }
    }
  }

}

UtilityFunctions.gs

/**
 *  Sorts file objects by their name property, alphabetically
 *  @param { { name : string } } lvalue
 *  @param { { name : string } } rvalue
 *  @returns { boolean } the lexical comparison of lvalue.name,rvalue.name
 **/ 
function fileSorter(lvalue, rvalue) {
  if (lvalue.name > rvalue.name) return 1;
  return (lvalue.name < rvalue.name) ? -1 : 0;
}

Problem

I'm unit-testing the code, with the failing test case consisting of the following steps :

The code to do the above looks like this:

  QUnit.test("inserting a node having the same package as the node it is assigned to",
             function() { 
               // create the base node
               var node = SheetDataNode("example", "main.go", 3, 1)
               // insert an other node, with identical package name
               var otherNode = SheetDataNode(node.getPackageName(), "logUtility.go", 12, 3)
               node.insertNode(otherNode)
               // node should contain two files, and neither a left child nor a right child
               deepEqual(node.getFiles().map(function(val) { 
                 return val.name
               }), 
                         ["logUtility.go", "main.go"], 
                         "node contains the right file names")
               equal(node.getFiles().length, 2, "A package got added to the node")
               ok(!node.getLeftChild(), "leftChild still unset")
               ok(!node.getRightChild(), "rightChild still unset")
             })

Here is screenshot of the failing assertions:

enter image description here

Remember that the method under test is like this:

insertNode : function(node) { 
      // set the current node as the head node
      var currentNode = this;
      // while we are on a non-null node
      while (currentNode) { 
        // if the package of node is the same as that of currentNode
        if (currentNode.getPackageName() === node.getPackageName()) { 
          // simply add the files of node to currentNode._files
          currentNode.addFiles(node.getFiles())
          return
        }
        // if the package of node "comes before" that of currentNode, move to the left
        if (currentNode.getPackageName() > node.getPackageName()) { 
          // if the left child of node is defined, that becomes the current node
          if (currentNode.getLeftChild()) currentNode = currentNode.getLeftChild()
          // else construct it, and we're done
          else { 
            currentNode.setLeftChild(node)
            return
          }
        }
        // if the package of node "comes after" that of currentNode, move to the right
        if (currentNode.getPackageName() < node.getPackageName()) {
          // if the right child of node is defined, that becomes the current node
          if (currentNode.getRightChild()) currentNode = currentNode.getRightChild()
          // else construct it, and we're done
          else {
            currentNode.setRightChild(node)
            return 
          }
        }
        throw Error("Whoa, some infinite looping was about to happen!")
      }

The test against the method addFiles, which has this code:

  QUnit.test("testing method addFiles",
             function() { 
               // create the base node
               var node = SheetDataNode("example", "main.go", 3, 1)
               // create an array of files to add
               const filesToAdd = [{
                 name : 'aFile.go',
                 complexity : 10
               }, {
                 name : 'anotherFile.go',
                 complexity : 10
               }, {
                 name : 'yetAnotherFile.go',
                 complexity : 10
               }]
               // is node.getFiles() an array?!
               ok(Array.isArray(node.getFiles()), "node.getFiles() is an array")

               // add the files
               node.addFiles(filesToAdd)
               Logger.log(node.getFiles())
               // node.getFiles() should be an Array
               ok(Array.isArray(node.getFiles()), "node.getFiles() is still an array")
               // node.getFiles should now contain filesToAdd
               equal(node.getFiles().length, 1 + filesToAdd.length, "node.getFiles().length increased by the length of the files to add")
             })

passes:

enter image description here, as do the other tests against insertNode, meaning the problem might exist with how we try to reference currentNode in insertNode for array property modification. If so, I have no idea how else to reference, in Google Apps Script, the SheetDataNode to undergo state change


Solution

  • I was able to solve the problem, with inspiration from the MDN docs on closures, by changing the private function property declaration from :

    _addFile = function(file) { 
        for (var f in _files) { 
          if (file.name < _files[f].name) { 
            _files.splice(f, 0, file)
            return 
          }
        }
        _files.push(file)
      }
    

    to

    function _addFile(file) { 
        for (var f in _files) { 
          if (file.name < _files[f].name) { 
            _files.splice(f, 0, file)
            return 
          }
        }
        _files.push(file)
      }
    

    idk why this works, because I forgot the difference between declaring method like a function variable (what I was doing), and preceding the name of the method with function like it's any other function. I'll have to (re-)learn that...