From 4ae35576a5f8c6167ebd5a49da1c656adb6bc62f Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 29 Mar 2017 13:15:46 -0700 Subject: [PATCH 1/2] [Features] Added Autoflow Tabular to open source features. Fixes #1469 --- platform/features/autoflow/plugin.js | 51 +++ .../res/templates/autoflow-tabular.html | 26 ++ .../autoflow/src/AutoflowTableLinker.js | 169 +++++++++ .../autoflow/src/AutoflowTabularController.js | 324 +++++++++++++++++ .../features/autoflow/src/MCTAutoflowTable.js | 60 +++ .../autoflow/test/AutoflowTableLinkerSpec.js | 178 +++++++++ .../test/AutoflowTabularControllerSpec.js | 341 ++++++++++++++++++ .../autoflow/test/MCTAutoflowTableSpec.js | 39 ++ src/plugins/plugins.js | 10 +- 9 files changed, 1196 insertions(+), 2 deletions(-) create mode 100755 platform/features/autoflow/plugin.js create mode 100755 platform/features/autoflow/res/templates/autoflow-tabular.html create mode 100755 platform/features/autoflow/src/AutoflowTableLinker.js create mode 100755 platform/features/autoflow/src/AutoflowTabularController.js create mode 100755 platform/features/autoflow/src/MCTAutoflowTable.js create mode 100755 platform/features/autoflow/test/AutoflowTableLinkerSpec.js create mode 100755 platform/features/autoflow/test/AutoflowTabularControllerSpec.js create mode 100755 platform/features/autoflow/test/MCTAutoflowTableSpec.js diff --git a/platform/features/autoflow/plugin.js b/platform/features/autoflow/plugin.js new file mode 100755 index 0000000000..86affe181a --- /dev/null +++ b/platform/features/autoflow/plugin.js @@ -0,0 +1,51 @@ +define([ + 'text!./res/templates/autoflow-tabular.html', + './src/AutoflowTabularController', + './src/MCTAutoflowTable' +], function ( + autoflowTabularTemplate, + AutoflowTabularController, + MCTAutoflowTable +) { + return function (openmct) { + openmct.legacyRegistry.register("platform/features/autoflow", { + "name": "WARP Telemetry Adapter", + "description": "Retrieves telemetry from the WARP Server and provides related types and views.", + "resources": "res", + "extensions": { + "views": [ + { + "key": "autoflow", + "name": "Autoflow Tabular", + "cssClass": "icon-packet", + "description": "A tabular view of packet contents.", + "template": autoflowTabularTemplate, + "needs": [ + "telemetry" + ], + "delegation": true + } + ], + "controllers": [ + { + "key": "AutoflowTabularController", + "implementation": AutoflowTabularController, + "depends": [ + "$scope", + "$timeout", + "telemetrySubscriber" + ] + } + ], + "directives": [ + { + "key": "mctAutoflowTable", + "implementation": MCTAutoflowTable + } + ] + } + }); + openmct.legacyRegistry.enable("platform/features/autoflow"); + }; +}); + diff --git a/platform/features/autoflow/res/templates/autoflow-tabular.html b/platform/features/autoflow/res/templates/autoflow-tabular.html new file mode 100755 index 0000000000..59e181fc48 --- /dev/null +++ b/platform/features/autoflow/res/templates/autoflow-tabular.html @@ -0,0 +1,26 @@ +
+
+ + +
{{autoflow.updated()}}
+ +
+
+ + +
+
diff --git a/platform/features/autoflow/src/AutoflowTableLinker.js b/platform/features/autoflow/src/AutoflowTableLinker.js new file mode 100755 index 0000000000..0c9e5da5fd --- /dev/null +++ b/platform/features/autoflow/src/AutoflowTableLinker.js @@ -0,0 +1,169 @@ +/*global angular*/ +define( + [], + function () { + + /** + * The link step for the `mct-autoflow-table` directive; + * watches scope and updates the DOM appropriately. + * See documentation in `MCTAutoflowTable.js` for the rationale + * for including this directive, as well as for an explanation + * of which values are placed in scope. + * + * @constructor + * @param {Scope} scope the scope for this usage of the directive + * @param element the jqLite-wrapped element which used this directive + */ + function AutoflowTableLinker(scope, element) { + var objects, // Domain objects at last structure refresh + rows, // Number of rows from last structure refresh + priorClasses = {}, + valueSpans = {}; // Span elements to put data values in + + // Create a new name-value pair in the specified column + function createListItem(domainObject, ul) { + // Create a new li, and spans to go in it. + var li = angular.element('
  • '), + titleSpan = angular.element(''), + valueSpan = angular.element(''); + + // Place spans in the li, and li into the column. + // valueSpan must precede titleSpan in the DOM due to new CSS float approach + li.append(valueSpan).append(titleSpan); + ul.append(li); + + // Style appropriately + li.addClass('l-autoflow-row'); + titleSpan.addClass('l-autoflow-item l'); + valueSpan.addClass('l-autoflow-item r l-obj-val-format'); + + // Set text/tooltip for the name-value row + titleSpan.text(domainObject.getModel().name); + titleSpan.attr("title", domainObject.getModel().name); + + // Keep a reference to the span which will hold the + // data value, to populate in the next refreshValues call + valueSpans[domainObject.getId()] = valueSpan; + + return li; + } + + // Create a new column of name-value pairs in this table. + function createColumn(el) { + // Create a ul + var ul = angular.element('
      '); + + // Add it into the mct-autoflow-table + el.append(ul); + + // Style appropriately + ul.addClass('l-autoflow-col'); + + // Get the current col width and apply at time of column creation + // Important to do this here, as new columns could be created after + // the user has changed the width. + ul.css('width', scope.columnWidth + 'px'); + + // Return it, so some li elements can be added + return ul; + } + + // Change the width of the columns when user clicks the resize button. + function resizeColumn() { + element.find('ul').css('width', scope.columnWidth + 'px'); + } + + // Rebuild the DOM associated with this table. + function rebuild(domainObjects, rowCount) { + var activeColumn; + + // Empty out our cached span elements + valueSpans = {}; + + // Start with an empty DOM beneath this directive + element.html(""); + + // Add DOM elements for each domain object being displayed + // in this table. + domainObjects.forEach(function (object, index) { + // Start a new column if we'd run out of room + if (index % rowCount === 0) { + activeColumn = createColumn(element); + } + // Add the DOM elements for that object to whichever + // column (a `ul` element) is current. + createListItem(object, activeColumn); + }); + } + + // Update spans with values, as made available via the + // `values` attribute of this directive. + function refreshValues() { + // Get the available values + var values = scope.values || {}, + classes = scope.classes || {}; + + // Populate all spans with those values (or clear + // those spans if no value is available) + (objects || []).forEach(function (object) { + var id = object.getId(), + span = valueSpans[id], + value; + + if (span) { + // Look up the value... + value = values[id]; + // ...and convert to empty string if it's undefined + value = value === undefined ? "" : value; + span.attr("data-value", value); + + // Update the span + span.text(value); + span.attr("title", value); + span.removeClass(priorClasses[id]); + span.addClass(classes[id]); + priorClasses[id] = classes[id]; + } + // Also need stale/alert/ok class + // on span + }); + } + + // Refresh the DOM for this table, if necessary + function refreshStructure() { + // Only rebuild if number of rows or set of objects + // has changed; otherwise, our structure is still valid. + if (scope.objects !== objects || + scope.rows !== rows) { + + // Track those values to support future refresh checks + objects = scope.objects; + rows = scope.rows; + + // Rebuild the DOM + rebuild(objects || [], rows || 1); + + // Refresh all data values shown + refreshValues(); + } + } + + // Changing the domain objects in use or the number + // of rows should trigger a structure change (DOM rebuild) + scope.$watch("objects", refreshStructure); + scope.$watch("rows", refreshStructure); + + // When the current column width has been changed, resize the column + scope.$watch('columnWidth', resizeColumn); + + // When the last-updated time ticks, + scope.$watch("updated", refreshValues); + + // Update displayed values when the counter changes. + scope.$watch("counter", refreshValues); + + } + + return AutoflowTableLinker; + } +); diff --git a/platform/features/autoflow/src/AutoflowTabularController.js b/platform/features/autoflow/src/AutoflowTabularController.js new file mode 100755 index 0000000000..3d6901f5a7 --- /dev/null +++ b/platform/features/autoflow/src/AutoflowTabularController.js @@ -0,0 +1,324 @@ + +define( + ['moment'], + function (moment) { + + var ROW_HEIGHT = 16, + SLIDER_HEIGHT = 10, + INITIAL_COLUMN_WIDTH = 225, + MAX_COLUMN_WIDTH = 525, + COLUMN_WIDTH_STEP = 25, + DEBOUNCE_INTERVAL = 100, + DATE_FORMAT = "YYYY-DDD HH:mm:ss.SSS\\Z", + NOT_UPDATED = "No updates", + EMPTY_ARRAY = []; + + /** + * Responsible for supporting the autoflow tabular view. + * Implements the all-over logic which drives that view, + * mediating between template-provided areas, the included + * `mct-autoflow-table` directive, and the underlying + * domain object model. + * @constructor + */ + function AutflowTabularController( + $scope, + $timeout, + telemetrySubscriber + ) { + var filterValue = "", + filterValueLowercase = "", + subscription, + filteredObjects = [], + lastUpdated = {}, + updateText = NOT_UPDATED, + rangeValues = {}, + classes = {}, + limits = {}, + updatePending = false, + lastBounce = Number.NEGATIVE_INFINITY, + columnWidth = INITIAL_COLUMN_WIDTH, + rows = 1, + counter = 0; + + // Trigger an update of the displayed table by incrementing + // the counter that it watches. + function triggerDisplayUpdate() { + counter += 1; + } + + // Check whether or not an object's name matches the + // user-entered filter value. + function filterObject(domainObject) { + return (domainObject.getModel().name || "") + .toLowerCase() + .indexOf(filterValueLowercase) !== -1; + } + + // Comparator for sorting points back into packet order + function compareObject(objectA, objectB) { + var indexA = objectA.getModel().index || 0, + indexB = objectB.getModel().index || 0; + return indexA - indexB; + } + + // Update the list of currently-displayed objects; these + // will be the subset of currently subscribed-to objects + // which match a user-entered filter. + function doUpdateFilteredObjects() { + // Generate the list + filteredObjects = ( + subscription ? + subscription.getTelemetryObjects() : + [] + ).filter(filterObject).sort(compareObject); + + // Clear the pending flag + updatePending = false; + + // Track when this occurred, so that we can wait + // a whole before updating again. + lastBounce = Date.now(); + + triggerDisplayUpdate(); + } + + // Request an update to the list of current objects; this may + // run on a timeout to avoid excessive calls, e.g. while the user + // is typing a filter. + function updateFilteredObjects() { + // Don't do anything if an update is already scheduled + if (!updatePending) { + if (Date.now() > lastBounce + DEBOUNCE_INTERVAL) { + // Update immediately if it's been long enough + doUpdateFilteredObjects(); + } else { + // Otherwise, update later, and track that we have + // an update pending so that subsequent calls can + // be ignored. + updatePending = true; + $timeout(doUpdateFilteredObjects, DEBOUNCE_INTERVAL); + } + } + } + + // Track the latest data values for this domain object + function recordData(telemetryObject) { + // Get latest domain/range values for this object. + var id = telemetryObject.getId(), + domainValue = subscription.getDomainValue(telemetryObject), + rangeValue = subscription.getRangeValue(telemetryObject); + + // Track the most recent timestamp change observed... + if (domainValue !== undefined && domainValue !== lastUpdated[id]) { + lastUpdated[id] = domainValue; + // ... and update the displayable text for that timestamp + updateText = isNaN(domainValue) ? "" : + moment.utc(domainValue).format(DATE_FORMAT); + } + + // Store data values into the rangeValues structure, which + // will be used to populate the table itself. + // Note that we want full precision here. + rangeValues[id] = rangeValue; + + // Update limit states as well + classes[id] = limits[id] && (limits[id].evaluate({ + // This relies on external knowledge that the + // range value of a telemetry point is encoded + // in its datum as "value." + value: rangeValue + }) || {}).cssClass; + } + + + // Look at telemetry objects from the subscription; this is watched + // to detect changes from the subscription. + function subscribedTelemetry() { + return subscription ? + subscription.getTelemetryObjects() : EMPTY_ARRAY; + } + + // Update the data values which will be used to populate the table + function updateValues() { + subscribedTelemetry().forEach(recordData); + triggerDisplayUpdate(); + } + + // Getter-setter function for user-entered filter text. + function filter(value) { + // If value was specified, we're a setter + if (value !== undefined) { + // Store the new value + filterValue = value; + filterValueLowercase = value.toLowerCase(); + // Change which objects appear in the table + updateFilteredObjects(); + } + + // Always act as a getter + return filterValue; + } + + // Update the bounds (width and height) of this view; + // called from the mct-resize directive. Recalculates how + // many rows should appear in the contained table. + function setBounds(bounds) { + var availableSpace = bounds.height - SLIDER_HEIGHT; + rows = Math.max(1, Math.floor(availableSpace / ROW_HEIGHT)); + } + + // Increment the current column width, up to the defined maximum. + // When the max is hit, roll back to the default. + function increaseColumnWidth() { + columnWidth += COLUMN_WIDTH_STEP; + // Cycle down to the initial width instead of exceeding max + columnWidth = columnWidth > MAX_COLUMN_WIDTH ? + INITIAL_COLUMN_WIDTH : columnWidth; + } + + // Get displayable text for last-updated value + function updated() { + return updateText; + } + + // Unsubscribe, if a subscription is active. + function releaseSubscription() { + if (subscription) { + subscription.unsubscribe(); + subscription = undefined; + } + } + + // Update set of telemetry objects managed by this view + function updateTelemetryObjects(telemetryObjects) { + updateFilteredObjects(); + limits = {}; + telemetryObjects.forEach(function (telemetryObject) { + var id = telemetryObject.getId(); + limits[id] = telemetryObject.getCapability('limit'); + }); + } + + // Create a subscription for the represented domain object. + // This will resolve capability delegation as necessary. + function makeSubscription(domainObject) { + // Unsubscribe, if there is an existing subscription + releaseSubscription(); + + // Clear updated timestamp + lastUpdated = {}; + updateText = NOT_UPDATED; + + // Create a new subscription; telemetrySubscriber gets + // to do the meaningful work here. + subscription = domainObject && telemetrySubscriber.subscribe( + domainObject, + updateValues + ); + + // Our set of in-view telemetry objects may have changed, + // so update the set that is being passed down to the table. + updateFilteredObjects(); + } + + // Watch for changes to the set of objects which have telemetry + $scope.$watch(subscribedTelemetry, updateTelemetryObjects); + + // Watch for the represented domainObject (this field will + // be populated by mct-representation) + $scope.$watch("domainObject", makeSubscription); + + // Make sure we unsubscribe when this view is destroyed. + $scope.$on("$destroy", releaseSubscription); + + return { + /** + * Get the number of rows which should be shown in this table. + * @return {number} the number of rows to show + */ + getRows: function () { + return rows; + }, + /** + * Get the objects which should currently be displayed in + * this table. This will be watched, so the return value + * should be stable when this list is unchanging. Only + * objects which match the user-entered filter value should + * be returned here. + * @return {DomainObject[]} the domain objects to include in + * this table. + */ + getTelemetryObjects: function () { + return filteredObjects; + }, + /** + * Set the bounds (width/height) of this autoflow tabular view. + * The template must ensure that these bounds are tracked on + * the table area only. + * @param bounds the bounds; and object with `width` and + * `height` properties, both as numbers, in pixels. + */ + setBounds: setBounds, + /** + * Increments the width of the autoflow column. + * Setting does not yet persist. + */ + increaseColumnWidth: increaseColumnWidth, + /** + * Get-or-set the user-supplied filter value. + * @param {string} [value] the new filter value; omit to use + * as a getter + * @returns {string} the user-supplied filter value + */ + filter: filter, + /** + * Get all range values for use in this table. These will be + * returned as an object of key-value pairs, where keys are + * domain object IDs, and values are the most recently observed + * data values associated with those objects, formatted for + * display. + * @returns {object.} most recent values + */ + rangeValues: function () { + return rangeValues; + }, + /** + * Get CSS classes to apply to specific rows, representing limit + * states and/or stale states. These are returned as key-value + * pairs where keys are domain object IDs, and values are CSS + * classes to display for domain objects with those IDs. + * @returns {object.} CSS classes + */ + classes: function () { + return classes; + }, + /** + * Get the "last updated" text for this view; this will be + * the most recent timestamp observed for any telemetry- + * providing object, formatted for display. + * @returns {string} the time of the most recent update + */ + updated: updated, + /** + * Get the current column width, in pixels. + * @returns {number} column width + */ + columnWidth: function () { + return columnWidth; + }, + /** + * Keep a counter and increment this whenever the display + * should be updated; this will be watched by the + * `mct-autoflow-table`. + * @returns {number} a counter value + */ + counter: function () { + return counter; + } + }; + } + + return AutflowTabularController; + } +); diff --git a/platform/features/autoflow/src/MCTAutoflowTable.js b/platform/features/autoflow/src/MCTAutoflowTable.js new file mode 100755 index 0000000000..dc5c24f42c --- /dev/null +++ b/platform/features/autoflow/src/MCTAutoflowTable.js @@ -0,0 +1,60 @@ + +define( + ["./AutoflowTableLinker"], + function (AutoflowTableLinker) { + + /** + * The `mct-autoflow-table` directive specifically supports + * autoflow tabular views; it is not intended for use outside + * of that view. + * + * This directive is responsible for creating the structure + * of the table in this view, and for updating its values. + * While this is achievable using a regular Angular template, + * this is undesirable from the perspective of performance + * due to the number of watches that can be involved for large + * tables. Instead, this directive will maintain a small number + * of watches, rebuilding table structure only when necessary, + * and updating displayed values in the more common case of + * new data arriving. + * + * @constructor + */ + function MCTAutoflowTable() { + return { + // Only applicable at the element level + restrict: "E", + + // The link function; handles DOM update/manipulation + link: AutoflowTableLinker, + + // Parameters to pass from attributes into scope + scope: { + // Set of domain objects to show in the table + objects: "=", + + // Values for those objects, by ID + values: "=", + + // CSS classes to show for objects, by ID + classes: "=", + + // Number of rows to show before autoflowing + rows: "=", + + // Time of last update; watched to refresh values + updated: "=", + + // Current width of the autoflow column + columnWidth: "=", + + // A counter used to trigger display updates + counter: "=" + } + }; + } + + return MCTAutoflowTable; + + } +); diff --git a/platform/features/autoflow/test/AutoflowTableLinkerSpec.js b/platform/features/autoflow/test/AutoflowTableLinkerSpec.js new file mode 100755 index 0000000000..0ae08db4d8 --- /dev/null +++ b/platform/features/autoflow/test/AutoflowTableLinkerSpec.js @@ -0,0 +1,178 @@ + +define( + ["../src/AutoflowTableLinker"], + function (AutoflowTableLinker) { + + describe("The mct-autoflow-table linker", function () { + var cachedAngular, + mockAngular, + mockScope, + mockElement, + mockElements, + linker; + + // Utility function to generate more mock elements + function createMockElement(html) { + var mockEl = jasmine.createSpyObj( + "element-" + html, + [ + "append", + "addClass", + "removeClass", + "text", + "attr", + "html", + "css", + "find" + ] + ); + mockEl.testHtml = html; + mockEl.append.andReturn(mockEl); + mockElements.push(mockEl); + return mockEl; + } + + function createMockDomainObject(id) { + var mockDomainObject = jasmine.createSpyObj( + "domainObject-" + id, + ["getId", "getModel"] + ); + mockDomainObject.getId.andReturn(id); + mockDomainObject.getModel.andReturn({name: id.toUpperCase()}); + return mockDomainObject; + } + + function fireWatch(watchExpression, value) { + mockScope.$watch.calls.forEach(function (call) { + if (call.args[0] === watchExpression) { + call.args[1](value); + } + }); + } + + // AutoflowTableLinker accesses Angular in the global + // scope, since it is not injectable; we simulate that + // here by adding/removing it to/from the window object. + beforeEach(function () { + mockElements = []; + + mockAngular = jasmine.createSpyObj("angular", ["element"]); + mockScope = jasmine.createSpyObj("scope", ["$watch"]); + mockElement = createMockElement('
      '); + + mockAngular.element.andCallFake(createMockElement); + + if (window.angular !== undefined) { + cachedAngular = window.angular; + } + window.angular = mockAngular; + + linker = new AutoflowTableLinker(mockScope, mockElement); + }); + + afterEach(function () { + if (cachedAngular !== undefined) { + window.angular = cachedAngular; + } else { + delete window.angular; + } + }); + + it("watches for changes in inputs", function () { + expect(mockScope.$watch).toHaveBeenCalledWith( + "objects", + jasmine.any(Function) + ); + expect(mockScope.$watch).toHaveBeenCalledWith( + "rows", + jasmine.any(Function) + ); + expect(mockScope.$watch).toHaveBeenCalledWith( + "counter", + jasmine.any(Function) + ); + }); + + it("changes structure when domain objects change", function () { + // Set up scope + mockScope.rows = 4; + mockScope.objects = ['a', 'b', 'c', 'd', 'e', 'f'] + .map(createMockDomainObject); + + // Fire an update to the set of objects + fireWatch("objects"); + + // Should have rebuilt with two columns of + // four and two rows each; first, by clearing... + expect(mockElement.html).toHaveBeenCalledWith(""); + + // Should have appended two columns... + expect(mockElement.append.calls.length).toEqual(2); + + // ...which should have received two and four rows each + expect(mockElement.append.calls[0].args[0].append.calls.length) + .toEqual(4); + expect(mockElement.append.calls[1].args[0].append.calls.length) + .toEqual(2); + }); + + it("updates values", function () { + var mockSpans; + + mockScope.objects = ['a', 'b', 'c', 'd', 'e', 'f'] + .map(createMockDomainObject); + mockScope.values = { a: 0 }; + + // Fire an update to the set of values + fireWatch("objects"); + fireWatch("updated"); + + // Get all created spans + mockSpans = mockElements.filter(function (mockElem) { + return mockElem.testHtml === ''; + }); + + // First span should be a, should have gotten this value. + // This test detects, in particular, WTD-749 + expect(mockSpans[0].text).toHaveBeenCalledWith('A'); + expect(mockSpans[1].text).toHaveBeenCalledWith(0); + }); + + it("listens for changes in column width", function () { + var mockUL = createMockElement("
        "); + mockElement.find.andReturn(mockUL); + mockScope.columnWidth = 200; + fireWatch("columnWidth", mockScope.columnWidth); + expect(mockUL.css).toHaveBeenCalledWith("width", "200px"); + }); + + it("updates CSS classes", function () { + var mockSpans; + + mockScope.objects = ['a', 'b', 'c', 'd', 'e', 'f'] + .map(createMockDomainObject); + mockScope.values = { a: "a value to find" }; + mockScope.classes = { a: 'class-a' }; + + // Fire an update to the set of values + fireWatch("objects"); + fireWatch("updated"); + + // Figure out which span holds the relevant value... + mockSpans = mockElements.filter(function (mockElem) { + return mockElem.testHtml === ''; + }).filter(function (mockSpan) { + var attrCalls = mockSpan.attr.calls; + return attrCalls.some(function (call) { + return call.args[0] === 'title' && + call.args[1] === mockScope.values.a; + }); + }); + + // ...and make sure it also has had its class applied + expect(mockSpans[0].addClass) + .toHaveBeenCalledWith(mockScope.classes.a); + }); + }); + } +); diff --git a/platform/features/autoflow/test/AutoflowTabularControllerSpec.js b/platform/features/autoflow/test/AutoflowTabularControllerSpec.js new file mode 100755 index 0000000000..14edafab69 --- /dev/null +++ b/platform/features/autoflow/test/AutoflowTabularControllerSpec.js @@ -0,0 +1,341 @@ + +define( + ["../src/AutoflowTabularController"], + function (AutoflowTabularController) { + + describe("The autoflow tabular controller", function () { + var mockScope, + mockTimeout, + mockSubscriber, + mockDomainObject, + mockSubscription, + controller; + + // Fire watches that are registered as functions. + function fireFnWatches() { + mockScope.$watch.calls.forEach(function (call) { + if (typeof call.args[0] === 'function') { + call.args[1](call.args[0]()); + } + }); + } + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + "$scope", + ["$on", "$watch"] + ); + mockTimeout = jasmine.createSpy("$timeout"); + mockSubscriber = jasmine.createSpyObj( + "telemetrySubscriber", + ["subscribe"] + ); + mockDomainObject = jasmine.createSpyObj( + "domainObject", + ["getId", "getModel", "getCapability"] + ); + mockSubscription = jasmine.createSpyObj( + "subscription", + [ + "unsubscribe", + "getTelemetryObjects", + "getDomainValue", + "getRangeValue" + ] + ); + + mockSubscriber.subscribe.andReturn(mockSubscription); + mockDomainObject.getModel.andReturn({name: "something"}); + + controller = new AutoflowTabularController( + mockScope, + mockTimeout, + mockSubscriber + ); + }); + + it("listens for the represented domain object", function () { + expect(mockScope.$watch).toHaveBeenCalledWith( + "domainObject", + jasmine.any(Function) + ); + }); + + it("provides a getter-setter function for filtering", function () { + expect(controller.filter()).toEqual(""); + controller.filter("something"); + expect(controller.filter()).toEqual("something"); + }); + + it("tracks bounds and adjust number of rows accordingly", function () { + // Rows are 15px high, and need room for an 10px slider + controller.setBounds({ width: 700, height: 120 }); + expect(controller.getRows()).toEqual(6); // 110 usable height / 16px + controller.setBounds({ width: 700, height: 240 }); + expect(controller.getRows()).toEqual(14); // 230 usable height / 16px + }); + + it("subscribes to a represented object's telemetry", function () { + // Set up subscription, scope + mockSubscription.getTelemetryObjects + .andReturn([mockDomainObject]); + mockScope.domainObject = mockDomainObject; + + // Invoke the watcher with represented domain object + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + + // Should have subscribed to it + expect(mockSubscriber.subscribe).toHaveBeenCalledWith( + mockDomainObject, + jasmine.any(Function) + ); + + // Should report objects as reported from subscription + expect(controller.getTelemetryObjects()) + .toEqual([mockDomainObject]); + }); + + it("releases subscriptions on destroy", function () { + // Set up subscription... + mockSubscription.getTelemetryObjects + .andReturn([mockDomainObject]); + mockScope.domainObject = mockDomainObject; + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + + // Verify precondition + expect(mockSubscription.unsubscribe).not.toHaveBeenCalled(); + + // Make sure we're listening for $destroy + expect(mockScope.$on).toHaveBeenCalledWith( + "$destroy", + jasmine.any(Function) + ); + + // Fire a destroy event + mockScope.$on.mostRecentCall.args[1](); + + // Should have unsubscribed + expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + }); + + it("presents latest values and latest update state", function () { + // Make sure values are available + mockSubscription.getDomainValue.andReturn(402654321123); + mockSubscription.getRangeValue.andReturn(789); + mockDomainObject.getId.andReturn('testId'); + + // Set up subscription... + mockSubscription.getTelemetryObjects + .andReturn([mockDomainObject]); + mockScope.domainObject = mockDomainObject; + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + + // Fire subscription callback + mockSubscriber.subscribe.mostRecentCall.args[1](); + + // ...and exposed the results for template to consume + expect(controller.updated()).toEqual("1982-278 08:25:21.123Z"); + expect(controller.rangeValues().testId).toEqual(789); + }); + + it("sorts domain objects by index", function () { + var testIndexes = { a: 2, b: 1, c: 3, d: 0 }, + mockDomainObjects = Object.keys(testIndexes).sort().map(function (id) { + var mockDomainObj = jasmine.createSpyObj( + "domainObject", + ["getId", "getModel"] + ); + + mockDomainObj.getId.andReturn(id); + mockDomainObj.getModel.andReturn({ index: testIndexes[id] }); + + return mockDomainObj; + }); + + // Expose those domain objects... + mockSubscription.getTelemetryObjects.andReturn(mockDomainObjects); + mockScope.domainObject = mockDomainObject; + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + + // Fire subscription callback + mockSubscriber.subscribe.mostRecentCall.args[1](); + + // Controller should expose same objects, but sorted by index from model + expect(controller.getTelemetryObjects()).toEqual([ + mockDomainObjects[3], // d, index=0 + mockDomainObjects[1], // b, index=1 + mockDomainObjects[0], // a, index=2 + mockDomainObjects[2] // c, index=3 + ]); + }); + + it("uses a timeout to throttle update", function () { + // Set up subscription... + mockSubscription.getTelemetryObjects + .andReturn([mockDomainObject]); + mockScope.domainObject = mockDomainObject; + + // Set the object in view; should not need a timeout + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + expect(mockTimeout.calls.length).toEqual(0); + + // Next call should schedule an update on a timeout + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + expect(mockTimeout.calls.length).toEqual(1); + + // ...but this last one should not, since existing + // timeout will cover it + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + expect(mockTimeout.calls.length).toEqual(1); + }); + + it("allows changing column width", function () { + var initialWidth = controller.columnWidth(); + controller.increaseColumnWidth(); + expect(controller.columnWidth()).toBeGreaterThan(initialWidth); + }); + + describe("filter", function () { + var doFilter, + filteredObjects, + filteredObjectNames; + + beforeEach(function () { + var telemetryObjects, + updateFilteredObjects; + + telemetryObjects = [ + 'DEF123', + 'abc789', + '456abc', + '4ab3cdef', + 'hjs[12].*(){}^\\' + ].map(function (objectName, index) { + var mockTelemetryObject = jasmine.createSpyObj( + objectName, + ["getId", "getModel"] + ); + + mockTelemetryObject.getId.andReturn(objectName); + mockTelemetryObject.getModel.andReturn({ + name: objectName, + index: index + }); + + return mockTelemetryObject; + }); + + mockSubscription + .getTelemetryObjects + .andReturn(telemetryObjects); + + // Trigger domainObject change to create subscription. + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + + updateFilteredObjects = function () { + filteredObjects = controller.getTelemetryObjects(); + filteredObjectNames = filteredObjects.map(function (o) { + return o.getModel().name; + }); + }; + + doFilter = function (term) { + controller.filter(term); + // Filter is debounced so we have to force it to occur. + mockTimeout.mostRecentCall.args[0](); + updateFilteredObjects(); + }; + + updateFilteredObjects(); + }); + + it("initially shows all objects", function () { + expect(filteredObjectNames).toEqual([ + 'DEF123', + 'abc789', + '456abc', + '4ab3cdef', + 'hjs[12].*(){}^\\' + ]); + }); + + it("by blank string matches all objects", function () { + doFilter(''); + expect(filteredObjectNames).toEqual([ + 'DEF123', + 'abc789', + '456abc', + '4ab3cdef', + 'hjs[12].*(){}^\\' + ]); + }); + + it("exactly matches an object name", function () { + doFilter('4ab3cdef'); + expect(filteredObjectNames).toEqual(['4ab3cdef']); + }); + + it("partially matches object names", function () { + doFilter('abc'); + expect(filteredObjectNames).toEqual([ + 'abc789', + '456abc' + ]); + }); + + it("matches case insensitive names", function () { + doFilter('def'); + expect(filteredObjectNames).toEqual([ + 'DEF123', + '4ab3cdef' + ]); + }); + + it("works as expected with special characters", function () { + doFilter('[12]'); + expect(filteredObjectNames).toEqual(['hjs[12].*(){}^\\']); + doFilter('.*'); + expect(filteredObjectNames).toEqual(['hjs[12].*(){}^\\']); + doFilter('.*()'); + expect(filteredObjectNames).toEqual(['hjs[12].*(){}^\\']); + doFilter('.*?'); + expect(filteredObjectNames).toEqual([]); + doFilter('.+'); + expect(filteredObjectNames).toEqual([]); + }); + + it("exposes CSS classes from limits", function () { + var id = mockDomainObject.getId(), + testClass = "some-css-class", + mockLimitCapability = + jasmine.createSpyObj('limit', ['evaluate']); + + mockDomainObject.getCapability.andCallFake(function (key) { + return key === 'limit' && mockLimitCapability; + }); + mockLimitCapability.evaluate + .andReturn({ cssClass: testClass }); + + mockSubscription.getTelemetryObjects + .andReturn([mockDomainObject]); + + fireFnWatches(); + mockSubscriber.subscribe.mostRecentCall.args[1](); + + expect(controller.classes()[id]).toEqual(testClass); + }); + + it("exposes a counter that changes with each update", function () { + var i, prior; + + for (i = 0; i < 10; i += 1) { + prior = controller.counter(); + expect(controller.counter()).toEqual(prior); + mockSubscriber.subscribe.mostRecentCall.args[1](); + expect(controller.counter()).not.toEqual(prior); + } + }); + }); + }); + } +); diff --git a/platform/features/autoflow/test/MCTAutoflowTableSpec.js b/platform/features/autoflow/test/MCTAutoflowTableSpec.js new file mode 100755 index 0000000000..d86777b9b5 --- /dev/null +++ b/platform/features/autoflow/test/MCTAutoflowTableSpec.js @@ -0,0 +1,39 @@ + +define( + ["../src/MCTAutoflowTable"], + function (MCTAutoflowTable) { + + describe("The mct-autoflow-table directive", function () { + var mctAutoflowTable; + + beforeEach(function () { + mctAutoflowTable = new MCTAutoflowTable(); + }); + + // Real functionality is contained/tested in the linker, + // so just check to make sure we're exposing the directive + // appropriately. + it("is applicable at the element level", function () { + expect(mctAutoflowTable.restrict).toEqual("E"); + }); + + it("two-ways binds needed scope variables", function () { + expect(mctAutoflowTable.scope).toEqual({ + objects: "=", + values: "=", + rows: "=", + updated: "=", + classes: "=", + columnWidth: "=", + counter: "=" + }); + }); + + it("provides a link function", function () { + expect(mctAutoflowTable.link).toEqual(jasmine.any(Function)); + }); + + + }); + } +); diff --git a/src/plugins/plugins.js b/src/plugins/plugins.js index 428a7015a5..c1c5962923 100644 --- a/src/plugins/plugins.js +++ b/src/plugins/plugins.js @@ -23,11 +23,13 @@ define([ 'lodash', '../../platform/features/conductor/utcTimeSystem/src/UTCTimeSystem', - '../../example/generator/plugin' + '../../example/generator/plugin', + '../../platform/features/autoflow/plugin' ], function ( _, UTCTimeSystem, - GeneratorPlugin + GeneratorPlugin, + AutoflowPlugin ) { var bundleMap = { CouchDB: 'platform/persistence/couch', @@ -55,6 +57,10 @@ define([ }; }; + plugins.AutoflowView = function () { + return AutoflowPlugin; + }; + var conductorInstalled = false; plugins.Conductor = function (options) { From 59c61e72b81494cbca910fd0c0c29223fbf218ca Mon Sep 17 00:00:00 2001 From: Andrew Henry Date: Wed, 29 Mar 2017 17:15:29 -0700 Subject: [PATCH 2/2] [Features] Added option to specify a type to exclusively apply the Autoflow view to --- platform/features/autoflow/plugin.js | 81 ++++++++++++++-------------- src/plugins/plugins.js | 13 +++-- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/platform/features/autoflow/plugin.js b/platform/features/autoflow/plugin.js index 86affe181a..5d17cfbf09 100755 --- a/platform/features/autoflow/plugin.js +++ b/platform/features/autoflow/plugin.js @@ -7,45 +7,48 @@ define([ AutoflowTabularController, MCTAutoflowTable ) { - return function (openmct) { - openmct.legacyRegistry.register("platform/features/autoflow", { - "name": "WARP Telemetry Adapter", - "description": "Retrieves telemetry from the WARP Server and provides related types and views.", - "resources": "res", - "extensions": { - "views": [ - { - "key": "autoflow", - "name": "Autoflow Tabular", - "cssClass": "icon-packet", - "description": "A tabular view of packet contents.", - "template": autoflowTabularTemplate, - "needs": [ - "telemetry" - ], - "delegation": true - } - ], - "controllers": [ - { - "key": "AutoflowTabularController", - "implementation": AutoflowTabularController, - "depends": [ - "$scope", - "$timeout", - "telemetrySubscriber" - ] - } - ], - "directives": [ - { - "key": "mctAutoflowTable", - "implementation": MCTAutoflowTable - } - ] - } - }); - openmct.legacyRegistry.enable("platform/features/autoflow"); + return function (options) { + return function (openmct) { + openmct.legacyRegistry.register("platform/features/autoflow", { + "name": "WARP Telemetry Adapter", + "description": "Retrieves telemetry from the WARP Server and provides related types and views.", + "resources": "res", + "extensions": { + "views": [ + { + "key": "autoflow", + "name": "Autoflow Tabular", + "cssClass": "icon-packet", + "description": "A tabular view of packet contents.", + "template": autoflowTabularTemplate, + "type": options && options.type, + "needs": [ + "telemetry" + ], + "delegation": true + } + ], + "controllers": [ + { + "key": "AutoflowTabularController", + "implementation": AutoflowTabularController, + "depends": [ + "$scope", + "$timeout", + "telemetrySubscriber" + ] + } + ], + "directives": [ + { + "key": "mctAutoflowTable", + "implementation": MCTAutoflowTable + } + ] + } + }); + openmct.legacyRegistry.enable("platform/features/autoflow"); + }; }; }); diff --git a/src/plugins/plugins.js b/src/plugins/plugins.js index c1c5962923..70730a44dc 100644 --- a/src/plugins/plugins.js +++ b/src/plugins/plugins.js @@ -57,9 +57,16 @@ define([ }; }; - plugins.AutoflowView = function () { - return AutoflowPlugin; - }; + /** + * A tabular view showing the latest values of multiple telemetry points at + * once. Formatted so that labels and values are aligned. + * + * @param {Object} [options] Optional settings to apply to the autoflow + * tabular view. Currently supports one option, 'type'. + * @param {string} [options.type] The key of an object type to apply this view + * to exclusively. + */ + plugins.AutoflowView = AutoflowPlugin; var conductorInstalled = false;