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 @@
+
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) {