diff --git a/example/generator/bundle.js b/example/generator/bundle.js index c2edb09f23..8ea0205f31 100644 --- a/example/generator/bundle.js +++ b/example/generator/bundle.js @@ -75,8 +75,7 @@ define([ }, { "key": "delta", - "name": "Delta", - "format": "example.delta" + "name": "Delta" } ], "priority": -1 @@ -103,11 +102,13 @@ define([ "domains": [ { "key": "utc", - "name": "Time" + "name": "Time", + "format": "utc" }, { "key": "yesterday", - "name": "Yesterday" + "name": "Yesterday", + "format": "utc" }, { "key": "delta", diff --git a/example/generator/src/generatorWorker.js b/example/generator/src/generatorWorker.js index bb4e55ca4b..bbf1851346 100644 --- a/example/generator/src/generatorWorker.js +++ b/example/generator/src/generatorWorker.js @@ -24,6 +24,7 @@ (function () { + var FIFTEEN_MINUTES = 15 * 60 * 1000; var handlers = { subscribe: onSubscribe, @@ -51,6 +52,7 @@ function onSubscribe(message) { var data = message.data; + // Keep var start = Date.now(); var step = 1000 / data.dataRateInHz; var nextStep = start - (start % step) + step; @@ -82,8 +84,11 @@ function onRequest(message) { var data = message.data; - if (!data.start || !data.end) { - throw new Error('missing start and end!'); + if (data.end == undefined) { + data.end = Date.now(); + } + if (data.start == undefined){ + data.start = data.end - FIFTEEN_MINUTES; } var now = Date.now(); diff --git a/platform/features/conductor/utcTimeSystem/bundle.js b/platform/features/conductor/utcTimeSystem/bundle.js index df9a6c0d38..5db4bd968f 100644 --- a/platform/features/conductor/utcTimeSystem/bundle.js +++ b/platform/features/conductor/utcTimeSystem/bundle.js @@ -22,7 +22,7 @@ define([ "./src/UTCTimeSystem", - 'legacyRegistry' + "legacyRegistry" ], function ( UTCTimeSystem, legacyRegistry @@ -34,7 +34,23 @@ define([ "implementation": UTCTimeSystem, "depends": ["$timeout"] } + ], + "runs": [ + { + "implementation": function (openmct, $timeout) { + // Temporary shim to initialize the time conductor to + // something + if (!openmct.conductor.timeSystem()) { + var utcTimeSystem = new UTCTimeSystem($timeout); + + openmct.conductor.timeSystem(utcTimeSystem, utcTimeSystem.defaults().bounds); + } + }, + "depends": ["openmct", "$timeout"], + "priority": "fallback" + } ] } }); + }); diff --git a/platform/features/conductor/utcTimeSystem/src/UTCTimeSystem.js b/platform/features/conductor/utcTimeSystem/src/UTCTimeSystem.js index 920db90332..a933761eac 100644 --- a/platform/features/conductor/utcTimeSystem/src/UTCTimeSystem.js +++ b/platform/features/conductor/utcTimeSystem/src/UTCTimeSystem.js @@ -25,7 +25,7 @@ define([ '../../core/src/timeSystems/LocalClock' ], function (TimeSystem, LocalClock) { var FIFTEEN_MINUTES = 15 * 60 * 1000, - DEFAULT_PERIOD = 1000; + DEFAULT_PERIOD = 100; /** * This time system supports UTC dates and provides a ticking clock source. diff --git a/platform/features/plot/src/PlotController.js b/platform/features/plot/src/PlotController.js index 0cd9287498..458ef0410d 100644 --- a/platform/features/plot/src/PlotController.js +++ b/platform/features/plot/src/PlotController.js @@ -217,8 +217,8 @@ define( if (handle) { handle.unsubscribe(); handle = undefined; - conductor.off("timeOfInterest", changeTimeOfInterest); } + conductor.off("timeOfInterest", changeTimeOfInterest); } function requery() { diff --git a/platform/features/table/bundle.js b/platform/features/table/bundle.js index 29e2642a6b..904f99e391 100644 --- a/platform/features/table/bundle.js +++ b/platform/features/table/bundle.js @@ -22,25 +22,21 @@ define([ "./src/directives/MCTTable", - "./src/controllers/RealtimeTableController", - "./src/controllers/HistoricalTableController", + "./src/controllers/TelemetryTableController", "./src/controllers/TableOptionsController", '../../commonUI/regions/src/Region', '../../commonUI/browse/src/InspectorRegion', "text!./res/templates/table-options-edit.html", - "text!./res/templates/rt-table.html", - "text!./res/templates/historical-table.html", + "text!./res/templates/telemetry-table.html", "legacyRegistry" ], function ( MCTTable, - RealtimeTableController, - HistoricalTableController, + TelemetryTableController, TableOptionsController, Region, InspectorRegion, tableOptionsEditTemplate, - rtTableTemplate, - historicalTableTemplate, + telemetryTableTemplate, legacyRegistry ) { /** @@ -65,9 +61,9 @@ define([ "types": [ { "key": "table", - "name": "Historical Telemetry Table", - "cssClass": "icon-tabular", - "description": "A static table of all values over time for all included telemetry elements. Rows are timestamped data values for each telemetry element; columns are data fields. The number of rows is based on the range of your query. New incoming data must be manually re-queried for.", + "name": "Telemetry Table", + "cssClass": "icon-tabular-realtime", + "description": "A table of values over a given time period. The table will be automatically updated with new values as they become available", "priority": 861, "features": "creation", "delegates": [ @@ -85,42 +81,13 @@ define([ "views": [ "table" ] - }, - { - "key": "rttable", - "name": "Real-time Telemetry Table", - "cssClass": "icon-tabular-realtime", - "description": "A scrolling table of latest values for all included telemetry elements. Rows are timestamped data values for each telemetry element; columns are data fields. New incoming data is automatically added to the view.", - "priority": 860, - "features": "creation", - "delegates": [ - "telemetry" - ], - "inspector": tableInspector, - "contains": [ - { - "has": "telemetry" - } - ], - "model": { - "composition": [] - }, - "views": [ - "rt-table", - "scrolling-table" - ] } ], "controllers": [ { - "key": "HistoricalTableController", - "implementation": HistoricalTableController, - "depends": ["$scope", "telemetryHandler", "telemetryFormatter", "$timeout", "openmct"] - }, - { - "key": "RealtimeTableController", - "implementation": RealtimeTableController, - "depends": ["$scope", "telemetryHandler", "telemetryFormatter", "openmct"] + "key": "TelemetryTableController", + "implementation": TelemetryTableController, + "depends": ["$scope", "$timeout", "openmct"] }, { "key": "TableOptionsController", @@ -131,21 +98,10 @@ define([ ], "views": [ { - "name": "Historical Table", + "name": "Telemetry Table", "key": "table", - "template": historicalTableTemplate, - "cssClass": "icon-tabular", - "needs": [ - "telemetry" - ], - "delegation": true, - "editable": false - }, - { - "name": "Real-time Table", - "key": "rt-table", "cssClass": "icon-tabular-realtime", - "template": rtTableTemplate, + "template": telemetryTableTemplate, "needs": [ "telemetry" ], diff --git a/platform/features/table/res/templates/mct-table.html b/platform/features/table/res/templates/mct-table.html index 7e24be2c43..056e8aac33 100644 --- a/platform/features/table/res/templates/mct-table.html +++ b/platform/features/table/res/templates/mct-table.html @@ -49,7 +49,7 @@ - @@ -60,9 +60,9 @@ + ng-click="table.onRowClick($event, visibleRow.rowIndex)"> - - - \ No newline at end of file diff --git a/platform/features/table/res/templates/historical-table.html b/platform/features/table/res/templates/telemetry-table.html similarity index 62% rename from platform/features/table/res/templates/historical-table.html rename to platform/features/table/res/templates/telemetry-table.html index c2abbf5708..5bda288f1c 100644 --- a/platform/features/table/res/templates/historical-table.html +++ b/platform/features/table/res/templates/telemetry-table.html @@ -1,12 +1,14 @@ -
\ No newline at end of file diff --git a/platform/features/table/src/DomainColumn.js b/platform/features/table/src/DomainColumn.js deleted file mode 100644 index 9e5c4d7813..0000000000 --- a/platform/features/table/src/DomainColumn.js +++ /dev/null @@ -1,62 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -/** - * Module defining DomainColumn. - */ -define( - [], - function () { - - /** - * A column which will report telemetry domain values - * (typically, timestamps.) Used by the ScrollingListController. - * - * @memberof platform/features/table - * @constructor - * @param domainMetadata an object with the machine- and human- - * readable names for this domain (in `key` and `name` - * fields, respectively.) - * @param {TelemetryFormatter} telemetryFormatter the telemetry - * formatting service, for making values human-readable. - */ - function DomainColumn(domainMetadata, telemetryFormatter) { - this.domainMetadata = domainMetadata; - this.telemetryFormatter = telemetryFormatter; - } - - DomainColumn.prototype.getTitle = function () { - return this.domainMetadata.name; - }; - - DomainColumn.prototype.getValue = function (domainObject, datum) { - return { - text: this.telemetryFormatter.formatDomainValue( - datum[this.domainMetadata.key], - this.domainMetadata.format - ) - }; - }; - - return DomainColumn; - } -); diff --git a/platform/features/table/src/NameColumn.js b/platform/features/table/src/NameColumn.js deleted file mode 100644 index 2499ed505b..0000000000 --- a/platform/features/table/src/NameColumn.js +++ /dev/null @@ -1,52 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -/** - * Module defining NameColumn. Created by vwoeltje on 11/18/14. - */ -define( - [], - function () { - - /** - * A column which will report the name of the domain object - * which exposed specific telemetry values. - * - * @memberof platform/features/table - * @constructor - */ - function NameColumn() { - } - - NameColumn.prototype.getTitle = function () { - return "Name"; - }; - - NameColumn.prototype.getValue = function (domainObject) { - return { - text: domainObject.getModel().name - }; - }; - - return NameColumn; - } -); diff --git a/platform/features/table/src/RangeColumn.js b/platform/features/table/src/RangeColumn.js deleted file mode 100644 index 1b4c64b8a9..0000000000 --- a/platform/features/table/src/RangeColumn.js +++ /dev/null @@ -1,65 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -/** - * Module defining DomainColumn. Created by vwoeltje on 11/18/14. - */ -define( - [], - function () { - - /** - * A column which will report telemetry range values - * (typically, measurements.) Used by the ScrollingListController. - * - * @memberof platform/features/table - * @constructor - * @param rangeMetadata an object with the machine- and human- - * readable names for this range (in `key` and `name` - * fields, respectively.) - * @param {TelemetryFormatter} telemetryFormatter the telemetry - * formatting service, for making values human-readable. - */ - function RangeColumn(rangeMetadata, telemetryFormatter) { - this.rangeMetadata = rangeMetadata; - this.telemetryFormatter = telemetryFormatter; - } - - RangeColumn.prototype.getTitle = function () { - return this.rangeMetadata.name; - }; - - RangeColumn.prototype.getValue = function (domainObject, datum) { - var range = this.rangeMetadata.key, - limit = domainObject.getCapability('limit'), - value = isNaN(datum[range]) ? datum[range] : parseFloat(datum[range]), - alarm = limit && limit.evaluate(datum, range); - - return { - cssClass: alarm && alarm.cssClass, - text: typeof (value) === 'undefined' ? undefined : this.telemetryFormatter.formatRangeValue(value) - }; - }; - - return RangeColumn; - } -); diff --git a/platform/features/table/src/TableConfiguration.js b/platform/features/table/src/TableConfiguration.js index fee22d47bc..a59ffa457f 100644 --- a/platform/features/table/src/TableConfiguration.js +++ b/platform/features/table/src/TableConfiguration.js @@ -21,12 +21,8 @@ *****************************************************************************/ define( - [ - './DomainColumn', - './RangeColumn', - './NameColumn' - ], - function (DomainColumn, RangeColumn, NameColumn) { + [], + function () { /** * Class that manages table metadata, state, and contents. @@ -34,10 +30,10 @@ define( * @param domainObject * @constructor */ - function TableConfiguration(domainObject, telemetryFormatter) { + function TableConfiguration(domainObject, openmct) { this.domainObject = domainObject; this.columns = []; - this.telemetryFormatter = telemetryFormatter; + this.openmct = openmct; } /** @@ -47,61 +43,51 @@ define( */ TableConfiguration.prototype.populateColumns = function (metadata) { var self = this; + var telemetryApi = this.openmct.telemetry; this.columns = []; if (metadata) { metadata.forEach(function (metadatum) { - //Push domains first - (metadatum.domains || []).forEach(function (domainMetadata) { - self.addColumn(new DomainColumn(domainMetadata, - self.telemetryFormatter)); - }); - (metadatum.ranges || []).forEach(function (rangeMetadata) { - self.addColumn(new RangeColumn(rangeMetadata, - self.telemetryFormatter)); + var formatter = telemetryApi.getValueFormatter(metadatum); + + self.columns.push({ + getKey: function () { + return metadatum.key; + }, + getTitle: function () { + return metadatum.name; + }, + getValue: function (telemetryDatum, limitEvaluator) { + var isValueColumn = !!(metadatum.hints.y || metadatum.hints.range); + var alarm = isValueColumn && + limitEvaluator && + limitEvaluator.evaluate(telemetryDatum, metadatum); + var value = { + text: formatter ? formatter.format(telemetryDatum[metadatum.key]) + : telemetryDatum[metadatum.key], + value: telemetryDatum[metadatum.key] + }; + + if (alarm) { + value.cssClass = alarm.cssClass; + } + return value; + } }); }); - - if (this.columns.length > 0) { - self.addColumn(new NameColumn(), 0); - } } return this; }; - /** - * Add a column definition to this Table - * @param {RangeColumn | DomainColumn | NameColumn} column - * @param {Number} [index] Where the column should appear (will be - * affected by column filtering) - */ - TableConfiguration.prototype.addColumn = function (column, index) { - if (typeof index === 'undefined') { - this.columns.push(column); - } else { - this.columns.splice(index, 0, column); - } - }; - - /** - * @private - * @param column - * @returns {*|string} - */ - TableConfiguration.prototype.getColumnTitle = function (column) { - return column.getTitle(); - }; - /** * Get a simple list of column titles * @returns {Array} The titles of the columns */ TableConfiguration.prototype.getHeaders = function () { - var self = this; return this.columns.map(function (column, i) { - return self.getColumnTitle(column) || 'Column ' + (i + 1); + return column.getTitle() || 'Column ' + (i + 1); }); }; @@ -113,17 +99,16 @@ define( * @returns {Object} Key value pairs where the key is the column * title, and the value is the formatted value from the provided datum. */ - TableConfiguration.prototype.getRowValues = function (telemetryObject, datum) { - var self = this; + TableConfiguration.prototype.getRowValues = function (limitEvaluator, datum) { return this.columns.reduce(function (rowObject, column, i) { - var columnTitle = self.getColumnTitle(column) || 'Column ' + (i + 1), - columnValue = column.getValue(telemetryObject, datum); + var columnTitle = column.getTitle() || 'Column ' + (i + 1), + columnValue = column.getValue(datum, limitEvaluator); if (columnValue !== undefined && columnValue.text === undefined) { columnValue.text = ''; } // Don't replace something with nothing. - // This occurs when there are multiple columns with the + // This occurs when there are multiple columns with the same // column title if (rowObject[columnTitle] === undefined || rowObject[columnTitle].text === undefined || diff --git a/platform/features/table/src/TelemetryCollection.js b/platform/features/table/src/TelemetryCollection.js new file mode 100644 index 0000000000..ddd91377d3 --- /dev/null +++ b/platform/features/table/src/TelemetryCollection.js @@ -0,0 +1,255 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2016, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT 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 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. + *****************************************************************************/ + +define( + [ + 'lodash', + 'EventEmitter' + ], + function (_, EventEmitter) { + + /** + * @constructor + */ + function TelemetryCollection() { + EventEmitter.call(this, arguments); + this.telemetry = []; + this.highBuffer = []; + this.sortField = undefined; + this.lastBounds = {}; + + _.bindAll(this, [ + 'addOne', + 'iteratee' + ]); + } + + TelemetryCollection.prototype = Object.create(EventEmitter.prototype); + + TelemetryCollection.prototype.iteratee = function (item) { + return _.get(item, this.sortField); + }; + + /** + * This function is optimized for ticking - it assumes that start and end + * bounds will only increase and as such this cannot be used for decreasing + * bounds changes. + * + * An implication of this is that data will not be discarded that exceeds + * the given end bounds. For arbitrary bounds changes, it's assumed that + * a telemetry requery is performed anyway, and the collection is cleared + * and repopulated. + * + * @fires TelemetryCollection#added + * @fires TelemetryCollection#discarded + * @param bounds + */ + TelemetryCollection.prototype.bounds = function (bounds) { + var startChanged = this.lastBounds.start !== bounds.start; + var endChanged = this.lastBounds.end !== bounds.end; + var startIndex = 0; + var endIndex = 0; + var discarded; + var added; + var testValue; + + // If collection is not sorted by a time field, we cannot respond to + // bounds events + if (this.sortField === undefined) { + return; + } + + if (startChanged) { + testValue = _.set({}, this.sortField, bounds.start); + // Calculate the new index of the first item within the bounds + startIndex = _.sortedIndex(this.telemetry, testValue, this.sortField); + discarded = this.telemetry.splice(0, startIndex); + } + if (endChanged) { + testValue = _.set({}, this.sortField, bounds.end); + // Calculate the new index of the last item in bounds + endIndex = _.sortedLastIndex(this.highBuffer, testValue, this.sortField); + added = this.highBuffer.splice(0, endIndex); + this.telemetry = this.telemetry.concat(added); + } + + if (discarded && discarded.length > 0) { + /** + * A `discarded` event is thrown when telemetry data fall out of + * bounds due to a bounds change event + * @type {object[]} discarded the telemetry data + * discarded as a result of the bounds change + */ + this.emit('discarded', discarded); + } + if (added && added.length > 0) { + /** + * An `added` event is thrown when a bounds change results in + * received telemetry falling within the new bounds. + * @type {object[]} added the telemetry data that is now within bounds + */ + this.emit('added', added); + } + this.lastBounds = bounds; + }; + + /** + * Determines is a given telemetry datum is within the bounds currently + * defined for this telemetry collection. + * @private + * @param datum + * @returns {boolean} + */ + TelemetryCollection.prototype.inBounds = function (datum) { + var noBoundsDefined = !this.lastBounds || (this.lastBounds.start === undefined && this.lastBounds.end === undefined); + var withinBounds = + _.get(datum, this.sortField) >= this.lastBounds.start && + _.get(datum, this.sortField) <= this.lastBounds.end; + + return noBoundsDefined || withinBounds; + }; + + /** + * Adds an individual item to the collection. Used internally only + * @private + * @param item + */ + TelemetryCollection.prototype.addOne = function (item) { + var isDuplicate = false; + var boundsDefined = this.lastBounds && + (this.lastBounds.start !== undefined && this.lastBounds.end !== undefined); + var array; + var boundsLow; + var boundsHigh; + + // If collection is not sorted by a time field, we cannot respond to + // bounds events, so no bounds checking necessary + if (this.sortField === undefined) { + this.telemetry.push(item); + return true; + } + + // Insert into either in-bounds array, or the out of bounds high buffer. + // Data in the high buffer will be re-evaluated for possible insertion on next tick + + if (boundsDefined) { + boundsHigh = _.get(item, this.sortField) > this.lastBounds.end; + boundsLow = _.get(item, this.sortField) < this.lastBounds.start; + + if (!boundsHigh && !boundsLow) { + array = this.telemetry; + } else if (boundsHigh) { + array = this.highBuffer; + } + } else { + array = this.telemetry; + } + + // If out of bounds low, disregard data + if (!boundsLow) { + + // Going to check for duplicates. Bound the search problem to + // items around the given time. Use sortedIndex because it + // employs a binary search which is O(log n). Can use binary search + // based on time stamp because the array is guaranteed ordered due + // to sorted insertion. + var startIx = _.sortedIndex(array, item, this.sortField); + + if (startIx !== array.length) { + var endIx = _.sortedLastIndex(array, item, this.sortField); + + // Create an array of potential dupes, based on having the + // same time stamp + var potentialDupes = array.slice(startIx, endIx + 1); + // Search potential dupes for exact dupe + isDuplicate = _.findIndex(potentialDupes, _.isEqual.bind(undefined, item)) > -1; + } + + if (!isDuplicate) { + array.splice(startIx, 0, item); + + //Return true if it was added and in bounds + return array === this.telemetry; + } + } + return false; + }; + + /** + * Add an array of objects to this telemetry collection + * @fires TelemetryCollection#added + * @param {object[]} items + */ + TelemetryCollection.prototype.add = function (items) { + var added = items.filter(this.addOne); + this.emit('added', added); + }; + + /** + * Clears the contents of the telemetry collection + */ + TelemetryCollection.prototype.clear = function () { + this.telemetry = []; + }; + + /** + * Sorts the telemetry collection based on the provided sort field + * specifier. + * @example + * // First build some mock telemetry for the purpose of an example + * let now = Date.now(); + * let telemetry = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(function (value) { + * return { + * // define an object property to demonstrate nested paths + * timestamp: { + * ms: now - value * 1000, + * text: + * }, + * value: value + * } + * }); + * let collection = new TelemetryCollection(); + * + * collection.add(telemetry); + * + * // Sort by telemetry value + * collection.sort("value"); + * + * // Sort by ms since epoch + * collection.sort("timestamp.ms"); + * + * // Sort by formatted date text + * collection.sort("timestamp.text"); + * + * + * @param {string} sortField An object property path. + */ + TelemetryCollection.prototype.sort = function (sortField) { + this.sortField = sortField; + if (sortField !== undefined) { + this.telemetry = _.sortBy(this.telemetry, this.iteratee); + } + }; + + return TelemetryCollection; + } +); diff --git a/platform/features/table/src/controllers/HistoricalTableController.js b/platform/features/table/src/controllers/HistoricalTableController.js deleted file mode 100644 index 0f56f6b4ee..0000000000 --- a/platform/features/table/src/controllers/HistoricalTableController.js +++ /dev/null @@ -1,141 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -define( - [ - './TelemetryTableController' - ], - function (TableController) { - var BATCH_SIZE = 1000; - - /** - * Extends TelemetryTableController and adds real-time streaming - * support. - * @memberof platform/features/table - * @param $scope - * @param telemetryHandler - * @param telemetryFormatter - * @constructor - */ - function HistoricalTableController($scope, telemetryHandler, telemetryFormatter, $timeout, openmct) { - var self = this; - - this.$timeout = $timeout; - this.timeoutHandle = undefined; - this.batchSize = BATCH_SIZE; - - $scope.$on("$destroy", function () { - if (self.timeoutHandle) { - self.$timeout.cancel(self.timeoutHandle); - } - }); - - TableController.call(this, $scope, telemetryHandler, telemetryFormatter, openmct); - } - - HistoricalTableController.prototype = Object.create(TableController.prototype); - - /** - * Set provided row data on scope, and cancel loading spinner - * @private - */ - HistoricalTableController.prototype.doneProcessing = function (rowData) { - this.$scope.rows = rowData; - this.$scope.loading = false; - }; - - /** - * @private - */ - HistoricalTableController.prototype.registerChangeListeners = function () { - TableController.prototype.registerChangeListeners.call(this); - //Change of bounds in time conductor - this.changeListeners.push(this.$scope.$on('telemetry:display:bounds', - this.boundsChange.bind(this)) - ); - }; - - /** - * @private - */ - HistoricalTableController.prototype.boundsChange = function (event, bounds, follow) { - // If in follow mode, don't bother re-subscribing, data will be - // received from existing subscription. - if (follow !== true) { - this.subscribe(); - } - }; - - /** - * Processes an array of objects, formatting the telemetry available - * for them and setting it on scope when done - * @private - */ - HistoricalTableController.prototype.processTelemetryObjects = function (objects, offset, start, rowData) { - var telemetryObject = objects[offset], - series, - i = start, - pointCount, - end; - - //No more objects to process - if (!telemetryObject) { - return this.doneProcessing(rowData); - } - - series = this.handle.getSeries(telemetryObject); - - pointCount = series.getPointCount(); - end = Math.min(start + this.batchSize, pointCount); - - //Process rows in a batch with size not exceeding a maximum length - for (; i < end; i++) { - rowData.push(this.table.getRowValues(telemetryObject, - this.handle.makeDatum(telemetryObject, series, i))); - } - - //Done processing all rows for this object. - if (end >= pointCount) { - offset++; - end = 0; - } - - // Done processing either a batch or an object, yield process - // before continuing processing - this.timeoutHandle = this.$timeout(this.processTelemetryObjects.bind(this, objects, offset, end, rowData)); - }; - - /** - * Populates historical data on scope when it becomes available from - * the telemetry API - */ - HistoricalTableController.prototype.addHistoricalData = function () { - if (this.timeoutHandle) { - this.$timeout.cancel(this.timeoutHandle); - } - - this.timeoutHandle = this.$timeout(this.processTelemetryObjects.bind(this, this.handle.getTelemetryObjects(), 0, 0, [])); - }; - - return HistoricalTableController; - } -); diff --git a/platform/features/table/src/controllers/MCTTableController.js b/platform/features/table/src/controllers/MCTTableController.js index 3ab1887c29..d9f3a9f680 100644 --- a/platform/features/table/src/controllers/MCTTableController.js +++ b/platform/features/table/src/controllers/MCTTableController.js @@ -1,7 +1,10 @@ define( - ['zepto'], - function ($) { + [ + 'zepto', + 'lodash' + ], + function ($, _) { /** * A controller for the MCTTable directive. Populates scope with @@ -12,13 +15,13 @@ define( * @param element * @constructor */ - function MCTTableController($scope, $timeout, element, exportService, formatService, openmct) { + function MCTTableController($scope, $window, element, exportService, formatService, openmct) { var self = this; this.$scope = $scope; this.element = $(element[0]); - this.$timeout = $timeout; - this.maxDisplayRows = 50; + this.$window = $window; + this.maxDisplayRows = 100; this.scrollable = this.element.find('.l-view-section.scrolling').first(); this.resultsHeader = this.element.find('.mct-table>thead').first(); @@ -27,15 +30,39 @@ define( this.conductor = openmct.conductor; this.toiFormatter = undefined; this.formatService = formatService; + this.callbacks = {}; //Bind all class functions to 'this' - Object.keys(MCTTableController.prototype).filter(function (key) { - return typeof MCTTableController.prototype[key] === 'function'; - }).forEach(function (key) { - this[key] = MCTTableController.prototype[key].bind(this); - }.bind(this)); + _.bindAll(this, [ + 'addRows', + 'binarySearch', + 'buildLargestRow', + 'changeBounds', + 'changeTimeOfInterest', + 'changeTimeSystem', + 'destroyConductorListeners', + 'digest', + 'filterAndSort', + 'filterRows', + 'firstVisible', + 'insertSorted', + 'lastVisible', + 'onRowClick', + 'onScroll', + 'removeRows', + 'resize', + 'scrollToBottom', + 'scrollToRow', + 'setElementSizes', + 'setHeaders', + 'setRows', + 'setTimeOfInterestRow', + 'setVisibleRows', + 'sortComparator', + 'sortRows' + ]); - this.scrollable.on('scroll', this.onScroll.bind(this)); + this.scrollable.on('scroll', this.onScroll); $scope.visibleRows = []; @@ -86,7 +113,7 @@ define( $scope.sortDirection = 'asc'; } self.setRows($scope.rows); - self.setTimeOfInterest(self.conductor.timeOfInterest()); + self.setTimeOfInterestRow(self.conductor.timeOfInterest()); }; /* @@ -95,20 +122,28 @@ define( $scope.$watchCollection('filters', function () { self.setRows($scope.rows); }); - $scope.$watch('headers', this.setHeaders); + $scope.$watch('headers', function (newHeaders, oldHeaders) { + if (newHeaders !== oldHeaders) { + this.setHeaders(newHeaders); + } + }.bind(this)); $scope.$watch('rows', this.setRows); /* * Listen for rows added individually (eg. for real-time tables) */ - $scope.$on('add:row', this.addRow); - $scope.$on('remove:row', this.removeRow); + $scope.$on('add:rows', this.addRows); + $scope.$on('remove:rows', this.removeRows); /** * Populated from the default-sort attribute on MctTable * directive tag. */ - $scope.$watch('sortColumn', $scope.toggleSort); + $scope.$watch('defaultSort', function (newColumn, oldColumn) { + if (newColumn !== oldColumn) { + $scope.toggleSort(newColumn); + } + }); /* * Listen for resize events to trigger recalculation of table width @@ -125,7 +160,7 @@ define( this.destroyConductorListeners(); this.conductor.on('timeSystem', this.changeTimeSystem); - this.conductor.on('timeOfInterest', this.setTimeOfInterest); + this.conductor.on('timeOfInterest', this.changeTimeOfInterest); this.conductor.on('bounds', this.changeBounds); // If time system defined, set initially @@ -135,12 +170,20 @@ define( } }.bind(this)); - $scope.$on('$destroy', this.destroyConductorListeners); + $scope.$on('$destroy', function () { + this.scrollable.off('scroll', this.onScroll); + this.destroyConductorListeners(); + + // In case for some reason this controller instance lingers around, + // destroy scope as it can be extremely large for large tables. + delete this.$scope; + + }.bind(this)); } MCTTableController.prototype.destroyConductorListeners = function () { this.conductor.off('timeSystem', this.changeTimeSystem); - this.conductor.off('timeOfInterest', this.setTimeOfInterest); + this.conductor.off('timeOfInterest', this.changeTimeOfInterest); this.conductor.off('bounds', this.changeBounds); }; @@ -155,15 +198,7 @@ define( * @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; - }); - } + this.scrollable[0].scrollTop = this.scrollable[0].scrollHeight; }; /** @@ -171,18 +206,24 @@ define( * `add:row` broadcast event. * @private */ - MCTTableController.prototype.addRow = function (event, rowIndex) { - var row = this.$scope.rows[rowIndex]; - + MCTTableController.prototype.addRows = function (event, rows) { //Does the row pass the current filter? - if (this.filterRows([row]).length === 1) { - //Insert the row into the correct position in the array - this.insertSorted(this.$scope.displayRows, row); + if (this.filterRows(rows).length > 0) { + rows.forEach(this.insertSorted.bind(this, this.$scope.displayRows)); //Resize the columns , then update the rows visible in the table - this.resize([this.$scope.sizingRow, row]) - .then(this.setVisibleRows.bind(this)) - .then(this.scrollToBottom.bind(this)); + this.resize([this.$scope.sizingRow].concat(rows)) + .then(this.setVisibleRows) + .then(function () { + if (this.$scope.autoScroll) { + this.scrollToBottom(); + } + }.bind(this)); + + var toi = this.conductor.timeOfInterest(); + if (toi !== -1) { + this.setTimeOfInterestRow(toi); + } } }; @@ -191,31 +232,47 @@ define( * `remove:row` broadcast event. * @private */ - MCTTableController.prototype.removeRow = function (event, rowIndex) { - var row = this.$scope.rows[rowIndex], + MCTTableController.prototype.removeRows = function (event, rows) { + var indexInDisplayRows; + rows.forEach(function (row) { // Do a sequential search here. Only way of finding row is by // object equality, so array is in effect unsorted. indexInDisplayRows = this.$scope.displayRows.indexOf(row); - if (indexInDisplayRows !== -1) { - this.$scope.displayRows.splice(indexInDisplayRows, 1); - this.setVisibleRows(); - } + if (indexInDisplayRows !== -1) { + this.$scope.displayRows.splice(indexInDisplayRows, 1); + } + }, this); + + this.$scope.sizingRow = this.buildLargestRow([this.$scope.sizingRow].concat(rows)); + + this.setElementSizes(); + this.setVisibleRows() + .then(function () { + if (this.$scope.autoScroll) { + 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(); + this.$window.requestAnimationFrame(function () { + this.setVisibleRows(); + this.digest(); + + // 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) - 20) { + this.$scope.autoScroll = false; + } else { + this.$scope.autoScroll = true; + } + this.scrolling = false; + }.bind(this)); }; /** @@ -293,8 +350,7 @@ define( this.$scope.visibleRows[0].rowIndex === start && this.$scope.visibleRows[this.$scope.visibleRows.length - 1] .rowIndex === end) { - - return; // don't update if no changes are required. + return this.digest(); } } //Set visible rows from display rows, based on calculated offset. @@ -307,6 +363,7 @@ define( contents: row }; }); + return this.digest(); }; /** @@ -522,6 +579,28 @@ define( return largestRow; }; + // Will effectively cap digests at 60Hz + // Also turns digest into a promise allowing code to force digest, then + // schedule something to happen afterwards + MCTTableController.prototype.digest = function () { + var scope = this.$scope; + var self = this; + var raf = this.$window.requestAnimationFrame; + var promise = this.digestPromise; + + if (!promise) { + self.digestPromise = promise = new Promise(function (resolve) { + raf(function () { + scope.$digest(); + self.digestPromise = undefined; + resolve(); + }); + }); + } + + return promise; + }; + /** * Calculates the widest row in the table, and if necessary, resizes * the table accordingly @@ -533,7 +612,7 @@ define( */ MCTTableController.prototype.resize = function (rows) { this.$scope.sizingRow = this.buildLargestRow(rows); - return this.$timeout(this.setElementSizes.bind(this)); + return this.digest().then(this.setElementSizes); }; /** @@ -562,19 +641,20 @@ define( } this.$scope.displayRows = this.filterAndSort(newRows || []); - this.resize(newRows) - .then(this.setVisibleRows) + return this.resize(newRows) + .then(function (rows) { + return this.setVisibleRows(rows); + }.bind(this)) //Timeout following setVisibleRows to allow digest to // perform DOM changes, otherwise scrollTo won't work. - .then(this.$timeout) .then(function () { //If TOI specified, scroll to it var timeOfInterest = this.conductor.timeOfInterest(); if (timeOfInterest) { - this.setTimeOfInterest(timeOfInterest); + this.setTimeOfInterestRow(timeOfInterest); + this.scrollToRow(this.$scope.toiRowIndex); } }.bind(this)); - }; /** @@ -615,6 +695,7 @@ define( }; /** + * Scroll the view to a given row index * @param displayRowIndex {number} The index in the displayed rows * to scroll to. */ @@ -635,7 +716,7 @@ define( * Update rows with new data. If filtering is enabled, rows * will be sorted before display. */ - MCTTableController.prototype.setTimeOfInterest = function (newTOI) { + MCTTableController.prototype.setTimeOfInterestRow = function (newTOI) { var isSortedByTime = this.$scope.timeColumns && this.$scope.timeColumns.indexOf(this.$scope.sortColumn) !== -1; @@ -652,17 +733,24 @@ define( if (rowIndex > 0 && rowIndex < this.$scope.displayRows.length) { this.$scope.toiRowIndex = rowIndex; - this.scrollToRow(this.$scope.toiRowIndex); } } }; + MCTTableController.prototype.changeTimeOfInterest = function (newTOI) { + this.setTimeOfInterestRow(newTOI); + this.scrollToRow(this.$scope.toiRowIndex); + }; + /** * On zoom, pan, etc. reset TOI * @param bounds */ MCTTableController.prototype.changeBounds = function (bounds) { - this.setTimeOfInterest(this.conductor.timeOfInterest()); + this.setTimeOfInterestRow(this.conductor.timeOfInterest()); + if (this.$scope.toiRowIndex !== -1) { + this.scrollToRow(this.$scope.toiRowIndex); + } }; /** diff --git a/platform/features/table/src/controllers/RealtimeTableController.js b/platform/features/table/src/controllers/RealtimeTableController.js deleted file mode 100644 index c6ff7b8aee..0000000000 --- a/platform/features/table/src/controllers/RealtimeTableController.js +++ /dev/null @@ -1,76 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -define( - [ - './TelemetryTableController' - ], - function (TableController) { - - /** - * Extends TelemetryTableController and adds real-time streaming - * support. - * @memberof platform/features/table - * @param $scope - * @param telemetryHandler - * @param telemetryFormatter - * @constructor - */ - function RealtimeTableController($scope, telemetryHandler, telemetryFormatter, openmct) { - TableController.call(this, $scope, telemetryHandler, telemetryFormatter, openmct); - - this.maxRows = 100000; - } - - RealtimeTableController.prototype = Object.create(TableController.prototype); - - /** - * Overrides method on TelemetryTableController providing handling - * for realtime data. - */ - RealtimeTableController.prototype.addRealtimeData = function () { - var self = this, - datum, - row; - this.handle.getTelemetryObjects().forEach(function (telemetryObject) { - datum = self.handle.getDatum(telemetryObject); - if (datum) { - //Populate row values from telemetry datum - row = self.table.getRowValues(telemetryObject, datum); - self.$scope.rows.push(row); - - //Inform table that a new row has been added - if (self.$scope.rows.length > self.maxRows) { - self.$scope.$broadcast('remove:row', 0); - self.$scope.rows.shift(); - } - - self.$scope.$broadcast('add:row', - self.$scope.rows.length - 1); - } - }); - this.$scope.loading = false; - }; - - return RealtimeTableController; - } -); diff --git a/platform/features/table/src/controllers/TelemetryTableController.js b/platform/features/table/src/controllers/TelemetryTableController.js index 7d6cbc2bec..daf042893e 100644 --- a/platform/features/table/src/controllers/TelemetryTableController.js +++ b/platform/features/table/src/controllers/TelemetryTableController.js @@ -19,6 +19,7 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ +/* global console*/ /** * This bundle adds a table view for displaying telemetry data. @@ -26,9 +27,13 @@ */ define( [ - '../TableConfiguration' + '../TableConfiguration', + '../../../../../src/api/objects/object-utils', + '../TelemetryCollection', + 'lodash' + ], - function (TableConfiguration) { + function (TableConfiguration, objectUtils, TelemetryCollection, _) { /** * The TableController is responsible for getting data onto the page @@ -36,183 +41,412 @@ define( * configuration, and telemetry subscriptions. * @memberof platform/features/table * @param $scope - * @param telemetryHandler - * @param telemetryFormatter * @constructor */ function TelemetryTableController( $scope, - telemetryHandler, - telemetryFormatter, + $timeout, openmct ) { - var self = this; this.$scope = $scope; + this.$timeout = $timeout; + this.openmct = openmct; + this.batchSize = 1000; + + /* + * Initialization block + */ this.columns = {}; //Range and Domain columns - this.handle = undefined; - this.telemetryHandler = telemetryHandler; - this.table = new TableConfiguration($scope.domainObject, - telemetryFormatter); - this.changeListeners = []; - this.conductor = openmct.conductor; - - $scope.rows = []; - - // Subscribe to telemetry when a domain object becomes available - this.$scope.$watch('domainObject', function () { - self.subscribe(); - self.registerChangeListeners(); - }); - - this.destroy = this.destroy.bind(this); - - // Unsubscribe when the plot is destroyed - this.$scope.$on("$destroy", this.destroy); + this.unobserveObject = undefined; + this.subscriptions = []; this.timeColumns = []; + $scope.rows = []; + this.table = new TableConfiguration($scope.domainObject, + openmct); + this.lastBounds = this.openmct.conductor.bounds(); + this.lastRequestTime = 0; + this.telemetry = new TelemetryCollection(); + /* + * Create a new format object from legacy object, and replace it + * when it changes + */ + this.newObject = objectUtils.toNewFormat($scope.domainObject.getModel(), + $scope.domainObject.getId()); - this.sortByTimeSystem = this.sortByTimeSystem.bind(this); - this.conductor.on('timeSystem', this.sortByTimeSystem); - this.conductor.off('timeSystem', this.sortByTimeSystem); + _.bindAll(this, [ + 'destroy', + 'sortByTimeSystem', + 'loadColumns', + 'getHistoricalData', + 'subscribeToNewData', + 'changeBounds', + 'setScroll', + 'addRowsToTable', + 'removeRowsFromTable' + ]); + + // Retrieve data when domain object is available. + // Also deferring telemetry request makes testing easier as controller + // construction has no unintended consequences. + $scope.$watch("domainObject", function () { + this.getData(); + this.registerChangeListeners(); + }.bind(this)); + + this.setScroll(this.openmct.conductor.follow()); + + this.$scope.$on("$destroy", this.destroy); } + /** + * @private + * @param {boolean} scroll + */ + TelemetryTableController.prototype.setScroll = function (scroll) { + this.$scope.autoScroll = scroll; + }; + /** * Based on the selected time system, find a matching domain column * to sort by. By default will just match on key. - * @param timeSystem + * + * @private + * @param {TimeSystem} timeSystem */ TelemetryTableController.prototype.sortByTimeSystem = function (timeSystem) { var scope = this.$scope; + var sortColumn; scope.defaultSort = undefined; + if (timeSystem) { this.table.columns.forEach(function (column) { - if (column.domainMetadata && column.domainMetadata.key === timeSystem.metadata.key) { - scope.defaultSort = column.getTitle(); + if (column.getKey() === timeSystem.metadata.key) { + sortColumn = column; } }); + if (sortColumn) { + scope.defaultSort = sortColumn.getTitle(); + this.telemetry.sort(sortColumn.getTitle() + '.value'); + } } }; - TelemetryTableController.prototype.unregisterChangeListeners = function () { - this.changeListeners.forEach(function (listener) { - return listener && listener(); - }); - this.changeListeners = []; - }; - /** - * Defer registration of change listeners until domain object is - * available in order to avoid race conditions + * Attaches listeners that respond to state change in domain object, + * conductor, and receipt of telemetry + * * @private */ TelemetryTableController.prototype.registerChangeListeners = function () { - var self = this; - this.unregisterChangeListeners(); - - // When composition changes, re-subscribe to the various - // telemetry subscriptions - this.changeListeners.push(this.$scope.$watchCollection( - 'domainObject.getModel().composition', - function (newVal, oldVal) { - if (newVal !== oldVal) { - self.subscribe(); - } - }) - ); - }; - - /** - * Release the current subscription (called when scope is destroyed) - */ - TelemetryTableController.prototype.destroy = function () { - if (this.handle) { - this.handle.unsubscribe(); - this.handle = undefined; + if (this.unobserveObject) { + this.unobserveObject(); } - }; - /** - * Function for handling realtime data when it is available. This - * will be called by the telemetry framework when new data is - * available. - * - * Method should be overridden by specializing class. - */ - TelemetryTableController.prototype.addRealtimeData = function () { - }; - - /** - * Function for handling historical data. Will be called by - * telemetry framework when requested historical data is available. - * Should be overridden by specializing class. - */ - TelemetryTableController.prototype.addHistoricalData = function () { - }; - - /** - 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 () { - if (this.handle) { - this.handle.unsubscribe(); - } - this.$scope.loading = true; - - this.handle = this.$scope.domainObject && this.telemetryHandler.handle( - this.$scope.domainObject, - this.addRealtimeData.bind(this), - true // Lossless + this.unobserveObject = this.openmct.objects.observe(this.newObject, "*", + function (domainObject) { + this.newObject = domainObject; + this.getData(); + }.bind(this) ); - this.handle.request({}).then(this.addHistoricalData.bind(this)); + this.openmct.conductor.on('timeSystem', this.sortByTimeSystem); + this.openmct.conductor.on('bounds', this.changeBounds); + this.openmct.conductor.on('follow', this.setScroll); - this.setup(); - }; - - TelemetryTableController.prototype.populateColumns = function (telemetryMetadata) { - this.table.populateColumns(telemetryMetadata); - - //Identify time columns - telemetryMetadata.forEach(function (metadatum) { - //Push domains first - (metadatum.domains || []).forEach(function (domainMetadata) { - this.timeColumns.push(domainMetadata.name); - }.bind(this)); - }.bind(this)); - - var timeSystem = this.conductor.timeSystem(); - if (timeSystem) { - this.sortByTimeSystem(timeSystem); - } + this.telemetry.on('added', this.addRowsToTable); + this.telemetry.on('discarded', this.removeRowsFromTable); }; /** - * Setup table columns based on domain object metadata + * On receipt of new telemetry, informs mct-table directive that new rows + * are available and passes populated rows to it + * + * @private + * @param rows */ - TelemetryTableController.prototype.setup = function () { - var handle = this.handle, - self = this; + TelemetryTableController.prototype.addRowsToTable = function (rows) { + this.$scope.$broadcast('add:rows', rows); + }; - if (handle) { - this.timeColumns = []; - handle.promiseTelemetryObjects().then(function () { - self.$scope.headers = []; - self.$scope.rows = []; + /** + * When rows are to be removed, informs mct-table directive. Row removal + * happens when rows call outside the bounds of the time conductor + * + * @private + * @param rows + */ + TelemetryTableController.prototype.removeRowsFromTable = function (rows) { + this.$scope.$broadcast('remove:rows', rows); + }; - self.populateColumns(handle.getMetadata()); - self.filterColumns(); + /** + * On Time Conductor bounds change, update displayed telemetry. In the + * case of a tick, previously visible telemetry that is now out of band + * will be removed from the table. + * @param {openmct.TimeConductorBounds~TimeConductorBounds} bounds + */ + TelemetryTableController.prototype.changeBounds = function (bounds) { + var follow = this.openmct.conductor.follow(); + var isTick = follow && + bounds.start !== this.lastBounds.start && + bounds.end !== this.lastBounds.end; - // When table column configuration changes, (due to being - // selected or deselected), filter columns appropriately. - self.changeListeners.push(self.$scope.$watchCollection( - 'domainObject.getModel().configuration.table.columns', - self.filterColumns.bind(self) - )); - }); + if (isTick) { + this.telemetry.bounds(bounds); + } else { + // Is fixed bounds change + this.getData(); } + this.lastBounds = bounds; + }; + + /** + * Clean controller, deregistering listeners etc. + */ + TelemetryTableController.prototype.destroy = function () { + + this.openmct.conductor.off('timeSystem', this.sortByTimeSystem); + this.openmct.conductor.off('bounds', this.changeBounds); + this.openmct.conductor.off('follow', this.setScroll); + + this.subscriptions.forEach(function (subscription) { + subscription(); + }); + + if (this.unobserveObject) { + this.unobserveObject(); + } + this.subscriptions = []; + + if (this.timeoutHandle) { + this.$timeout.cancel(this.timeoutHandle); + } + + // In case controller instance lingers around (currently there is a + // temporary memory leak with PlotController), clean up scope as it + // can be extremely large. + this.$scope = null; + this.table = null; + }; + + /** + * For given objects, populate column metadata and table headers. + * @private + * @param {module:openmct.DomainObject[]} objects the domain objects for + * which columns should be populated + */ + TelemetryTableController.prototype.loadColumns = function (objects) { + var telemetryApi = this.openmct.telemetry; + + this.$scope.headers = []; + + if (objects.length > 0) { + var metadatas = objects.map(telemetryApi.getMetadata.bind(telemetryApi)); + var allColumns = telemetryApi.commonValuesForHints(metadatas, []); + + this.table.populateColumns(allColumns); + + var domainColumns = telemetryApi.commonValuesForHints(metadatas, ['x']); + this.timeColumns = domainColumns.map(function (metadatum) { + return metadatum.name; + }); + + this.filterColumns(); + + // Default to no sort on underlying telemetry collection. Sorting + // is necessary to do bounds filtering, but this is only possible + // if data matches selected time system + this.telemetry.sort(undefined); + + var timeSystem = this.openmct.conductor.timeSystem(); + if (timeSystem) { + this.sortByTimeSystem(timeSystem); + } + + } + return objects; + }; + + /** + * Request telemetry data from an historical store for given objects. + * @private + * @param {object[]} The domain objects to request telemetry for + * @returns {Promise} resolved when historical data is available + */ + TelemetryTableController.prototype.getHistoricalData = function (objects) { + var self = this; + var openmct = this.openmct; + var bounds = openmct.conductor.bounds(); + var scope = this.$scope; + var rowData = []; + var processedObjects = 0; + var requestTime = this.lastRequestTime = Date.now(); + var telemetryCollection = this.telemetry; + + var promise = new Promise(function (resolve, reject) { + /* + * On completion of batched processing, set the rows on scope + */ + function finishProcessing() { + telemetryCollection.add(rowData); + scope.rows = telemetryCollection.telemetry; + scope.loading = false; + + resolve(scope.rows); + } + + /* + * Process a batch of historical data + */ + function processData(historicalData, index, limitEvaluator) { + if (index >= historicalData.length) { + processedObjects++; + + if (processedObjects === objects.length) { + finishProcessing(); + } + } else { + rowData = rowData.concat(historicalData.slice(index, index + self.batchSize) + .map(self.table.getRowValues.bind(self.table, limitEvaluator))); + + /* + Use timeout to yield process to other UI activities. On + return, process next batch + */ + self.timeoutHandle = self.$timeout(function () { + processData(historicalData, index + self.batchSize, limitEvaluator); + }); + } + } + + function makeTableRows(object, historicalData) { + // Only process the most recent request + if (requestTime === self.lastRequestTime) { + var limitEvaluator = openmct.telemetry.limitEvaluator(object); + processData(historicalData, 0, limitEvaluator); + } else { + resolve(rowData); + } + } + + /* + Use the telemetry API to request telemetry for a given object + */ + function requestData(object) { + return openmct.telemetry.request(object, { + start: bounds.start, + end: bounds.end + }).then(makeTableRows.bind(undefined, object)) + .catch(reject); + } + this.$timeout.cancel(this.timeoutHandle); + + if (objects.length > 0) { + objects.forEach(requestData); + } else { + scope.loading = false; + resolve([]); + } + }.bind(this)); + + return promise; + }; + + + /** + * Subscribe to real-time data for the given objects. + * @private + * @param {object[]} objects The objects to subscribe to. + */ + TelemetryTableController.prototype.subscribeToNewData = function (objects) { + var telemetryApi = this.openmct.telemetry; + var telemetryCollection = this.telemetry; + //Set table max length to avoid unbounded growth. + //var maxRows = 100000; + var maxRows = Number.MAX_VALUE; + var limitEvaluator; + var added = false; + var scope = this.$scope; + var table = this.table; + + this.subscriptions.forEach(function (subscription) { + subscription(); + }); + this.subscriptions = []; + + function newData(domainObject, datum) { + limitEvaluator = telemetryApi.limitEvaluator(domainObject); + added = telemetryCollection.add([table.getRowValues(limitEvaluator, datum)]); + + //Inform table that a new row has been added + if (scope.rows.length > maxRows) { + scope.$broadcast('remove:rows', scope.rows[0]); + scope.rows.shift(); + } + if (!scope.loading && added) { + scope.$broadcast('add:row', + scope.rows.length - 1); + } + } + + objects.forEach(function (object) { + this.subscriptions.push( + telemetryApi.subscribe(object, newData.bind(this, object), {})); + }.bind(this)); + + return objects; + }; + + /** + * Request historical data, and subscribe to for real-time data. + * @private + * @returns {Promise} A promise that is resolved once subscription is + * established, and historical telemetry is received and processed. + */ + TelemetryTableController.prototype.getData = function () { + var telemetryApi = this.openmct.telemetry; + var compositionApi = this.openmct.composition; + var scope = this.$scope; + var newObject = this.newObject; + + this.telemetry.clear(); + this.telemetry.bounds(this.openmct.conductor.bounds()); + + this.$scope.loading = true; + + function error(e) { + scope.loading = false; + console.error(e.stack); + } + + function filterForTelemetry(objects) { + return objects.filter(telemetryApi.canProvideTelemetry.bind(telemetryApi)); + } + + function getDomainObjects() { + var objects = [newObject]; + var composition = compositionApi.get(newObject); + + if (composition) { + return composition + .load() + .then(function (children) { + return objects.concat(children); + }); + } else { + return Promise.resolve(objects); + } + } + scope.rows = []; + + return getDomainObjects() + .then(filterForTelemetry) + .then(this.loadColumns) + .then(this.subscribeToNewData) + .then(this.getHistoricalData) + .catch(error); }; /** diff --git a/platform/features/table/src/directives/MCTTable.js b/platform/features/table/src/directives/MCTTable.js index dad23c2eb5..70a2b6665c 100644 --- a/platform/features/table/src/directives/MCTTable.js +++ b/platform/features/table/src/directives/MCTTable.js @@ -77,13 +77,13 @@ define( * * @constructor */ - function MCTTable($timeout) { + function MCTTable() { return { restrict: "E", template: TableTemplate, controller: [ '$scope', - '$timeout', + '$window', '$element', 'exportService', 'formatService', @@ -94,6 +94,7 @@ define( scope: { headers: "=", rows: "=", + formatCell: "=?", enableFilter: "=?", enableSort: "=?", autoScroll: "=?", @@ -104,7 +105,7 @@ define( timeColumns: "=?", // Indicate a column to sort on. Allows control of sort // via configuration (eg. for default sort column). - sortColumn: "=?" + defaultSort: "=?" } }; } diff --git a/platform/features/table/test/DomainColumnSpec.js b/platform/features/table/test/DomainColumnSpec.js deleted file mode 100644 index 3c144b8427..0000000000 --- a/platform/features/table/test/DomainColumnSpec.js +++ /dev/null @@ -1,80 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -/** - * MergeModelsSpec. Created by vwoeltje on 11/6/14. - */ -define( - ["../src/DomainColumn"], - function (DomainColumn) { - - var TEST_DOMAIN_VALUE = "some formatted domain value"; - - describe("A domain column", function () { - var mockDatum, - testMetadata, - mockFormatter, - column; - - beforeEach(function () { - - mockFormatter = jasmine.createSpyObj( - "formatter", - ["formatDomainValue", "formatRangeValue"] - ); - testMetadata = { - key: "testKey", - name: "Test Name", - format: "Test Format" - }; - mockFormatter.formatDomainValue.andReturn(TEST_DOMAIN_VALUE); - - column = new DomainColumn(testMetadata, mockFormatter); - }); - - it("reports a column header from domain metadata", function () { - expect(column.getTitle()).toEqual("Test Name"); - }); - - describe("when given a datum", function () { - beforeEach(function () { - mockDatum = { - testKey: "testKeyValue" - }; - }); - - it("looks up data from the given datum", function () { - expect(column.getValue(undefined, mockDatum)) - .toEqual({ text: TEST_DOMAIN_VALUE }); - }); - - it("uses formatter to format domain values as requested", function () { - column.getValue(undefined, mockDatum); - expect(mockFormatter.formatDomainValue) - .toHaveBeenCalledWith("testKeyValue", "Test Format"); - }); - - }); - - }); - } -); diff --git a/platform/features/table/test/NameColumnSpec.js b/platform/features/table/test/NameColumnSpec.js deleted file mode 100644 index 13e858c2ed..0000000000 --- a/platform/features/table/test/NameColumnSpec.js +++ /dev/null @@ -1,56 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -/** - * MergeModelsSpec. Created by vwoeltje on 11/6/14. - */ -define( - ["../src/NameColumn"], - function (NameColumn) { - - describe("A name column", function () { - var mockDomainObject, - column; - - beforeEach(function () { - mockDomainObject = jasmine.createSpyObj( - "domainObject", - ["getModel"] - ); - mockDomainObject.getModel.andReturn({ - name: "Test object name" - }); - column = new NameColumn(); - }); - - it("reports a column header", function () { - expect(column.getTitle()).toEqual("Name"); - }); - - it("looks up name from an object's model", function () { - expect(column.getValue(mockDomainObject).text) - .toEqual("Test object name"); - }); - - }); - } -); diff --git a/platform/features/table/test/RangeColumnSpec.js b/platform/features/table/test/RangeColumnSpec.js deleted file mode 100644 index 473f26ae56..0000000000 --- a/platform/features/table/test/RangeColumnSpec.js +++ /dev/null @@ -1,74 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -/** - * MergeModelsSpec. Created by vwoeltje on 11/6/14. - */ -define( - ["../src/RangeColumn"], - function (RangeColumn) { - - var TEST_RANGE_VALUE = "some formatted range value"; - - describe("A range column", function () { - var testDatum, - testMetadata, - mockFormatter, - mockDomainObject, - column; - - beforeEach(function () { - testDatum = { testKey: 123, otherKey: 456 }; - mockFormatter = jasmine.createSpyObj( - "formatter", - ["formatDomainValue", "formatRangeValue"] - ); - testMetadata = { - key: "testKey", - name: "Test Name" - }; - mockDomainObject = jasmine.createSpyObj( - "domainObject", - ["getModel", "getCapability"] - ); - mockFormatter.formatRangeValue.andReturn(TEST_RANGE_VALUE); - - column = new RangeColumn(testMetadata, mockFormatter); - }); - - it("reports a column header from range metadata", function () { - expect(column.getTitle()).toEqual("Test Name"); - }); - - it("formats range values as numbers", function () { - expect(column.getValue(mockDomainObject, testDatum).text) - .toEqual(TEST_RANGE_VALUE); - - // Make sure that service interactions were as expected - expect(mockFormatter.formatRangeValue) - .toHaveBeenCalledWith(testDatum.testKey); - expect(mockFormatter.formatDomainValue) - .not.toHaveBeenCalled(); - }); - }); - } -); diff --git a/platform/features/table/test/TableConfigurationSpec.js b/platform/features/table/test/TableConfigurationSpec.js index dcac2a9876..9538335a86 100644 --- a/platform/features/table/test/TableConfigurationSpec.js +++ b/platform/features/table/test/TableConfigurationSpec.js @@ -22,13 +22,14 @@ define( [ - "../src/TableConfiguration", - "../src/DomainColumn" + "../src/TableConfiguration" ], - function (Table, DomainColumn) { + function (Table) { describe("A table", function () { var mockDomainObject, + mockAPI, + mockTelemetryAPI, mockTelemetryFormatter, table, mockModel; @@ -49,90 +50,63 @@ define( mockTelemetryFormatter = jasmine.createSpyObj('telemetryFormatter', [ - 'formatDomainValue', - 'formatRangeValue' + 'format' ]); - mockTelemetryFormatter.formatDomainValue.andCallFake(function (valueIn) { - return valueIn; - }); - mockTelemetryFormatter.formatRangeValue.andCallFake(function (valueIn) { + mockTelemetryFormatter.format.andCallFake(function (valueIn) { return valueIn; }); - table = new Table(mockDomainObject, mockTelemetryFormatter); - }); + mockTelemetryAPI = jasmine.createSpyObj('telemetryAPI', [ + 'getValueFormatter' + ]); + mockAPI = { + telemetry: mockTelemetryAPI + }; + mockTelemetryAPI.getValueFormatter.andReturn(mockTelemetryFormatter); - it("Add column with no index adds new column to the end", function () { - var firstColumn = {title: 'First Column'}, - secondColumn = {title: 'Second Column'}, - thirdColumn = {title: 'Third Column'}; - - table.addColumn(firstColumn); - table.addColumn(secondColumn); - table.addColumn(thirdColumn); - - expect(table.columns).toBeDefined(); - expect(table.columns.length).toBe(3); - expect(table.columns[0]).toBe(firstColumn); - expect(table.columns[1]).toBe(secondColumn); - expect(table.columns[2]).toBe(thirdColumn); - }); - - it("Add column with index adds new column at the specified" + - " position", function () { - var firstColumn = {title: 'First Column'}, - secondColumn = {title: 'Second Column'}, - thirdColumn = {title: 'Third Column'}; - - table.addColumn(firstColumn); - table.addColumn(thirdColumn); - table.addColumn(secondColumn, 1); - - expect(table.columns).toBeDefined(); - expect(table.columns.length).toBe(3); - expect(table.columns[0]).toBe(firstColumn); - expect(table.columns[1]).toBe(secondColumn); - expect(table.columns[2]).toBe(thirdColumn); + table = new Table(mockDomainObject, mockAPI); }); describe("Building columns from telemetry metadata", function () { - var metadata = [{ - ranges: [ - { - name: 'Range 1', - key: 'range1' - }, - { - name: 'Range 2', - key: 'range2' + var metadata = [ + { + name: 'Range 1', + key: 'range1', + hints: { + y: 1 } - ], - domains: [ - { - name: 'Domain 1', - key: 'domain1', - format: 'utc' - }, - { - name: 'Domain 2', - key: 'domain2', - format: 'utc' + }, + { + name: 'Range 2', + key: 'range2', + hints: { + y: 2 } - ] - }]; + }, + { + name: 'Domain 1', + key: 'domain1', + format: 'utc', + hints: { + x: 1 + } + }, + { + name: 'Domain 2', + key: 'domain2', + format: 'utc', + hints: { + x: 2 + } + } + ]; beforeEach(function () { table.populateColumns(metadata); }); it("populates columns", function () { - expect(table.columns.length).toBe(5); - }); - - it("Build columns populates columns with domains to the left", function () { - expect(table.columns[1] instanceof DomainColumn).toBeTruthy(); - expect(table.columns[2] instanceof DomainColumn).toBeTruthy(); - expect(table.columns[3] instanceof DomainColumn).toBeFalsy(); + expect(table.columns.length).toBe(4); }); it("Produces headers for each column based on title", function () { @@ -141,7 +115,7 @@ define( spyOn(firstColumn, 'getTitle'); headers = table.getHeaders(); - expect(headers.length).toBe(5); + expect(headers.length).toBe(4); expect(firstColumn.getTitle).toHaveBeenCalled(); }); @@ -178,23 +152,33 @@ define( beforeEach(function () { datum = { - 'range1': 'range 1 value', - 'range2': 'range 2 value', + 'range1': 10, + 'range2': 20, 'domain1': 0, 'domain2': 1 }; - rowValues = table.getRowValues(mockDomainObject, datum); + var limitEvaluator = { + evaluate: function () { + return { + "cssClass": "alarm-class" + }; + } + }; + rowValues = table.getRowValues(limitEvaluator, datum); }); it("Returns a value for every column", function () { expect(rowValues['Range 1'].text).toBeDefined(); - expect(rowValues['Range 1'].text).toEqual('range 1' + - ' value'); + expect(rowValues['Range 1'].text).toEqual(10); }); - it("Uses the telemetry formatter to appropriately format" + + it("Applies appropriate css class if limit violated.", function () { + expect(rowValues['Range 1'].cssClass).toEqual("alarm-class"); + }); + + it("Uses telemetry formatter to appropriately format" + " telemetry values", function () { - expect(mockTelemetryFormatter.formatRangeValue).toHaveBeenCalled(); + expect(mockTelemetryFormatter.format).toHaveBeenCalled(); }); }); }); diff --git a/platform/features/table/test/TelemetryCollectionSpec.js b/platform/features/table/test/TelemetryCollectionSpec.js new file mode 100644 index 0000000000..014e5c684e --- /dev/null +++ b/platform/features/table/test/TelemetryCollectionSpec.js @@ -0,0 +1,191 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2016, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT 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 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. + *****************************************************************************/ + +define( + [ + "../src/TelemetryCollection" + ], + function (TelemetryCollection) { + + describe("A telemetry collection", function () { + + var collection; + var telemetryObjects; + var ms; + var integerTextMap = ["ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", + "SIX", "SEVEN", "EIGHT", "NINE", "TEN", "ELEVEN"]; + + beforeEach(function () { + telemetryObjects = [0,9,2,4,7,8,5,1,3,6].map(function (number) { + ms = number * 1000; + return { + timestamp: ms, + value: { + integer: number, + text: integerTextMap[number] + } + }; + }); + collection = new TelemetryCollection(); + }); + + it("Sorts inserted telemetry by specified field", + function () { + collection.sort('value.integer'); + collection.add(telemetryObjects); + expect(collection.telemetry[0].value.integer).toBe(0); + expect(collection.telemetry[1].value.integer).toBe(1); + expect(collection.telemetry[2].value.integer).toBe(2); + expect(collection.telemetry[3].value.integer).toBe(3); + + collection.sort('value.text'); + expect(collection.telemetry[0].value.text).toBe("EIGHT"); + expect(collection.telemetry[1].value.text).toBe("FIVE"); + expect(collection.telemetry[2].value.text).toBe("FOUR"); + expect(collection.telemetry[3].value.text).toBe("NINE"); + } + ); + + describe("on bounds change", function () { + var discardedCallback; + + beforeEach(function () { + discardedCallback = jasmine.createSpy("discarded"); + collection.on("discarded", discardedCallback); + collection.sort("timestamp"); + collection.add(telemetryObjects); + collection.bounds({start: 5000, end: 8000}); + }); + + + it("emits an event indicating that telemetry has " + + "been discarded", function () { + expect(discardedCallback).toHaveBeenCalled(); + }); + + it("discards telemetry data with a time stamp " + + "before specified start bound", function () { + var discarded = discardedCallback.mostRecentCall.args[0]; + + // Expect 5 because as an optimization, the TelemetryCollection + // will not consider telemetry values that exceed the upper + // bounds. Arbitrary bounds changes in which the end bound is + // decreased is assumed to require a new historical query, and + // hence re-population of the collection anyway + expect(discarded.length).toBe(5); + expect(discarded[0].value.integer).toBe(0); + expect(discarded[1].value.integer).toBe(1); + expect(discarded[4].value.integer).toBe(4); + }); + }); + + describe("when adding telemetry to a collection", function () { + var addedCallback; + beforeEach(function () { + collection.sort("timestamp"); + collection.add(telemetryObjects); + addedCallback = jasmine.createSpy("added"); + collection.on("added", addedCallback); + }); + + it("emits an event", + function () { + var addedObject = { + timestamp: 10000, + value: { + integer: 10, + text: integerTextMap[10] + } + }; + collection.add([addedObject]); + expect(addedCallback).toHaveBeenCalledWith([addedObject]); + } + ); + it("inserts in the correct order", + function () { + var addedObjectA = { + timestamp: 10000, + value: { + integer: 10, + text: integerTextMap[10] + } + }; + var addedObjectB = { + timestamp: 11000, + value: { + integer: 11, + text: integerTextMap[11] + } + }; + collection.add([addedObjectB, addedObjectA]); + + expect(collection.telemetry[11]).toBe(addedObjectB); + } + ); + }); + + describe("buffers telemetry", function () { + var addedObjectA; + var addedObjectB; + + beforeEach(function () { + collection.sort("timestamp"); + collection.add(telemetryObjects); + + addedObjectA = { + timestamp: 10000, + value: { + integer: 10, + text: integerTextMap[10] + } + }; + addedObjectB = { + timestamp: 11000, + value: { + integer: 11, + text: integerTextMap[11] + } + }; + + collection.bounds({start: 0, end: 10000}); + collection.add([addedObjectA, addedObjectB]); + }); + it("when it falls outside of bounds", function () { + expect(collection.highBuffer).toBeDefined(); + expect(collection.highBuffer.length).toBe(1); + expect(collection.highBuffer[0]).toBe(addedObjectB); + }); + it("and adds it to collection when it falls within bounds", function () { + expect(collection.telemetry.length).toBe(11); + collection.bounds({start: 0, end: 11000}); + expect(collection.telemetry.length).toBe(12); + expect(collection.telemetry[11]).toBe(addedObjectB); + }); + it("and removes it from the buffer when it falls within bounds", function () { + expect(collection.highBuffer.length).toBe(1); + collection.bounds({start: 0, end: 11000}); + expect(collection.highBuffer.length).toBe(0); + }); + }); + }); + } +); diff --git a/platform/features/table/test/controllers/HistoricalTableControllerSpec.js b/platform/features/table/test/controllers/HistoricalTableControllerSpec.js deleted file mode 100644 index 39f7d1a8f5..0000000000 --- a/platform/features/table/test/controllers/HistoricalTableControllerSpec.js +++ /dev/null @@ -1,380 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -define( - [ - "../../src/controllers/HistoricalTableController" - ], - function (TableController) { - - describe('The Table Controller', function () { - var mockScope, - mockTelemetryHandler, - mockTelemetryHandle, - mockTelemetryFormatter, - mockDomainObject, - mockTable, - mockConfiguration, - mockAngularTimeout, - mockTimeoutHandle, - watches, - mockConductor, - controller; - - function promise(value) { - return { - then: function (callback) { - return promise(callback(value)); - } - }; - } - - function getCallback(target, event) { - return target.calls.filter(function (call) { - return call.args[0] === event; - })[0].args[1]; - } - - beforeEach(function () { - watches = {}; - mockScope = jasmine.createSpyObj('scope', [ - '$on', - '$watch', - '$watchCollection' - ]); - - 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; - }); - - mockTimeoutHandle = jasmine.createSpy("timeoutHandle"); - mockAngularTimeout = jasmine.createSpy("$timeout"); - mockAngularTimeout.andReturn(mockTimeoutHandle); - mockAngularTimeout.cancel = jasmine.createSpy("cancelTimeout"); - - mockConfiguration = { - 'range1': true, - 'range2': true, - 'domain1': true - }; - - mockTable = jasmine.createSpyObj('table', - [ - 'populateColumns', - 'buildColumnConfiguration', - 'getRowValues', - 'saveColumnConfiguration' - ] - ); - mockTable.columns = []; - mockTable.buildColumnConfiguration.andReturn(mockConfiguration); - - mockDomainObject = jasmine.createSpyObj('domainObject', [ - 'getCapability', - 'useCapability', - 'getModel' - ]); - mockDomainObject.getModel.andReturn({}); - - mockScope.domainObject = mockDomainObject; - - mockTelemetryHandle = jasmine.createSpyObj('telemetryHandle', [ - 'request', - 'promiseTelemetryObjects', - 'getTelemetryObjects', - 'getMetadata', - 'getSeries', - 'unsubscribe', - 'makeDatum' - ]); - mockTelemetryHandle.promiseTelemetryObjects.andReturn(promise(undefined)); - mockTelemetryHandle.request.andReturn(promise(undefined)); - mockTelemetryHandle.getTelemetryObjects.andReturn([]); - mockTelemetryHandle.getMetadata.andReturn([]); - - mockTelemetryHandler = jasmine.createSpyObj('telemetryHandler', [ - 'handle' - ]); - mockTelemetryHandler.handle.andReturn(mockTelemetryHandle); - - mockConductor = jasmine.createSpyObj("conductor", [ - "timeSystem", - "on", - "off" - ]); - - controller = new TableController(mockScope, mockTelemetryHandler, - mockTelemetryFormatter, mockAngularTimeout, {conductor: mockConductor}); - - controller.table = mockTable; - controller.handle = mockTelemetryHandle; - }); - - it('subscribes to telemetry handler for telemetry updates', function () { - controller.subscribe(); - expect(mockTelemetryHandler.handle).toHaveBeenCalled(); - expect(mockTelemetryHandle.request).toHaveBeenCalled(); - }); - - it('Unsubscribes from telemetry when scope is destroyed', function () { - controller.handle = mockTelemetryHandle; - watches.$destroy(); - expect(mockTelemetryHandle.unsubscribe).toHaveBeenCalled(); - }); - - describe('makes use of the table', function () { - - it('to create column definitions from telemetry' + - ' metadata', function () { - controller.setup(); - expect(mockTable.populateColumns).toHaveBeenCalled(); - }); - - it('to create column configuration, which is written to the' + - ' object model', function () { - controller.setup(); - expect(mockTable.buildColumnConfiguration).toHaveBeenCalled(); - }); - }); - - it('updates the rows on scope when historical telemetry is received', function () { - var mockSeries = { - getPointCount: function () { - return 5; - }, - getDomainValue: function () { - return 'Domain Value'; - }, - getRangeValue: function () { - return 'Range Value'; - } - }, - mockRow = {'domain': 'Domain Value', 'range': 'Range' + - ' Value'}; - - mockTelemetryHandle.makeDatum.andCallFake(function () { - return mockRow; - }); - mockTable.getRowValues.andReturn(mockRow); - mockTelemetryHandle.getTelemetryObjects.andReturn([mockDomainObject]); - mockTelemetryHandle.getSeries.andReturn(mockSeries); - - controller.addHistoricalData(mockDomainObject, mockSeries); - - // Angular timeout is called a minumum of twice, regardless - // of batch size used. - expect(mockAngularTimeout).toHaveBeenCalled(); - mockAngularTimeout.mostRecentCall.args[0](); - expect(mockAngularTimeout.calls.length).toEqual(2); - mockAngularTimeout.mostRecentCall.args[0](); - - expect(controller.$scope.rows.length).toBe(5); - expect(controller.$scope.rows[0]).toBe(mockRow); - }); - - it('filters the visible columns based on configuration', function () { - controller.filterColumns(); - expect(controller.$scope.headers.length).toBe(3); - expect(controller.$scope.headers[2]).toEqual('domain1'); - - mockConfiguration.domain1 = false; - controller.filterColumns(); - expect(controller.$scope.headers.length).toBe(2); - expect(controller.$scope.headers[2]).toBeUndefined(); - }); - - describe('creates event listeners', function () { - beforeEach(function () { - spyOn(controller, 'subscribe'); - spyOn(controller, 'filterColumns'); - }); - - it('triggers telemetry subscription update when domain' + - ' object changes', function () { - controller.registerChangeListeners(); - //'watches' object is populated by fake scope watch and - // watchCollection functions defined above - expect(watches.domainObject).toBeDefined(); - watches.domainObject(mockDomainObject); - expect(controller.subscribe).toHaveBeenCalled(); - }); - - it('triggers telemetry subscription update when domain' + - ' object composition changes', function () { - controller.registerChangeListeners(); - expect(watches['domainObject.getModel().composition']).toBeDefined(); - watches['domainObject.getModel().composition']([], []); - expect(controller.subscribe).toHaveBeenCalled(); - }); - - it('triggers telemetry subscription update when time' + - ' conductor bounds change', function () { - controller.registerChangeListeners(); - expect(watches['telemetry:display:bounds']).toBeDefined(); - watches['telemetry:display:bounds'](); - expect(controller.subscribe).toHaveBeenCalled(); - }); - - it('triggers refiltering of the columns when configuration' + - ' changes', function () { - controller.setup(); - expect(watches['domainObject.getModel().configuration.table.columns']).toBeDefined(); - watches['domainObject.getModel().configuration.table.columns'](); - expect(controller.filterColumns).toHaveBeenCalled(); - }); - - }); - describe('After populating columns', function () { - var metadata; - beforeEach(function () { - metadata = [{domains: [{name: 'time domain 1'}, {name: 'time domain 2'}]}, {domains: [{name: 'time domain 3'}, {name: 'time domain 4'}]}]; - controller.populateColumns(metadata); - }); - - it('Automatically identifies time columns', function () { - expect(controller.timeColumns.length).toBe(4); - expect(controller.timeColumns[0]).toBe('time domain 1'); - }); - - it('Automatically sorts by time column that matches current' + - ' time system', function () { - var key = 'time_domain_1', - name = 'time domain 1', - mockTimeSystem = { - metadata: { - key: key - } - }; - - mockTable.columns = [ - { - domainMetadata: { - key: key - }, - getTitle: function () { - return name; - } - }, - { - domainMetadata: { - key: 'anotherColumn' - }, - getTitle: function () { - return 'some other column'; - } - }, - { - domainMetadata: { - key: 'thirdColumn' - }, - getTitle: function () { - return 'a third column'; - } - } - ]; - - expect(mockConductor.on).toHaveBeenCalledWith('timeSystem', jasmine.any(Function)); - getCallback(mockConductor.on, 'timeSystem')(mockTimeSystem); - expect(controller.$scope.defaultSort).toBe(name); - }); - }); - describe('Yields thread', function () { - var mockSeries, - mockRow; - - beforeEach(function () { - mockSeries = { - getPointCount: function () { - return 5; - }, - getDomainValue: function () { - return 'Domain Value'; - }, - getRangeValue: function () { - return 'Range Value'; - } - }; - mockRow = {'domain': 'Domain Value', 'range': 'Range Value'}; - - mockTelemetryHandle.makeDatum.andCallFake(function () { - return mockRow; - }); - mockTable.getRowValues.andReturn(mockRow); - mockTelemetryHandle.getTelemetryObjects.andReturn([mockDomainObject]); - mockTelemetryHandle.getSeries.andReturn(mockSeries); - }); - it('when row count exceeds batch size', function () { - controller.batchSize = 3; - controller.addHistoricalData(mockDomainObject, mockSeries); - - //Timeout is called a minimum of two times - expect(mockAngularTimeout).toHaveBeenCalled(); - mockAngularTimeout.mostRecentCall.args[0](); - - expect(mockAngularTimeout.calls.length).toEqual(2); - mockAngularTimeout.mostRecentCall.args[0](); - - //Because it yields, timeout will have been called a - // third time for the batch. - expect(mockAngularTimeout.calls.length).toEqual(3); - mockAngularTimeout.mostRecentCall.args[0](); - - expect(controller.$scope.rows.length).toBe(5); - expect(controller.$scope.rows[0]).toBe(mockRow); - }); - it('cancelling any outstanding timeouts', function () { - controller.batchSize = 3; - controller.addHistoricalData(mockDomainObject, mockSeries); - - expect(mockAngularTimeout).toHaveBeenCalled(); - mockAngularTimeout.mostRecentCall.args[0](); - - controller.addHistoricalData(mockDomainObject, mockSeries); - - expect(mockAngularTimeout.cancel).toHaveBeenCalledWith(mockTimeoutHandle); - }); - it('cancels timeout on scope destruction', function () { - controller.batchSize = 3; - controller.addHistoricalData(mockDomainObject, mockSeries); - - //Destroy is used by parent class as well, so multiple - // calls are made to scope.$on - var destroyCalls = mockScope.$on.calls.filter(function (call) { - return call.args[0] === '$destroy'; - }); - //Call destroy function - expect(destroyCalls.length).toEqual(2); - - destroyCalls[0].args[1](); - expect(mockAngularTimeout.cancel).toHaveBeenCalledWith(mockTimeoutHandle); - - }); - }); - }); - } -); diff --git a/platform/features/table/test/controllers/MCTTableControllerSpec.js b/platform/features/table/test/controllers/MCTTableControllerSpec.js index 61e4a2eece..f57b981c50 100644 --- a/platform/features/table/test/controllers/MCTTableControllerSpec.js +++ b/platform/features/table/test/controllers/MCTTableControllerSpec.js @@ -39,21 +39,13 @@ define( var controller, mockScope, watches, - mockTimeout, + mockWindow, mockElement, mockExportService, mockConductor, mockFormatService, mockFormat; - function promise(value) { - return { - then: function (callback) { - return promise(callback(value)); - } - }; - } - function getCallback(target, event) { return target.calls.filter(function (call) { return call.args[0] === event; @@ -66,7 +58,8 @@ define( mockScope = jasmine.createSpyObj('scope', [ '$watch', '$on', - '$watchCollection' + '$watchCollection', + '$digest' ]); mockScope.$watchCollection.andCallFake(function (event, callback) { watches[event] = callback; @@ -86,8 +79,11 @@ define( ]); mockScope.displayHeaders = true; - mockTimeout = jasmine.createSpy('$timeout'); - mockTimeout.andReturn(promise(undefined)); + mockWindow = jasmine.createSpyObj('$window', ['requestAnimationFrame']); + mockWindow.requestAnimationFrame.andCallFake(function (f) { + return f(); + }); + mockFormat = jasmine.createSpyObj('formatter', [ 'parse', 'format' @@ -99,7 +95,7 @@ define( controller = new MCTTableController( mockScope, - mockTimeout, + mockWindow, mockElement, mockExportService, mockFormatService, @@ -114,12 +110,12 @@ define( expect(mockScope.$watch).toHaveBeenCalledWith('rows', jasmine.any(Function)); }); - it('destroys listeners on destruction', function () { - expect(mockScope.$on).toHaveBeenCalledWith('$destroy', controller.destroyConductorListeners); + it('unregisters listeners on destruction', function () { + expect(mockScope.$on).toHaveBeenCalledWith('$destroy', jasmine.any(Function)); getCallback(mockScope.$on, '$destroy')(); expect(mockConductor.off).toHaveBeenCalledWith('timeSystem', controller.changeTimeSystem); - expect(mockConductor.off).toHaveBeenCalledWith('timeOfInterest', controller.setTimeOfInterest); + expect(mockConductor.off).toHaveBeenCalledWith('timeOfInterest', controller.changeTimeOfInterest); expect(mockConductor.off).toHaveBeenCalledWith('bounds', controller.changeBounds); }); @@ -233,9 +229,20 @@ define( //Mock setting the rows on scope var rowsCallback = getCallback(mockScope.$watch, 'rows'); - rowsCallback(rowsAsc); + var setRowsPromise = rowsCallback(rowsAsc); + var promiseResolved = false; + setRowsPromise.then(function () { + promiseResolved = true; + }); + + waitsFor(function () { + return promiseResolved; + }, "promise to resolve", 100); + + runs(function () { + expect(mockScope.toiRowIndex).toBe(2); + }); - expect(mockScope.toiRowIndex).toBe(2); }); }); @@ -287,7 +294,7 @@ define( }); it('Supports adding rows individually', function () { - var addRowFunc = getCallback(mockScope.$on, 'add:row'), + var addRowFunc = getCallback(mockScope.$on, 'add:rows'), row4 = { 'col1': {'text': 'row3 col1'}, 'col2': {'text': 'ghi'}, @@ -296,15 +303,15 @@ define( controller.setRows(testRows); expect(mockScope.displayRows.length).toBe(3); testRows.push(row4); - addRowFunc(undefined, 3); + addRowFunc(undefined, [row4]); expect(mockScope.displayRows.length).toBe(4); }); it('Supports removing rows individually', function () { - var removeRowFunc = getCallback(mockScope.$on, 'remove:row'); + var removeRowFunc = getCallback(mockScope.$on, 'remove:rows'); controller.setRows(testRows); expect(mockScope.displayRows.length).toBe(3); - removeRowFunc(undefined, 2); + removeRowFunc(undefined, [testRows[2]]); expect(mockScope.displayRows.length).toBe(2); expect(controller.setVisibleRows).toHaveBeenCalled(); }); @@ -366,7 +373,7 @@ define( it('Allows sort column to be changed externally by ' + 'setting or changing sortBy attribute', function () { mockScope.displayRows = testRows; - var sortByCB = getCallback(mockScope.$watch, 'sortColumn'); + var sortByCB = getCallback(mockScope.$watch, 'defaultSort'); sortByCB('col2'); expect(mockScope.sortDirection).toEqual('asc'); @@ -381,10 +388,21 @@ define( it('updates visible rows in scope', function () { var oldRows; mockScope.rows = testRows; - controller.setRows(testRows); + var setRowsPromise = controller.setRows(testRows); + var promiseResolved = false; + setRowsPromise.then(function () { + promiseResolved = true; + }); oldRows = mockScope.visibleRows; mockScope.toggleSort('col2'); - expect(mockScope.visibleRows).not.toEqual(oldRows); + + waitsFor(function () { + return promiseResolved; + }, "promise to resolve", 100); + + runs(function () { + expect(mockScope.visibleRows).not.toEqual(oldRows); + }); }); it('correctly sorts rows of differing types', function () { @@ -464,21 +482,10 @@ define( mockScope.displayRows = controller.sortRows(testRows.slice(0)); - mockScope.rows.push(row4); - controller.addRow(undefined, mockScope.rows.length - 1); + controller.addRows(undefined, [row4, row5, row6, row6]); expect(mockScope.displayRows[0].col2.text).toEqual('xyz'); - - mockScope.rows.push(row5); - controller.addRow(undefined, mockScope.rows.length - 1); - expect(mockScope.displayRows[4].col2.text).toEqual('aaa'); - - mockScope.rows.push(row6); - controller.addRow(undefined, mockScope.rows.length - 1); - expect(mockScope.displayRows[2].col2.text).toEqual('ggg'); - - //Add a duplicate row - mockScope.rows.push(row6); - controller.addRow(undefined, mockScope.rows.length - 1); + expect(mockScope.displayRows[6].col2.text).toEqual('aaa'); + //Added a duplicate row expect(mockScope.displayRows[2].col2.text).toEqual('ggg'); expect(mockScope.displayRows[3].col2.text).toEqual('ggg'); }); @@ -493,13 +500,11 @@ define( mockScope.displayRows = controller.sortRows(testRows.slice(0)); mockScope.displayRows = controller.filterRows(testRows); - mockScope.rows.push(row5); - controller.addRow(undefined, mockScope.rows.length - 1); + controller.addRows(undefined, [row5]); expect(mockScope.displayRows.length).toBe(2); expect(mockScope.displayRows[1].col2.text).toEqual('aaa'); - mockScope.rows.push(row6); - controller.addRow(undefined, mockScope.rows.length - 1); + controller.addRows(undefined, [row6]); expect(mockScope.displayRows.length).toBe(2); //Row was not added because does not match filter }); @@ -512,12 +517,10 @@ define( mockScope.displayRows = testRows.slice(0); - mockScope.rows.push(row5); - controller.addRow(undefined, mockScope.rows.length - 1); + controller.addRows(undefined, [row5]); expect(mockScope.displayRows[3].col2.text).toEqual('aaa'); - mockScope.rows.push(row6); - controller.addRow(undefined, mockScope.rows.length - 1); + controller.addRows(undefined, [row6]); expect(mockScope.displayRows[4].col2.text).toEqual('ggg'); }); @@ -535,8 +538,7 @@ define( mockScope.displayRows = testRows.slice(0); - mockScope.rows.push(row7); - controller.addRow(undefined, mockScope.rows.length - 1); + controller.addRows(undefined, [row7]); expect(controller.$scope.sizingRow.col2).toEqual({text: 'some longer string'}); }); diff --git a/platform/features/table/test/controllers/RealtimeTableControllerSpec.js b/platform/features/table/test/controllers/RealtimeTableControllerSpec.js deleted file mode 100644 index bf29c3d7bd..0000000000 --- a/platform/features/table/test/controllers/RealtimeTableControllerSpec.js +++ /dev/null @@ -1,171 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2016, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -define( - [ - "../../src/controllers/RealtimeTableController" - ], - function (TableController) { - - describe('The real-time table controller', function () { - var mockScope, - mockTelemetryHandler, - mockTelemetryHandle, - mockTelemetryFormatter, - mockDomainObject, - mockTable, - mockConfiguration, - watches, - mockTableRow, - mockConductor, - 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', - '$digest', - '$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', - [ - 'populateColumns', - 'buildColumnConfiguration', - 'getRowValues', - 'saveColumnConfiguration' - ] - ); - mockTable.columns = []; - mockTable.buildColumnConfiguration.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', - 'request', - 'getMetadata' - ]); - - // Arbitrary array with non-zero length, contents are not - // used by mocks - mockTelemetryHandle.getTelemetryObjects.andReturn([{}]); - mockTelemetryHandle.promiseTelemetryObjects.andReturn(promise(undefined)); - mockTelemetryHandle.getDatum.andReturn({}); - mockTelemetryHandle.request.andReturn(promise(undefined)); - mockTelemetryHandle.getMetadata.andReturn([]); - - mockTelemetryHandler = jasmine.createSpyObj('telemetryHandler', [ - 'handle' - ]); - mockTelemetryHandler.handle.andReturn(mockTelemetryHandle); - - mockConductor = jasmine.createSpyObj('conductor', [ - 'on', - 'off', - 'bounds', - 'timeSystem', - 'timeOfInterest' - ]); - - controller = new TableController(mockScope, mockTelemetryHandler, mockTelemetryFormatter, {conductor: mockConductor}); - 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); - }); - - describe('receives new telemetry', function () { - beforeEach(function () { - controller.subscribe(); - mockScope.rows = []; - }); - - it('updates table with new streaming telemetry', function () { - mockTelemetryHandler.handle.mostRecentCall.args[1](); - expect(mockScope.$broadcast).toHaveBeenCalledWith('add:row', 0); - }); - it('observes the row limit', function () { - var i = 0; - controller.maxRows = 10; - - //Fill rows array with elements - for (; i < 10; i++) { - mockScope.rows.push({row: i}); - } - mockTelemetryHandler.handle.mostRecentCall.args[1](); - expect(mockScope.rows.length).toBe(controller.maxRows); - expect(mockScope.rows[mockScope.rows.length - 1]).toBe(mockTableRow); - expect(mockScope.rows[0].row).toBe(1); - }); - }); - }); - } -); diff --git a/platform/features/table/test/controllers/TelemetryTableControllerSpec.js b/platform/features/table/test/controllers/TelemetryTableControllerSpec.js new file mode 100644 index 0000000000..4f403edc32 --- /dev/null +++ b/platform/features/table/test/controllers/TelemetryTableControllerSpec.js @@ -0,0 +1,364 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2016, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT 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 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. + *****************************************************************************/ + +define( + [ + '../../src/controllers/TelemetryTableController', + '../../../../../src/api/objects/object-utils', + 'lodash' + ], + function (TelemetryTableController, objectUtils, _) { + + describe('The TelemetryTableController', function () { + + var controller, + mockScope, + mockTimeout, + mockConductor, + mockAPI, + mockDomainObject, + mockTelemetryAPI, + mockObjectAPI, + mockCompositionAPI, + unobserve, + mockBounds; + + function getCallback(target, event) { + return target.calls.filter(function (call) { + return call.args[0] === event; + })[0].args[1]; + } + + beforeEach(function () { + mockBounds = { + start: 0, + end: 10 + }; + mockConductor = jasmine.createSpyObj("conductor", [ + "bounds", + "follow", + "on", + "off", + "timeSystem" + ]); + mockConductor.bounds.andReturn(mockBounds); + mockConductor.follow.andReturn(false); + + mockDomainObject = jasmine.createSpyObj("domainObject", [ + "getModel", + "getId", + "useCapability" + ]); + mockDomainObject.getModel.andReturn({}); + mockDomainObject.getId.andReturn("mockId"); + mockDomainObject.useCapability.andReturn(true); + + mockCompositionAPI = jasmine.createSpyObj("compositionAPI", [ + "get" + ]); + + mockObjectAPI = jasmine.createSpyObj("objectAPI", [ + "observe" + ]); + unobserve = jasmine.createSpy("unobserve"); + mockObjectAPI.observe.andReturn(unobserve); + + mockScope = jasmine.createSpyObj("scope", [ + "$on", + "$watch", + "$broadcast" + ]); + mockScope.domainObject = mockDomainObject; + + mockTelemetryAPI = jasmine.createSpyObj("telemetryAPI", [ + "canProvideTelemetry", + "subscribe", + "getMetadata", + "commonValuesForHints", + "request", + "limitEvaluator", + "getValueFormatter" + ]); + mockTelemetryAPI.commonValuesForHints.andReturn([]); + mockTelemetryAPI.request.andReturn(Promise.resolve([])); + + + mockTelemetryAPI.canProvideTelemetry.andReturn(false); + + mockTimeout = jasmine.createSpy("timeout"); + mockTimeout.andReturn(1); // Return something + mockTimeout.cancel = jasmine.createSpy("cancel"); + + mockAPI = { + conductor: mockConductor, + objects: mockObjectAPI, + telemetry: mockTelemetryAPI, + composition: mockCompositionAPI + }; + controller = new TelemetryTableController(mockScope, mockTimeout, mockAPI); + }); + + describe('listens for', function () { + beforeEach(function () { + controller.registerChangeListeners(); + }); + it('object mutation', function () { + var calledObject = mockObjectAPI.observe.mostRecentCall.args[0]; + + expect(mockObjectAPI.observe).toHaveBeenCalled(); + expect(calledObject.identifier.key).toEqual(mockDomainObject.getId()); + }); + it('conductor changes', function () { + expect(mockConductor.on).toHaveBeenCalledWith("timeSystem", jasmine.any(Function)); + expect(mockConductor.on).toHaveBeenCalledWith("bounds", jasmine.any(Function)); + expect(mockConductor.on).toHaveBeenCalledWith("follow", jasmine.any(Function)); + }); + }); + + describe('deregisters all listeners on scope destruction', function () { + var timeSystemListener, + boundsListener, + followListener; + + beforeEach(function () { + controller.registerChangeListeners(); + + timeSystemListener = getCallback(mockConductor.on, "timeSystem"); + boundsListener = getCallback(mockConductor.on, "bounds"); + followListener = getCallback(mockConductor.on, "follow"); + + var destroy = getCallback(mockScope.$on, "$destroy"); + destroy(); + }); + + it('object mutation', function () { + expect(unobserve).toHaveBeenCalled(); + }); + it('conductor changes', function () { + expect(mockConductor.off).toHaveBeenCalledWith("timeSystem", timeSystemListener); + expect(mockConductor.off).toHaveBeenCalledWith("bounds", boundsListener); + expect(mockConductor.off).toHaveBeenCalledWith("follow", followListener); + }); + }); + + describe ('Subscribes to new data', function () { + var mockComposition, + mockTelemetryObject, + mockChildren, + unsubscribe, + done; + + beforeEach(function () { + mockComposition = jasmine.createSpyObj("composition", [ + "load" + ]); + + mockTelemetryObject = jasmine.createSpyObj("mockTelemetryObject", [ + "something" + ]); + mockTelemetryObject.identifier = { + key: "mockTelemetryObject" + }; + + unsubscribe = jasmine.createSpy("unsubscribe"); + mockTelemetryAPI.subscribe.andReturn(unsubscribe); + + mockChildren = [mockTelemetryObject]; + mockComposition.load.andReturn(Promise.resolve(mockChildren)); + mockCompositionAPI.get.andReturn(mockComposition); + + mockTelemetryAPI.canProvideTelemetry.andCallFake(function (obj) { + return obj.identifier.key === mockTelemetryObject.identifier.key; + }); + + done = false; + controller.getData().then(function () { + done = true; + }); + }); + + it('fetches historical data', function () { + waitsFor(function () { + return done; + }, "getData to return", 100); + + runs(function () { + expect(mockTelemetryAPI.request).toHaveBeenCalledWith(mockTelemetryObject, jasmine.any(Object)); + }); + }); + + it('fetches historical data for the time period specified by the conductor bounds', function () { + waitsFor(function () { + return done; + }, "getData to return", 100); + + runs(function () { + expect(mockTelemetryAPI.request).toHaveBeenCalledWith(mockTelemetryObject, mockBounds); + }); + }); + + it('subscribes to new data', function () { + waitsFor(function () { + return done; + }, "getData to return", 100); + + runs(function () { + expect(mockTelemetryAPI.subscribe).toHaveBeenCalledWith(mockTelemetryObject, jasmine.any(Function), {}); + }); + + }); + it('and unsubscribes on view destruction', function () { + waitsFor(function () { + return done; + }, "getData to return", 100); + + runs(function () { + var destroy = getCallback(mockScope.$on, "$destroy"); + destroy(); + + expect(unsubscribe).toHaveBeenCalled(); + }); + }); + }); + + it('When in real-time mode, enables auto-scroll', function () { + controller.registerChangeListeners(); + + var followCallback = getCallback(mockConductor.on, "follow"); + //Confirm pre-condition + expect(mockScope.autoScroll).toBeFalsy(); + + //Mock setting the conductor to 'follow' mode + followCallback(true); + expect(mockScope.autoScroll).toBe(true); + }); + + describe('populates table columns', function () { + var domainMetadata; + var allMetadata; + var mockTimeSystem; + + beforeEach(function () { + domainMetadata = [{ + key: "column1", + name: "Column 1", + hints: {} + }]; + + allMetadata = [{ + key: "column1", + name: "Column 1", + hints: {} + }, { + key: "column2", + name: "Column 2", + hints: {} + }, { + key: "column3", + name: "Column 3", + hints: {} + }]; + + mockTimeSystem = { + metadata: { + key: "column1" + } + }; + + mockTelemetryAPI.commonValuesForHints.andCallFake(function (metadata, hints) { + if (_.eq(hints, ["x"])) { + return domainMetadata; + } else if (_.eq(hints, [])) { + return allMetadata; + } + }); + + controller.loadColumns([mockDomainObject]); + }); + + it('based on metadata for given objects', function () { + expect(mockScope.headers).toBeDefined(); + expect(mockScope.headers.length).toBeGreaterThan(0); + expect(mockScope.headers.indexOf(allMetadata[0].name)).not.toBe(-1); + expect(mockScope.headers.indexOf(allMetadata[1].name)).not.toBe(-1); + expect(mockScope.headers.indexOf(allMetadata[2].name)).not.toBe(-1); + }); + + it('and sorts by column matching time system', function () { + expect(mockScope.defaultSort).not.toEqual("Column 1"); + controller.sortByTimeSystem(mockTimeSystem); + expect(mockScope.defaultSort).toEqual("Column 1"); + }); + + it('batches processing of rows for performance when receiving historical telemetry', function () { + var mockHistoricalData = [ + { + "column1": 1, + "column2": 2, + "column3": 3 + },{ + "column1": 4, + "column2": 5, + "column3": 6 + }, { + "column1": 7, + "column2": 8, + "column3": 9 + } + ]; + controller.batchSize = 2; + mockTelemetryAPI.request.andReturn(Promise.resolve(mockHistoricalData)); + controller.getHistoricalData([mockDomainObject]); + + waitsFor(function () { + return !!controller.timeoutHandle; + }, "first batch to be processed", 100); + + runs(function () { + //Verify that timeout is being used to yield process + expect(mockTimeout).toHaveBeenCalled(); + mockTimeout.mostRecentCall.args[0](); + expect(mockTimeout.calls.length).toBe(2); + mockTimeout.mostRecentCall.args[0](); + expect(mockScope.rows.length).toBe(3); + }); + }); + }); + + it('Removes telemetry rows from table when they fall out of bounds', function () { + var discardedRows = [ + {"column1": "value 1"}, + {"column2": "value 2"}, + {"column3": "value 3"} + ]; + + spyOn(controller.telemetry, "on").andCallThrough(); + + controller.registerChangeListeners(); + expect(controller.telemetry.on).toHaveBeenCalledWith("discarded", jasmine.any(Function)); + var onDiscard = getCallback(controller.telemetry.on, "discarded"); + onDiscard(discardedRows); + expect(mockScope.$broadcast).toHaveBeenCalledWith("remove:rows", discardedRows); + }); + + }); + });