Merge branch 'open1033' into open-master

This commit is contained in:
Victor Woeltjen 2015-04-06 08:20:02 -07:00
commit 58d66871c1
57 changed files with 2411 additions and 103 deletions

View File

@ -12,8 +12,8 @@
"platform/features/plot",
"platform/features/scrolling",
"platform/forms",
"platform/persistence/cache",
"platform/persistence/couch",
"platform/persistence/queue",
"platform/persistence/elastic",
"example/generator"
]

View File

@ -0,0 +1,27 @@
This bundle provides `dialogService`, which can be used to prompt
for user input.
## `getUserChoice`
The `getUserChoice` method is useful for displaying a message and a set of
buttons. This method returns a promise which will resolve to the user's
chosen option (or, more specifically, its `key`), and will be rejected if
the user closes the dialog with the X in the top-right;
The `dialogModel` given as an argument to this method should have the
following properties.
* `title`: The title to display at the top of the dialog.
* `hint`: Short message to display below the title.
* `template`: Identifying key (as will be passed to `mct-include`) for
the template which will be used to populate the inner area of the dialog.
* `model`: Model to pass in the `ng-model` attribute of
`mct-include`.
* `parameters`: Parameters to pass in the `parameters` attribute of
`mct-include`.
* `options`: An array of options describing each button at the bottom.
Each option may have the following properties:
* `name`: Human-readable name to display in the button.
* `key`: Machine-readable key, to pass as the result of the resolved
promise when clicked.
* `description`: Description to show in tool tip on hover.

View File

@ -17,6 +17,10 @@
"key": "overlay-dialog",
"templateUrl": "templates/overlay-dialog.html"
},
{
"key": "overlay-options",
"templateUrl": "templates/overlay-options.html"
},
{
"key": "form-dialog",
"templateUrl": "templates/dialog.html"

View File

@ -0,0 +1,24 @@
<mct-container key="overlay">
<div class="abs top-bar">
<div class="title">{{ngModel.dialog.title}}</div>
<div class="hint">{{ngModel.dialog.hint}}</div>
</div>
<div class="abs form outline editor">
<div class='abs contents l-dialog'>
<mct-include key="ngModel.dialog.template"
parameters="ngModel.dialog.parameters"
ng-model="ngModel.dialog.model">
</mct-include>
</div>
</div>
<div class="abs bottom-bar">
<a ng-repeat="option in ngModel.dialog.options"
href=''
class="btn lg"
title="{{option.description}}"
ng-click="ngModel.confirm(option.key)"
ng-class="{ major: $first, subtle: !$first }">
{{option.name}}
</a>
</div>
</mct-container>

View File

@ -26,7 +26,7 @@ define(
dialogVisible = false;
}
function getUserInput(formModel, value) {
function getDialogResponse(key, model, resultGetter) {
// We will return this result as a promise, because user
// input is asynchronous.
var deferred = $q.defer(),
@ -35,9 +35,9 @@ define(
// Confirm function; this will be passed in to the
// overlay-dialog template and associated with a
// OK button click
function confirm() {
function confirm(value) {
// Pass along the result
deferred.resolve(overlayModel.value);
deferred.resolve(resultGetter ? resultGetter() : value);
// Stop showing the dialog
dismiss();
@ -51,6 +51,10 @@ define(
dismiss();
}
// Add confirm/cancel callbacks
model.confirm = confirm;
model.cancel = cancel;
if (dialogVisible) {
// Only one dialog should be shown at a time.
// The application design should be such that
@ -58,26 +62,15 @@ define(
$log.warn([
"Dialog already showing; ",
"unable to show ",
formModel.name
model.name
].join(""));
deferred.reject();
} else {
// To be passed to the overlay-dialog template,
// via ng-model
overlayModel = {
title: formModel.name,
message: formModel.message,
structure: formModel,
value: value,
confirm: confirm,
cancel: cancel
};
// Add the overlay using the OverlayService, which
// will handle actual insertion into the DOM
overlay = overlayService.createOverlay(
"overlay-dialog",
overlayModel
key,
model
);
// Track that a dialog is already visible, to
@ -88,6 +81,35 @@ define(
return deferred.promise;
}
function getUserInput(formModel, value) {
var overlayModel = {
title: formModel.name,
message: formModel.message,
structure: formModel,
value: value
};
// Provide result from the model
function resultGetter() {
return overlayModel.value;
}
// Show the overlay-dialog
return getDialogResponse(
"overlay-dialog",
overlayModel,
resultGetter
);
}
function getUserChoice(dialogModel) {
// Show the overlay-options dialog
return getDialogResponse(
"overlay-options",
{ dialog: dialogModel }
);
}
return {
/**
* Request user input via a window-modal dialog.
@ -100,7 +122,14 @@ define(
* user input cannot be obtained (for instance,
* because the user cancelled the dialog)
*/
getUserInput: getUserInput
getUserInput: getUserInput,
/**
* Request that the user chooses from a set of options,
* which will be shown as buttons.
*
* @param dialogModel a description of the dialog to show
*/
getUserChoice: getUserChoice
};
}

View File

@ -86,6 +86,19 @@ define(
expect(mockDeferred.reject).not.toHaveBeenCalled();
});
it("provides an options dialogs", function () {
var dialogModel = {};
dialogService.getUserChoice(dialogModel);
expect(mockOverlayService.createOverlay).toHaveBeenCalledWith(
'overlay-options',
{
dialog: dialogModel,
confirm: jasmine.any(Function),
cancel: jasmine.any(Function)
}
);
});
});
}
);

View File

@ -10,7 +10,7 @@
{
"key": "EditController",
"implementation": "controllers/EditController.js",
"depends": [ "$scope", "navigationService" ]
"depends": [ "$scope", "$q", "navigationService" ]
},
{
"key": "EditActionController",

View File

@ -13,24 +13,18 @@ define(
function SaveAction($location, context) {
var domainObject = context.domainObject;
// Look up the object's "editor.completion" capability;
// Invoke any save behavior introduced by the editor capability;
// this is introduced by EditableDomainObject which is
// used to insulate underlying objects from changes made
// during editing.
function getEditorCapability() {
return domainObject.getCapability("editor");
}
// Invoke any save behavior introduced by the editor.completion
// capability.
function doSave(editor) {
return editor.save();
function doSave() {
return domainObject.getCapability("editor").save();
}
// Discard the current root view (which will be the editing
// UI, which will have been pushed atop the Browise UI.)
function returnToBrowse() {
$location.path("/browse");
return $location.path("/browse");
}
return {
@ -41,7 +35,7 @@ define(
* cancellation has completed
*/
perform: function () {
return doSave(getEditorCapability()).then(returnToBrowse);
return doSave().then(returnToBrowse);
}
};
}

View File

@ -29,6 +29,13 @@ define(
cache.markDirty(editableObject);
};
// Delegate refresh to the original object; this avoids refreshing
// the editable instance of the object, and ensures that refresh
// correctly targets the "real" version of the object.
persistence.refresh = function () {
return domainObject.getCapability('persistence').refresh();
};
return persistence;
}

View File

@ -33,7 +33,7 @@ define(
// removed from the layer which gets dependency
// injection.
function resolvePromise(value) {
return value && value.then ? value : {
return (value && value.then) ? value : {
then: function (callback) {
return resolvePromise(callback(value));
}
@ -50,19 +50,7 @@ define(
// Persist the underlying domain object
function doPersist() {
return persistenceCapability.persist();
}
// Save any other objects that have been modified in the cache.
// IMPORTANT: This must not be called until after this object
// has been marked as clean.
function saveOthers() {
return cache.saveAll();
}
// Indicate that this object has been saved.
function markClean() {
return cache.markClean(editableObject);
return domainObject.getCapability('persistence').persist();
}
return {
@ -70,14 +58,15 @@ define(
* Save any changes that have been made to this domain object
* (as well as to others that might have been retrieved and
* modified during the editing session)
* @param {boolean} nonrecursive if true, save only this
* object (and not other objects with associated changes)
* @returns {Promise} a promise that will be fulfilled after
* persistence has completed.
*/
save: function () {
return resolvePromise(doMutate())
.then(doPersist)
.then(markClean)
.then(saveOthers);
save: function (nonrecursive) {
return nonrecursive ?
resolvePromise(doMutate()).then(doPersist) :
resolvePromise(cache.saveAll());
},
/**
* Cancel editing; Discard any changes that have been made to

View File

@ -14,12 +14,12 @@ define(
* navigated domain object into the scope.
* @constructor
*/
function EditController($scope, navigationService) {
function EditController($scope, $q, navigationService) {
function setNavigation(domainObject) {
// Wrap the domain object such that all mutation is
// confined to edit mode (until Save)
$scope.navigatedObject =
domainObject && new EditableDomainObject(domainObject);
domainObject && new EditableDomainObject(domainObject, $q);
}
setNavigation(navigationService.getNavigation());

View File

@ -48,7 +48,7 @@ define(
* and provides a "working copy" of the object's
* model to allow changes to be easily cancelled.
*/
function EditableDomainObject(domainObject) {
function EditableDomainObject(domainObject, $q) {
// The cache will hold all domain objects reached from
// the initial EditableDomainObject; this ensures that
// different versions of the same editable domain object
@ -81,7 +81,7 @@ define(
return editableObject;
}
cache = new EditableDomainObjectCache(EditableDomainObjectImpl);
cache = new EditableDomainObjectCache(EditableDomainObjectImpl, $q);
return cache.getEditableObject(domainObject);
}

View File

@ -29,10 +29,11 @@ define(
* constructor function which takes a regular domain object as
* an argument, and returns an editable domain object as its
* result.
* @param $q Angular's $q, for promise handling
* @constructor
* @memberof module:editor/object/editable-domain-object-cache
*/
function EditableDomainObjectCache(EditableDomainObject) {
function EditableDomainObjectCache(EditableDomainObject, $q) {
var cache = new EditableModelCache(),
dirty = {},
root;
@ -88,23 +89,20 @@ define(
* Initiate a save on all objects that have been cached.
*/
saveAll: function () {
var object;
// Get a list of all dirty objects
var objects = Object.keys(dirty).map(function (k) {
return dirty[k];
});
// Clear dirty set, since we're about to save.
dirty = {};
// Most save logic is handled by the "editor.completion"
// capability, but this in turn will typically invoke
// Save All. An infinite loop is avoided by marking
// objects as clean as we go.
while (Object.keys(dirty).length > 0) {
// Pick the first dirty object
object = dirty[Object.keys(dirty)[0]];
// Mark non-dirty to avoid successive invocations
this.markClean(object);
// Invoke its save behavior
object.getCapability('editor').save();
}
// capability, so that is delegated here.
return $q.all(objects.map(function (object) {
// Save; pass a nonrecursive flag to avoid looping
return object.getCapability('editor').save(true);
}));
}
};
}

View File

@ -15,7 +15,7 @@ define(
beforeEach(function () {
mockPersistence = jasmine.createSpyObj(
"persistence",
[ "persist" ]
[ "persist", "refresh" ]
);
mockEditableObject = jasmine.createSpyObj(
"editableObject",
@ -30,6 +30,8 @@ define(
[ "markDirty" ]
);
mockDomainObject.getCapability.andReturn(mockPersistence);
capability = new EditablePersistenceCapability(
mockPersistence,
mockEditableObject,
@ -49,6 +51,18 @@ define(
expect(mockPersistence.persist).not.toHaveBeenCalled();
});
it("refreshes using the original domain object's persistence", function () {
// Refreshing needs to delegate via the unwrapped domain object.
// Otherwise, only the editable version of the object will be updated;
// we instead want the real version of the object to receive these
// changes.
expect(mockDomainObject.getCapability).not.toHaveBeenCalled();
expect(mockPersistence.refresh).not.toHaveBeenCalled();
capability.refresh();
expect(mockDomainObject.getCapability).toHaveBeenCalledWith('persistence');
expect(mockPersistence.refresh).toHaveBeenCalled();
});
});
}
);

View File

@ -5,7 +5,7 @@ define(
function (EditorCapability) {
"use strict";
describe("An editable context capability", function () {
describe("The editor capability", function () {
var mockPersistence,
mockEditableObject,
mockDomainObject,
@ -32,6 +32,8 @@ define(
);
mockCallback = jasmine.createSpy("callback");
mockDomainObject.getCapability.andReturn(mockPersistence);
model = { someKey: "some value", x: 42 };
capability = new EditorCapability(
@ -42,8 +44,8 @@ define(
);
});
it("mutates the real domain object on save", function () {
capability.save().then(mockCallback);
it("mutates the real domain object on nonrecursive save", function () {
capability.save(true).then(mockCallback);
// Wait for promise to resolve
waitsFor(function () {
@ -60,19 +62,6 @@ define(
});
});
it("marks the saved object as clean in the editing cache", function () {
capability.save().then(mockCallback);
// Wait for promise to resolve
waitsFor(function () {
return mockCallback.calls.length > 0;
}, 250);
runs(function () {
expect(mockCache.markClean).toHaveBeenCalledWith(mockEditableObject);
});
});
it("tells the cache to save others", function () {
capability.save().then(mockCallback);

View File

@ -7,6 +7,7 @@ define(
describe("The Edit mode controller", function () {
var mockScope,
mockQ,
mockNavigationService,
mockObject,
mockCapability,
@ -17,6 +18,7 @@ define(
"$scope",
[ "$on" ]
);
mockQ = jasmine.createSpyObj('$q', ['when', 'all']);
mockNavigationService = jasmine.createSpyObj(
"navigationService",
[ "getNavigation", "addListener", "removeListener" ]
@ -37,6 +39,7 @@ define(
controller = new EditController(
mockScope,
mockQ,
mockNavigationService
);
});

View File

@ -1,4 +1,4 @@
/*global define,describe,it,expect,beforeEach*/
/*global define,describe,it,expect,beforeEach,jasmine*/
define(
["../../src/objects/EditableDomainObjectCache"],
@ -10,6 +10,7 @@ define(
var captured,
completionCapability,
object,
mockQ,
cache;
@ -33,6 +34,7 @@ define(
}
beforeEach(function () {
mockQ = jasmine.createSpyObj('$q', ['when', 'all']);
captured = {};
completionCapability = {
save: function () {
@ -40,7 +42,7 @@ define(
}
};
cache = new EditableDomainObjectCache(WrapObject);
cache = new EditableDomainObjectCache(WrapObject, mockQ);
});
it("wraps objects using provided constructor", function () {

View File

@ -65,6 +65,11 @@
"implementation": "models/PersistedModelProvider.js",
"depends": [ "persistenceService", "$q", "PERSISTENCE_SPACE" ]
},
{
"provides": "modelService",
"type": "decorator",
"implementation": "models/CachingModelDecorator.js"
},
{
"provides": "typeService",
"type": "provider",
@ -162,7 +167,8 @@
},
{
"key": "mutation",
"implementation": "capabilities/MutationCapability.js"
"implementation": "capabilities/MutationCapability.js",
"depends": [ "now" ]
},
{
"key": "delegation",

View File

@ -50,9 +50,9 @@ define(
* which will expose this capability
* @constructor
*/
function MutationCapability(domainObject) {
function MutationCapability(now, domainObject) {
function mutate(mutator) {
function mutate(mutator, timestamp) {
// Get the object's model and clone it, so the
// mutator function has a temporary copy to work with.
var model = domainObject.getModel(),
@ -73,7 +73,8 @@ define(
if (model !== result) {
copyValues(model, result);
}
model.modified = Date.now();
model.modified = (typeof timestamp === 'number') ?
timestamp : now();
}
// Report the result of the mutation
@ -109,8 +110,11 @@ define(
* handled as one of the above.
*
*
* @params {function} mutator the function which will make
* @param {function} mutator the function which will make
* changes to the domain object's model.
* @param {number} [timestamp] timestamp to record for
* this mutation (otherwise, system time will be
* used)
* @returns {Promise.<boolean>} a promise for the result
* of the mutation; true if changes were made.
*/

View File

@ -22,6 +22,36 @@ define(
* @constructor
*/
function PersistenceCapability(persistenceService, SPACE, domainObject) {
// Cache modified timestamp
var modified = domainObject.getModel().modified;
// Update a domain object's model upon refresh
function updateModel(model) {
var modified = model.modified;
return domainObject.useCapability("mutation", function () {
return model;
}, modified);
}
// For refresh; update a domain object model, only if there
// are no unsaved changes.
function updatePersistenceTimestamp() {
var modified = domainObject.getModel().modified;
domainObject.useCapability("mutation", function (model) {
model.persisted = modified;
}, modified);
}
// Utility function for creating promise-like objects which
// resolve synchronously when possible
function fastPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return fastPromise(callback(value));
}
};
}
return {
/**
* Persist any changes which have been made to this
@ -31,12 +61,29 @@ define(
* if not.
*/
persist: function () {
updatePersistenceTimestamp();
return persistenceService.updateObject(
SPACE,
domainObject.getId(),
domainObject.getModel()
);
},
/**
* Update this domain object to match the latest from
* persistence.
* @returns {Promise} a promise which will be resolved
* when the update is complete
*/
refresh: function () {
var model = domainObject.getModel();
// Only update if we don't have unsaved changes
return (model.modified === model.persisted) ?
persistenceService.readObject(
SPACE,
domainObject.getId()
).then(updateModel) :
fastPromise(false);
},
/**
* Get the space in which this domain object is persisted;
* this is useful when, for example, decided which space a

View File

@ -0,0 +1,115 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* The caching model decorator maintains a cache of loaded domain
* object models, and ensures that duplicate models for the same
* object are not provided.
* @constructor
*/
function CachingModelDecorator(modelService) {
var cache = {},
cached = {};
// Update the cached instance of a model to a new value.
// We update in-place to ensure there is only ever one instance
// of any given model exposed by the modelService as a whole.
function updateModel(id, model) {
var oldModel = cache[id];
// Same object instance is a possibility, so don't copy
if (oldModel === model) {
return model;
}
// If we'd previously cached an undefined value, or are now
// seeing undefined, replace the item in the cache entirely.
if (oldModel === undefined || model === undefined) {
cache[id] = model;
return model;
}
// Otherwise, empty out the old model...
Object.keys(oldModel).forEach(function (k) {
delete oldModel[k];
});
// ...and replace it with the contents of the new model.
Object.keys(model).forEach(function (k) {
oldModel[k] = model[k];
});
return oldModel;
}
// Fast-resolving promise
function fastPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return fastPromise(callback(value));
}
};
}
// Store this model in the cache
function cacheModel(id, model) {
cache[id] = cached[id] ? updateModel(id, model) : model;
cached[id] = true;
}
// Check if an id is not in cache, for lookup filtering
function notCached(id) {
return !cached[id];
}
// Store the provided models in our cache
function cacheAll(models) {
Object.keys(models).forEach(function (id) {
cacheModel(id, models[id]);
});
}
// Expose the cache (for promise chaining)
function giveCache() {
return cache;
}
return {
/**
* Get models for these specified string identifiers.
* These will be given as an object containing keys
* and values, where keys are object identifiers and
* values are models.
* This result may contain either a subset or a
* superset of the total objects.
*
* @param {Array<string>} ids the string identifiers for
* models of interest.
* @returns {Promise<object>} a promise for an object
* containing key-value pairs, where keys are
* ids and values are models
* @method
*/
getModels: function (ids) {
var neededIds = ids.filter(notCached);
// Look up if we have unknown IDs
if (neededIds.length > 0) {
return modelService.getModels(neededIds)
.then(cacheAll)
.then(giveCache);
}
// Otherwise, just expose the cache directly
return fastPromise(cache);
}
};
}
return CachingModelDecorator;
}
);

View File

@ -18,6 +18,14 @@ define(
*/
function ModelAggregator($q, providers) {
// Pick a domain object model to use, favoring the one
// with the most recent timestamp
function pick(a, b) {
var aModified = (a || {}).modified || Number.NEGATIVE_INFINITY,
bModified = (b || {}).modified || Number.NEGATIVE_INFINITY;
return (aModified > bModified) ? a : (b || a);
}
// Merge results from multiple providers into one
// large result object.
function mergeModels(provided, ids) {
@ -25,7 +33,7 @@ define(
ids.forEach(function (id) {
provided.forEach(function (models) {
if (models[id]) {
result[id] = models[id];
result[id] = pick(result[id], models[id]);
}
});
});

View File

@ -10,12 +10,15 @@ define(
describe("The mutation capability", function () {
var testModel,
mockNow,
domainObject = { getModel: function () { return testModel; } },
mutation;
beforeEach(function () {
testModel = { number: 6 };
mutation = new MutationCapability(domainObject);
mockNow = jasmine.createSpy('now');
mockNow.andReturn(12321);
mutation = new MutationCapability(mockNow, domainObject);
});
it("allows mutation of a model", function () {
@ -41,6 +44,24 @@ define(
// Number should not have been changed
expect(testModel.number).toEqual(6);
});
it("attaches a timestamp on mutation", function () {
// Verify precondition
expect(testModel.modified).toBeUndefined();
mutation.invoke(function (m) {
m.number = m.number * 7;
});
// Should have gotten a timestamp from 'now'
expect(testModel.modified).toEqual(12321);
});
it("allows a timestamp to be provided", function () {
mutation.invoke(function (m) {
m.number = m.number * 7;
}, 42);
// Should have gotten a timestamp from 'now'
expect(testModel.modified).toEqual(42);
});
});
}
);

View File

@ -16,15 +16,30 @@ define(
SPACE = "some space",
persistence;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
beforeEach(function () {
mockPersistenceService = jasmine.createSpyObj(
"persistenceService",
[ "updateObject" ]
[ "updateObject", "readObject" ]
);
mockDomainObject = {
getId: function () { return id; },
getModel: function () { return model; }
getModel: function () { return model; },
useCapability: jasmine.createSpy()
};
// Simulate mutation capability
mockDomainObject.useCapability.andCallFake(function (capability, mutator) {
if (capability === 'mutation') {
model = mutator(model) || model;
}
});
persistence = new PersistenceCapability(
mockPersistenceService,
SPACE,
@ -49,6 +64,31 @@ define(
expect(persistence.getSpace()).toEqual(SPACE);
});
it("updates persisted timestamp on persistence", function () {
model.modified = 12321;
persistence.persist();
expect(model.persisted).toEqual(12321);
});
it("refreshes the domain object model from persistence", function () {
var refreshModel = { someOtherKey: "some other value" };
mockPersistenceService.readObject.andReturn(asPromise(refreshModel));
persistence.refresh();
expect(model).toEqual(refreshModel);
});
it("does not overwrite unpersisted changes on refresh", function () {
var refreshModel = { someOtherKey: "some other value" },
mockCallback = jasmine.createSpy();
model.modified = 2;
model.persisted = 1;
mockPersistenceService.readObject.andReturn(asPromise(refreshModel));
persistence.refresh().then(mockCallback);
expect(model).not.toEqual(refreshModel);
// Should have also indicated that no changes were actually made
expect(mockCallback).toHaveBeenCalledWith(false);
});
});
}
);

View File

@ -0,0 +1,132 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../../src/models/CachingModelDecorator"],
function (CachingModelDecorator) {
"use strict";
describe("The caching model decorator", function () {
var mockModelService,
mockCallback,
testModels,
decorator;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
function fakePromise() {
var chains = [],
callbacks = [];
return {
then: function (callback) {
var next = fakePromise();
callbacks.push(callback);
chains.push(next);
return next;
},
resolve: function (value) {
callbacks.forEach(function (cb, i) {
chains[i].resolve(cb(value));
});
}
};
}
beforeEach(function () {
mockCallback = jasmine.createSpy();
mockModelService = jasmine.createSpyObj('modelService', ['getModels']);
testModels = {
a: { someKey: "some value" },
b: { someOtherKey: "some other value" }
};
mockModelService.getModels.andReturn(asPromise(testModels));
decorator = new CachingModelDecorator(mockModelService);
});
it("loads models from its wrapped model service", function () {
decorator.getModels(['a', 'b']).then(mockCallback);
expect(mockCallback).toHaveBeenCalledWith(testModels);
});
it("does not try to reload cached models", function () {
mockModelService.getModels.andReturn(asPromise({ a: testModels.a }));
decorator.getModels(['a']);
mockModelService.getModels.andReturn(asPromise(testModels));
decorator.getModels(['a', 'b']);
expect(mockModelService.getModels).not.toHaveBeenCalledWith(['a', 'b']);
expect(mockModelService.getModels.mostRecentCall.args[0]).toEqual(['b']);
});
it("does not call its wrapped model service if not needed", function () {
decorator.getModels(['a', 'b']);
expect(mockModelService.getModels.calls.length).toEqual(1);
decorator.getModels(['a', 'b']).then(mockCallback);
expect(mockModelService.getModels.calls.length).toEqual(1);
// Verify that we still got back our models, even though
// no new call to the wrapped service was made
expect(mockCallback).toHaveBeenCalledWith(testModels);
});
it("ensures a single object instance, even for multiple concurrent calls", function () {
var promiseA, promiseB, mockCallback = jasmine.createSpy();
promiseA = fakePromise();
promiseB = fakePromise();
// Issue two calls before those promises resolve
mockModelService.getModels.andReturn(promiseA);
decorator.getModels(['a']);
mockModelService.getModels.andReturn(promiseB);
decorator.getModels(['a']).then(mockCallback);
// Then resolve those promises. Note that we're whiteboxing here
// to figure out which promises to resolve (that is, we know that
// two thens are chained after each getModels)
promiseA.resolve(testModels);
promiseB.resolve({
a: { someNewKey: "some other value" }
});
// Ensure that we have a pointer-identical instance
expect(mockCallback.mostRecentCall.args[0].a)
.toEqual({ someNewKey: "some other value" });
expect(mockCallback.mostRecentCall.args[0].a)
.toBe(testModels.a);
});
it("is robust against updating with undefined values", function () {
var promiseA, promiseB, mockCallback = jasmine.createSpy();
promiseA = fakePromise();
promiseB = fakePromise();
// Issue two calls before those promises resolve
mockModelService.getModels.andReturn(promiseA);
decorator.getModels(['a']);
mockModelService.getModels.andReturn(promiseB);
decorator.getModels(['a']).then(mockCallback);
// Some model providers might erroneously add undefined values
// under requested keys, so handle that
promiseA.resolve({
a: undefined
});
promiseB.resolve({
a: { someNewKey: "some other value" }
});
// Should still have gotten the model
expect(mockCallback.mostRecentCall.args[0].a)
.toEqual({ someNewKey: "some other value" });
});
});
}
);

View File

@ -12,8 +12,8 @@ define(
var mockQ,
mockProviders,
modelList = [
{ "a": { someKey: "some value" } },
{ "b": { someOtherKey: "some other value" } }
{ "a": { someKey: "some value" }, "b": undefined },
{ "b": { someOtherKey: "some other value" }, "a": undefined }
],
aggregator;

View File

@ -17,6 +17,7 @@
"models/PersistedModelProvider",
"models/RootModelProvider",
"models/StaticModelProvider",
"models/CachingModelDecorator",
"objects/DomainObject",
"objects/DomainObjectProvider",

View File

@ -148,8 +148,11 @@ define(
* failure of this request
*/
updateObject: function (space, key, value) {
addToCache(space, key, value);
return persistenceService.updateObject(space, key, value);
return persistenceService.updateObject(space, key, value)
.then(function (result) {
addToCache(space, key, value);
return result;
});
},
/**
* Delete an object in a specific space. This will

View File

@ -0,0 +1,2 @@
This bundle implements a connection to an external ElasticSearch persistence
store in Open MCT Web.

View File

@ -0,0 +1,43 @@
{
"name": "Couch Persistence",
"description": "Adapter to read and write objects using a CouchDB instance.",
"extensions": {
"components": [
{
"provides": "persistenceService",
"type": "provider",
"implementation": "ElasticPersistenceProvider.js",
"depends": [ "$http", "$q", "PERSISTENCE_SPACE", "ELASTIC_ROOT", "ELASTIC_PATH" ]
}
],
"constants": [
{
"key": "PERSISTENCE_SPACE",
"value": "mct"
},
{
"key": "ELASTIC_ROOT",
"value": "/elastic"
},
{
"key": "ELASTIC_PATH",
"value": "mct/domain_object"
},
{
"key": "ELASTIC_INDICATOR_INTERVAL",
"value": 15000
}
],
"indicators": [
{
"implementation": "ElasticIndicator.js",
"depends": [
"$http",
"$interval",
"ELASTIC_ROOT",
"ELASTIC_INDICATOR_INTERVAL"
]
}
]
}
}

View File

@ -0,0 +1,95 @@
/*global define*/
define(
[],
function () {
"use strict";
// Set of connection states; changing among these states will be
// reflected in the indicator's appearance.
// CONNECTED: Everything nominal, expect to be able to read/write.
// DISCONNECTED: HTTP failed; maybe misconfigured, disconnected.
// PENDING: Still trying to connect, and haven't failed yet.
var CONNECTED = {
text: "Connected",
glyphClass: "ok",
description: "Connected to the domain object database."
},
DISCONNECTED = {
text: "Disconnected",
glyphClass: "err",
description: "Unable to connect to the domain object database."
},
PENDING = {
text: "Checking connection..."
};
/**
* Indicator for the current CouchDB connection. Polls CouchDB
* at a regular interval (defined by bundle constants) to ensure
* that the database is available.
*/
function ElasticIndicator($http, $interval, PATH, INTERVAL) {
// Track the current connection state
var state = PENDING;
// Callback if the HTTP request to Couch fails
function handleError(err) {
state = DISCONNECTED;
}
// Callback if the HTTP request succeeds.
function handleResponse(response) {
state = CONNECTED;
}
// Try to connect to CouchDB, and update the indicator.
function updateIndicator() {
$http.get(PATH).then(handleResponse, handleError);
}
// Update the indicator initially, and start polling.
updateIndicator();
$interval(updateIndicator, INTERVAL, false);
return {
/**
* Get the glyph (single character used as an icon)
* to display in this indicator. This will return "D",
* which should appear as a database icon.
* @returns {string} the character of the database icon
*/
getGlyph: function () {
return "D";
},
/**
* Get the name of the CSS class to apply to the glyph.
* This is used to color the glyph to match its
* state (one of ok, caution or err)
* @returns {string} the CSS class to apply to this glyph
*/
getGlyphClass: function () {
return state.glyphClass;
},
/**
* Get the text that should appear in the indicator.
* @returns {string} brief summary of connection status
*/
getText: function () {
return state.text;
},
/**
* Get a longer-form description of the current connection
* space, suitable for display in a tooltip
* @returns {string} longer summary of connection status
*/
getDescription: function () {
return state.description;
}
};
}
return ElasticIndicator;
}
);

View File

@ -0,0 +1,179 @@
/*global define*/
define(
[],
function () {
'use strict';
// JSLint doesn't like underscore-prefixed properties,
// so hide them here.
var SRC = "_source",
REV = "_version",
ID = "_id",
CONFLICT = 409;
/**
* The ElasticPersistenceProvider reads and writes JSON documents
* (more specifically, domain object models) to/from an ElasticSearch
* instance.
* @constructor
*/
function ElasticPersistenceProvider($http, $q, SPACE, ROOT, PATH) {
var spaces = [ SPACE ],
revs = {};
// Convert a subpath to a full path, suitable to pass
// to $http.
function url(subpath) {
return ROOT + '/' + PATH + '/' + subpath;
}
// Issue a request using $http; get back the plain JS object
// from the expected JSON response
function request(subpath, method, value, params) {
return $http({
method: method,
url: url(subpath),
params: params,
data: value
}).then(function (response) {
return response.data;
}, function (response) {
return (response || {}).data;
});
}
// Shorthand methods for GET/PUT methods
function get(subpath) {
return request(subpath, "GET");
}
function put(subpath, value, params) {
return request(subpath, "PUT", value, params);
}
function del(subpath) {
return request(subpath, "DELETE");
}
// Get a domain object model out of CouchDB's response
function getModel(response) {
if (response && response[SRC]) {
revs[response[ID]] = response[REV];
return response[SRC];
} else {
return undefined;
}
}
// Handle an update error
function handleError(response, key) {
var error = new Error("Persistence error.");
if ((response || {}).status === CONFLICT) {
error.key = "revision";
// Load the updated model, then reject the promise
return get(key).then(function (response) {
error.model = response[SRC];
return $q.reject(error);
});
}
// Reject the promise
return $q.reject(error);
}
// Check the response to a create/update/delete request;
// track the rev if it's valid, otherwise return false to
// indicate that the request failed.
function checkResponse(response, key) {
var error;
if (response && !response.error) {
revs[key] = response[REV];
return response;
} else {
return handleError(response, key);
}
}
return {
/**
* List all persistence spaces which this provider
* recognizes.
*
* @returns {Promise.<string[]>} a promise for a list of
* spaces supported by this provider
*/
listSpaces: function () {
return $q.when(spaces);
},
/**
* List all objects (by their identifiers) that are stored
* in the given persistence space, per this provider.
* @param {string} space the space to check
* @returns {Promise.<string[]>} a promise for the list of
* identifiers
*/
listObjects: function (space) {
return $q.when([]);
},
/**
* Create a new object in the specified persistence space.
* @param {string} space the space in which to store the object
* @param {string} key the identifier for the persisted object
* @param {object} value a JSONifiable object that should be
* stored and associated with the provided identifier
* @returns {Promise.<boolean>} a promise for an indication
* of the success (true) or failure (false) of this
* operation
*/
createObject: function (space, key, value) {
return put(key, value).then(checkResponse);
},
/**
* Read an existing object back from persistence.
* @param {string} space the space in which to look for
* the object
* @param {string} key the identifier for the persisted object
* @returns {Promise.<object>} a promise for the stored
* object; this will resolve to undefined if no such
* object is found.
*/
readObject: function (space, key) {
return get(key).then(getModel);
},
/**
* Update an existing object in the specified persistence space.
* @param {string} space the space in which to store the object
* @param {string} key the identifier for the persisted object
* @param {object} value a JSONifiable object that should be
* stored and associated with the provided identifier
* @returns {Promise.<boolean>} a promise for an indication
* of the success (true) or failure (false) of this
* operation
*/
updateObject: function (space, key, value) {
function checkUpdate(response) {
return checkResponse(response, key);
}
return put(key, value, { version: revs[key] })
.then(checkUpdate);
},
/**
* Delete an object in the specified persistence space.
* @param {string} space the space from which to delete this
* object
* @param {string} key the identifier of the persisted object
* @param {object} value a JSONifiable object that should be
* deleted
* @returns {Promise.<boolean>} a promise for an indication
* of the success (true) or failure (false) of this
* operation
*/
deleteObject: function (space, key, value) {
return del(key).then(checkResponse);
}
};
}
return ElasticPersistenceProvider;
}
);

View File

@ -0,0 +1,90 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/ElasticIndicator"],
function (ElasticIndicator) {
"use strict";
describe("The ElasticSearch status indicator", function () {
var mockHttp,
mockInterval,
testPath,
testInterval,
mockPromise,
indicator;
beforeEach(function () {
mockHttp = jasmine.createSpyObj("$http", [ "get" ]);
mockInterval = jasmine.createSpy("$interval");
mockPromise = jasmine.createSpyObj("promise", [ "then" ]);
testPath = "/test/path";
testInterval = 12321; // Some number
mockHttp.get.andReturn(mockPromise);
indicator = new ElasticIndicator(
mockHttp,
mockInterval,
testPath,
testInterval
);
});
it("polls for changes", function () {
expect(mockInterval).toHaveBeenCalledWith(
jasmine.any(Function),
testInterval,
false
);
});
it("has a database icon", function () {
expect(indicator.getGlyph()).toEqual("D");
});
it("consults the database at the configured path", function () {
expect(mockHttp.get).toHaveBeenCalledWith(testPath);
});
it("changes when the database connection is nominal", function () {
var initialText = indicator.getText(),
initialDescrption = indicator.getDescription(),
initialGlyphClass = indicator.getGlyphClass();
// Nominal just means getting back an objeect, without
// an error field.
mockPromise.then.mostRecentCall.args[0]({ data: {} });
// Verify that these values changed;
// don't test for specific text.
expect(indicator.getText()).not.toEqual(initialText);
expect(indicator.getGlyphClass()).not.toEqual(initialGlyphClass);
expect(indicator.getDescription()).not.toEqual(initialDescrption);
// Do check for specific class
expect(indicator.getGlyphClass()).toEqual("ok");
});
it("changes when the server cannot be reached", function () {
var initialText = indicator.getText(),
initialDescrption = indicator.getDescription(),
initialGlyphClass = indicator.getGlyphClass();
// Nominal just means getting back an objeect, without
// an error field.
mockPromise.then.mostRecentCall.args[1]({ data: {} });
// Verify that these values changed;
// don't test for specific text.
expect(indicator.getText()).not.toEqual(initialText);
expect(indicator.getGlyphClass()).not.toEqual(initialGlyphClass);
expect(indicator.getDescription()).not.toEqual(initialDescrption);
// Do check for specific class
expect(indicator.getGlyphClass()).toEqual("err");
});
});
}
);

View File

@ -0,0 +1,195 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/ElasticPersistenceProvider"],
function (ElasticPersistenceProvider) {
"use strict";
describe("The ElasticSearch persistence provider", function () {
var mockHttp,
mockQ,
testSpace = "testSpace",
testRoot = "/test",
testPath = "db",
capture,
provider;
function mockPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return mockPromise(callback(value));
}
};
}
beforeEach(function () {
mockHttp = jasmine.createSpy("$http");
mockQ = jasmine.createSpyObj("$q", ["when", "reject"]);
mockQ.when.andCallFake(mockPromise);
mockQ.reject.andCallFake(function (value) {
return {
then: function (ignored, callback) {
return mockPromise(callback(value));
}
};
});
// Capture promise results
capture = jasmine.createSpy("capture");
provider = new ElasticPersistenceProvider(
mockHttp,
mockQ,
testSpace,
testRoot,
testPath
);
});
it("reports available spaces", function () {
provider.listSpaces().then(capture);
expect(capture).toHaveBeenCalledWith([testSpace]);
});
// General pattern of tests below is to simulate ElasticSearch's
// response, verify that request looks like what ElasticSearch
// would expect, and finally verify that ElasticPersistenceProvider's
// return values match what is expected.
it("lists all available documents", function () {
// Not implemented yet
provider.listObjects().then(capture);
expect(capture).toHaveBeenCalledWith([]);
});
it("allows object creation", function () {
var model = { someKey: "some value" };
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 1 }
}));
provider.createObject("testSpace", "abc", model).then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "PUT",
data: model
});
expect(capture.mostRecentCall.args[0]).toBeTruthy();
});
it("allows object models to be read back", function () {
var model = { someKey: "some value" };
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 1, "_source": model }
}));
provider.readObject("testSpace", "abc").then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "GET"
});
expect(capture).toHaveBeenCalledWith(model);
});
it("allows object update", function () {
var model = { someKey: "some value" };
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 43, "_source": {} }
}));
provider.updateObject("testSpace", "abc", model).then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "PUT",
params: { version: 42 },
data: model
});
expect(capture.mostRecentCall.args[0]).toBeTruthy();
});
it("allows object deletion", function () {
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.deleteObject("testSpace", "abc", {}).then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "DELETE"
});
expect(capture.mostRecentCall.args[0]).toBeTruthy();
});
it("returns undefined when objects are not found", function () {
// Act like a 404
mockHttp.andReturn({
then: function (success, fail) {
return mockPromise(fail());
}
});
provider.readObject("testSpace", "abc").then(capture);
expect(capture).toHaveBeenCalledWith(undefined);
});
it("handles rejection due to version", function () {
var model = { someKey: "some value" },
mockErrorCallback = jasmine.createSpy('error');
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "status": 409, "error": "Revision error..." }
}));
provider.updateObject("testSpace", "abc", model).then(
capture,
mockErrorCallback
);
expect(capture).not.toHaveBeenCalled();
expect(mockErrorCallback).toHaveBeenCalled();
});
it("handles rejection due to unknown reasons", function () {
var model = { someKey: "some value" },
mockErrorCallback = jasmine.createSpy('error');
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_version": 42, "_source": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "status": 410, "error": "Revision error..." }
}));
provider.updateObject("testSpace", "abc", model).then(
capture,
mockErrorCallback
);
expect(capture).not.toHaveBeenCalled();
expect(mockErrorCallback).toHaveBeenCalled();
});
});
}
);

View File

@ -0,0 +1,4 @@
[
"ElasticIndicator",
"ElasticPersistenceProvider"
]

View File

@ -0,0 +1,5 @@
This bundle provides an Overwrite/Cancel dialog when persisting
domain objects, if persistence fails. It is meant to be paired
with a persistence adapter which performs revision-checking
on update calls, in order to provide the user interface for
choosing between Overwrite and Cancel in that situation.

View File

@ -0,0 +1,42 @@
{
"extensions": {
"components": [
{
"type": "decorator",
"provides": "capabilityService",
"implementation": "QueuingPersistenceCapabilityDecorator.js",
"depends": [ "persistenceQueue" ]
}
],
"services": [
{
"key": "persistenceQueue",
"implementation": "PersistenceQueue.js",
"depends": [
"$q",
"$timeout",
"dialogService",
"PERSISTENCE_QUEUE_DELAY"
]
}
],
"constants": [
{
"key": "PERSISTENCE_QUEUE_DELAY",
"value": 5
}
],
"templates": [
{
"key": "persistence-failure-dialog",
"templateUrl": "templates/persistence-failure-dialog.html"
}
],
"controllers": [
{
"key": "PersistenceFailureController",
"implementation": "PersistenceFailureController.js"
}
]
}
}

View File

@ -0,0 +1,31 @@
<span ng-controller="PersistenceFailureController as controller">
<div ng-if="ngModel.revised.length > 0">
External changes have been made to the following objects:
<ul>
<li ng-repeat="failure in ngModel.revised">
<mct-representation key="'label'"
mct-object="failure.domainObject">
</mct-representation>
was modified at
<b>{{controller.formatTimestamp(failure.error.model.modified)}}</b>
by
<i>{{controller.formatUsername(failure.error.model.modifier)}}</i>
</li>
</ul>
You may overwrite these objects, or discard your changes to keep
the updates that were made externally.
</div>
<div ng-if="ngModel.unrecoverable.length > 0">
Changes to these objects could not be saved for unknown reasons:
<ul>
<li ng-repeat="failure in ngModel.unrecoverable">
<mct-representation key="'label'"
mct-object="failure.domainObject">
</mct-representation>
</li>
</ul>
</div>
</span>

View File

@ -0,0 +1,8 @@
/*global define*/
define({
REVISION_ERROR_KEY: "revision",
OVERWRITE_KEY: "overwrite",
TIMESTAMP_FORMAT: "YYYY-MM-DD HH:mm:ss\\Z",
UNKNOWN_USER: "unknown user"
});

View File

@ -0,0 +1,32 @@
/*global define*/
define(
['moment', './PersistenceFailureConstants'],
function (moment, Constants) {
"use strict";
/**
* Controller to support the template to be shown in the
* dialog shown for persistence failures.
*/
function PersistenceFailureController() {
return {
/**
* Format a timestamp for display in the dialog.
*/
formatTimestamp: function (timestamp) {
return moment.utc(timestamp)
.format(Constants.TIMESTAMP_FORMAT);
},
/**
* Format a user name for display in the dialog.
*/
formatUsername: function (username) {
return username || Constants.UNKNOWN_USER;
}
};
}
return PersistenceFailureController;
}
);

View File

@ -0,0 +1,54 @@
/*global define*/
define(
['./PersistenceFailureConstants'],
function (PersistenceFailureConstants) {
"use strict";
var OVERWRITE_CANCEL_OPTIONS = [
{
name: "Overwrite",
key: PersistenceFailureConstants.OVERWRITE_KEY
},
{
name: "Discard",
key: "cancel"
}
],
OK_OPTIONS = [ { name: "OK", key: "ok" } ];
/**
* Populates a `dialogModel` to pass to `dialogService.getUserChoise`
* in order to choose between Overwrite and Cancel.
*/
function PersistenceFailureDialog(failures) {
var revisionErrors = [],
otherErrors = [];
// Place this failure into an appropriate group
function categorizeFailure(failure) {
// Check if the error is due to object revision
var isRevisionError = ((failure || {}).error || {}).key ===
PersistenceFailureConstants.REVISION_ERROR_KEY;
// Push the failure into the appropriate group
(isRevisionError ? revisionErrors : otherErrors).push(failure);
}
// Separate into revision errors, and other errors
failures.forEach(categorizeFailure);
return {
title: "Save Error",
template: "persistence-failure-dialog",
model: {
revised: revisionErrors,
unrecoverable: otherErrors
},
options: revisionErrors.length > 0 ?
OVERWRITE_CANCEL_OPTIONS : OK_OPTIONS
};
}
return PersistenceFailureDialog;
}
);

View File

@ -0,0 +1,110 @@
/*global define*/
define(
['./PersistenceFailureDialog', './PersistenceFailureConstants'],
function (PersistenceFailureDialog, PersistenceFailureConstants) {
"use strict";
function PersistenceFailureHandler($q, dialogService) {
// Refresh revision information for the domain object associated
// with this persistence failure
function refresh(failure) {
// Refresh the domain object to the latest from persistence
return failure.persistence.refresh();
}
// Issue a new persist call for the domain object associated with
// this failure.
function persist(failure) {
// Note that we reissue the persist request here, but don't
// return it, to avoid a circular wait. We trust that the
// PersistenceQueue will behave correctly on the next round
// of flushing.
failure.requeue();
}
// Retry persistence (overwrite) for this set of failed attempts
function retry(failures) {
var models = {};
// Cache a copy of the model
function cacheModel(failure) {
// Clone...
models[failure.id] = JSON.parse(JSON.stringify(
failure.domainObject.getModel()
));
}
// Mutate a domain object to restore its model
function remutate(failure) {
var model = models[failure.id];
return failure.domainObject.useCapability(
"mutation",
function () { return model; },
model.modified
);
}
// Cache the object models we might want to save
failures.forEach(cacheModel);
// Strategy here:
// * Cache all of the models we might want to save (above)
// * Refresh all domain objects (so they are latest versions)
// * Re-insert the cached domain object models
// * Invoke persistence again
return $q.all(failures.map(refresh)).then(function () {
return $q.all(failures.map(remutate));
}).then(function () {
return $q.all(failures.map(persist));
});
}
// Discard changes for a failed refresh
function discard(failure) {
var persistence =
failure.domainObject.getCapability('persistence');
return persistence.refresh();
}
// Discard changes associated with a failed save
function discardAll(failures) {
return $q.all(failures.map(discard));
}
// Handle failures in persistence
function handleFailures(failures) {
// Prepare dialog for display
var dialogModel = new PersistenceFailureDialog(failures),
revisionErrors = dialogModel.model.revised;
// Handle user input (did they choose to overwrite?)
function handleChoice(key) {
// If so, try again
if (key === PersistenceFailureConstants.OVERWRITE_KEY) {
return retry(revisionErrors);
} else {
return discardAll(revisionErrors);
}
}
// Prompt for user input, the overwrite if they said so.
return dialogService.getUserChoice(dialogModel)
.then(handleChoice, handleChoice);
}
return {
/**
* Handle persistence failures by providing the user with a
* dialog summarizing these failures, and giving the option
* to overwrite/cancel as appropriate.
* @param {Array} failures persistence failures, as prepared
* by PersistenceQueueHandler
*/
handle: handleFailures
};
}
return PersistenceFailureHandler;
}
);

View File

@ -0,0 +1,56 @@
/*global define*/
define(
[
'./PersistenceQueueImpl',
'./PersistenceQueueHandler',
'./PersistenceFailureHandler'
],
function (
PersistenceQueueImpl,
PersistenceQueueHandler,
PersistenceFailureHandler
) {
"use strict";
/**
* The PersistenceQueue is used by the QueuingPersistenceCapability
* to aggregrate calls for object persistence. These are then issued
* in a group, such that if some or all are rejected, this result can
* be shown to the user (again, in a group.)
*
* This constructor is exposed as a service, but handles only the
* wiring of injected dependencies; behavior is implemented in the
* various component parts.
*
* @param $timeout Angular's $timeout
* @param {PersistenceQueueHandler} handler handles actual
* persistence when the queue is flushed
* @param {number} [DELAY] optional; delay in milliseconds between
* attempts to flush the queue
*/
function PersistenceQueue(
$q,
$timeout,
dialogService,
PERSISTENCE_QUEUE_DELAY
) {
// Wire up injected dependencies
return new PersistenceQueueImpl(
$q,
$timeout,
new PersistenceQueueHandler(
$q,
new PersistenceFailureHandler(
$q,
dialogService
)
),
PERSISTENCE_QUEUE_DELAY
);
}
return PersistenceQueue;
}
);

View File

@ -0,0 +1,90 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* Handles actual persistence invocations for queeud persistence
* attempts, in a group. Handling in a group in this manner
* also allows failure to be handled in a group (e.g. in a single
* summary dialog.)
* @param $q Angular's $q, for promises
* @param {PersistenceFailureHandler} handler to invoke in the event
* that a persistence attempt fails.
*/
function PersistenceQueueHandler($q, failureHandler) {
// Handle a group of persistence invocations
function persistGroup(ids, persistences, domainObjects, queue) {
var failures = [];
// Try to persist a specific domain object
function tryPersist(id) {
// Look up its persistence capability from the provided
// id->persistence object.
var persistence = persistences[id],
domainObject = domainObjects[id];
// Put a domain object back in the queue
// (e.g. after Overwrite)
function requeue() {
return queue.put(domainObject, persistence);
}
// Handle success
function succeed(value) {
return value;
}
// Handle failure (build up a list of failures)
function fail(error) {
failures.push({
id: id,
persistence: persistence,
domainObject: domainObject,
requeue: requeue,
error: error
});
return false;
}
// Invoke the actual persistence capability, then
// note success or failures
return persistence.persist().then(succeed, fail);
}
// Handle any failures from the full operation
function handleFailure(value) {
return failures.length > 0 ?
failureHandler.handle(failures) :
value;
}
// Try to persist everything, then handle any failures
return $q.all(ids.map(tryPersist)).then(handleFailure);
}
return {
/**
* Invoke the persist method on the provided persistence
* capabilities.
* @param {Object.<string,PersistenceCapability>} persistences
* capabilities to invoke, in id->capability pairs.
* @param {Object.<string,DomainObject>} domainObjects
* associated domain objects, in id->object pairs.
* @param {PersistenceQueue} queue the persistence queue,
* to requeue as necessary
*/
persist: function (persistences, domainObjects, queue) {
var ids = Object.keys(persistences);
return persistGroup(ids, persistences, domainObjects, queue);
}
};
}
return PersistenceQueueHandler;
}
);

View File

@ -0,0 +1,114 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* The PersistenceQueue is used by the QueuingPersistenceCapability
* to aggregrate calls for object persistence. These are then issued
* in a group, such that if some or all are rejected, this result can
* be shown to the user (again, in a group.)
*
* This implementation is separate out from PersistenceQueue, which
* handles the wiring of injected dependencies into an instance of
* this class.
*
* @param $timeout Angular's $timeout
* @param {PersistenceQueueHandler} handler handles actual
* persistence when the queue is flushed
* @param {number} [DELAY] optional; delay in milliseconds between
* attempts to flush the queue
*/
function PersistenceQueueImpl($q, $timeout, handler, DELAY) {
var self,
persistences = {},
objects = {},
lastObservedSize = 0,
pendingTimeout,
flushPromise,
activeDefer = $q.defer();
// Check if the queue's size has stopped increasing)
function quiescent() {
return Object.keys(persistences).length === lastObservedSize;
}
// Persist all queued objects
function flush() {
// Get a local reference to the active promise;
// this will be replaced with a promise for the next round
// of persistence calls, so we want to make sure we clear
// the correct one when this flush completes.
var flushingDefer = activeDefer;
// Clear the active promise for a queue flush
function clearFlushPromise(value) {
flushPromise = undefined;
flushingDefer.resolve(value);
return value;
}
// Persist all queued objects
flushPromise = handler.persist(persistences, objects, self)
.then(clearFlushPromise, clearFlushPromise);
// Reset queue, etc.
persistences = {};
objects = {};
lastObservedSize = 0;
pendingTimeout = undefined;
activeDefer = $q.defer();
}
// Schedule a flushing of the queue (that is, plan to flush
// all objects in the queue)
function scheduleFlush() {
function maybeFlush() {
// Timeout fired, so clear it
pendingTimeout = undefined;
// Only flush when we've stopped receiving updates
(quiescent() ? flush : scheduleFlush)();
// Update lastObservedSize to detect quiescence
lastObservedSize = Object.keys(persistences).length;
}
// If we are already flushing the queue...
if (flushPromise) {
// Wait until that's over before considering a flush
flushPromise.then(maybeFlush);
} else {
// Otherwise, schedule a flush on a timeout (to give
// a window for other updates to get aggregated)
pendingTimeout = pendingTimeout ||
$timeout(maybeFlush, DELAY, false);
}
return activeDefer.promise;
}
// If no delay is provided, use a default
DELAY = DELAY || 0;
self = {
/**
* Queue persistence of a domain object.
* @param {DomainObject} domainObject the domain object
* @param {PersistenceCapability} persistence the object's
* undecorated persistence capability
*/
put: function (domainObject, persistence) {
var id = domainObject.getId();
persistences[id] = persistence;
objects[id] = domainObject;
return scheduleFlush();
}
};
return self;
}
return PersistenceQueueImpl;
}
);

View File

@ -0,0 +1,30 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* The QueuingPersistenceCapability places `persist` calls in a queue
* to be handled in batches.
* @param {PersistenceQueue} queue of persistence calls
* @param {PersistenceCapability} persistence the wrapped persistence
* capability
* @param {DomainObject} domainObject the domain object which exposes
* the capability
*/
function QueuingPersistenceCapability(queue, persistence, domainObject) {
var queuingPersistence = Object.create(persistence);
// Override persist calls to queue them instead
queuingPersistence.persist = function () {
return queue.put(domainObject, persistence);
};
return queuingPersistence;
}
return QueuingPersistenceCapability;
}
);

View File

@ -0,0 +1,76 @@
/*global define,Promise*/
/**
* Module defining CoreCapabilityProvider. Created by vwoeltje on 11/7/14.
*/
define(
['./QueuingPersistenceCapability'],
function (QueuingPersistenceCapability) {
"use strict";
/**
* Capability decorator. Adds queueing support to persistence
* capabilities for domain objects, such that persistence attempts
* will be handled in batches (allowing failure notification to
* also be presented in batches.)
*
* @constructor
*/
function QueuingPersistenceCapabilityDecorator(
persistenceQueue,
capabilityService
) {
function decoratePersistence(capabilities) {
var originalPersistence = capabilities.persistence;
if (originalPersistence) {
capabilities.persistence = function (domainObject) {
// Get/instantiate the original
var original =
(typeof originalPersistence === 'function') ?
originalPersistence(domainObject) :
originalPersistence;
// Provide a decorated version
return new QueuingPersistenceCapability(
persistenceQueue,
original,
domainObject
);
};
}
return capabilities;
}
function getCapabilities(model) {
return decoratePersistence(
capabilityService.getCapabilities(model)
);
}
return {
/**
* Get all capabilities associated with a given domain
* object.
*
* This returns a promise for an object containing key-value
* pairs, where keys are capability names and values are
* either:
*
* * Capability instances
* * Capability constructors (which take a domain object
* as their argument.)
*
*
* @param {*} model the object model
* @returns {Object.<string,function|Capability>} all
* capabilities known to be valid for this model, as
* key-value pairs
*/
getCapabilities: getCapabilities
};
}
return QueuingPersistenceCapabilityDecorator;
}
);

View File

@ -0,0 +1,16 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceFailureConstants"],
function (PersistenceFailureConstants) {
"use strict";
describe("Persistence failure constants", function () {
it("defines an overwrite key", function () {
expect(PersistenceFailureConstants.OVERWRITE_KEY)
.toEqual(jasmine.any(String));
});
});
}
);

View File

@ -0,0 +1,27 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceFailureController"],
function (PersistenceFailureController) {
"use strict";
describe("The persistence failure controller", function () {
var controller;
beforeEach(function () {
controller = new PersistenceFailureController();
});
it("converts timestamps to human-readable dates", function () {
expect(controller.formatTimestamp(402514331000))
.toEqual("1982-10-03 17:32:11Z");
});
it("provides default user names", function () {
expect(controller.formatUsername(undefined))
.toEqual(jasmine.any(String));
});
});
}
);

View File

@ -0,0 +1,38 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceFailureDialog", "../src/PersistenceFailureConstants"],
function (PersistenceFailureDialog, Constants) {
"use strict";
describe("The persistence failure dialog", function () {
var testFailures,
dialog;
beforeEach(function () {
testFailures = [
{ error: { key: Constants.REVISION_ERROR_KEY }, someKey: "abc" },
{ error: { key: "..." }, someKey: "def" },
{ error: { key: Constants.REVISION_ERROR_KEY }, someKey: "ghi" },
{ error: { key: Constants.REVISION_ERROR_KEY }, someKey: "jkl" },
{ error: { key: "..." }, someKey: "mno" }
];
dialog = new PersistenceFailureDialog(testFailures);
});
it("categorizes failures", function () {
expect(dialog.model.revised).toEqual([
testFailures[0], testFailures[2], testFailures[3]
]);
expect(dialog.model.unrecoverable).toEqual([
testFailures[1], testFailures[4]
]);
});
it("provides an overwrite option", function () {
expect(dialog.options[0].key).toEqual(Constants.OVERWRITE_KEY);
});
});
}
);

View File

@ -0,0 +1,97 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceFailureHandler", "../src/PersistenceFailureConstants"],
function (PersistenceFailureHandler, Constants) {
"use strict";
describe("The persistence failure handler", function () {
var mockQ,
mockDialogService,
mockFailures,
mockPromise,
handler;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
function makeMockFailure(id, index) {
var mockFailure = jasmine.createSpyObj(
'failure-' + id,
['requeue']
),
mockPersistence = jasmine.createSpyObj(
'persistence-' + id,
['refresh', 'persist']
);
mockFailure.domainObject = jasmine.createSpyObj(
'domainObject',
['getCapability', 'useCapability', 'getModel']
);
mockFailure.domainObject.getCapability.andCallFake(function (c) {
return (c === 'persistence') && mockPersistence;
});
mockFailure.domainObject.getModel.andReturn({ id: id, modified: index });
mockFailure.persistence = mockPersistence;
mockFailure.id = id;
mockFailure.error = { key: Constants.REVISION_ERROR_KEY };
return mockFailure;
}
beforeEach(function () {
mockQ = jasmine.createSpyObj('$q', ['all', 'when']);
mockDialogService = jasmine.createSpyObj('dialogService', ['getUserChoice']);
mockFailures = ['a', 'b', 'c'].map(makeMockFailure);
mockPromise = jasmine.createSpyObj('promise', ['then']);
mockDialogService.getUserChoice.andReturn(mockPromise);
mockQ.all.andReturn(mockPromise);
mockPromise.then.andReturn(mockPromise);
handler = new PersistenceFailureHandler(mockQ, mockDialogService);
});
it("shows a dialog to handle failures", function () {
handler.handle(mockFailures);
expect(mockDialogService.getUserChoice).toHaveBeenCalled();
});
it("overwrites on request", function () {
mockQ.all.andReturn(asPromise([]));
handler.handle(mockFailures);
// User chooses overwrite
mockPromise.then.mostRecentCall.args[0](Constants.OVERWRITE_KEY);
// Should refresh, remutate, and requeue all objects
mockFailures.forEach(function (mockFailure, i) {
expect(mockFailure.persistence.refresh).toHaveBeenCalled();
expect(mockFailure.requeue).toHaveBeenCalled();
expect(mockFailure.domainObject.useCapability).toHaveBeenCalledWith(
'mutation',
jasmine.any(Function),
i // timestamp
);
expect(mockFailure.domainObject.useCapability.mostRecentCall.args[1]())
.toEqual({ id: mockFailure.id, modified: i });
});
});
it("discards on request", function () {
mockQ.all.andReturn(asPromise([]));
handler.handle(mockFailures);
// User chooses overwrite
mockPromise.then.mostRecentCall.args[0](false);
// Should refresh, but not remutate, and requeue all objects
mockFailures.forEach(function (mockFailure, i) {
expect(mockFailure.persistence.refresh).toHaveBeenCalled();
expect(mockFailure.requeue).not.toHaveBeenCalled();
expect(mockFailure.domainObject.useCapability).not.toHaveBeenCalled();
});
});
});
}
);

View File

@ -0,0 +1,116 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceQueueHandler"],
function (PersistenceQueueHandler) {
"use strict";
var TEST_ERROR = { someKey: "some value" };
describe("The persistence queue handler", function () {
var mockQ,
mockFailureHandler,
mockPersistences,
mockDomainObjects,
mockQueue,
mockRejection,
handler;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
function makeMockPersistence(id) {
var mockPersistence = jasmine.createSpyObj(
'persistence-' + id,
[ 'persist', 'refresh' ]
);
mockPersistence.persist.andReturn(asPromise(true));
return mockPersistence;
}
function makeMockDomainObject(id) {
var mockDomainObject = jasmine.createSpyObj(
'domainObject-' + id,
[ 'getId' ]
);
mockDomainObject.getId.andReturn(id);
return mockDomainObject;
}
beforeEach(function () {
mockQ = jasmine.createSpyObj('$q', ['all']);
mockFailureHandler = jasmine.createSpyObj('handler', ['handle']);
mockQueue = jasmine.createSpyObj('queue', ['put']);
mockPersistences = {};
mockDomainObjects = {};
['a', 'b', 'c'].forEach(function (id) {
mockPersistences[id] = makeMockPersistence(id);
mockDomainObjects[id] = makeMockDomainObject(id);
});
mockRejection = jasmine.createSpyObj('rejection', ['then']);
mockQ.all.andReturn(asPromise([]));
mockRejection.then.andCallFake(function (callback, fallback) {
return asPromise(fallback({ someKey: "some value" }));
});
handler = new PersistenceQueueHandler(mockQ, mockFailureHandler);
});
it("invokes persistence on all members in the group", function () {
handler.persist(mockPersistences, mockDomainObjects, mockQueue);
expect(mockPersistences.a.persist).toHaveBeenCalled();
expect(mockPersistences.b.persist).toHaveBeenCalled();
expect(mockPersistences.c.persist).toHaveBeenCalled();
// No failures in this group
expect(mockFailureHandler.handle).not.toHaveBeenCalled();
});
it("handles failures that occur", function () {
mockPersistences.b.persist.andReturn(mockRejection);
mockPersistences.c.persist.andReturn(mockRejection);
handler.persist(mockPersistences, mockDomainObjects, mockQueue);
expect(mockFailureHandler.handle).toHaveBeenCalledWith([
{
id: 'b',
persistence: mockPersistences.b,
domainObject: mockDomainObjects.b,
requeue: jasmine.any(Function),
error: TEST_ERROR
},
{
id: 'c',
persistence: mockPersistences.c,
domainObject: mockDomainObjects.c,
requeue: jasmine.any(Function),
error: TEST_ERROR
}
]);
});
it("provides a requeue method for failures", function () {
// This method is needed by PersistenceFailureHandler
// to allow requeuing of objects for persistence when
// Overwrite is chosen.
mockPersistences.b.persist.andReturn(mockRejection);
handler.persist(mockPersistences, mockDomainObjects, mockQueue);
// Verify precondition
expect(mockQueue.put).not.toHaveBeenCalled();
// Invoke requeue
mockFailureHandler.handle.mostRecentCall.args[0][0].requeue();
// Should have returned the object to the queue
expect(mockQueue.put).toHaveBeenCalledWith(
mockDomainObjects.b,
mockPersistences.b
);
});
});
}
);

View File

@ -0,0 +1,133 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceQueueImpl"],
function (PersistenceQueueImpl) {
"use strict";
var TEST_DELAY = 42;
describe("The implemented persistence queue", function () {
var mockQ,
mockTimeout,
mockHandler,
mockDeferred,
mockPromise,
queue;
function makeMockDomainObject(id) {
var mockDomainObject = jasmine.createSpyObj(
'domainObject-' + id,
[ 'getId' ]
);
mockDomainObject.getId.andReturn(id);
return mockDomainObject;
}
function makeMockPersistence(id) {
var mockPersistence = jasmine.createSpyObj(
'persistence-' + id,
[ 'persist' ]
);
return mockPersistence;
}
beforeEach(function () {
mockQ = jasmine.createSpyObj('$q', ['when', 'defer']);
mockTimeout = jasmine.createSpy('$timeout');
mockHandler = jasmine.createSpyObj('handler', ['persist']);
mockDeferred = jasmine.createSpyObj('deferred', ['resolve']);
mockDeferred.promise = jasmine.createSpyObj('promise', ['then']);
mockPromise = jasmine.createSpyObj('promise', ['then']);
mockQ.defer.andReturn(mockDeferred);
mockTimeout.andReturn({});
mockHandler.persist.andReturn(mockPromise);
mockPromise.then.andReturn(mockPromise);
queue = new PersistenceQueueImpl(
mockQ,
mockTimeout,
mockHandler,
TEST_DELAY
);
});
it("schedules a timeout to persist objects", function () {
expect(mockTimeout).not.toHaveBeenCalled();
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
expect(mockTimeout).toHaveBeenCalledWith(
jasmine.any(Function),
TEST_DELAY,
false
);
});
it("does not schedule multiple timeouts for multiple objects", function () {
// Put three objects in without triggering the timeout;
// shouldn't schedule multiple timeouts
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
expect(mockTimeout.calls.length).toEqual(1);
});
it("returns a promise", function () {
expect(queue.put(makeMockDomainObject('a'), makeMockPersistence('a')))
.toEqual(mockDeferred.promise);
});
it("waits for quiescence to proceed", function () {
// Keep adding objects to the queue between timeouts.
// Should keep scheduling timeouts instead of resolving.
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
expect(mockTimeout.calls.length).toEqual(1);
mockTimeout.mostRecentCall.args[0]();
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
expect(mockTimeout.calls.length).toEqual(2);
mockTimeout.mostRecentCall.args[0]();
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
expect(mockTimeout.calls.length).toEqual(3);
mockTimeout.mostRecentCall.args[0]();
expect(mockHandler.persist).not.toHaveBeenCalled();
});
it("persists upon quiescence", function () {
// Add objects to the queue, but fire two timeouts afterward
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
mockTimeout.mostRecentCall.args[0]();
mockTimeout.mostRecentCall.args[0]();
expect(mockHandler.persist).toHaveBeenCalled();
});
it("waits on an active flush, while flushing", function () {
// Persist some objects
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
mockTimeout.mostRecentCall.args[0]();
mockTimeout.mostRecentCall.args[0]();
expect(mockTimeout.calls.length).toEqual(2);
// Adding a new object should not trigger a new timeout,
// because we haven't completed the previous flush
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
expect(mockTimeout.calls.length).toEqual(2);
});
it("clears the active flush after it has completed", function () {
// Persist some objects
queue.put(makeMockDomainObject('a'), makeMockPersistence('a'));
queue.put(makeMockDomainObject('b'), makeMockPersistence('b'));
mockTimeout.mostRecentCall.args[0]();
mockTimeout.mostRecentCall.args[0]();
expect(mockTimeout.calls.length).toEqual(2);
// Resolve the promise from handler.persist
mockPromise.then.calls[0].args[0](true);
// Adding a new object should now trigger a new timeout,
// because we have completed the previous flush
queue.put(makeMockDomainObject('c'), makeMockPersistence('c'));
expect(mockTimeout.calls.length).toEqual(3);
});
});
}
);

View File

@ -0,0 +1,35 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/PersistenceQueue"],
function (PersistenceQueue) {
"use strict";
describe("The persistence queue", function () {
var mockQ,
mockTimeout,
mockDialogService,
queue;
beforeEach(function () {
mockQ = jasmine.createSpyObj("$q", ['defer']);
mockTimeout = jasmine.createSpy("$timeout");
mockDialogService = jasmine.createSpyObj(
'dialogService',
['getUserChoice']
);
queue = new PersistenceQueue(mockQ, mockTimeout, mockDialogService);
});
// PersistenceQueue is just responsible for handling injected
// dependencies and wiring the PersistenceQueueImpl and its
// handlers. Functionality is tested there, so our test here is
// minimal (get back expected interface, no exceptions)
it("provides a queue with a put method", function () {
expect(queue.put).toEqual(jasmine.any(Function));
});
});
}
);

View File

@ -0,0 +1,63 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/QueuingPersistenceCapabilityDecorator"],
function (QueuingPersistenceCapabilityDecorator) {
"use strict";
describe("A queuing persistence capability decorator", function () {
var mockQueue,
mockCapabilityService,
mockPersistenceConstructor,
mockPersistence,
mockDomainObject,
testModel,
decorator;
beforeEach(function () {
mockQueue = jasmine.createSpyObj('queue', ['put']);
mockCapabilityService = jasmine.createSpyObj(
'capabilityService',
['getCapabilities']
);
testModel = { someKey: "some value" };
mockPersistence = jasmine.createSpyObj(
'persistence',
['persist', 'refresh']
);
mockPersistenceConstructor = jasmine.createSpy();
mockDomainObject = jasmine.createSpyObj(
'domainObject',
['getId']
);
mockCapabilityService.getCapabilities.andReturn({
persistence: mockPersistenceConstructor
});
mockPersistenceConstructor.andReturn(mockPersistence);
decorator = new QueuingPersistenceCapabilityDecorator(
mockQueue,
mockCapabilityService
);
});
// Here, we verify that the decorator wraps the calls it is expected
// to wrap; remaining responsibility belongs to
// QueuingPersistenceCapability itself, which has its own tests.
it("delegates to its wrapped service", function () {
decorator.getCapabilities(testModel);
expect(mockCapabilityService.getCapabilities)
.toHaveBeenCalledWith(testModel);
});
it("wraps its persistence capability's constructor", function () {
decorator.getCapabilities(testModel).persistence(mockDomainObject);
expect(mockPersistenceConstructor).toHaveBeenCalledWith(mockDomainObject);
});
});
}
);

View File

@ -0,0 +1,46 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/QueuingPersistenceCapability"],
function (QueuingPersistenceCapability) {
"use strict";
describe("A queuing persistence capability", function () {
var mockQueue,
mockPersistence,
mockDomainObject,
persistence;
beforeEach(function () {
mockQueue = jasmine.createSpyObj('queue', ['put']);
mockPersistence = jasmine.createSpyObj(
'persistence',
['persist', 'refresh']
);
mockDomainObject = {};
persistence = new QueuingPersistenceCapability(
mockQueue,
mockPersistence,
mockDomainObject
);
});
it("puts a request for persistence into the queue on persist", function () {
// Verify precondition
expect(mockQueue.put).not.toHaveBeenCalled();
// Invoke persistence
persistence.persist();
// Should have queued
expect(mockQueue.put).toHaveBeenCalledWith(
mockDomainObject,
mockPersistence
);
});
it("exposes other methods from the wrapped persistence capability", function () {
expect(persistence.refresh).toBe(mockPersistence.refresh);
});
});
}
);

View File

@ -0,0 +1,11 @@
[
"PersistenceFailureConstants",
"PersistenceFailureController",
"PersistenceFailureDialog",
"PersistenceFailureHandler",
"PersistenceQueue",
"PersistenceQueueHandler",
"PersistenceQueueImpl",
"QueuingPersistenceCapability",
"QueuingPersistenceCapabilityDecorator"
]