From 7da1a218bae9e303fb5520c0d7365bb1998ace33 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 8 Mar 2016 21:36:33 -0800 Subject: [PATCH] [Tables] #707 Added auto-scroll, addressed race condition in Sinewave and event telemetry providers Fixed issue with visible padding row Incremental improvements Added tests Added tests for sorted insert, and fixed lint errors --- example/eventGenerator/bundle.js | 7 + .../src/EventTelemetryProvider.js | 13 +- .../src/SinewaveTelemetryProvider.js | 10 +- platform/features/table/bundle.js | 5 +- .../{mct-data-table.html => mct-table.html} | 5 +- .../table/res/templates/rt-table.html | 3 +- .../table/res/templates/scrolling.html | 9 + .../features/table/src/TableConfiguration.js | 8 +- .../src/controllers/MCTTableController.js | 247 ++++++++++++++---- .../controllers/RTTelemetryTableController.js | 64 +++-- .../controllers/TelemetryTableController.js | 30 ++- .../features/table/src/directives/MCTTable.js | 18 +- .../table/test/TableConfigurationSpec.js | 8 +- .../controllers/MCTTableControllerSpec.js | 100 ++++++- .../RTTelemetryTableControllerSpec.js | 146 +++++++++++ 15 files changed, 548 insertions(+), 125 deletions(-) rename platform/features/table/res/templates/{mct-data-table.html => mct-table.html} (95%) create mode 100644 platform/features/table/res/templates/scrolling.html create mode 100644 platform/features/table/test/controllers/RTTelemetryTableControllerSpec.js diff --git a/example/eventGenerator/bundle.js b/example/eventGenerator/bundle.js index 608f61eaac..6773442494 100644 --- a/example/eventGenerator/bundle.js +++ b/example/eventGenerator/bundle.js @@ -57,6 +57,13 @@ define([ }, "telemetry": { "source": "eventGenerator", + "domains": [ + { + "key": "time", + "name": "Time", + "format": "utc" + } + ], "ranges": [ { "format": "string" diff --git a/example/eventGenerator/src/EventTelemetryProvider.js b/example/eventGenerator/src/EventTelemetryProvider.js index 79bdec0c40..eb368ebd69 100644 --- a/example/eventGenerator/src/EventTelemetryProvider.js +++ b/example/eventGenerator/src/EventTelemetryProvider.js @@ -36,7 +36,9 @@ define( function EventTelemetryProvider($q, $timeout) { var subscriptions = [], - genInterval = 1000; + genInterval = 1000, + generating = false, + id = Math.random() * 100000; // function matchesSource(request) { @@ -78,10 +80,13 @@ define( } function startGenerating() { + generating = true; $timeout(function () { handleSubscriptions(); - if (subscriptions.length > 0) { + if (generating && subscriptions.length > 0) { startGenerating(); + } else { + generating = false; } }, genInterval); } @@ -91,8 +96,6 @@ define( callback: callback, requests: requests }; - console.log("subscribe... " + Date.now() / 1000 + " request:" + - " " + requests[0].id); function unsubscribe() { subscriptions = subscriptions.filter(function (s) { return s !== subscription; @@ -100,7 +103,7 @@ define( } subscriptions.push(subscription); - if (subscriptions.length === 1) { + if (!generating) { startGenerating(); } diff --git a/example/generator/src/SinewaveTelemetryProvider.js b/example/generator/src/SinewaveTelemetryProvider.js index c4062e659c..a50cf4b2cf 100644 --- a/example/generator/src/SinewaveTelemetryProvider.js +++ b/example/generator/src/SinewaveTelemetryProvider.js @@ -34,7 +34,8 @@ define( * @constructor */ function SinewaveTelemetryProvider($q, $timeout) { - var subscriptions = []; + var subscriptions = [], + generating = false; // function matchesSource(request) { @@ -75,10 +76,13 @@ define( } function startGenerating() { + generating = true; $timeout(function () { handleSubscriptions(); - if (subscriptions.length > 0) { + if (generating && subscriptions.length > 0) { startGenerating(); + } else { + generating = false; } }, 1000); } @@ -97,7 +101,7 @@ define( subscriptions.push(subscription); - if (subscriptions.length === 1) { + if (!generating) { startGenerating(); } diff --git a/platform/features/table/bundle.js b/platform/features/table/bundle.js index 1ee305ac2d..a635fb039a 100644 --- a/platform/features/table/bundle.js +++ b/platform/features/table/bundle.js @@ -101,7 +101,8 @@ define([ "composition": [] }, "views": [ - "realtime" + "rt-table", + "scrolling-table" ] } ], @@ -137,7 +138,7 @@ define([ }, { "name": "Real-time Table", - "key": "realtime", + "key": "rt-table", "glyph": "\ue605", "templateUrl": "templates/rt-table.html", "needs": [ diff --git a/platform/features/table/res/templates/mct-data-table.html b/platform/features/table/res/templates/mct-table.html similarity index 95% rename from platform/features/table/res/templates/mct-data-table.html rename to platform/features/table/res/templates/mct-table.html index b637bb0418..2f7ade13b4 100644 --- a/platform/features/table/res/templates/mct-data-table.html +++ b/platform/features/table/res/templates/mct-table.html @@ -1,5 +1,7 @@
+ enableSort="true" + auto-scroll="autoScroll"> \ No newline at end of file diff --git a/platform/features/table/res/templates/scrolling.html b/platform/features/table/res/templates/scrolling.html new file mode 100644 index 0000000000..88eed36af7 --- /dev/null +++ b/platform/features/table/res/templates/scrolling.html @@ -0,0 +1,9 @@ +
+ + +
\ No newline at end of file diff --git a/platform/features/table/src/TableConfiguration.js b/platform/features/table/src/TableConfiguration.js index 72ccc0c707..29e0aaf774 100644 --- a/platform/features/table/src/TableConfiguration.js +++ b/platform/features/table/src/TableConfiguration.js @@ -54,10 +54,6 @@ define( if (metadata) { - if (metadata.length > 1){ - self.addColumn(new NameColumn(), 0); - } - metadata.forEach(function (metadatum) { //Push domains first (metadatum.domains || []).forEach(function (domainMetadata) { @@ -67,6 +63,10 @@ define( self.addColumn(new RangeColumn(rangeMetadata, self.telemetryFormatter)); }); }); + + if (this.columns.length > 0){ + self.addColumn(new NameColumn(), 0); + } } return this; }; diff --git a/platform/features/table/src/controllers/MCTTableController.js b/platform/features/table/src/controllers/MCTTableController.js index 6fc64a5094..80270bce06 100644 --- a/platform/features/table/src/controllers/MCTTableController.js +++ b/platform/features/table/src/controllers/MCTTableController.js @@ -5,6 +5,15 @@ define( function () { "use strict"; + /** + * A controller for the MCTTable directive. Populates scope with + * data used for populating, sorting, and filtering + * tables. + * @param $scope + * @param $timeout + * @param element + * @constructor + */ function MCTTableController($scope, $timeout, element) { var self = this; @@ -12,10 +21,11 @@ define( this.element = element; this.$timeout = $timeout; this.maxDisplayRows = 50; - element.find('div').on('scroll', this.setVisibleRows.bind(this)); - this.scrollable = element.find('div')[0]; - $scope.visibleRows = []; + this.scrollable = element.find('div'); + this.scrollable.on('scroll', this.onScroll.bind(this)); + + $scope.visibleRows = []; $scope.overrideRowPositioning = false; /** @@ -51,27 +61,82 @@ define( self.updateRows($scope.rows); }; + /* + * Define watches to listen for changes to headers and rows. + */ $scope.$watchCollection('filters', function () { - self.updateRows(self.$scope.displayRows); + self.updateRows($scope.rows); }); - $scope.$watchCollection('headers', this.updateHeaders.bind(this)); + $scope.$watch('headers', this.updateHeaders.bind(this)); $scope.$watch('rows', this.updateRows.bind(this)); - $scope.$on('newRow', this.newRow.bind(this)); - } - MCTTableController.prototype.newRow = function (event, newRow) { - this.$scope.displayRows.push(newRow); - this.filterAndSort(this.$scope.displayRows); - this.$timeout(this.setElementSizes(), 0); + /* + * Listen for rows added individually (eg. for real-time tables) + */ + $scope.$on('addRow', this.newRow.bind(this)); } /** - * Re-synchronize between data rows and visible rows, based on array - * content and scroll state. + * If auto-scroll is enabled, this function will scroll to the + * bottom of the page + * @private + */ + MCTTableController.prototype.scrollToBottom = function() { + var self = this; + + //Use timeout to defer execution until next digest when any + // pending UI changes have completed, eg. a new row in the table. + if (this.$scope.autoScroll) { + this.$timeout(function(){ + self.scrollable[0].scrollTop = self.scrollable[0].scrollHeight; + }); + } + }; + + /** + * Handles a row add event. Rows can be added as needed using the + * `addRow` broadcast event. + * @private + */ + MCTTableController.prototype.newRow = function (event, row) { + //Add row to the filtered, sorted list of all rows + if (this.filterRows([row]).length > 0) { + this.insertSorted(this.$scope.displayRows, row); + } + + //Keep 'rows' synchronized as it provides the unsorted, + // unfiltered model for this view + if (!this.$scope.rows) { + this.$scope.rows = []; + } + this.$scope.rows.push(row); + + this.$timeout(this.setElementSizes.bind(this)) + .then(this.scrollToBottom.bind(this)); + }; + + /** + * @private + */ + MCTTableController.prototype.onScroll = function (event) { + //If user scrolls away from bottom, disable auto-scroll. + // Auto-scroll will be re-enabled if user scrolls to bottom again. + if (this.scrollable[0].scrollTop < (this.scrollable[0].scrollHeight - this.scrollable[0].offsetHeight)) { + this.$scope.autoScroll = false; + } else { + this.$scope.autoScroll = true; + } + this.setVisibleRows(); + this.$scope.$digest(); + }; + + /** + * Sets visible rows based on array + * content and current scroll state. */ MCTTableController.prototype.setVisibleRows = function () { var self = this, - target = this.scrollable, + target = this.scrollable[0], topScroll = target.scrollTop, bottomScroll = topScroll + target.offsetHeight, firstVisible, @@ -87,7 +152,7 @@ define( // rows (if data added) if (this.$scope.visibleRows.length != this.$scope.displayRows.length){ start = 0; - end = this.$scope.displayRows.length-1; + end = this.$scope.displayRows.length; } else { //Data is in sync, and no need to calculate scroll, // so do nothing. @@ -114,13 +179,12 @@ define( if (start < 0) { start = 0; - //end = this.$scope.visibleRows.length - 1; - end = Math.min(this.maxDisplayRows, this.$scope.displayRows.length) - 1; + end = Math.min(this.maxDisplayRows, this.$scope.displayRows.length); } else if (end >= this.$scope.displayRows.length) { - end = this.$scope.displayRows.length - 1; + end = this.$scope.displayRows.length; start = end - this.maxDisplayRows + 1; } - if (this.$scope.visibleRows[0].rowIndex === start && + if (this.$scope.visibleRows[0] && this.$scope.visibleRows[0].rowIndex === start && this.$scope.visibleRows[this.$scope.visibleRows.length - 1] .rowIndex === end) { @@ -137,8 +201,6 @@ define( contents: row }; }); - - this.$scope.$digest(); }; /** @@ -156,7 +218,7 @@ define( this.$scope.filters = {}; } // Reset column sort information unless the new headers - // contain the column current sorted on. + // contain the column currently sorted on. if (this.$scope.enableSort && newHeaders.indexOf(this.$scope.sortColumn) === -1) { this.$scope.sortColumn = undefined; this.$scope.sortDirection = undefined; @@ -175,7 +237,6 @@ define( firstRow = tbody.find('tr'), column = firstRow.find('td'), headerHeight = thead.prop('offsetHeight'), - //row height is hard-coded for now. rowHeight = 20, overallHeight = headerHeight + (rowHeight * (this.$scope.displayRows ? this.$scope.displayRows.length - 1 : 0)); @@ -192,6 +253,80 @@ define( this.$scope.overrideRowPositioning = true; }; + /** + * @private + */ + MCTTableController.prototype.insertSorted = function(array, element) { + var index = -1, + self = this, + sortKey = this.$scope.sortColumn; + + function binarySearch(searchArray, searchElement, min, max){ + var sampleAt = Math.floor((max - min) / 2) + min; + + if (max < min) { + return min; // Element is not in array, min gives direction + } + + switch(self.sortComparator(searchElement[sortKey].text, searchArray[sampleAt][sortKey].text)) { + case -1: + return binarySearch(searchArray, searchElement, min, sampleAt - 1); + case 0 : + return sampleAt; + case 1 : + return binarySearch(searchArray, searchElement, sampleAt + 1, max); + } + } + + if (!this.$scope.sortColumn || !this.$scope.sortDirection) { + //No sorting applied, push it on the end. + index = array.length; + } else { + //Sort is enabled, perform binary search to find insertion point + index = binarySearch(array, element, 0, array.length - 1); + } + if (index === -1){ + array.unshift(element); + } else if (index === array.length){ + array.push(element); + } else { + array.splice(index, 0, element); + } + }; + + /** + * Compare two variables, returning a number that represents + * which is larger. Similar to the default array sort + * comparator, but does not coerce all values to string before + * conversion. Strings are lowercased before comparison. + * + * @private + */ + MCTTableController.prototype.sortComparator = function(a, b) { + var result = 0, + sortDirectionMultiplier; + + if (typeof a === "string" && typeof b === "string") { + a = a.toLowerCase(); + b = b.toLowerCase(); + } + + if (a < b) { + result = -1; + } + if (a > b) { + result = 1; + } + + if (this.$scope.sortDirection === 'asc') { + sortDirectionMultiplier = 1; + } else if (this.$scope.sortDirection === 'desc') { + sortDirectionMultiplier = -1; + } + + return result * sortDirectionMultiplier; + }; + /** * Returns a new array which is a result of applying the sort * criteria defined in $scope. @@ -199,38 +334,12 @@ define( * Does not modify the array that was passed in. */ MCTTableController.prototype.sortRows = function(rowsToSort) { - /** - * Compare two variables, returning a number that represents - * which is larger. Similar to the default array sort - * comparator, but does not coerce all values to string before - * conversion. Strings are lowercased before comparison. - */ - function genericComparator(a, b) { - if (typeof a === "string" && typeof b === "string") { - a = a.toLowerCase(); - b = b.toLowerCase(); - } - - if (a < b) { - return -1; - } - if (a > b) { - return 1; - } - return 0; - } + var self = this, + sortKey = this.$scope.sortColumn; if (!this.$scope.sortColumn || !this.$scope.sortDirection) { return rowsToSort; } - var sortKey = this.$scope.sortColumn, - sortDirectionMultiplier; - - if (this.$scope.sortDirection === 'asc') { - sortDirectionMultiplier = 1; - } else if (this.$scope.sortDirection === 'desc') { - sortDirectionMultiplier = -1; - } return rowsToSort.slice(0).sort(function(a, b) { //If the values to compare can be compared as @@ -239,8 +348,7 @@ define( var valA = isNaN(a[sortKey].text) ? a[sortKey].text : parseFloat(a[sortKey].text), valB = isNaN(b[sortKey].text) ? b[sortKey].text : parseFloat(b[sortKey].text); - return genericComparator(valA, valB) * - sortDirectionMultiplier; + return self.sortComparator(valA, valB); }); }; @@ -271,9 +379,10 @@ define( return largestRow; }, JSON.parse(JSON.stringify(rows[0] || {}))); + largestRow = JSON.parse(JSON.stringify(largestRow)); + // Pad with characters to accomodate variable-width fonts, // and remove characters that would allow word-wrapping. - largestRow = JSON.parse(JSON.stringify(largestRow)); Object.keys(largestRow).forEach(function(key) { var padCharacters, i; @@ -289,8 +398,15 @@ define( return largestRow; }; + /** + * Calculates the widest row in the table, pads that row, and adds + * it to the table. Allows the table to size itself, then uses this + * as basis for column dimensions. + * @private + */ MCTTableController.prototype.resize = function (){ - var largestRow = this.findLargestRow(this.$scope.displayRows); + var largestRow = this.findLargestRow(this.$scope.displayRows), + self = this; this.$scope.visibleRows = [ { rowIndex: 0, @@ -299,9 +415,18 @@ define( } ]; - this.$timeout(this.setElementSizes.bind(this)); + //Wait a timeout to allow digest of previous change to visible + // rows to happen. + this.$timeout(function() { + //Remove temporary padding row used for setting column widths + self.$scope.visibleRows = []; + self.setElementSizes(); + }); }; + /** + * @priate + */ MCTTableController.prototype.filterAndSort = function(rows) { var displayRows = rows; if (this.$scope.enableFilter) { @@ -319,19 +444,25 @@ define( * will be sorted before display. */ MCTTableController.prototype.updateRows = function (newRows) { + //Reset visible rows because new row data available. this.$scope.visibleRows = []; + this.$scope.overrideRowPositioning = false; + //Nothing to show because no columns visible if (!this.$scope.displayHeaders) { return; } - this.filterAndSort(newRows || []); + //Apply filters and sort a copy of the the new rows + this.filterAndSort((newRows || []).slice(0)); + //Resize columns appropriately this.resize(); }; /** - * Filter rows. + * Applies user defined filters to rows. These filters are based on + * the text entered in the search areas in each column */ MCTTableController.prototype.filterRows = function(rowsToFilter) { var filters = {}, diff --git a/platform/features/table/src/controllers/RTTelemetryTableController.js b/platform/features/table/src/controllers/RTTelemetryTableController.js index 4b5c0ac5ae..b596189000 100644 --- a/platform/features/table/src/controllers/RTTelemetryTableController.js +++ b/platform/features/table/src/controllers/RTTelemetryTableController.js @@ -21,23 +21,16 @@ *****************************************************************************/ /*global define*/ -/** - * This bundle adds a table view for displaying telemetry data. - * @namespace platform/features/table - */ define( [ - './TelemetryTableController', - '../TableConfiguration', - '../NameColumn' + './TelemetryTableController' ], - function (TableController, Table, NameColumn) { + function (TableController) { "use strict"; /** - * The TableController is responsible for getting data onto the page - * in the table widget. This includes handling composition, - * configuration, and telemetry subscriptions. + * Extends TelemetryTableController and adds real-time streaming + * support. * @memberof platform/features/table * @param $scope * @param telemetryHandler @@ -46,16 +39,44 @@ define( */ function RTTelemetryTableController($scope, telemetryHandler, telemetryFormatter) { TableController.call(this, $scope, telemetryHandler, telemetryFormatter); + + $scope.autoScroll = false; + + /* + * Determine if auto-scroll should be enabled. Is enabled + * automatically when telemetry type is string + */ + function hasStringTelemetry(domainObject) { + var telemetry = domainObject && + domainObject.getCapability('telemetry'), + metadata = telemetry ? telemetry.getMetadata() : {}, + ranges = metadata.ranges || []; + + return ranges.some(function (range) { + return range.format === 'string'; + }); + } + $scope.$watch('domainObject', function(domainObject) { + //When a domain object becomes available, check whether the + // view should auto-scroll to the bottom. + if (domainObject && hasStringTelemetry(domainObject)){ + $scope.autoScroll = true; + } + }); } RTTelemetryTableController.prototype = Object.create(TableController.prototype); /** - Create a new telemetry subscription. + Override the subscribe function defined on the parent controller in + order to handle realtime telemetry instead of historical. */ RTTelemetryTableController.prototype.subscribe = function() { - console.trace(); var self = this; + self.$scope.rows = undefined; + (this.subscriptions || []).forEach(function(unsubscribe){ + unsubscribe(); + }); if (this.handle) { this.handle.unsubscribe(); @@ -66,11 +87,8 @@ define( self.handle.getTelemetryObjects().forEach(function(telemetryObject){ datum = self.handle.getDatum(telemetryObject); if (datum) { - if (!self.$scope.rows) { - self.$scope.rows = [self.table.getRowValues(telemetryObject, datum)]; - } else { - self.updateRows(telemetryObject, datum); - } + var rowValue = self.table.getRowValues(telemetryObject, datum); + self.$scope.$broadcast('addRow', rowValue); } }); @@ -85,16 +103,6 @@ define( this.setup(); }; - /** - * Add data to rows - * @param object The object for which data is available (table may - * be composed of multiple objects) - * @param datum The data received from the telemetry source - */ - RTTelemetryTableController.prototype.updateRows = function (object, datum) { - this.$scope.$broadcast('newRow', this.table.getRowValues(object, datum)); - }; - return RTTelemetryTableController; } ); diff --git a/platform/features/table/src/controllers/TelemetryTableController.js b/platform/features/table/src/controllers/TelemetryTableController.js index b94cf5b8a9..6833331d90 100644 --- a/platform/features/table/src/controllers/TelemetryTableController.js +++ b/platform/features/table/src/controllers/TelemetryTableController.js @@ -72,21 +72,22 @@ define( this.$scope.$on("$destroy", this.destroy.bind(this)); } + /** + * Defer registration of change listeners until domain object is + * available in order to avoid race conditions + * @private + */ TelemetryTableController.prototype.registerChangeListeners = function() { - //Defer registration of change listeners until domain object is - // available in order to avoid race conditions - this.changeListeners.forEach(function (listener) { return listener && listener(); }); this.changeListeners = []; // When composition changes, re-subscribe to the various // telemetry subscriptions - //this.changeListeners.push(this.$scope.$watchCollection('domainObject.getModel().composition', this.subscribe.bind(this))); + this.changeListeners.push(this.$scope.$watchCollection('domainObject.getModel().composition', this.subscribe.bind(this))); //Change of bounds in time conductor this.changeListeners.push(this.$scope.$on('telemetry:display:bounds', this.subscribe.bind(this))); - }; /** @@ -100,16 +101,15 @@ define( }; /** - Create a new subscription. This is called when + Create a new subscription. This can be overridden by children to + change default behaviour (which is to retrieve historical telemetry + only). */ TelemetryTableController.prototype.subscribe = function() { - console.trace(); if (this.handle) { this.handle.unsubscribe(); } - this.$scope.rows = []; - //Noop because not supporting realtime data right now function noop(){ } @@ -127,12 +127,17 @@ define( /** * Add any historical data available + * @private */ TelemetryTableController.prototype.addHistoricalData = function(domainObject, series) { - var i; + var i, + newRows = []; + for (i=0; i < series.getPointCount(); i++) { - this.updateRows(domainObject, this.handle.makeDatum(domainObject, series, i)); + newRows.push(this.table.getRowValues(domainObject, this.handle.makeDatum(domainObject, series, i))); } + + this.$scope.rows = newRows; }; /** @@ -160,7 +165,7 @@ define( }; /** - * Add data to rows + * @private * @param object The object for which data is available (table may * be composed of multiple objects) * @param datum The data received from the telemetry source @@ -172,6 +177,7 @@ define( /** * When column configuration changes, update the visible headers * accordingly. + * @private */ TelemetryTableController.prototype.filterColumns = function (columnConfig) { if (!columnConfig){ diff --git a/platform/features/table/src/directives/MCTTable.js b/platform/features/table/src/directives/MCTTable.js index 17ca12b141..575e830395 100644 --- a/platform/features/table/src/directives/MCTTable.js +++ b/platform/features/table/src/directives/MCTTable.js @@ -1,20 +1,30 @@ /*global define*/ define( - ["../controllers/MCTTableController"], - function (MCTTableController) { + [ + "../controllers/MCTTableController", + "text!../../res/templates/mct-table.html" + ], + function (MCTTableController, TableTemplate) { "use strict"; + /** + * Defines a generic 'Table' component. The table can be populated + * en-masse by setting the rows attribute, or rows can be added as + * needed via a broadcast 'addRow' event. + * @constructor + */ function MCTTable($timeout) { return { restrict: "E", - templateUrl: "platform/features/table/res/templates/mct-data-table.html", + template: TableTemplate, controller: ['$scope', '$timeout', '$element', MCTTableController], scope: { headers: "=", rows: "=", enableFilter: "=?", - enableSort: "=?" + enableSort: "=?", + autoScroll: "=?" }, }; } diff --git a/platform/features/table/test/TableConfigurationSpec.js b/platform/features/table/test/TableConfigurationSpec.js index efba3c2063..86a18aee5a 100644 --- a/platform/features/table/test/TableConfigurationSpec.js +++ b/platform/features/table/test/TableConfigurationSpec.js @@ -120,13 +120,13 @@ define( }); it("populates the columns attribute", function() { - expect(table.columns.length).toBe(4); + expect(table.columns.length).toBe(5); }); it("Build columns populates columns with domains to the left", function() { - expect(table.columns[0] instanceof DomainColumn).toBeTruthy(); expect(table.columns[1] instanceof DomainColumn).toBeTruthy(); - expect(table.columns[2] instanceof DomainColumn).toBeFalsy(); + expect(table.columns[2] instanceof DomainColumn).toBeTruthy(); + expect(table.columns[3] instanceof DomainColumn).toBeFalsy(); }); it("Produces headers for each column based on title", function() { @@ -135,7 +135,7 @@ define( spyOn(firstColumn, 'getTitle'); headers = table.getHeaders(); - expect(headers.length).toBe(4); + expect(headers.length).toBe(5); expect(firstColumn.getTitle).toHaveBeenCalled(); }); diff --git a/platform/features/table/test/controllers/MCTTableControllerSpec.js b/platform/features/table/test/controllers/MCTTableControllerSpec.js index e4f8d170d7..e7a5d1e08d 100644 --- a/platform/features/table/test/controllers/MCTTableControllerSpec.js +++ b/platform/features/table/test/controllers/MCTTableControllerSpec.js @@ -48,6 +48,8 @@ define( watches = {}; mockScope = jasmine.createSpyObj('scope', [ + '$watch', + '$on', '$watchCollection' ]); mockScope.$watchCollection.andCallFake(function(event, callback) { @@ -62,14 +64,15 @@ define( mockScope.displayHeaders = true; mockTimeout = jasmine.createSpy('$timeout'); + mockTimeout.andReturn(promise(undefined)); controller = new MCTTableController(mockScope, mockTimeout, mockElement); }); it('Reacts to changes to filters, headers, and rows', function() { expect(mockScope.$watchCollection).toHaveBeenCalledWith('filters', jasmine.any(Function)); - expect(mockScope.$watchCollection).toHaveBeenCalledWith('headers', jasmine.any(Function)); - expect(mockScope.$watchCollection).toHaveBeenCalledWith('rows', jasmine.any(Function)); + expect(mockScope.$watch).toHaveBeenCalledWith('headers', jasmine.any(Function)); + expect(mockScope.$watch).toHaveBeenCalledWith('rows', jasmine.any(Function)); }); describe('rows', function() { @@ -116,6 +119,19 @@ define( expect(mockScope.displayRows).toEqual(testRows); }); + it('Supports adding rows individually', function() { + var addRowFunc = mockScope.$on.mostRecentCall.args[1], + row4 = { + 'col1': {'text': 'row3 col1'}, + 'col2': {'text': 'ghi'}, + 'col3': {'text': 'row3 col3'} + }; + controller.updateRows(testRows); + expect(mockScope.displayRows.length).toBe(3); + addRowFunc(row4); + expect(mockScope.displayRows.length).toBe(4); + }); + describe('sorting', function() { var sortedRows; @@ -149,7 +165,87 @@ define( expect(sortedRows[1].col2.text).toEqual('def'); expect(sortedRows[2].col2.text).toEqual('abc'); }); + + describe('Adding new rows', function() { + var row4, + row5, + row6; + + beforeEach(function() { + row4 = { + 'col1': {'text': 'row5 col1'}, + 'col2': {'text': 'xyz'}, + 'col3': {'text': 'row5 col3'} + }; + row5 = { + 'col1': {'text': 'row6 col1'}, + 'col2': {'text': 'aaa'}, + 'col3': {'text': 'row6 col3'} + }; + row6 = { + 'col1': {'text': 'row6 col1'}, + 'col2': {'text': 'ggg'}, + 'col3': {'text': 'row6 col3'} + }; + }); + + it('Adds new rows at the correct sort position when' + + ' sorted ', function() { + mockScope.sortColumn = 'col2'; + mockScope.sortDirection = 'desc'; + + mockScope.displayRows = controller.sortRows(testRows); + + controller.newRow(undefined, row4); + expect(mockScope.displayRows[0].col2.text).toEqual('xyz'); + controller.newRow(undefined, row5); + expect(mockScope.displayRows[4].col2.text).toEqual('aaa'); + controller.newRow(undefined, row6); + expect(mockScope.displayRows[2].col2.text).toEqual('ggg'); + + //Add a duplicate row + controller.newRow(undefined, row6); + expect(mockScope.displayRows[2].col2.text).toEqual('ggg'); + expect(mockScope.displayRows[3].col2.text).toEqual('ggg'); + }); + + it('Adds new rows at the correct sort position when' + + ' sorted and filtered', function() { + mockScope.sortColumn = 'col2'; + mockScope.sortDirection = 'desc'; + mockScope.filters = {'col2': 'a'};//Include only + // rows with 'a' + + mockScope.displayRows = controller.sortRows(testRows); + mockScope.displayRows = controller.filterRows(testRows); + + controller.newRow(undefined, row5); + expect(mockScope.displayRows.length).toBe(2); + expect(mockScope.displayRows[1].col2.text).toEqual('aaa'); + + controller.newRow(undefined, row6); + expect(mockScope.displayRows.length).toBe(2); + //Row was not added because does not match filter + }); + + it('Adds new rows at the correct sort position when' + + ' not sorted ', function() { + mockScope.sortColumn = undefined; + mockScope.sortDirection = undefined; + mockScope.filters = {}; + + mockScope.displayRows = testRows; + + controller.newRow(undefined, row5); + expect(mockScope.displayRows[3].col2.text).toEqual('aaa'); + controller.newRow(undefined, row6); + expect(mockScope.displayRows[4].col2.text).toEqual('ggg'); + }); + + }); }); + + }); }); }); diff --git a/platform/features/table/test/controllers/RTTelemetryTableControllerSpec.js b/platform/features/table/test/controllers/RTTelemetryTableControllerSpec.js new file mode 100644 index 0000000000..a54a95f202 --- /dev/null +++ b/platform/features/table/test/controllers/RTTelemetryTableControllerSpec.js @@ -0,0 +1,146 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,xit*/ + +define( + [ + "../../src/controllers/RTTelemetryTableController" + ], + function (TableController) { + "use strict"; + + describe('The real-time table controller', function() { + var mockScope, + mockTelemetryHandler, + mockTelemetryHandle, + mockTelemetryFormatter, + mockDomainObject, + mockTable, + mockConfiguration, + watches, + mockTableRow, + controller; + + function promise(value) { + return { + then: function (callback){ + return promise(callback(value)); + } + }; + } + + beforeEach(function() { + watches = {}; + mockTableRow = {'col1': 'val1', 'col2': 'row2'}; + + mockScope = jasmine.createSpyObj('scope', [ + '$on', + '$watch', + '$watchCollection', + '$broadcast' + ]); + mockScope.$on.andCallFake(function(expression, callback){ + watches[expression] = callback; + }); + mockScope.$watch.andCallFake(function(expression, callback){ + watches[expression] = callback; + }); + mockScope.$watchCollection.andCallFake(function(expression, callback){ + watches[expression] = callback; + }); + + mockConfiguration = { + 'range1': true, + 'range2': true, + 'domain1': true + }; + + mockTable = jasmine.createSpyObj('table', + [ + 'buildColumns', + 'getColumnConfiguration', + 'getRowValues', + 'saveColumnConfiguration' + ] + ); + mockTable.columns = []; + mockTable.getColumnConfiguration.andReturn(mockConfiguration); + mockTable.getRowValues.andReturn(mockTableRow); + + mockDomainObject= jasmine.createSpyObj('domainObject', [ + 'getCapability', + 'useCapability', + 'getModel' + ]); + mockDomainObject.getModel.andReturn({}); + mockDomainObject.getCapability.andReturn( + { + getMetadata: function(){ + return {ranges: [{format: 'string'}]}; + } + }); + + mockScope.domainObject = mockDomainObject; + + mockTelemetryHandle = jasmine.createSpyObj('telemetryHandle', [ + 'getMetadata', + 'unsubscribe', + 'getDatum', + 'promiseTelemetryObjects', + 'getTelemetryObjects' + ]); + // Arbitrary array with non-zero length, contents are not + // used by mocks + mockTelemetryHandle.getTelemetryObjects.andReturn([{}]); + mockTelemetryHandle.promiseTelemetryObjects.andReturn(promise(undefined)); + mockTelemetryHandle.getDatum.andReturn({}); + + mockTelemetryHandler = jasmine.createSpyObj('telemetryHandler', [ + 'handle' + ]); + mockTelemetryHandler.handle.andReturn(mockTelemetryHandle); + + controller = new TableController(mockScope, mockTelemetryHandler, mockTelemetryFormatter); + controller.table = mockTable; + controller.handle = mockTelemetryHandle; + }); + + it('registers for streaming telemetry', function() { + controller.subscribe(); + expect(mockTelemetryHandler.handle).toHaveBeenCalledWith(jasmine.any(Object), jasmine.any(Function), true); + }); + + it('updates table with new streaming telemetry', function() { + controller.subscribe(); + mockTelemetryHandler.handle.mostRecentCall.args[1](); + expect(mockScope.$broadcast).toHaveBeenCalledWith('addRow', mockTableRow); + }); + + it('enables autoscroll for event telemetry', function() { + controller.subscribe(); + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + expect(mockScope.autoScroll).toBe(true); + }); + + }); + } +);