From 51680f6d39aa94ce2b702d99b18b895331cc4ece Mon Sep 17 00:00:00 2001
From: Tira + Joao <aodhner+jpereira@pivotal.io>
Date: Tue, 14 Mar 2017 17:11:55 -0400
Subject: [PATCH 3/5] Add RangeBoundryNavigator

- Update CopyData and move the Spec
- Column selection working (still todo: row/column interaction)
- Corrects regression test
- Remove duplicated checkbox on grid
---
 .gitignore                                         |   1 +
 .../copy_selected_columns_feature_test.py          |  62 ++-------
 web/pgadmin/static/js/selection/column_selector.js |   5 +-
 web/pgadmin/static/js/selection/copy_data.js       |  57 ++++-----
 .../js/selection/range_boundary_navigator.js       |  97 ++++++++++++++
 .../static/js/selection/tests/copy_data_spec.js    |  35 ------
 web/regression/feature_utils/base_feature_test.py  |   1 +
 web/regression/feature_utils/pgadmin_page.py       |  10 ++
 .../javascript/selection}/column_selector_spec.js  |  20 +++
 .../javascript/selection/copy_data_spec.js         |  95 ++++++++++++++
 .../selection/range_boundary_navigator_spec.js     | 139 +++++++++++++++++++++
 web/regression/test_utils.py                       |   1 +
 12 files changed, 408 insertions(+), 115 deletions(-)
 create mode 100644 web/pgadmin/static/js/selection/range_boundary_navigator.js
 delete mode 100644 web/pgadmin/static/js/selection/tests/copy_data_spec.js
 rename {test/javascript => web/regression/javascript/selection}/column_selector_spec.js (87%)
 create mode 100644 web/regression/javascript/selection/copy_data_spec.js
 create mode 100644 web/regression/javascript/selection/range_boundary_navigator_spec.js

diff --git a/.gitignore b/.gitignore
index 3ccd178..f6d1490 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,4 @@ runtime/ui_BrowserWindow.h
 web/config_local.py
 web/regression/test_config.json
 node_modules/
+yarn.lock
diff --git a/web/pgadmin/feature_tests/copy_selected_columns_feature_test.py b/web/pgadmin/feature_tests/copy_selected_columns_feature_test.py
index ab74819..2037234 100644
--- a/web/pgadmin/feature_tests/copy_selected_columns_feature_test.py
+++ b/web/pgadmin/feature_tests/copy_selected_columns_feature_test.py
@@ -8,35 +8,17 @@ from regression.feature_utils.base_feature_test import BaseFeatureTest
 
 
 class CopySelectedColumnsFeatureTest(BaseFeatureTest):
-    def setUp(self):
-        # DO NOT CHECK THIS IN
-        self.server = {
-            "db": "postgres",
-            "host": "localhost",
-            "username": "pivotal",
-            "db_password": "",
-            "port": 5432,
-            "name": "like, whatever"
-        }
-        # IT IS NOT REAL.
-
+    def before(self):
         connection = test_utils.get_db_connection(self.server['db'],
                                                   self.server['username'],
                                                   self.server['db_password'],
                                                   self.server['host'],
                                                   self.server['port'])
         test_utils.drop_database(connection, "acceptance_test_db")
-
         test_utils.create_database(self.server, "acceptance_test_db")
-
         test_utils.create_table(self.server, "acceptance_test_db", "test_table")
-
-
-        super(CopySelectedColumnsFeatureTest, self).setUp()
-
         self.page.add_server(self.server)
 
-
     def runTest(self):
         self.page.toggle_open_tree_item(self.server['name'])
         self.page.toggle_open_tree_item('Databases')
@@ -50,54 +32,36 @@ class CopySelectedColumnsFeatureTest(BaseFeatureTest):
         self.page.driver.switch_to_frame(self.page.driver.find_element_by_tag_name("iframe"))
         self.page.find_by_id("btn-flash").click()
 
-        self._copies_rows()
-        # self._copies_columns()
+        # self._copies_rows()
+        self._copies_columns()
 
     def _copies_rows(self):
         pyperclip.copy("old clipboard contents")
         time.sleep(5)
         self.page.find_by_xpath("//*[contains(@class, 'sc l0 r0 sc-cb')]/input").click()
-        time.sleep(5)
         self.page.find_by_xpath("//*[@id='btn-copy-row']").click()
 
-        self.assertEqual(
-"""'Some-Name','6'
-""",
-            pyperclip.paste())
+        self.assertEqual("'Some-Name','6'",
+                         pyperclip.paste())
 
     def _copies_columns(self):
         pyperclip.copy("old clipboard contents")
-        self.page.toggle_open_tree_item(self.server['name'])
-        self.page.toggle_open_tree_item('Databases')
-        self.page.toggle_open_tree_item('acceptance_test_db')
-        time.sleep(5)
-        self.page.find_by_partial_link_text("Tools").click()
-        self.page.find_by_partial_link_text("Query Tool").click()
-        self.page.click_tab('Query-1')
-        time.sleep(5)
-        ActionChains(self.page.driver).send_keys("SELECT * FROM test_table").perform()
-        self.page.driver.switch_to_frame(self.page.driver.find_element_by_tag_name("iframe"))
 
-        self.page.find_by_id("btn-flash").click()
-
-        time.sleep(5)
-
-        self.page.find_by_xpath("//*[@data-test='output-column-header' and .='name']/input").click()
-        self.page.find_by_xpath("//*[@id='btn-copy-row]").click()
+        self.page.find_by_xpath("//*[@data-test='output-column-header' and contains(., 'name')]/input").click()
+        self.page.find_by_xpath("//*[@id='btn-copy-row']").click()
 
         self.assertEqual(
-"""
-name
-Some-Name
-""",
+            """'Some-Name'
+'Some-Other-Name'""",
             pyperclip.paste())
 
-    def tearDown(self):
+    def after(self):
+        self.page.close_query_tool()
         self.page.remove_server(self.server)
-        self.app_starter.stop_app()
+
         connection = test_utils.get_db_connection(self.server['db'],
                                                   self.server['username'],
                                                   self.server['db_password'],
                                                   self.server['host'],
                                                   self.server['port'])
-        test_utils.drop_database(connection, "acceptance_test_db")
\ No newline at end of file
+        test_utils.drop_database(connection, "acceptance_test_db")
diff --git a/web/pgadmin/static/js/selection/column_selector.js b/web/pgadmin/static/js/selection/column_selector.js
index 845b9a9..e102ae5 100644
--- a/web/pgadmin/static/js/selection/column_selector.js
+++ b/web/pgadmin/static/js/selection/column_selector.js
@@ -52,8 +52,11 @@ define(['jquery', 'slickgrid'], function ($) {
 
     function getColumnsWithCheckboxes() {
       return _.map(columnDefinitions, function (columnDefinition) {
+        var name = columnDefinition.name;
+        if(columnDefinition.id != "_checkbox_selector")
+          name = "<div data-test='output-column-header'><input data-id='checkbox-" + columnDefinition.id + "' type='checkbox'>" + columnDefinition.name + "</div>";
         return _.extend(columnDefinition, {
-          name: "<div data-test='output-column-header'><input data-id='checkbox-" + columnDefinition.id + "' type='checkbox'>" + columnDefinition.name + "</div>"
+          name: name
         });
       });
     }
diff --git a/web/pgadmin/static/js/selection/copy_data.js b/web/pgadmin/static/js/selection/copy_data.js
index 264defe..29f8e8b 100644
--- a/web/pgadmin/static/js/selection/copy_data.js
+++ b/web/pgadmin/static/js/selection/copy_data.js
@@ -1,7 +1,6 @@
-define(['jquery', 'js/selection/clipboard'], function ($, clipboard) {
+define(['jquery', 'sources/selection/clipboard', 'sources/selection/range_boundary_navigator'], function ($, clipboard, rangeBoundaryNavigator) {
   var copyData = function () {
-    var self = this, grid, data, rows, selection, copied_text = '', copied_data = '';
-    self.copied_rows = [];
+    var self = this;
 
     // Disable copy button
     $("#btn-copy-row").prop('disabled', true);
@@ -10,36 +9,34 @@ define(['jquery', 'js/selection/clipboard'], function ($, clipboard) {
       $("#btn-paste-row").prop('disabled', false);
     }
 
-    grid = self.slickgrid;
-    selection = grid.getSelectionModel().getSelectedRanges();
-    data = grid.getData();
-    rows = grid.getSelectedRows();
-    // Iterate over all the selected rows & fetch data
-    for (var i = 0; i < rows.length; i += 1) {
-      var idx = rows[i],
-        _rowData = data[idx],
-        _values = [];
-      self.copied_rows.push(_rowData);
-      // Convert it as CSV for clipboard
-      for (var j = 0; j < self.columns.length; j += 1) {
-
-        var val = _rowData[self.columns[j].pos];
-        if(val && _.isObject(val))
-          val = "'" + JSON.stringify(val) + "'";
-        else if(val && typeof val != "number" && typeof true != "boolean")
-          val = "'" + val.toString() + "'";
-        else if (_.isNull(val) || _.isUndefined(val))
-          val = '';
-        _values.push(val);
-      }
-      // Append to main text string
-      if (_values.length > 0)
-        copied_text += _values.toString() + "\n";
+    var grid = self.slickgrid;
+    var columnDefinitions = grid.getColumns();
+    var selectedRanges = grid.getSelectionModel().getSelectedRanges();
+    var data = grid.getData();
+    var rows = grid.getSelectedRows();
+
+
+    if (allTheRangesAreFullRows(selectedRanges, columnDefinitions)) {
+      self.copied_rows = rows.map(function (rowIndex) {
+        return data[rowIndex];
+      });
+    } else {
+      self.copied_rows = [];
     }
 
+    var csvText = rangeBoundaryNavigator.rangesToCsv(data, columnDefinitions, selectedRanges);
+
     // If there is something to set into clipboard
-    if (copied_text)
-      clipboard.copyTextToClipboard(copied_text);
+    if (csvText)
+      clipboard.copyTextToClipboard(csvText);
   };
+
+  var allTheRangesAreFullRows = function (ranges, columnDefinitions){
+    var colRangeBounds = ranges.map(function (range) {
+      return [range.fromCell, range.toCell];
+    });
+    return _.isEqual(_.union.apply(null, colRangeBounds), [0, columnDefinitions.length - 1]);
+  };
+
   return copyData
 });
\ No newline at end of file
diff --git a/web/pgadmin/static/js/selection/range_boundary_navigator.js b/web/pgadmin/static/js/selection/range_boundary_navigator.js
new file mode 100644
index 0000000..f7d64b9
--- /dev/null
+++ b/web/pgadmin/static/js/selection/range_boundary_navigator.js
@@ -0,0 +1,97 @@
+define(function () {
+  return {
+    getUnion: function (dimensionBounds) {
+      dimensionBounds.sort();
+      var boundsUnion = [];
+      if (!_.isEmpty(dimensionBounds)) {
+        boundsUnion = [dimensionBounds[0]];
+      }
+
+      dimensionBounds.forEach(function (bound) {
+        var previousBound = _.last(boundsUnion);
+        if (bound[0] <= previousBound[1] + 1) {
+          if (bound[1] > previousBound[1]) {
+            previousBound[1] = bound[1];
+          }
+        } else {
+          boundsUnion.push(bound);
+        }
+      });
+      return boundsUnion;
+    },
+
+    mapDimensionBoundaryUnion: function (unionedDimensionBoundaries, iteratee) {
+      var mapResult = [];
+      unionedDimensionBoundaries.forEach(function (subrange) {
+        for (var index = subrange[0]; index <= subrange[1]; index += 1) {
+          mapResult.push(iteratee(index));
+        }
+      });
+      return mapResult;
+    },
+
+    mapOver2DArray: function (rowRangeBounds, colRangeBounds, processCell, rowCollector) {
+      var unionedRowRanges = this.getUnion(rowRangeBounds);
+      var unionedColRanges = this.getUnion(colRangeBounds);
+
+      return this.mapDimensionBoundaryUnion(unionedRowRanges, function (rowId) {
+        var rowData = this.mapDimensionBoundaryUnion(unionedColRanges, function (colId) {
+          return processCell(rowId, colId);
+        });
+        return rowCollector(rowData);
+      }.bind(this));
+    },
+
+    rangesToCsv: function (data, columnDefinitions, selectedRanges) {
+      var rowRangeBounds = selectedRanges.map(function (range) {
+        return [range.fromRow, range.toRow];
+      });
+      var colRangeBounds = selectedRanges.map(function (range) {
+        return [range.fromCell, range.toCell];
+      });
+
+      if(_.isUndefined(columnDefinitions[0].pos)){
+        colRangeBounds = this.removeFirstColumn(colRangeBounds);
+      }
+
+      var csvRows = this.mapOver2DArray(rowRangeBounds, colRangeBounds, this.csvCell.bind(this, data, columnDefinitions), function (rowData) {
+        return rowData.join(',');
+      });
+      return csvRows.join('\n');
+    },
+
+    removeFirstColumn: function (colRangeBounds) {
+      var unionedColRanges = this.getUnion(colRangeBounds);
+
+      var firstSubrangeStartsAt0 = function () {
+        return unionedColRanges[0][0] == 0;
+      };
+
+      function firstSubrangeIsJustFirstColumn() {
+        return unionedColRanges[0][1] == 0;
+      }
+
+      if(firstSubrangeStartsAt0()){
+        if(firstSubrangeIsJustFirstColumn()){
+          unionedColRanges.shift();
+        } else {
+          unionedColRanges[0][0] = 1;
+        }
+      }
+      return unionedColRanges;
+    },
+
+    csvCell: function (data, columnDefinitions, rowId, colId) {
+      var val = data[rowId][columnDefinitions[colId].pos];
+
+      if (val && _.isObject(val)) {
+        val = "'" + JSON.stringify(val) + "'";
+      } else if (val && typeof val != "number" && typeof val != "boolean") {
+        val = "'" + val.toString() + "'";
+      } else if (_.isNull(val) || _.isUndefined(val)) {
+        val = '';
+      }
+      return val;
+    }
+  };
+});
\ No newline at end of file
diff --git a/web/pgadmin/static/js/selection/tests/copy_data_spec.js b/web/pgadmin/static/js/selection/tests/copy_data_spec.js
deleted file mode 100644
index 03b4cfe..0000000
--- a/web/pgadmin/static/js/selection/tests/copy_data_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-define(
-  ["jquery",
-    "slickgrid/slick.grid",
-    "slickgrid/slick.rowselectionmodel",
-    "js/selection/copy_data",
-    "js/selection/clipboard"
-  ],
-  function ($, SlickGrid, RowSelectionModel, copyData, clipboard) {
-    describe('copyData', function () {
-      it('copies selected rows', function () {
-        var data = [{"brand":"nike","id":"1","size":"12"},
-          {"brand":"adidas","id":"2","size":"13"},
-          {"brand":"puma","id":"3","size":"9"}];
-        var columns = [{"name":"id","label":"id<br> numeric","cell":"number","can_edit":false,"type":"numeric"},
-          {"name":"brand","label":"brand<br> character varying","cell":"string","can_edit":false,"type":"character varying"},
-          {"name":"size","label":"size<br> numeric","cell":"number","can_edit":false,"type":"numeric"}];
-        var gridContainer = $("<div id='grid'></div>");
-        $("body").append(gridContainer);
-        var grid = new Slick.Grid("#grid", data, columns, {});
-        grid.setSelectionModel(new Slick.RowSelectionModel({selectActiveRow: false}));
-
-
-        var sqlEditor = {slickgrid: grid, columns: columns};
-        spyOn(clipboard, 'copyTextToClipboard');
-        spyOn(grid, 'getSelectedRows').and.returnValue([0, 2]);
-
-        copyData.apply(sqlEditor);
-
-        expect(sqlEditor.copied_rows.length).toBe(2);
-        expect(clipboard.copyTextToClipboard).toHaveBeenCalled();
-
-      })
-    })
-  }
-);
\ No newline at end of file
diff --git a/web/regression/feature_utils/base_feature_test.py b/web/regression/feature_utils/base_feature_test.py
index 34bf4f4..fa5a41c 100644
--- a/web/regression/feature_utils/base_feature_test.py
+++ b/web/regression/feature_utils/base_feature_test.py
@@ -19,6 +19,7 @@ class BaseFeatureTest(BaseTestGenerator):
 
         self.page = PgadminPage(self.driver, app_config)
         try:
+            self.page.driver.switch_to.default_content()
             self.page.wait_for_app()
             self.page.wait_for_spinner_to_disappear()
             self.page.reset_layout()
diff --git a/web/regression/feature_utils/pgadmin_page.py b/web/regression/feature_utils/pgadmin_page.py
index cba4824..cc16884 100644
--- a/web/regression/feature_utils/pgadmin_page.py
+++ b/web/regression/feature_utils/pgadmin_page.py
@@ -46,6 +46,16 @@ class PgadminPage:
 
         self.find_by_xpath("//*[@id='tree']//*[.='" + server_config['name'] + "']")
 
+    def close_query_tool(self):
+        self.driver.switch_to.default_content()
+        tab = self.find_by_xpath("//*[contains(@class,'wcPanelTab') and contains(.,'" + "Query" + "')]")
+        ActionChains(self.driver).context_click(tab).perform()
+        self.find_by_xpath("//li[contains(@class, 'context-menu-item')]/span[contains(text(), 'Remove Panel')]").click()
+        self.driver.switch_to.frame(self.driver.find_elements_by_tag_name("iframe")[0])
+        time.sleep(.5)
+        self.click_element(self.find_by_xpath('//button[contains(@class, "ajs-button") and contains(.,"Yes")]'))
+        self.driver.switch_to.default_content()
+
     def remove_server(self, server_config):
         self.driver.switch_to.default_content()
         self.find_by_xpath("//*[@id='tree']//*[.='" + server_config['name'] + "' and @class='aciTreeItem']").click()
diff --git a/test/javascript/column_selector_spec.js b/web/regression/javascript/selection/column_selector_spec.js
similarity index 87%
rename from test/javascript/column_selector_spec.js
rename to web/regression/javascript/selection/column_selector_spec.js
index 30a98d4..15b3460 100644
--- a/test/javascript/column_selector_spec.js
+++ b/web/regression/javascript/selection/column_selector_spec.js
@@ -25,6 +25,26 @@ define(
           }]
       });
 
+      describe("when it is the checkbox column", function () {
+        it("does not create a checkbox", function () {
+          var checkboxColumn = {
+            id: '_checkbox_selector',
+            name: 'checkbox column',
+            selectable: true
+          };
+          columns.push(checkboxColumn);
+
+          var columnSelector = new ColumnSelector(columns);
+          columns = columnSelector.getColumnsWithCheckboxes();
+          var grid = new SlickGrid(container, data, columns, options);
+
+          grid.registerPlugin(columnSelector);
+          grid.invalidate();
+
+          expect(container.find('.slick-header-columns input').length).toBe(2)
+        });
+      });
+
       it("renders a checkbox in the column header", function () {
         var columnSelector = new ColumnSelector(columns);
         columns = columnSelector.getColumnsWithCheckboxes();
diff --git a/web/regression/javascript/selection/copy_data_spec.js b/web/regression/javascript/selection/copy_data_spec.js
new file mode 100644
index 0000000..f26ce38
--- /dev/null
+++ b/web/regression/javascript/selection/copy_data_spec.js
@@ -0,0 +1,95 @@
+define(
+  ["jquery",
+    "slickgrid/slick.grid",
+    "slickgrid/slick.rowselectionmodel",
+    "sources/selection/copy_data",
+    "sources/selection/clipboard"
+  ],
+  function ($, SlickGrid, RowSelectionModel, copyData, clipboard) {
+    describe('copyData', function () {
+      var grid, sqlEditor;
+
+      beforeEach(function () {
+        var data = [[1, "nike", "12"],
+          [2, "adidas", "13"],
+          [3, "puma", "9"]];
+
+        var columns = [
+          {
+            name: "id",
+            pos: 0,
+            label: "id<br> numeric",
+            cell: "number",
+            can_edit: false,
+            type: "numeric"
+          },
+          {
+            name: "brand",
+            pos: 1,
+            label: "brand<br> character varying",
+            cell: "string",
+            can_edit: false,
+            type: "character varying"
+          },
+          {
+            name: "size",
+            pos: 2,
+            label: "size<br> numeric",
+            cell: "number",
+            can_edit: false,
+            type: "numeric"
+          }];
+        var gridContainer = $("<div id='grid'></div>");
+        $("body").append(gridContainer);
+        grid = new Slick.Grid("#grid", data, columns, {});
+        grid.setSelectionModel(new Slick.RowSelectionModel({selectActiveRow: false}));
+        sqlEditor = {slickgrid: grid};
+      });
+
+      describe("when rows are selected", function () {
+        beforeEach(function () {
+          grid.getSelectionModel().setSelectedRows([0, 2])
+        });
+
+        it("copies them", function () {
+          spyOn(clipboard, 'copyTextToClipboard');
+
+          copyData.apply(sqlEditor);
+
+          expect(sqlEditor.copied_rows.length).toBe(2);
+
+          expect(clipboard.copyTextToClipboard).toHaveBeenCalled();
+          expect(clipboard.copyTextToClipboard.calls.mostRecent().args[0]).toContain("1,'nike','12'");
+          expect(clipboard.copyTextToClipboard.calls.mostRecent().args[0]).toContain("3,'puma','9'");
+        });
+      });
+
+      describe("when a column is selected", function () {
+        beforeEach(function () {
+          var firstColumn = new Slick.Range(0, 0, 2, 0);
+          grid.getSelectionModel().setSelectedRanges([firstColumn])
+        });
+
+        it("copies text to the clipboard", function () {
+          spyOn(clipboard, 'copyTextToClipboard');
+
+          copyData.apply(sqlEditor);
+
+          expect(clipboard.copyTextToClipboard).toHaveBeenCalled();
+
+          var copyArg = clipboard.copyTextToClipboard.calls.mostRecent().args[0];
+          var rowStrings = copyArg.split('\n');
+          expect(rowStrings[0]).toBe("1");
+          expect(rowStrings[1]).toBe("2");
+          expect(rowStrings[2]).toBe("3");
+        });
+
+        it("sets copied_rows to empty", function () {
+          copyData.apply(sqlEditor);
+
+          expect(sqlEditor.copied_rows.length).toBe(0);
+        });
+      });
+    });
+  }
+);
\ No newline at end of file
diff --git a/web/regression/javascript/selection/range_boundary_navigator_spec.js b/web/regression/javascript/selection/range_boundary_navigator_spec.js
new file mode 100644
index 0000000..27b2beb
--- /dev/null
+++ b/web/regression/javascript/selection/range_boundary_navigator_spec.js
@@ -0,0 +1,139 @@
+define(['sources/selection/range_boundary_navigator'], function (rangeBoundaryNavigator) {
+
+  describe("#getUnion", function () {
+    describe("when the ranges completely overlap", function () {
+      it("returns a list with that range", function () {
+        var ranges = [[1, 4], [1, 4], [1, 4]];
+
+        var union = rangeBoundaryNavigator.getUnion(ranges);
+
+        expect(union).toEqual([[1, 4]]);
+      });
+    });
+
+    describe("when the ranges all overlap partially or touch", function () {
+      it("returns one long range", function () {
+        var rangeBounds = [[3, 6], [1, 4], [7, 14]];
+
+        var union = rangeBoundaryNavigator.getUnion(rangeBounds);
+
+        expect(union).toEqual([[1, 14]]);
+      });
+
+      describe("when one range is a subset of another", function () {
+        it("returns the larger range", function () {
+          var rangeBounds = [[2, 6], [1, 14], [8, 10]];
+
+          var union = rangeBoundaryNavigator.getUnion(rangeBounds);
+
+          expect(union).toEqual([[1, 14]]);
+        })
+      })
+    });
+
+    describe("when the ranges do not touch", function () {
+      it("returns them in order from lowest to highest", function () {
+        var rangeBounds = [[3, 6], [1, 1], [8, 10]];
+
+        var union = rangeBoundaryNavigator.getUnion(rangeBounds);
+
+        expect(union).toEqual([[1, 1], [3, 6], [8, 10]]);
+      });
+    });
+  });
+
+
+  describe("#mapDimensionBoundaryUnion", function () {
+    it("returns a list of the results of the callback", function () {
+      var rangeBounds = [[0, 1], [3, 3]];
+      var callback = function () {
+        return 'hello';
+      };
+      var result = rangeBoundaryNavigator.mapDimensionBoundaryUnion(rangeBounds, callback);
+      expect(result).toEqual(['hello', 'hello', 'hello']);
+    });
+
+    it("calls the callback with each index in the dimension", function () {
+      var rangeBounds = [[0, 1], [3, 3]];
+      var callback = jasmine.createSpy('callbackSpy');
+      rangeBoundaryNavigator.mapDimensionBoundaryUnion(rangeBounds, callback);
+      expect(callback.calls.allArgs()).toEqual([[0], [1], [3]]);
+    });
+  });
+
+  describe("#mapOver2DArray", function () {
+    var data, rowCollector, processCell;
+    beforeEach(function () {
+      data = [[0, 1, 2, 3], [2, 2, 2, 2], [4, 5, 6, 7]];
+      processCell = function (rowIndex, columnIndex) {
+        return data[rowIndex][columnIndex];
+      };
+      rowCollector = function (rowData) {
+        return JSON.stringify(rowData);
+      };
+    });
+
+    it("calls the callback for each item in the ranges", function () {
+      var rowRanges = [[0, 0], [2, 2]];
+      var colRanges = [[0, 3]];
+
+      var selectionResult = rangeBoundaryNavigator.mapOver2DArray(rowRanges, colRanges, processCell, rowCollector);
+
+      expect(selectionResult).toEqual(["[0,1,2,3]", "[4,5,6,7]"]);
+    });
+
+    describe("when the ranges are out of order/duplicated", function () {
+      var rowRanges, colRanges;
+      beforeEach(function () {
+        rowRanges = [[2, 2], [2, 2], [0, 0]];
+        colRanges = [[0, 3]];
+      });
+
+      it("uses the union of the ranges", function () {
+        spyOn(rangeBoundaryNavigator, "getUnion").and.callThrough();
+
+        var selectionResult = rangeBoundaryNavigator.mapOver2DArray(rowRanges, colRanges, processCell, rowCollector);
+
+        expect(rangeBoundaryNavigator.getUnion).toHaveBeenCalledWith(rowRanges);
+        expect(rangeBoundaryNavigator.getUnion).toHaveBeenCalledWith(colRanges);
+        expect(selectionResult).toEqual(["[0,1,2,3]", "[4,5,6,7]"]);
+      });
+    });
+  });
+
+  describe("#rangesToCsv", function () {
+    var data, columnDefinitions, ranges;
+    beforeEach(function () {
+      data = [[1, "nike", "12"],
+        [2, "adidas", "13"],
+        [3, "puma", "9"],
+        [4, "converse", "10"]];
+      columnDefinitions = [{name: 'id', pos: 0}, {name: 'brand', pos: 1}, {name: 'size', pos: 2}];
+      ranges = [new Slick.Range(0, 0, 0, 2), new Slick.Range(3, 0, 3, 2)];
+    });
+
+    it("returns csv for the provided ranges", function () {
+
+      var csvResult = rangeBoundaryNavigator.rangesToCsv(data, columnDefinitions, ranges);
+
+      expect(csvResult).toEqual("1,'nike','12'\n4,'converse','10'");
+    });
+
+    describe("when there is an extra column with checkboxes", function () {
+      beforeEach(function () {
+        columnDefinitions = [{name: 'not-a-data-column'}, {name: 'id', pos: 0}, {name: 'brand', pos: 1}, {
+          name: 'size',
+          pos: 2
+        }];
+        ranges = [new Slick.Range(0, 0, 0, 3), new Slick.Range(3, 0, 3, 3)];
+
+      });
+
+      it("returns csv for the columns with data", function () {
+        var csvResult = rangeBoundaryNavigator.rangesToCsv(data, columnDefinitions, ranges);
+
+        expect(csvResult).toEqual("1,'nike','12'\n4,'converse','10'");
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/web/regression/test_utils.py b/web/regression/test_utils.py
index f9cf933..145af86 100644
--- a/web/regression/test_utils.py
+++ b/web/regression/test_utils.py
@@ -165,6 +165,7 @@ def create_table(server, db_name, table_name):
         pg_cursor = connection.cursor()
         pg_cursor.execute('''CREATE TABLE "%s" (name VARCHAR, value NUMERIC)''' % table_name)
         pg_cursor.execute('''INSERT INTO "%s" VALUES ('Some-Name', 6)''' % table_name)
+        pg_cursor.execute('''INSERT INTO "%s" VALUES ('Some-Other-Name', 22)''' % table_name)
         connection.set_isolation_level(old_isolation_level)
         connection.commit()
 
-- 
2.10.1 (Apple Git-78)

