diff --git a/platform/core/bundle.json b/platform/core/bundle.json index be1f181f44..4b33f48f35 100644 --- a/platform/core/bundle.json +++ b/platform/core/bundle.json @@ -183,7 +183,7 @@ { "key": "mutation", "implementation": "capabilities/MutationCapability.js", - "depends": [ "now" ] + "depends": [ "topic", "now" ] }, { "key": "delegation", @@ -200,6 +200,10 @@ "key": "throttle", "implementation": "services/Throttle.js", "depends": [ "$timeout" ] + }, + { + "key": "topic", + "implementation": "services/Topic.js" } ], "roots": [ diff --git a/platform/core/src/capabilities/MutationCapability.js b/platform/core/src/capabilities/MutationCapability.js index c20c90deab..4268f7c323 100644 --- a/platform/core/src/capabilities/MutationCapability.js +++ b/platform/core/src/capabilities/MutationCapability.js @@ -29,6 +29,8 @@ define( function () { "use strict"; + var TOPIC_PREFIX = "mutation:"; + // Utility function to overwrite a destination object // with the contents of a source object. function copyValues(destination, source) { @@ -71,7 +73,8 @@ define( * which will expose this capability * @constructor */ - function MutationCapability(now, domainObject) { + function MutationCapability(topic, now, domainObject) { + var t = topic(TOPIC_PREFIX + domainObject.getId()); function mutate(mutator, timestamp) { // Get the object's model and clone it, so the @@ -96,6 +99,7 @@ define( copyValues(model, result); } model.modified = useTimestamp ? timestamp : now(); + t.notify(model); } // Report the result of the mutation @@ -107,6 +111,10 @@ define( return fastPromise(mutator(clone)).then(handleMutation); } + function listen(listener) { + return t.listen(listener); + } + return { /** * Alias of `mutate`, used to support useCapability. @@ -139,10 +147,19 @@ define( * @returns {Promise.} a promise for the result * of the mutation; true if changes were made. */ - mutate: mutate + mutate: mutate, + /** + * Listen for mutations of this domain object's model. + * The provided listener will be invoked with the domain + * object's new model after any changes. To stop listening, + * invoke the function returned by this method. + * @param {Function} listener function to call on mutation + * @returns {Function} a function to stop listening + */ + listen: listen }; } return MutationCapability; } -); \ No newline at end of file +); diff --git a/platform/core/src/services/Throttle.js b/platform/core/src/services/Throttle.js index 0c86a403c7..c0493a733a 100644 --- a/platform/core/src/services/Throttle.js +++ b/platform/core/src/services/Throttle.js @@ -1,3 +1,24 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ /*global define*/ define( diff --git a/platform/core/src/services/Topic.js b/platform/core/src/services/Topic.js new file mode 100644 index 0000000000..894274b71c --- /dev/null +++ b/platform/core/src/services/Topic.js @@ -0,0 +1,87 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * The `topic` service provides a way to create both named, + * shared listeners and anonymous, private listeners. + * + * Usage: + * + * ``` + * var t = topic('foo'); // Use/create a named topic + * t.listen(function () { ... }); + * t.notify({ some: "message" }); + * ``` + * + * Named topics are shared; multiple calls to `topic` + * with the same argument will return a single object instance. + * Anonymous topics (where `topic`has been called with no + * arguments) are private; each call returns a new instance. + * + * @returns {Function} + */ + function Topic() { + var topics = {}; + + function createTopic() { + var listeners = []; + + return { + listen: function (listener) { + listeners.push(listener); + return function unlisten() { + listeners = listeners.filter(function (l) { + return l !== listener; + }); + }; + }, + notify: function (message) { + listeners.forEach(function (listener) { + listener(message); + }); + } + }; + } + + /** + * Use and (if necessary) create a new topic. + * @param {string} [key] name of the topic to use + */ + return function (key) { + if (arguments.length < 1) { + return createTopic(); + } else { + topics[key] = topics[key] || createTopic(); + return topics[key]; + } + }; + } + + return Topic; + } +); diff --git a/platform/core/test/capabilities/MutationCapabilitySpec.js b/platform/core/test/capabilities/MutationCapabilitySpec.js index 2dadfa7dfa..434ddfb098 100644 --- a/platform/core/test/capabilities/MutationCapabilitySpec.js +++ b/platform/core/test/capabilities/MutationCapabilitySpec.js @@ -25,21 +25,33 @@ * MutationCapabilitySpec. Created by vwoeltje on 11/6/14. */ define( - ["../../src/capabilities/MutationCapability"], - function (MutationCapability) { + [ + "../../src/capabilities/MutationCapability", + "../../src/services/Topic" + ], + function (MutationCapability, Topic) { "use strict"; describe("The mutation capability", function () { var testModel, + topic, mockNow, - domainObject = { getModel: function () { return testModel; } }, + domainObject = { + getId: function () { return "test-id"; }, + getModel: function () { return testModel; } + }, mutation; beforeEach(function () { testModel = { number: 6 }; + topic = new Topic(); mockNow = jasmine.createSpy('now'); mockNow.andReturn(12321); - mutation = new MutationCapability(mockNow, domainObject); + mutation = new MutationCapability( + topic, + mockNow, + domainObject + ); }); it("allows mutation of a model", function () { @@ -83,6 +95,42 @@ define( // Should have gotten a timestamp from 'now' expect(testModel.modified).toEqual(42); }); + + it("notifies listeners of mutation", function () { + var mockCallback = jasmine.createSpy('callback'); + mutation.listen(mockCallback); + mutation.invoke(function (m) { + m.number = 8; + }); + expect(mockCallback).toHaveBeenCalled(); + expect(mockCallback.mostRecentCall.args[0].number) + .toEqual(8); + }); + + it("allows listeners to stop listening", function () { + var mockCallback = jasmine.createSpy('callback'); + mutation.listen(mockCallback)(); // Unlisten immediately + mutation.invoke(function (m) { + m.number = 8; + }); + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it("shares listeners across instances", function () { + var mockCallback = jasmine.createSpy('callback'), + otherMutation = new MutationCapability( + topic, + mockNow, + domainObject + ); + mutation.listen(mockCallback); + otherMutation.invoke(function (m) { + m.number = 8; + }); + expect(mockCallback).toHaveBeenCalled(); + expect(mockCallback.mostRecentCall.args[0].number) + .toEqual(8); + }); }); } -); \ No newline at end of file +); diff --git a/platform/core/test/services/ThrottleSpec.js b/platform/core/test/services/ThrottleSpec.js index 173fad8006..bcaf2af363 100644 --- a/platform/core/test/services/ThrottleSpec.js +++ b/platform/core/test/services/ThrottleSpec.js @@ -1,3 +1,24 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( diff --git a/platform/core/test/services/TopicSpec.js b/platform/core/test/services/TopicSpec.js new file mode 100644 index 0000000000..b389b19579 --- /dev/null +++ b/platform/core/test/services/TopicSpec.js @@ -0,0 +1,70 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/services/Topic"], + function (Topic) { + "use strict"; + + describe("The 'topic' service", function () { + var topic, + testMessage, + mockCallback; + + beforeEach(function () { + testMessage = { someKey: "some value"}; + mockCallback = jasmine.createSpy('callback'); + topic = new Topic(); + }); + + it("notifies listeners on a topic", function () { + topic("abc").listen(mockCallback); + topic("abc").notify(testMessage); + expect(mockCallback).toHaveBeenCalledWith(testMessage); + }); + + it("does not notify listeners across topics", function () { + topic("abc").listen(mockCallback); + topic("xyz").notify(testMessage); + expect(mockCallback).not.toHaveBeenCalledWith(testMessage); + }); + + it("does not notify listeners after unlistening", function () { + topic("abc").listen(mockCallback)(); // Unlisten immediately + topic("abc").notify(testMessage); + expect(mockCallback).not.toHaveBeenCalledWith(testMessage); + }); + + it("provides anonymous private topics", function () { + var t1 = topic(), t2 = topic(); + + t1.listen(mockCallback); + t2.notify(testMessage); + expect(mockCallback).not.toHaveBeenCalledWith(testMessage); + t1.notify(testMessage); + expect(mockCallback).toHaveBeenCalledWith(testMessage); + }); + + }); + } +); diff --git a/platform/core/test/suite.json b/platform/core/test/suite.json index 9c939acf5e..acc7391d02 100644 --- a/platform/core/test/suite.json +++ b/platform/core/test/suite.json @@ -25,6 +25,7 @@ "services/Now", "services/Throttle", + "services/Topic", "types/MergeModels", "types/TypeCapability", @@ -35,4 +36,4 @@ "views/ViewCapability", "views/ViewProvider" -] \ No newline at end of file +] diff --git a/platform/features/plot/src/elements/PlotUpdater.js b/platform/features/plot/src/elements/PlotUpdater.js index f05632d8f3..08485763d5 100644 --- a/platform/features/plot/src/elements/PlotUpdater.js +++ b/platform/features/plot/src/elements/PlotUpdater.js @@ -52,6 +52,7 @@ define( origin = [0, 0], domainExtrema, rangeExtrema, + buffers = {}, bufferArray = [], domainOffset; @@ -63,11 +64,10 @@ define( // Check if this set of ids matches the current set of ids // (used to detect if line preparation can be skipped) function idsMatch(nextIds) { - return nextIds.map(function (id, index) { - return ids[index] === id; - }).reduce(function (a, b) { - return a && b; - }, true); + return ids.length === nextIds.length && + nextIds.every(function (id, index) { + return ids[index] === id; + }); } // Prepare plot lines for this group of telemetry objects @@ -76,7 +76,7 @@ define( next = {}; // Detect if we already have everything we need prepared - if (ids.length === nextIds.length && idsMatch(nextIds)) { + if (idsMatch(nextIds)) { // Nothing to prepare, move on return; } @@ -90,13 +90,13 @@ define( // Create buffers for these objects bufferArray = ids.map(function (id) { - var buffer = new PlotLineBuffer( - domainOffset, - INITIAL_SIZE, - maxPoints - ); - next[id] = lines[id] || new PlotLine(buffer); - return buffer; + buffers[id] = buffers[id] || new PlotLineBuffer( + domainOffset, + INITIAL_SIZE, + maxPoints + ); + next[id] = lines[id] || new PlotLine(buffers[id]); + return buffers[id]; }); } diff --git a/platform/telemetry/src/TelemetrySubscription.js b/platform/telemetry/src/TelemetrySubscription.js index 844f1ab62b..808d8c5c5d 100644 --- a/platform/telemetry/src/TelemetrySubscription.js +++ b/platform/telemetry/src/TelemetrySubscription.js @@ -59,6 +59,7 @@ define( telemetryObjects = [], pool = lossless ? new TelemetryQueue() : new TelemetryTable(), metadatas, + unlistenToMutation, updatePending; // Look up domain objects which have telemetry capabilities. @@ -146,23 +147,59 @@ define( telemetryObjects = objects; metadatas = objects.map(lookupMetadata); // Fire callback, as this will be the first time that - // telemetry objects are available + // telemetry objects are available, or these objects + // will have changed. if (callback) { callback(); } return objects; } - // Get a reference to relevant objects (those with telemetry - // capabilities) and subscribe to their telemetry updates. - // Keep a reference to their promised return values, as these - // will be unsubscribe functions. (This must be a promise - // because delegation is supported, and retrieving delegate - // telemetry-capable objects may be an asynchronous operation.) - telemetryObjectPromise = promiseRelevantObjects(domainObject); - unsubscribePromise = telemetryObjectPromise - .then(cacheObjectReferences) - .then(subscribeAll); + function unsubscribeAll() { + return unsubscribePromise.then(function (unsubscribes) { + return $q.all(unsubscribes.map(function (unsubscribe) { + return unsubscribe(); + })); + }); + } + + function initialize() { + // Get a reference to relevant objects (those with telemetry + // capabilities) and subscribe to their telemetry updates. + // Keep a reference to their promised return values, as these + // will be unsubscribe functions. (This must be a promise + // because delegation is supported, and retrieving delegate + // telemetry-capable objects may be an asynchronous operation.) + telemetryObjectPromise = promiseRelevantObjects(domainObject); + unsubscribePromise = telemetryObjectPromise + .then(cacheObjectReferences) + .then(subscribeAll); + } + + function idsMatch(ids) { + return ids.length === telemetryObjects.length && + ids.every(function (id, index) { + return telemetryObjects[index].getId() === id; + }); + } + + function modelChange(model) { + if (!idsMatch((model || {}).composition || [])) { + // Reinitialize if composition has changed + unsubscribeAll().then(initialize); + } + } + + function addMutationListener() { + var mutation = domainObject && + domainObject.getCapability('mutation'); + if (mutation) { + return mutation.listen(modelChange); + } + } + + initialize(); + unlistenToMutation = addMutationListener(); return { /** @@ -172,11 +209,10 @@ define( * @memberof TelemetrySubscription */ unsubscribe: function () { - return unsubscribePromise.then(function (unsubscribes) { - return $q.all(unsubscribes.map(function (unsubscribe) { - return unsubscribe(); - })); - }); + if (unlistenToMutation) { + unlistenToMutation(); + } + return unsubscribeAll(); }, /** * Get the most recent domain value that has been observed @@ -264,4 +300,4 @@ define( return TelemetrySubscription; } -); \ No newline at end of file +); diff --git a/platform/telemetry/test/TelemetrySubscriptionSpec.js b/platform/telemetry/test/TelemetrySubscriptionSpec.js index 458a2f685e..1715504b6e 100644 --- a/platform/telemetry/test/TelemetrySubscriptionSpec.js +++ b/platform/telemetry/test/TelemetrySubscriptionSpec.js @@ -32,7 +32,9 @@ define( mockDomainObject, mockCallback, mockTelemetry, + mockMutation, mockUnsubscribe, + mockUnlisten, mockSeries, testMetadata, subscription; @@ -59,7 +61,12 @@ define( "telemetry", ["subscribe", "getMetadata"] ); + mockMutation = jasmine.createSpyObj( + "mutation", + ["mutate", "listen"] + ); mockUnsubscribe = jasmine.createSpy("unsubscribe"); + mockUnlisten = jasmine.createSpy("unlisten"); mockSeries = jasmine.createSpyObj( "series", [ "getPointCount", "getDomainValue", "getRangeValue" ] @@ -68,12 +75,19 @@ define( mockQ.when.andCallFake(mockPromise); mockDomainObject.hasCapability.andReturn(true); - mockDomainObject.getCapability.andReturn(mockTelemetry); + mockDomainObject.getCapability.andCallFake(function (c) { + return { + telemetry: mockTelemetry, + mutation: mockMutation + }[c]; + }); mockDomainObject.getId.andReturn('test-id'); mockTelemetry.subscribe.andReturn(mockUnsubscribe); mockTelemetry.getMetadata.andReturn(testMetadata); + mockMutation.listen.andReturn(mockUnlisten); + mockSeries.getPointCount.andReturn(42); mockSeries.getDomainValue.andReturn(123456); mockSeries.getRangeValue.andReturn(789); @@ -213,6 +227,22 @@ define( expect(mockCallback2) .toHaveBeenCalledWith([ mockDomainObject ]); }); + + it("reinitializes on mutation", function () { + expect(mockTelemetry.subscribe.calls.length).toEqual(1); + // Notify of a mutation which appears to change composition + mockMutation.listen.mostRecentCall.args[0]({ + composition: ['Z'] + }); + // Use subscribe call as an indication of reinitialization + expect(mockTelemetry.subscribe.calls.length).toEqual(2); + }); + + it("stops listening for mutation on unsubscribe", function () { + expect(mockUnlisten).not.toHaveBeenCalled(); + subscription.unsubscribe(); + expect(mockUnlisten).toHaveBeenCalled(); + }); }); } -); \ No newline at end of file +);