Merge remote-tracking branch 'github/master' into open182

Merge latest from master branch into topic branch for
nasa/openmctweb#182
This commit is contained in:
Victor Woeltjen
2015-10-27 11:49:36 -07:00
46 changed files with 7595 additions and 1474 deletions

View File

@ -1 +1 @@
web: node app.js --port $PORT --include example/localstorage web: node app.js --port $PORT

View File

@ -30,7 +30,8 @@
var CONSTANTS = { var CONSTANTS = {
DIAGRAM_WIDTH: 800, DIAGRAM_WIDTH: 800,
DIAGRAM_HEIGHT: 500 DIAGRAM_HEIGHT: 500
}; },
TOC_HEAD = "# Table of Contents";
GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be defined GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be defined
(function () { (function () {
@ -44,6 +45,7 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
split = require("split"), split = require("split"),
stream = require("stream"), stream = require("stream"),
nomnoml = require('nomnoml'), nomnoml = require('nomnoml'),
toc = require("markdown-toc"),
Canvas = require('canvas'), Canvas = require('canvas'),
options = require("minimist")(process.argv.slice(2)); options = require("minimist")(process.argv.slice(2));
@ -110,6 +112,9 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
done(); done();
}; };
transform._flush = function (done) { transform._flush = function (done) {
// Prepend table of contents
markdown =
[ TOC_HEAD, toc(markdown).content, "", markdown ].join("\n");
this.push("<html><body>\n"); this.push("<html><body>\n");
this.push(marked(markdown)); this.push(marked(markdown));
this.push("\n</body></html>\n"); this.push("\n</body></html>\n");
@ -179,13 +184,17 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
glob(options['in'] + "/**/*.@(html|css|png)", {}, function (err, files) { glob(options['in'] + "/**/*.@(html|css|png)", {}, function (err, files) {
files.forEach(function (file) { files.forEach(function (file) {
var destination = file.replace(options['in'], options.out), var destination = file.replace(options['in'], options.out),
destPath = path.dirname(destination); destPath = path.dirname(destination),
streamOptions = {};
if (file.match(/png$/)){
streamOptions.encoding = 'binary';
} else {
streamOptions.encoding = 'utf8';
}
mkdirp(destPath, function (err) { mkdirp(destPath, function (err) {
fs.createReadStream(file, { encoding: 'utf8' }) fs.createReadStream(file, streamOptions)
.pipe(fs.createWriteStream(destination, { .pipe(fs.createWriteStream(destination, streamOptions));
encoding: 'utf8'
}));
}); });
}); });
}); });

View File

@ -35,16 +35,26 @@ in __any of these tiers__.
* _DOM_: The rendered HTML document, composed from HTML templates which * _DOM_: The rendered HTML document, composed from HTML templates which
have been processed by AngularJS and will be updated by AngularJS have been processed by AngularJS and will be updated by AngularJS
to reflect changes from the presentation layer. User interactions to reflect changes from the presentation layer. User interactions
are initiated from here and invoke behavior in the presentation layer. are initiated from here and invoke behavior in the presentation layer. HTML 
templates are written in Angulars template syntax; see the [Angular documentation on templates](https://docs.angularjs.org/guide/templates). 
These describe the page as actually seen by the user. Conceptually, 
stylesheets (controlling the look­and­feel of the rendered templates) belong 
in this grouping as well. 
* [_Presentation layer_](#presentation-layer): The presentation layer * [_Presentation layer_](#presentation-layer): The presentation layer
is responsible for updating (and providing information to update) is responsible for updating (and providing information to update)
the displayed state of the application. The presentation layer consists the displayed state of the application. The presentation layer consists
primarily of _controllers_ and _directives_. The presentation layer is primarily of _controllers_ and _directives_. The presentation layer is
concerned with inspecting the information model and preparing it for concerned with inspecting the information model and preparing it for
display. display.
* [_Information model_](#information-model): The information model * [_Information model_](#information-model): Provides a common (within Open MCT 
describes the state and behavior of the objects with which the user Web) set of interfaces for dealing with “things” ­ domain objects ­ within the 
interacts. system. User­facing concerns in a Open MCT Web application are expressed as 
domain objects; examples include folders (used to organize other domain 
objects), layouts (used to build displays), or telemetry points (used as 
handles for streams of remote measurements.) These domain objects expose a 
common set of interfaces to allow reusable user interfaces to be built in the 
presentation and template tiers; the specifics of these behaviors are then 
mapped to interactions with underlying services. 
* [_Service infrastructure_](#service-infrastructure): The service * [_Service infrastructure_](#service-infrastructure): The service
infrastructure is responsible for providing the underlying general infrastructure is responsible for providing the underlying general
functionality needed to support the information model. This includes functionality needed to support the information model. This includes
@ -52,7 +62,9 @@ in __any of these tiers__.
back-end. back-end.
* _Back-end_: The back-end is out of the scope of Open MCT Web, except * _Back-end_: The back-end is out of the scope of Open MCT Web, except
for the interfaces which are utilized by adapters participating in the for the interfaces which are utilized by adapters participating in the
service infrastructure. service infrastructure. Includes the underlying persistence stores, telemetry 
streams, and so forth which the Open MCT Web client is being used to interact 
with.
## Application Start-up ## Application Start-up

File diff suppressed because it is too large Load Diff

View File

@ -29,8 +29,9 @@
Sections: Sections:
<ul> <ul>
<li><a href="api/">API</a></li> <li><a href="api/">API</a></li>
<li><a href="guide/">Developer Guide</a></li>
<li><a href="architecture/">Architecture Overview</a></li> <li><a href="architecture/">Architecture Overview</a></li>
<li><a href="guide/">Developer Guide</a></li>
<li><a href="tutorials/">Tutorials</a></li>
</ul> </ul>
</body> </body>
</html> </html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

3055
docs/src/tutorials/index.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,8 @@
"split": "^1.0.0", "split": "^1.0.0",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"nomnoml": "^0.0.3", "nomnoml": "^0.0.3",
"canvas": "^1.2.7" "canvas": "^1.2.7",
"markdown-toc": "^0.11.7"
}, },
"scripts": { "scripts": {
"start": "node app.js", "start": "node app.js",

View File

@ -1,4 +1,9 @@
{ {
"configuration": {
"paths": {
"uuid": "uuid"
}
},
"extensions": { "extensions": {
"routes": [ "routes": [
{ {

View File

@ -25,7 +25,7 @@
* Module defining CreateService. Created by vwoeltje on 11/10/14. * Module defining CreateService. Created by vwoeltje on 11/10/14.
*/ */
define( define(
["../../lib/uuid"], ["uuid"],
function (uuid) { function (uuid) {
"use strict"; "use strict";

View File

@ -30,6 +30,14 @@
"category": "contextual", "category": "contextual",
"implementation": "actions/LinkAction.js", "implementation": "actions/LinkAction.js",
"depends": ["locationService", "linkService"] "depends": ["locationService", "linkService"]
},
{
"key": "follow",
"name": "Go To Original",
"description": "Go to the original, un-linked instance of this object.",
"glyph": "\u00F4",
"category": "contextual",
"implementation": "actions/GoToOriginalAction.js"
} }
], ],
"components": [ "components": [
@ -52,7 +60,8 @@
"key": "location", "key": "location",
"name": "Location Capability", "name": "Location Capability",
"description": "Provides a capability for retrieving the location of an object based upon it's context.", "description": "Provides a capability for retrieving the location of an object based upon it's context.",
"implementation": "capabilities/LocationCapability" "implementation": "capabilities/LocationCapability",
"depends": [ "$q", "$injector" ]
} }
], ],
"services": [ "services": [

View File

@ -0,0 +1,62 @@
/*****************************************************************************
* 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";
/**
* Implements the "Go To Original" action, which follows a link back
* to an original instance of an object.
*
* @implements {Action}
* @constructor
* @private
* @memberof platform/entanglement
* @param {ActionContext} context the context in which the action
* will be performed
*/
function GoToOriginalAction(context) {
this.domainObject = context.domainObject;
}
GoToOriginalAction.prototype.perform = function () {
return this.domainObject.getCapability("location").getOriginal()
.then(function (originalObject) {
var actionCapability =
originalObject.getCapability("action");
return actionCapability &&
actionCapability.perform("navigate");
});
};
GoToOriginalAction.appliesTo = function (context) {
var domainObject = context.domainObject;
return domainObject && domainObject.hasCapability("location")
&& domainObject.getCapability("location").isLink();
};
return GoToOriginalAction;
}
);

View File

@ -1,3 +1,25 @@
/*****************************************************************************
* 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 */ /*global define */
define( define(
@ -12,11 +34,41 @@ define(
* *
* @constructor * @constructor
*/ */
function LocationCapability(domainObject) { function LocationCapability($q, $injector, domainObject) {
this.domainObject = domainObject; this.domainObject = domainObject;
this.$q = $q;
this.$injector = $injector;
return this; return this;
} }
/**
* Get an instance of this domain object in its original location.
*
* @returns {Promise.<DomainObject>} a promise for the original
* instance of this domain object
*/
LocationCapability.prototype.getOriginal = function () {
var id;
if (this.isOriginal()) {
return this.$q.when(this.domainObject);
}
id = this.domainObject.getId();
this.objectService =
this.objectService || this.$injector.get("objectService");
// Assume that an object will be correctly contextualized when
// loaded directly from the object service; this is true
// so long as LocatingObjectDecorator is present, and that
// decorator is also contained in this bundle.
return this.objectService.getObjects([id])
.then(function (objects) {
return objects[id];
});
};
/** /**
* Set the primary location (the parent id) of the current domain * Set the primary location (the parent id) of the current domain
* object. * object.
@ -78,10 +130,6 @@ define(
return !this.isLink(); return !this.isLink();
}; };
function createLocationCapability(domainObject) { return LocationCapability;
return new LocationCapability(domainObject);
}
return createLocationCapability;
} }
); );

View File

@ -0,0 +1,95 @@
/*****************************************************************************
* 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,describe,beforeEach,it,jasmine,expect */
define(
[
'../../src/actions/GoToOriginalAction',
'../DomainObjectFactory',
'../ControlledPromise'
],
function (GoToOriginalAction, domainObjectFactory, ControlledPromise) {
'use strict';
describe("The 'go to original' action", function () {
var testContext,
originalDomainObject,
mockLocationCapability,
mockOriginalActionCapability,
originalPromise,
action;
beforeEach(function () {
mockLocationCapability = jasmine.createSpyObj(
'location',
[ 'isLink', 'isOriginal', 'getOriginal' ]
);
mockOriginalActionCapability = jasmine.createSpyObj(
'action',
[ 'perform', 'getActions' ]
);
originalPromise = new ControlledPromise();
mockLocationCapability.getOriginal.andReturn(originalPromise);
mockLocationCapability.isLink.andReturn(true);
mockLocationCapability.isOriginal.andCallFake(function () {
return !mockLocationCapability.isLink();
});
testContext = {
domainObject: domainObjectFactory({
capabilities: {
location: mockLocationCapability
}
})
};
originalDomainObject = domainObjectFactory({
capabilities: {
action: mockOriginalActionCapability
}
});
action = new GoToOriginalAction(testContext);
});
it("is applicable to links", function () {
expect(GoToOriginalAction.appliesTo(testContext))
.toBeTruthy();
});
it("is not applicable to originals", function () {
mockLocationCapability.isLink.andReturn(false);
expect(GoToOriginalAction.appliesTo(testContext))
.toBeFalsy();
});
it("navigates to original objects when performed", function () {
expect(mockOriginalActionCapability.perform)
.not.toHaveBeenCalled();
action.perform();
originalPromise.resolve(originalDomainObject);
expect(mockOriginalActionCapability.perform)
.toHaveBeenCalledWith('navigate');
});
});
}
);

View File

@ -1,3 +1,25 @@
/*****************************************************************************
* 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,describe,it,expect,beforeEach,jasmine */ /*global define,describe,it,expect,beforeEach,jasmine */
define( define(
@ -7,6 +29,7 @@ define(
'../ControlledPromise' '../ControlledPromise'
], ],
function (LocationCapability, domainObjectFactory, ControlledPromise) { function (LocationCapability, domainObjectFactory, ControlledPromise) {
'use strict';
describe("LocationCapability", function () { describe("LocationCapability", function () {
@ -14,13 +37,17 @@ define(
var locationCapability, var locationCapability,
persistencePromise, persistencePromise,
mutationPromise, mutationPromise,
mockQ,
mockInjector,
mockObjectService,
domainObject; domainObject;
beforeEach(function () { beforeEach(function () {
domainObject = domainObjectFactory({ domainObject = domainObjectFactory({
id: "testObject",
capabilities: { capabilities: {
context: { context: {
getParent: function() { getParent: function () {
return domainObjectFactory({id: 'root'}); return domainObjectFactory({id: 'root'});
} }
}, },
@ -35,6 +62,11 @@ define(
} }
}); });
mockQ = jasmine.createSpyObj("$q", ["when"]);
mockInjector = jasmine.createSpyObj("$injector", ["get"]);
mockObjectService =
jasmine.createSpyObj("objectService", ["getObjects"]);
persistencePromise = new ControlledPromise(); persistencePromise = new ControlledPromise();
domainObject.capabilities.persistence.persist.andReturn( domainObject.capabilities.persistence.persist.andReturn(
persistencePromise persistencePromise
@ -49,7 +81,11 @@ define(
} }
); );
locationCapability = new LocationCapability(domainObject); locationCapability = new LocationCapability(
mockQ,
mockInjector,
domainObject
);
}); });
it("returns contextual location", function () { it("returns contextual location", function () {
@ -88,6 +124,57 @@ define(
expect(whenComplete).toHaveBeenCalled(); expect(whenComplete).toHaveBeenCalled();
}); });
describe("when used to load an original instance", function () {
var objectPromise,
qPromise,
originalObjects,
mockCallback;
function resolvePromises() {
if (mockQ.when.calls.length > 0) {
qPromise.resolve(mockQ.when.mostRecentCall.args[0]);
}
if (mockObjectService.getObjects.calls.length > 0) {
objectPromise.resolve(originalObjects);
}
}
beforeEach(function () {
objectPromise = new ControlledPromise();
qPromise = new ControlledPromise();
originalObjects = {
testObject: domainObjectFactory()
};
mockInjector.get.andCallFake(function (key) {
return key === 'objectService' && mockObjectService;
});
mockObjectService.getObjects.andReturn(objectPromise);
mockQ.when.andReturn(qPromise);
mockCallback = jasmine.createSpy('callback');
});
it("provides originals directly", function () {
domainObject.model.location = 'root';
locationCapability.getOriginal().then(mockCallback);
expect(mockCallback).not.toHaveBeenCalled();
resolvePromises();
expect(mockCallback)
.toHaveBeenCalledWith(domainObject);
});
it("loads from the object service for links", function () {
domainObject.model.location = 'some-other-root';
locationCapability.getOriginal().then(mockCallback);
expect(mockCallback).not.toHaveBeenCalled();
resolvePromises();
expect(mockCallback)
.toHaveBeenCalledWith(originalObjects.testObject);
});
});
}); });
}); });
} }

View File

@ -1,5 +1,9 @@
[ [
"actions/AbstractComposeAction", "actions/AbstractComposeAction",
"actions/CopyAction",
"actions/GoToOriginalAction",
"actions/LinkAction",
"actions/MoveAction",
"services/CopyService", "services/CopyService",
"services/LinkService", "services/LinkService",
"services/MoveService", "services/MoveService",

View File

@ -159,7 +159,9 @@ define(
// Update dimensions and origin based on extrema of plots // Update dimensions and origin based on extrema of plots
PlotUpdater.prototype.updateBounds = function () { PlotUpdater.prototype.updateBounds = function () {
var bufferArray = this.bufferArray, var bufferArray = this.bufferArray.filter(function (lineBuffer) {
return lineBuffer.getLength() > 0; // Ignore empty lines
}),
priorDomainOrigin = this.origin[0], priorDomainOrigin = this.origin[0],
priorDomainDimensions = this.dimensions[0]; priorDomainDimensions = this.dimensions[0];

View File

@ -202,6 +202,38 @@ define(
expect(updater.getDimensions()[1]).toBeGreaterThan(20); expect(updater.getDimensions()[1]).toBeGreaterThan(20);
}); });
describe("when no data is initially available", function () {
beforeEach(function () {
testDomainValues = {};
testRangeValues = {};
updater = new PlotUpdater(
mockSubscription,
testDomain,
testRange,
1350 // Smaller max size for easier testing
);
});
it("has no line data", function () {
// Either no lines, or empty lines are fine
expect(updater.getLineBuffers().map(function (lineBuffer) {
return lineBuffer.getLength();
}).reduce(function (a, b) {
return a + b;
}, 0)).toEqual(0);
});
it("determines initial domain bounds from first available data", function () {
testDomainValues.a = 123;
testRangeValues.a = 456;
updater.update();
expect(updater.getOrigin()[0]).toEqual(jasmine.any(Number));
expect(updater.getOrigin()[1]).toEqual(jasmine.any(Number));
expect(isNaN(updater.getOrigin()[0])).toBeFalsy();
expect(isNaN(updater.getOrigin()[1])).toBeFalsy();
});
});
}); });
} }
); );

View File

@ -13,7 +13,7 @@
"provides": "searchService", "provides": "searchService",
"type": "provider", "type": "provider",
"implementation": "ElasticSearchProvider.js", "implementation": "ElasticSearchProvider.js",
"depends": [ "$http", "objectService", "ELASTIC_ROOT" ] "depends": [ "$http", "ELASTIC_ROOT" ]
} }
], ],
"constants": [ "constants": [

View File

@ -24,16 +24,16 @@
/** /**
* Module defining ElasticSearchProvider. Created by shale on 07/16/2015. * Module defining ElasticSearchProvider. Created by shale on 07/16/2015.
*/ */
define( define([
[],
function () { ], function (
) {
"use strict"; "use strict";
// JSLint doesn't like underscore-prefixed properties, var ID_PROPERTY = '_id',
// so hide them here. SOURCE_PROPERTY = '_source',
var ID = "_id", SCORE_PROPERTY = '_score';
SCORE = "_score",
DEFAULT_MAX_RESULTS = 100;
/** /**
* A search service which searches through domain objects in * A search service which searches through domain objects in
@ -41,173 +41,105 @@ define(
* *
* @constructor * @constructor
* @param $http Angular's $http service, for working with urls. * @param $http Angular's $http service, for working with urls.
* @param {ObjectService} objectService the service from which
* domain objects can be gotten.
* @param ROOT the constant `ELASTIC_ROOT` which allows us to * @param ROOT the constant `ELASTIC_ROOT` which allows us to
* interact with ElasticSearch. * interact with ElasticSearch.
*/ */
function ElasticSearchProvider($http, objectService, ROOT) { function ElasticSearchProvider($http, ROOT) {
this.$http = $http; this.$http = $http;
this.objectService = objectService;
this.root = ROOT; this.root = ROOT;
} }
/** /**
* Searches through the filetree for domain objects using a search * Search for domain objects using elasticsearch as a search provider.
* term. This is done through querying elasticsearch. Returns a
* promise for a result object that has the format
* {hits: searchResult[], total: number, timedOut: boolean}
* where a searchResult has the format
* {id: string, object: domainObject, score: number}
* *
* Notes: * @param {String} searchTerm the term to search by.
* * The order of the results is from highest to lowest score, * @param {Number} [maxResults] the max numer of results to return.
* as elsaticsearch determines them to be. * @returns {Promise} promise for a modelResults object.
* * Uses the fuzziness operator to get more results.
* * More about this search's behavior at
* https://www.elastic.co/guide/en/elasticsearch/reference/current/search-uri-request.html
*
* @param searchTerm The text input that is the query.
* @param timestamp The time at which this function was called.
* This timestamp is used as a unique identifier for this
* query and the corresponding results.
* @param maxResults (optional) The maximum number of results
* that this function should return.
* @param timeout (optional) The time after which the search should
* stop calculations and return partial results. Elasticsearch
* does not guarentee that this timeout will be strictly followed.
*/ */
ElasticSearchProvider.prototype.query = function query(searchTerm, timestamp, maxResults, timeout) { ElasticSearchProvider.prototype.query = function (searchTerm, maxResults) {
var $http = this.$http, var searchUrl = this.root + '/_search/',
objectService = this.objectService, params = {},
root = this.root, provider = this;
esQuery;
function addFuzziness(searchTerm, editDistance) { searchTerm = this.cleanTerm(searchTerm);
if (!editDistance) { searchTerm = this.fuzzyMatchUnquotedTerms(searchTerm);
editDistance = '';
}
return searchTerm.split(' ').map(function (s) { params.q = searchTerm;
// Don't add fuzziness for quoted strings params.size = maxResults;
if (s.indexOf('"') !== -1) {
return s;
} else {
return s + '~' + editDistance;
}
}).join(' ');
}
// Currently specific to elasticsearch return this
function processSearchTerm(searchTerm) { .$http({
var spaceIndex; method: "GET",
url: searchUrl,
// Cut out any extra spaces params: params
while (searchTerm.substr(0, 1) === ' ') { })
searchTerm = searchTerm.substring(1, searchTerm.length); .then(function success(succesResponse) {
} return provider.parseResponse(succesResponse);
while (searchTerm.substr(searchTerm.length - 1, 1) === ' ') { }, function error(errorResponse) {
searchTerm = searchTerm.substring(0, searchTerm.length - 1); // Gracefully fail.
} return {
spaceIndex = searchTerm.indexOf(' '); hits: [],
while (spaceIndex !== -1) { total: 0
searchTerm = searchTerm.substring(0, spaceIndex) + };
searchTerm.substring(spaceIndex + 1, searchTerm.length); });
spaceIndex = searchTerm.indexOf(' '); };
}
// Add fuzziness for completeness /**
searchTerm = addFuzziness(searchTerm); * Clean excess whitespace from a search term and return the cleaned
* version.
return searchTerm; *
} * @private
* @param {string} the search term to clean.
// Processes results from the format that elasticsearch returns to * @returns {string} search terms cleaned of excess whitespace.
// a list of searchResult objects, then returns a result object */
// (See documentation for query for object descriptions) ElasticSearchProvider.prototype.cleanTerm = function (term) {
function processResults(rawResults, timestamp) { return term.trim().replace(/ +/g, ' ');
var results = rawResults.data.hits.hits, };
resultsLength = results.length,
ids = [], /**
scores = {}, * Add fuzzy matching markup to search terms that are not quoted.
searchResults = [], *
i; * The following:
* hello welcome "to quoted village" have fun
// Get the result objects' IDs * will become
for (i = 0; i < resultsLength; i += 1) { * hello~ welcome~ "to quoted village" have~ fun~
ids.push(results[i][ID]); *
} * @private
*/
// Get the result objects' scores ElasticSearchProvider.prototype.fuzzyMatchUnquotedTerms = function (query) {
for (i = 0; i < resultsLength; i += 1) { var matchUnquotedSpaces = '\\s+(?=([^"]*"[^"]*")*[^"]*$)',
scores[ids[i]] = results[i][SCORE]; matcher = new RegExp(matchUnquotedSpaces, 'g');
}
return query
// Get the domain objects from their IDs .replace(matcher, '~ ')
return objectService.getObjects(ids).then(function (objects) { .replace(/$/, '~')
var j, .replace(/"~+/, '"');
id; };
for (j = 0; j < resultsLength; j += 1) { /**
id = ids[j]; * Parse the response from ElasticSearch and convert it to a
* modelResults object.
// Include items we can get models for *
if (objects[id].getModel) { * @private
// Format the results as searchResult objects * @param response a ES response object from $http
searchResults.push({ * @returns modelResults
id: id, */
object: objects[id], ElasticSearchProvider.prototype.parseResponse = function (response) {
score: scores[id] var results = response.data.hits.hits,
searchResults = results.map(function (result) {
return {
id: result[ID_PROPERTY],
model: result[SOURCE_PROPERTY],
score: result[SCORE_PROPERTY]
};
}); });
}
}
return { return {
hits: searchResults, hits: searchResults,
total: rawResults.data.hits.total, total: response.data.hits.total
timedOut: rawResults.data.timed_out
}; };
});
}
// Check to see if the user provided a maximum
// number of results to display
if (!maxResults) {
// Else, we provide a default value.
maxResults = DEFAULT_MAX_RESULTS;
}
// If the user input is empty, we want to have no search results.
if (searchTerm !== '' && searchTerm !== undefined) {
// Process the search term
searchTerm = processSearchTerm(searchTerm);
// Create the query to elasticsearch
esQuery = root + "/_search/?q=" + searchTerm +
"&size=" + maxResults;
if (timeout) {
esQuery += "&timeout=" + timeout;
}
// Get the data...
return this.$http({
method: "GET",
url: esQuery
}).then(function (rawResults) {
// ...then process the data
return processResults(rawResults, timestamp);
}, function (err) {
// In case of error, return nothing. (To prevent
// infinite loading time.)
return {hits: [], total: 0};
});
} else {
return {hits: [], total: 0};
}
}; };
return ElasticSearchProvider; return ElasticSearchProvider;
} });
);

View File

@ -19,97 +19,151 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
/*global define,describe,it,expect,beforeEach,jasmine*/ /*global define,describe,it,expect,beforeEach,jasmine,spyOn,Promise,waitsFor*/
/** /**
* SearchSpec. Created by shale on 07/31/2015. * SearchSpec. Created by shale on 07/31/2015.
*/ */
define( define([
["../src/ElasticSearchProvider"], '../src/ElasticSearchProvider'
function (ElasticSearchProvider) { ], function (
"use strict"; ElasticSearchProvider
) {
'use strict';
// JSLint doesn't like underscore-prefixed properties, describe('ElasticSearchProvider', function () {
// so hide them here. var $http,
var ID = "_id", ROOT,
SCORE = "_score"; provider;
describe("The ElasticSearch search provider ", function () {
var mockHttp,
mockHttpPromise,
mockObjectPromise,
mockObjectService,
mockDomainObject,
provider,
mockProviderResults;
beforeEach(function () { beforeEach(function () {
mockHttp = jasmine.createSpy("$http"); $http = jasmine.createSpy('$http');
mockHttpPromise = jasmine.createSpyObj( ROOT = 'http://localhost:9200';
"promise",
[ "then" ]
);
mockHttp.andReturn(mockHttpPromise);
// allow chaining of promise.then().catch();
mockHttpPromise.then.andReturn(mockHttpPromise);
mockObjectService = jasmine.createSpyObj( provider = new ElasticSearchProvider($http, ROOT);
"objectService",
[ "getObjects" ]
);
mockObjectPromise = jasmine.createSpyObj(
"promise",
[ "then" ]
);
mockObjectService.getObjects.andReturn(mockObjectPromise);
mockDomainObject = jasmine.createSpyObj(
"domainObject",
[ "getId", "getModel" ]
);
provider = new ElasticSearchProvider(mockHttp, mockObjectService, "");
provider.query(' test "query" ', 0, undefined, 1000);
}); });
it("sends a query to ElasticSearch", function () { describe('query', function () {
expect(mockHttp).toHaveBeenCalled(); beforeEach(function () {
spyOn(provider, 'cleanTerm').andReturn('cleanedTerm');
spyOn(provider, 'fuzzyMatchUnquotedTerms').andReturn('fuzzy');
spyOn(provider, 'parseResponse').andReturn('parsedResponse');
$http.andReturn(Promise.resolve({}));
}); });
it("gets data from ElasticSearch", function () { it('cleans terms and adds fuzzyness', function () {
var data = { provider.query('hello', 10);
hits: { expect(provider.cleanTerm).toHaveBeenCalledWith('hello');
hits: [ expect(provider.fuzzyMatchUnquotedTerms)
{}, .toHaveBeenCalledWith('cleanedTerm');
{} });
],
total: 0 it('calls through to $http', function () {
provider.query('hello', 10);
expect($http).toHaveBeenCalledWith({
method: 'GET',
params: {
q: 'fuzzy',
size: 10
}, },
timed_out: false url: 'http://localhost:9200/_search/'
};
data.hits.hits[0][ID] = 1;
data.hits.hits[0][SCORE] = 1;
data.hits.hits[1][ID] = 2;
data.hits.hits[1][SCORE] = 2;
mockProviderResults = mockHttpPromise.then.mostRecentCall.args[0]({data: data});
expect(
mockObjectPromise.then.mostRecentCall.args[0]({
1: mockDomainObject,
2: mockDomainObject
}).hits.length
).toEqual(2);
});
it("returns nothing for an empty string query", function () {
expect(provider.query("").hits).toEqual([]);
});
it("returns something when there is an ElasticSearch error", function () {
mockProviderResults = mockHttpPromise.then.mostRecentCall.args[1]();
expect(mockProviderResults).toBeDefined();
}); });
}); });
it('gracefully fails when http fails', function () {
var promiseChainResolved = false;
$http.andReturn(Promise.reject());
provider
.query('hello', 10)
.then(function (results) {
expect(results).toEqual({
hits: [],
total: 0
});
promiseChainResolved = true;
});
waitsFor(function () {
return promiseChainResolved;
});
});
it('parses and returns when http succeeds', function () {
var promiseChainResolved = false;
$http.andReturn(Promise.resolve('successResponse'));
provider
.query('hello', 10)
.then(function (results) {
expect(provider.parseResponse)
.toHaveBeenCalledWith('successResponse');
expect(results).toBe('parsedResponse');
promiseChainResolved = true;
});
waitsFor(function () {
return promiseChainResolved;
});
});
});
it('can clean terms', function () {
expect(provider.cleanTerm(' asdhs ')).toBe('asdhs');
expect(provider.cleanTerm(' and some words'))
.toBe('and some words');
expect(provider.cleanTerm('Nice input')).toBe('Nice input');
});
it('can create fuzzy term matchers', function () {
expect(provider.fuzzyMatchUnquotedTerms('pwr dvc 43'))
.toBe('pwr~ dvc~ 43~');
expect(provider.fuzzyMatchUnquotedTerms(
'hello welcome "to quoted village" have fun'
)).toBe(
'hello~ welcome~ "to quoted village" have~ fun~'
);
});
it('can parse responses', function () {
var elasticSearchResponse = {
data: {
hits: {
total: 2,
hits: [
{
'_id': 'hit1Id',
'_source': 'hit1Model',
'_score': 0.56
},
{
'_id': 'hit2Id',
'_source': 'hit2Model',
'_score': 0.34
} }
); ]
}
}
};
expect(provider.parseResponse(elasticSearchResponse))
.toEqual({
hits: [
{
id: 'hit1Id',
model: 'hit1Model',
score: 0.56
},
{
id: 'hit2Id',
model: 'hit2Model',
score: 0.34
}
],
total: 2
});
});
});
});

View File

@ -48,8 +48,7 @@
"depends": [ "depends": [
"$q", "$q",
"$log", "$log",
"throttle", "modelService",
"objectService",
"workerService", "workerService",
"topic", "topic",
"GENERIC_SEARCH_ROOTS" "GENERIC_SEARCH_ROOTS"
@ -59,7 +58,7 @@
"provides": "searchService", "provides": "searchService",
"type": "aggregator", "type": "aggregator",
"implementation": "services/SearchAggregator.js", "implementation": "services/SearchAggregator.js",
"depends": [ "$q" ] "depends": [ "$q", "objectService" ]
} }
], ],
"workers": [ "workers": [

View File

@ -31,11 +31,6 @@
type="text" type="text"
ng-model="ngModel.input" ng-model="ngModel.input"
ng-keyup="controller.search()" /> ng-keyup="controller.search()" />
<!--mct-control key="'textfield'"
class="search-input"
ng-model="ngModel.input"
ng-keyup="controller.search()">
</mct-control-->
<!-- Search icon --> <!-- Search icon -->
<!-- ui symbols for search are 'd' and 'M' --> <!-- ui symbols for search are 'd' and 'M' -->
@ -78,9 +73,6 @@
Filtered by: {{ ngModel.filtersString }} Filtered by: {{ ngModel.filtersString }}
<!--div class="filter-options">
Filtered by: {{ ngModel.filtersString }}
</div-->
</div> </div>
<!-- This div exists to determine scroll bar location --> <!-- This div exists to determine scroll bar location -->

View File

@ -27,145 +27,154 @@
define(function () { define(function () {
"use strict"; "use strict";
var INITIAL_LOAD_NUMBER = 20,
LOAD_INCREMENT = 20;
function SearchController($scope, searchService) {
// numResults is the amount of results to display. Will get increased.
// fullResults holds the most recent complete searchService response object
var numResults = INITIAL_LOAD_NUMBER,
fullResults = {hits: []};
// Scope variables are:
// Variables used only in SearchController:
// results, an array of searchResult objects
// loading, whether search() is loading
// ngModel.input, the text of the search query
// ngModel.search, a boolean of whether to display search or the tree
// Variables used also in SearchMenuController:
// ngModel.filter, the function filter defined below
// ngModel.types, an array of type objects
// ngModel.checked, a dictionary of which type filter options are checked
// ngModel.checkAll, a boolean of whether to search all types
// ngModel.filtersString, a string list of what filters on the results are active
$scope.results = [];
$scope.loading = false;
// Filters searchResult objects by type. Allows types that are
// checked. (ngModel.checked['typekey'] === true)
// If hits is not provided, will use fullResults.hits
function filter(hits) {
var newResults = [],
i = 0;
if (!hits) {
hits = fullResults.hits;
}
// If checkAll is checked, search everything no matter what the other
// checkboxes' statuses are. Otherwise filter the search by types.
if ($scope.ngModel.checkAll) {
newResults = fullResults.hits.slice(0, numResults);
} else {
while (newResults.length < numResults && i < hits.length) {
// If this is of an acceptable type, add it to the list
if ($scope.ngModel.checked[hits[i].object.getModel().type]) {
newResults.push(fullResults.hits[i]);
}
i += 1;
}
}
$scope.results = newResults;
return newResults;
}
// Make function accessible from SearchMenuController
$scope.ngModel.filter = filter;
// For documentation, see search below
function search(maxResults) {
var inputText = $scope.ngModel.input;
if (inputText !== '' && inputText !== undefined) {
// We are starting to load.
$scope.loading = true;
// Update whether the file tree should be displayed
// Hide tree only when starting search
$scope.ngModel.search = true;
}
if (!maxResults) {
// Reset 'load more'
numResults = INITIAL_LOAD_NUMBER;
}
// Send the query
searchService.query(inputText, maxResults).then(function (result) {
// Store all the results before splicing off the front, so that
// we can load more to display later.
fullResults = result;
$scope.results = filter(result.hits);
// Update whether the file tree should be displayed
// Reveal tree only when finishing search
if (inputText === '' || inputText === undefined) {
$scope.ngModel.search = false;
}
// Now we are done loading.
$scope.loading = false;
});
}
return {
/** /**
* Search the filetree. Assumes that any search text will * Controller for search in Tree View.
* be in ngModel.input
* *
* @param maxResults (optional) The maximum number of results * Filtering is currently buggy; it filters after receiving results from
* that this function should return. If not provided, search * search providers, the downside of this is that it requires search
* service default will be used. * providers to provide objects for all possible results, which is
* potentially a hit to persistence, thus can be very very slow.
*
* Ideally, filtering should be handled before loading objects from the persistence
* store, the downside to this is that filters must be applied to object
* models, not object instances.
*
* @constructor
* @param $scope
* @param searchService
*/ */
search: search, function SearchController($scope, searchService) {
var controller = this;
/** this.$scope = $scope;
* Checks to see if there are more search results to display. If the answer is this.searchService = searchService;
* unclear, this function will err toward saying that there are more results. this.numberToDisplay = this.RESULTS_PER_PAGE;
*/ this.availabileResults = 0;
areMore: function () { this.$scope.results = [];
var i; this.$scope.loading = false;
this.pendingQuery = undefined;
// Check to see if any of the not displayed results are of an allowed type this.$scope.ngModel.filter = function () {
for (i = numResults; i < fullResults.hits.length; i += 1) { return controller.onFilterChange.apply(controller, arguments);
if ($scope.ngModel.checkAll || $scope.ngModel.checked[fullResults.hits[i].object.getModel().type]) {
return true;
}
}
// If none of the ones at hand are correct, there still may be more if we
// re-search with a larger maxResults
return fullResults.hits.length < fullResults.total;
},
/**
* Increases the number of search results to display, and then
* loads them, adding to the displayed results.
*/
loadMore: function () {
numResults += LOAD_INCREMENT;
if (numResults > fullResults.hits.length && fullResults.hits.length < fullResults.total) {
// Resend the query if we are out of items to display, but there are more to get
search(numResults);
} else {
// Otherwise just take from what we already have
$scope.results = filter(fullResults.hits);
}
}
}; };
} }
SearchController.prototype.RESULTS_PER_PAGE = 20;
/**
* Returns true if there are more results than currently displayed for the
* for the current query and filters.
*/
SearchController.prototype.areMore = function () {
return this.$scope.results.length < this.availableResults;
};
/**
* Display more results for the currently displayed query and filters.
*/
SearchController.prototype.loadMore = function () {
this.numberToDisplay += this.RESULTS_PER_PAGE;
this.dispatchSearch();
};
/**
* Reset search results, then search for the query string specified in
* scope.
*/
SearchController.prototype.search = function () {
var inputText = this.$scope.ngModel.input;
this.clearResults();
if (inputText) {
this.$scope.loading = true;
this.$scope.ngModel.search = true;
} else {
this.pendingQuery = undefined;
this.$scope.ngModel.search = false;
this.$scope.loading = false;
return;
}
this.dispatchSearch();
};
/**
* Dispatch a search to the search service if it hasn't already been
* dispatched.
*
* @private
*/
SearchController.prototype.dispatchSearch = function () {
var inputText = this.$scope.ngModel.input,
controller = this,
queryId = inputText + this.numberToDisplay;
if (this.pendingQuery === queryId) {
return; // don't issue multiple queries for the same term.
}
this.pendingQuery = queryId;
this
.searchService
.query(inputText, this.numberToDisplay, this.filterPredicate())
.then(function (results) {
if (controller.pendingQuery !== queryId) {
return; // another query in progress, so skip this one.
}
controller.onSearchComplete(results);
});
};
SearchController.prototype.filter = SearchController.prototype.onFilterChange;
/**
* Refilter results and update visible results when filters have changed.
*/
SearchController.prototype.onFilterChange = function () {
this.pendingQuery = undefined;
this.search();
};
/**
* Returns a predicate function that can be used to filter object models.
*
* @private
*/
SearchController.prototype.filterPredicate = function () {
if (this.$scope.ngModel.checkAll) {
return function () {
return true;
};
}
var includeTypes = this.$scope.ngModel.checked;
return function (model) {
return !!includeTypes[model.type];
};
};
/**
* Clear the search results.
*
* @private
*/
SearchController.prototype.clearResults = function () {
this.$scope.results = [];
this.availableResults = 0;
this.numberToDisplay = this.RESULTS_PER_PAGE;
};
/**
* Update search results from given `results`.
*
* @private
*/
SearchController.prototype.onSearchComplete = function (results) {
this.availableResults = results.total;
this.$scope.results = results.hits;
this.$scope.loading = false;
this.pendingQuery = undefined;
};
return SearchController; return SearchController;
}); });

View File

@ -19,21 +19,17 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
/*global define*/ /*global define,setTimeout*/
/** /**
* Module defining GenericSearchProvider. Created by shale on 07/16/2015. * Module defining GenericSearchProvider. Created by shale on 07/16/2015.
*/ */
define( define([
[],
function () {
"use strict";
var DEFAULT_MAX_RESULTS = 100, ], function (
DEFAULT_TIMEOUT = 1000,
MAX_CONCURRENT_REQUESTS = 100, ) {
FLUSH_INTERVAL = 0, "use strict";
stopTime;
/** /**
* A search service which searches through domain objects in * A search service which searches through domain objects in
@ -42,211 +38,243 @@ define(
* @constructor * @constructor
* @param $q Angular's $q, for promise consolidation. * @param $q Angular's $q, for promise consolidation.
* @param $log Anglar's $log, for logging. * @param $log Anglar's $log, for logging.
* @param {Function} throttle a function to throttle function invocations * @param {ModelService} modelService the model service.
* @param {ObjectService} objectService The service from which * @param {WorkerService} workerService the workerService.
* domain objects can be gotten. * @param {TopicService} topic the topic service.
* @param {WorkerService} workerService The service which allows * @param {Array} ROOTS An array of object Ids to begin indexing.
* more easy creation of web workers.
* @param {GENERIC_SEARCH_ROOTS} ROOTS An array of the root
* domain objects' IDs.
*/ */
function GenericSearchProvider($q, $log, throttle, objectService, workerService, topic, ROOTS) { function GenericSearchProvider($q, $log, modelService, workerService, topic, ROOTS) {
var indexed = {}, var provider = this;
pendingIndex = {},
pendingQueries = {},
toRequest = [],
worker = workerService.run('genericSearchWorker'),
mutationTopic = topic("mutation"),
indexingStarted = Date.now(),
pendingRequests = 0,
scheduleFlush;
this.worker = worker;
this.pendingQueries = pendingQueries;
this.$q = $q; this.$q = $q;
// pendingQueries is a dictionary with the key value pairs st this.$log = $log;
// the key is the timestamp and the value is the promise this.modelService = modelService;
function scheduleIdsForIndexing(ids) { this.indexedIds = {};
ids.forEach(function (id) { this.idsToIndex = [];
if (!indexed[id] && !pendingIndex[id]) { this.pendingIndex = {};
indexed[id] = true; this.pendingRequests = 0;
pendingIndex[id] = true;
toRequest.push(id);
}
});
scheduleFlush();
}
// Tell the web worker to add a domain object's model to its list of items. this.pendingQueries = {};
function indexItem(domainObject) {
var model = domainObject.getModel();
worker.postMessage({ this.worker = this.startWorker(workerService);
request: 'index', this.indexOnMutation(topic);
model: model,
id: domainObject.getId() ROOTS.forEach(function indexRoot(rootId) {
provider.scheduleForIndexing(rootId);
}); });
if (Array.isArray(model.composition)) {
scheduleIdsForIndexing(model.composition);
}
}
// Handles responses from the web worker. Namely, the results of
// a search request.
function handleResponse(event) {
var ids = [],
id;
// If we have the results from a search
if (event.data.request === 'search') {
// Convert the ids given from the web worker into domain objects
for (id in event.data.results) {
ids.push(id);
}
objectService.getObjects(ids).then(function (objects) {
var searchResults = [],
id;
// Create searchResult objects
for (id in objects) {
searchResults.push({
object: objects[id],
id: id,
score: event.data.results[id]
});
}
// Resove the promise corresponding to this
pendingQueries[event.data.timestamp].resolve({
hits: searchResults,
total: event.data.total,
timedOut: event.data.timedOut
});
});
}
}
function requestAndIndex(id) {
pendingRequests += 1;
objectService.getObjects([id]).then(function (objects) {
delete pendingIndex[id];
if (objects[id]) {
indexItem(objects[id]);
}
}, function () {
$log.warn("Failed to index domain object " + id);
}).then(function () {
pendingRequests -= 1;
scheduleFlush();
});
}
scheduleFlush = throttle(function flush() {
var batchSize =
Math.max(MAX_CONCURRENT_REQUESTS - pendingRequests, 0);
if (toRequest.length + pendingRequests < 1) {
$log.info([
'GenericSearch finished indexing after ',
((Date.now() - indexingStarted) / 1000).toFixed(2),
' seconds.'
].join(''));
} else {
toRequest.splice(-batchSize, batchSize)
.forEach(requestAndIndex);
}
}, FLUSH_INTERVAL);
worker.onmessage = handleResponse;
// Index the tree's contents once at the beginning
scheduleIdsForIndexing(ROOTS);
// Re-index items when they are mutated
mutationTopic.listen(function (domainObject) {
var id = domainObject.getId();
indexed[id] = false;
scheduleIdsForIndexing([id]);
});
} }
/** /**
* Searches through the filetree for domain objects which match * Maximum number of concurrent index requests to allow.
* the search term. This function is to be used as a fallback
* in the case where other search services are not avaliable.
* Returns a promise for a result object that has the format
* {hits: searchResult[], total: number, timedOut: boolean}
* where a searchResult has the format
* {id: string, object: domainObject, score: number}
*
* Notes:
* * The order of the results is not guarenteed.
* * A domain object qualifies as a match for a search input if
* the object's name property contains any of the search terms
* (which are generated by splitting the input at spaces).
* * Scores are higher for matches that have more of the terms
* as substrings.
*
* @param input The text input that is the query.
* @param timestamp The time at which this function was called.
* This timestamp is used as a unique identifier for this
* query and the corresponding results.
* @param maxResults (optional) The maximum number of results
* that this function should return.
* @param timeout (optional) The time after which the search should
* stop calculations and return partial results.
*/ */
GenericSearchProvider.prototype.query = function query(input, timestamp, maxResults, timeout) { GenericSearchProvider.prototype.MAX_CONCURRENT_REQUESTS = 100;
var terms = [],
searchResults = [],
pendingQueries = this.pendingQueries,
worker = this.worker,
defer = this.$q.defer();
// Tell the worker to search for items it has that match this searchInput. /**
// Takes the searchInput, as well as a max number of results (will return * Query the search provider for results.
// less than that if there are fewer matches). *
function workerSearch(searchInput, maxResults, timestamp, timeout) { * @param {String} input the string to search by.
var message = { * @param {Number} maxResults max number of results to return.
* @returns {Promise} a promise for a modelResults object.
*/
GenericSearchProvider.prototype.query = function (
input,
maxResults
) {
var queryId = this.dispatchSearch(input, maxResults),
pendingQuery = this.$q.defer();
this.pendingQueries[queryId] = pendingQuery;
return pendingQuery.promise;
};
/**
* Creates a search worker and attaches handlers.
*
* @private
* @param workerService
* @returns worker the created search worker.
*/
GenericSearchProvider.prototype.startWorker = function (workerService) {
var worker = workerService.run('genericSearchWorker'),
provider = this;
worker.addEventListener('message', function (messageEvent) {
provider.onWorkerMessage(messageEvent);
});
return worker;
};
/**
* Listen to the mutation topic and re-index objects when they are
* mutated.
*
* @private
* @param topic the topicService.
*/
GenericSearchProvider.prototype.indexOnMutation = function (topic) {
var mutationTopic = topic('mutation'),
provider = this;
mutationTopic.listen(function (mutatedObject) {
var id = mutatedObject.getId();
provider.indexedIds[id] = false;
provider.scheduleForIndexing(id);
});
};
/**
* Schedule an id to be indexed at a later date. If there are less
* pending requests then allowed, will kick off an indexing request.
*
* @private
* @param {String} id to be indexed.
*/
GenericSearchProvider.prototype.scheduleForIndexing = function (id) {
if (!this.indexedIds[id] && !this.pendingIndex[id]) {
this.indexedIds[id] = true;
this.pendingIndex[id] = true;
this.idsToIndex.push(id);
}
this.keepIndexing();
};
/**
* If there are less pending requests than concurrent requests, keep
* firing requests.
*
* @private
*/
GenericSearchProvider.prototype.keepIndexing = function () {
while (this.pendingRequests < this.MAX_CONCURRENT_REQUESTS &&
this.idsToIndex.length
) {
this.beginIndexRequest();
}
};
/**
* Pass an id and model to the worker to be indexed. If the model has
* composition, schedule those ids for later indexing.
*
* @private
* @param id a model id
* @param model a model
*/
GenericSearchProvider.prototype.index = function (id, model) {
var provider = this;
this.worker.postMessage({
request: 'index',
model: model,
id: id
});
if (Array.isArray(model.composition)) {
model.composition.forEach(function (id) {
provider.scheduleForIndexing(id);
});
}
};
/**
* Pulls an id from the indexing queue, loads it from the model service,
* and indexes it. Upon completion, tells the provider to keep
* indexing.
*
* @private
*/
GenericSearchProvider.prototype.beginIndexRequest = function () {
var idToIndex = this.idsToIndex.shift(),
provider = this;
this.pendingRequests += 1;
this.modelService
.getModels([idToIndex])
.then(function (models) {
delete provider.pendingIndex[idToIndex];
if (models[idToIndex]) {
provider.index(idToIndex, models[idToIndex]);
}
}, function () {
provider
.$log
.warn('Failed to index domain object ' + idToIndex);
})
.then(function () {
setTimeout(function () {
provider.pendingRequests -= 1;
provider.keepIndexing();
}, 0);
});
};
/**
* Handle messages from the worker. Only really knows how to handle search
* results, which are parsed, transformed into a modelResult object, which
* is used to resolve the corresponding promise.
* @private
*/
GenericSearchProvider.prototype.onWorkerMessage = function (event) {
if (event.data.request !== 'search') {
return;
}
var pendingQuery = this.pendingQueries[event.data.queryId],
modelResults = {
total: event.data.total
};
modelResults.hits = event.data.results.map(function (hit) {
return {
id: hit.item.id,
model: hit.item.model,
score: hit.matchCount
};
});
pendingQuery.resolve(modelResults);
delete this.pendingQueries[event.data.queryId];
};
/**
* @private
* @returns {Number} a unique, unusued query Id.
*/
GenericSearchProvider.prototype.makeQueryId = function () {
var queryId = Math.ceil(Math.random() * 100000);
while (this.pendingQueries[queryId]) {
queryId = Math.ceil(Math.random() * 100000);
}
return queryId;
};
/**
* Dispatch a search query to the worker and return a queryId.
*
* @private
* @returns {Number} a unique query Id for the query.
*/
GenericSearchProvider.prototype.dispatchSearch = function (
searchInput,
maxResults
) {
var queryId = this.makeQueryId();
this.worker.postMessage({
request: 'search', request: 'search',
input: searchInput, input: searchInput,
maxNumber: maxResults, maxResults: maxResults,
timestamp: timestamp, queryId: queryId
timeout: timeout });
};
worker.postMessage(message);
}
// If the input is nonempty, do a search return queryId;
if (input !== '' && input !== undefined) {
// Allow us to access this promise later to resolve it later
pendingQueries[timestamp] = defer;
// Check to see if the user provided a maximum
// number of results to display
if (!maxResults) {
// Else, we provide a default value
maxResults = DEFAULT_MAX_RESULTS;
}
// Similarly, check if timeout was provided
if (!timeout) {
timeout = DEFAULT_TIMEOUT;
}
// Send the query to the worker
workerSearch(input, maxResults, timestamp, timeout);
return defer.promise;
} else {
// Otherwise return an empty result
return { hits: [], total: 0 };
}
}; };
return GenericSearchProvider; return GenericSearchProvider;
} });
);

View File

@ -29,128 +29,127 @@
// An array of objects composed of domain object IDs and models // An array of objects composed of domain object IDs and models
// {id: domainObject's ID, model: domainObject's model} // {id: domainObject's ID, model: domainObject's model}
var indexedItems = []; var indexedItems = [],
TERM_SPLITTER = /[ _\*]/;
// Helper function for serach() function indexItem(id, model) {
function convertToTerms(input) { var vector = {
var terms = input; name: model.name
// Shave any spaces off of the ends of the input };
while (terms.substr(0, 1) === ' ') { vector.cleanName = model.name.trim();
terms = terms.substring(1, terms.length); vector.lowerCaseName = vector.cleanName.toLocaleLowerCase();
} vector.terms = vector.lowerCaseName.split(TERM_SPLITTER);
while (terms.substr(terms.length - 1, 1) === ' ') {
terms = terms.substring(0, terms.length - 1);
}
// Then split it at spaces and asterisks indexedItems.push({
terms = terms.split(/ |\*/); id: id,
vector: vector,
// Remove any empty strings from the terms model: model
while (terms.indexOf('') !== -1) { });
terms.splice(terms.indexOf(''), 1);
}
return terms;
} }
// Helper function for search() // Helper function for search()
function scoreItem(item, input, terms) { function convertToTerms(input) {
var name = item.model.name.toLocaleLowerCase(), var query = {
weight = 0.65, exactInput: input
score = 0.0, };
i; query.inputClean = input.trim();
query.inputLowerCase = query.inputClean.toLocaleLowerCase();
// Make the score really big if the item name and query.terms = query.inputLowerCase.split(TERM_SPLITTER);
// the original search input are the same query.exactTerms = query.inputClean.split(TERM_SPLITTER);
if (name === input) { return query;
score = 42;
}
for (i = 0; i < terms.length; i += 1) {
// Increase the score if the term is in the item name
if (name.indexOf(terms[i]) !== -1) {
score += 1;
// Add extra to the score if the search term exists
// as its own term within the items
if (name.split(' ').indexOf(terms[i]) !== -1) {
score += 0.5;
}
}
}
return score * weight;
} }
/** /**
* Gets search results from the indexedItems based on provided search * Gets search results from the indexedItems based on provided search
* input. Returns matching results from indexedItems, as well as the * input. Returns matching results from indexedItems
* timestamp that was passed to it.
* *
* @param data An object which contains: * @param data An object which contains:
* * input: The original string which we are searching with * * input: The original string which we are searching with
* * maxNumber: The maximum number of search results desired * * maxResults: The maximum number of search results desired
* * timestamp: The time identifier from when the query was made * * queryId: an id identifying this query, will be returned.
*/ */
function search(data) { function search(data) {
// This results dictionary will have domain object ID keys which // This results dictionary will have domain object ID keys which
// point to the value the domain object's score. // point to the value the domain object's score.
var results = {}, var results,
input = data.input.toLocaleLowerCase(), input = data.input,
terms = convertToTerms(input), query = convertToTerms(input),
message = { message = {
request: 'search', request: 'search',
results: {}, results: {},
total: 0, total: 0,
timestamp: data.timestamp, queryId: data.queryId
timedOut: false
}, },
score, matches = {};
i,
id;
// If the user input is empty, we want to have no search results. if (!query.inputClean) {
if (input !== '') { // No search terms, no results;
for (i = 0; i < indexedItems.length; i += 1) { return message;
// If this is taking too long, then stop
if (Date.now() > data.timestamp + data.timeout) {
message.timedOut = true;
break;
} }
// Score and add items // Two phases: find matches, then score matches.
score = scoreItem(indexedItems[i], input, terms); // Idea being that match finding should be fast, so that future scoring
if (score > 0) { // operations process fewer objects.
results[indexedItems[i].id] = score;
message.total += 1;
}
}
}
// Truncate results if there are more than maxResults query.terms.forEach(function findMatchingItems(term) {
if (message.total > data.maxResults) { indexedItems
i = 0; .filter(function matchesItem(item) {
for (id in results) { return item.vector.lowerCaseName.indexOf(term) !== -1;
message.results[id] = results[id]; })
i += 1; .forEach(function trackMatch(matchedItem) {
if (i >= data.maxResults) { if (!matches[matchedItem.id]) {
break; matches[matchedItem.id] = {
matchCount: 0,
item: matchedItem
};
} }
matches[matchedItem.id].matchCount += 1;
});
});
// Then, score matching items.
results = Object
.keys(matches)
.map(function asMatches(matchId) {
return matches[matchId];
})
.map(function prioritizeExactMatches(match) {
if (match.item.vector.name === query.exactInput) {
match.matchCount += 100;
} else if (match.item.vector.lowerCaseName ===
query.inputLowerCase) {
match.matchCount += 50;
} }
// TODO: This seems inefficient. return match;
} else { })
message.results = results; .map(function prioritizeCompleteTermMatches(match) {
match.item.vector.terms.forEach(function (term) {
if (query.terms.indexOf(term) !== -1) {
match.matchCount += 0.5;
} }
});
return match;
})
.sort(function compare(a, b) {
if (a.matchCount > b.matchCount) {
return -1;
}
if (a.matchCount < b.matchCount) {
return 1;
}
return 0;
});
message.total = results.length;
message.results = results
.slice(0, data.maxResults);
return message; return message;
} }
self.onmessage = function (event) { self.onmessage = function (event) {
if (event.data.request === 'index') { if (event.data.request === 'index') {
indexedItems.push({ indexItem(event.data.id, event.data.model);
id: event.data.id,
model: event.data.model
});
} else if (event.data.request === 'search') { } else if (event.data.request === 'search') {
self.postMessage(search(event.data)); self.postMessage(search(event.data));
} }

View File

@ -24,75 +24,113 @@
/** /**
* Module defining SearchAggregator. Created by shale on 07/16/2015. * Module defining SearchAggregator. Created by shale on 07/16/2015.
*/ */
define( define([
[],
function () { ], function (
) {
"use strict"; "use strict";
var DEFUALT_TIMEOUT = 1000,
DEFAULT_MAX_RESULTS = 100;
/** /**
* Allows multiple services which provide search functionality * Aggregates multiple search providers as a singular search provider.
* to be treated as one. * Search providers are expected to implement a `query` method which returns
* a promise for a `modelResults` object.
*
* The search aggregator combines the results from multiple providers,
* removes aggregates, and converts the results to domain objects.
* *
* @constructor * @constructor
* @param $q Angular's $q, for promise consolidation. * @param $q Angular's $q, for promise consolidation.
* @param objectService
* @param {SearchProvider[]} providers The search providers to be * @param {SearchProvider[]} providers The search providers to be
* aggregated. * aggregated.
*/ */
function SearchAggregator($q, providers) { function SearchAggregator($q, objectService, providers) {
this.$q = $q; this.$q = $q;
this.objectService = objectService;
this.providers = providers; this.providers = providers;
} }
/**
* If max results is not specified in query, use this as default.
*/
SearchAggregator.prototype.DEFAULT_MAX_RESULTS = 100;
/**
* Because filtering isn't implemented inside each provider, the fudge
* factor is a multiplier on the number of results returned-- more results
* than requested will be fetched, and then will be filtered. This helps
* provide more predictable pagination when large numbers of results are
* returned but very few results match filters.
*
* If a provider level filter implementation is implemented in the future,
* remove this.
*/
SearchAggregator.prototype.FUDGE_FACTOR = 5;
/** /**
* Sends a query to each of the providers. Returns a promise for * Sends a query to each of the providers. Returns a promise for
* a result object that has the format * a result object that has the format
* {hits: searchResult[], total: number, timedOut: boolean} * {hits: searchResult[], total: number}
* where a searchResult has the format * where a searchResult has the format
* {id: string, object: domainObject, score: number} * {id: string, object: domainObject, score: number}
* *
* @param inputText The text input that is the query. * @param {String} inputText The text input that is the query.
* @param maxResults (optional) The maximum number of results * @param {Number} maxResults (optional) The maximum number of results
* that this function should return. If not provided, a * that this function should return. If not provided, a
* default of 100 will be used. * default of 100 will be used.
* @param {Function} [filter] if provided, will be called for every
* potential modelResult. If it returns false, the model result will be
* excluded from the search results.
* @returns {Promise} A Promise for a search result object.
*/ */
SearchAggregator.prototype.query = function queryAll(inputText, maxResults) { SearchAggregator.prototype.query = function (
var $q = this.$q, inputText,
providers = this.providers, maxResults,
i, filter
timestamp = Date.now(), ) {
resultPromises = [];
// Remove duplicate objects that have the same ID. Modifies the passed var aggregator = this,
// array, and returns the number that were removed. resultPromises;
function filterDuplicates(results, total) {
var ids = {},
numRemoved = 0,
i;
for (i = 0; i < results.length; i += 1) { if (!maxResults) {
if (ids[results[i].id]) { maxResults = this.DEFAULT_MAX_RESULTS;
// If this result's ID is already there, remove the object
results.splice(i, 1);
numRemoved += 1;
// Reduce loop index because we shortened the array
i -= 1;
} else {
// Otherwise add the ID to the list of the ones we have seen
ids[results[i].id] = true;
}
} }
return numRemoved; resultPromises = this.providers.map(function (provider) {
} return provider.query(
inputText,
maxResults * aggregator.FUDGE_FACTOR
);
});
// Order the objects from highest to lowest score in the array. return this.$q
// Modifies the passed array, as well as returns the modified array. .all(resultPromises)
function orderByScore(results) { .then(function (providerResults) {
results.sort(function (a, b) { var modelResults = {
hits: [],
total: 0
};
providerResults.forEach(function (providerResult) {
modelResults.hits =
modelResults.hits.concat(providerResult.hits);
modelResults.total += providerResult.total;
});
modelResults = aggregator.orderByScore(modelResults);
modelResults = aggregator.applyFilter(modelResults, filter);
modelResults = aggregator.removeDuplicates(modelResults);
return aggregator.asObjectResults(modelResults);
});
};
/**
* Order model results by score descending and return them.
*/
SearchAggregator.prototype.orderByScore = function (modelResults) {
modelResults.hits.sort(function (a, b) {
if (a.score > b.score) { if (a.score > b.score) {
return -1; return -1;
} else if (b.score > a.score) { } else if (b.score > a.score) {
@ -101,45 +139,86 @@ define(
return 0; return 0;
} }
}); });
return results; return modelResults;
}
if (!maxResults) {
maxResults = DEFAULT_MAX_RESULTS;
}
// Send the query to all the providers
for (i = 0; i < providers.length; i += 1) {
resultPromises.push(
providers[i].query(inputText, timestamp, maxResults, DEFUALT_TIMEOUT)
);
}
// Get promises for results arrays
return $q.all(resultPromises).then(function (resultObjects) {
var results = [],
totalSum = 0,
i;
// Merge results
for (i = 0; i < resultObjects.length; i += 1) {
results = results.concat(resultObjects[i].hits);
totalSum += resultObjects[i].total;
}
// Order by score first, so that when removing repeats we keep the higher scored ones
orderByScore(results);
totalSum -= filterDuplicates(results, totalSum);
return {
hits: results,
total: totalSum,
timedOut: resultObjects.some(function (obj) {
return obj.timedOut;
})
}; };
/**
* Apply a filter to each model result, removing it from search results
* if it does not match.
*/
SearchAggregator.prototype.applyFilter = function (modelResults, filter) {
if (!filter) {
return modelResults;
}
var initialLength = modelResults.hits.length,
finalLength,
removedByFilter;
modelResults.hits = modelResults.hits.filter(function (hit) {
return filter(hit.model);
});
finalLength = modelResults.hits.length;
removedByFilter = initialLength - finalLength;
modelResults.total -= removedByFilter;
return modelResults;
};
/**
* Remove duplicate hits in a modelResults object, and decrement `total`
* each time a duplicate is removed.
*/
SearchAggregator.prototype.removeDuplicates = function (modelResults) {
var includedIds = {};
modelResults.hits = modelResults
.hits
.filter(function alreadyInResults(hit) {
if (includedIds[hit.id]) {
modelResults.total -= 1;
return false;
}
includedIds[hit.id] = true;
return true;
});
return modelResults;
};
/**
* Convert modelResults to objectResults by fetching them from the object
* service.
*
* @returns {Promise} for an objectResults object.
*/
SearchAggregator.prototype.asObjectResults = function (modelResults) {
var objectIds = modelResults.hits.map(function (modelResult) {
return modelResult.id;
});
return this
.objectService
.getObjects(objectIds)
.then(function (objects) {
var objectResults = {
total: modelResults.total
};
objectResults.hits = modelResults
.hits
.map(function asObjectResult(hit) {
return {
id: hit.id,
object: objects[hit.id],
score: hit.score
};
});
return objectResults;
}); });
}; };
return SearchAggregator; return SearchAggregator;
} });
);

View File

@ -4,12 +4,12 @@
* Administration. All rights reserved. * Administration. All rights reserved.
* *
* Open MCT Web is licensed under the Apache License, Version 2.0 (the * 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. * 'License'); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0. * http://www.apache.org/licenses/LICENSE-2.0.
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations * License for the specific language governing permissions and limitations
* under the License. * under the License.
@ -24,16 +24,14 @@
/** /**
* SearchSpec. Created by shale on 07/31/2015. * SearchSpec. Created by shale on 07/31/2015.
*/ */
define( define([
["../../src/controllers/SearchController"], '../../src/controllers/SearchController'
function (SearchController) { ], function (
"use strict"; SearchController
) {
'use strict';
// These should be the same as the ones on the top of the search controller describe('The search controller', function () {
var INITIAL_LOAD_NUMBER = 20,
LOAD_INCREMENT = 20;
describe("The search controller", function () {
var mockScope, var mockScope,
mockSearchService, mockSearchService,
mockPromise, mockPromise,
@ -54,34 +52,34 @@ define(
beforeEach(function () { beforeEach(function () {
mockScope = jasmine.createSpyObj( mockScope = jasmine.createSpyObj(
"$scope", '$scope',
[ "$watch" ] [ '$watch' ]
); );
mockScope.ngModel = {}; mockScope.ngModel = {};
mockScope.ngModel.input = "test input"; mockScope.ngModel.input = 'test input';
mockScope.ngModel.checked = {}; mockScope.ngModel.checked = {};
mockScope.ngModel.checked['mock.type'] = true; mockScope.ngModel.checked['mock.type'] = true;
mockScope.ngModel.checkAll = true; mockScope.ngModel.checkAll = true;
mockSearchService = jasmine.createSpyObj( mockSearchService = jasmine.createSpyObj(
"searchService", 'searchService',
[ "query" ] [ 'query' ]
); );
mockPromise = jasmine.createSpyObj( mockPromise = jasmine.createSpyObj(
"promise", 'promise',
[ "then" ] [ 'then' ]
); );
mockSearchService.query.andReturn(mockPromise); mockSearchService.query.andReturn(mockPromise);
mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}]; mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}];
mockSearchResult = jasmine.createSpyObj( mockSearchResult = jasmine.createSpyObj(
"searchResult", 'searchResult',
[ "" ] [ '' ]
); );
mockDomainObject = jasmine.createSpyObj( mockDomainObject = jasmine.createSpyObj(
"domainObject", 'domainObject',
[ "getModel" ] [ 'getModel' ]
); );
mockSearchResult.object = mockDomainObject; mockSearchResult.object = mockDomainObject;
mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'}); mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'});
@ -90,20 +88,44 @@ define(
controller.search(); controller.search();
}); });
it("sends queries to the search service", function () { it('has a default number of results per page', function () {
expect(mockSearchService.query).toHaveBeenCalled(); expect(controller.RESULTS_PER_PAGE).toBe(20);
}); });
it("populates the results with results from the search service", function () { it('sends queries to the search service', function () {
expect(mockSearchService.query).toHaveBeenCalledWith(
'test input',
controller.RESULTS_PER_PAGE,
jasmine.any(Function)
);
});
describe('filter query function', function () {
it('returns true when all types allowed', function () {
mockScope.ngModel.checkAll = true;
controller.onFilterChange();
var filterFn = mockSearchService.query.mostRecentCall.args[2];
expect(filterFn('askbfa')).toBe(true);
});
it('returns true only for matching checked types', function () {
mockScope.ngModel.checkAll = false;
controller.onFilterChange();
var filterFn = mockSearchService.query.mostRecentCall.args[2];
expect(filterFn({type: 'mock.type'})).toBe(true);
expect(filterFn({type: 'other.type'})).toBe(false);
});
});
it('populates the results with results from the search service', function () {
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function)); expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
mockPromise.then.mostRecentCall.args[0]({hits: []}); mockPromise.then.mostRecentCall.args[0]({hits: ['a']});
expect(mockScope.results).toBeDefined(); expect(mockScope.results.length).toBe(1);
expect(mockScope.results).toContain('a');
}); });
it("is loading until the service's promise fufills", function () { it('is loading until the service\'s promise fufills', function () {
// Send query
controller.search();
expect(mockScope.loading).toBeTruthy(); expect(mockScope.loading).toBeTruthy();
// Then resolve the promises // Then resolve the promises
@ -111,84 +133,40 @@ define(
expect(mockScope.loading).toBeFalsy(); expect(mockScope.loading).toBeFalsy();
}); });
it('detects when there are more results', function () {
it("displays only some results when there are many", function () {
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
mockPromise.then.mostRecentCall.args[0]({hits: bigArray(100)});
expect(mockScope.results).toBeDefined();
expect(mockScope.results.length).toBeLessThan(100);
});
it("detects when there are more results", function () {
mockScope.ngModel.checkAll = false;
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
mockPromise.then.mostRecentCall.args[0]({ mockPromise.then.mostRecentCall.args[0]({
hits: bigArray(INITIAL_LOAD_NUMBER + 5), hits: bigArray(controller.RESULTS_PER_PAGE),
total: INITIAL_LOAD_NUMBER + 5 total: controller.RESULTS_PER_PAGE + 5
});
// bigArray gives searchResults of type 'mock.type'
mockScope.ngModel.checked['mock.type'] = false;
mockScope.ngModel.checked['mock.type.2'] = true;
expect(controller.areMore()).toBeFalsy();
mockScope.ngModel.checked['mock.type'] = true;
expect(controller.areMore()).toBeTruthy();
}); });
it("can load more results", function () { expect(mockScope.results.length).toBe(controller.RESULTS_PER_PAGE);
var oldSize;
expect(mockPromise.then).toHaveBeenCalled();
mockPromise.then.mostRecentCall.args[0]({
hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1),
total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1
});
// These hits and total lengths are the case where the controller
// DOES NOT have to re-search to load more results
oldSize = mockScope.results.length;
expect(controller.areMore()).toBeTruthy(); expect(controller.areMore()).toBeTruthy();
controller.loadMore(); controller.loadMore();
expect(mockScope.results.length).toBeGreaterThan(oldSize);
});
it("can re-search to load more results", function () { expect(mockSearchService.query).toHaveBeenCalledWith(
var oldSize, 'test input',
oldCallCount; controller.RESULTS_PER_PAGE * 2,
jasmine.any(Function)
);
expect(mockPromise.then).toHaveBeenCalled();
mockPromise.then.mostRecentCall.args[0]({ mockPromise.then.mostRecentCall.args[0]({
hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT - 1), hits: bigArray(controller.RESULTS_PER_PAGE + 5),
total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1 total: controller.RESULTS_PER_PAGE + 5
});
// These hits and total lengths are the case where the controller
// DOES have to re-search to load more results
oldSize = mockScope.results.length;
oldCallCount = mockPromise.then.callCount;
expect(controller.areMore()).toBeTruthy();
controller.loadMore();
expect(mockPromise.then).toHaveBeenCalled();
// Make sure that a NEW call to search has been made
expect(oldCallCount).toBeLessThan(mockPromise.then.callCount);
mockPromise.then.mostRecentCall.args[0]({
hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1),
total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1
});
expect(mockScope.results.length).toBeGreaterThan(oldSize);
}); });
it("sets the ngModel.search flag", function () { expect(mockScope.results.length)
.toBe(controller.RESULTS_PER_PAGE + 5);
expect(controller.areMore()).toBe(false);
});
it('sets the ngModel.search flag', function () {
// Flag should be true with nonempty input // Flag should be true with nonempty input
expect(mockScope.ngModel.search).toEqual(true); expect(mockScope.ngModel.search).toEqual(true);
// Flag should be flase with empty input // Flag should be flase with empty input
mockScope.ngModel.input = ""; mockScope.ngModel.input = '';
controller.search(); controller.search();
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0}); mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
expect(mockScope.ngModel.search).toEqual(false); expect(mockScope.ngModel.search).toEqual(false);
@ -200,9 +178,8 @@ define(
expect(mockScope.ngModel.search).toEqual(false); expect(mockScope.ngModel.search).toEqual(false);
}); });
it("has a default results list to filter from", function () { it('attaches a filter function to scope', function () {
expect(mockScope.ngModel.filter()).toBeDefined(); expect(mockScope.ngModel.filter).toEqual(jasmine.any(Function));
}); });
}); });
} });
);

View File

@ -19,275 +19,321 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
/*global define,describe,it,expect,beforeEach,jasmine*/ /*global define,describe,it,expect,beforeEach,jasmine,Promise,spyOn,waitsFor,
runs*/
/** /**
* SearchSpec. Created by shale on 07/31/2015. * SearchSpec. Created by shale on 07/31/2015.
*/ */
define( define([
["../../src/services/GenericSearchProvider"], "../../src/services/GenericSearchProvider"
function (GenericSearchProvider) { ], function (
GenericSearchProvider
) {
"use strict"; "use strict";
describe("The generic search provider ", function () { describe('GenericSearchProvider', function () {
var mockQ, var $q,
mockLog, $log,
mockThrottle, modelService,
mockDeferred, models,
mockObjectService, workerService,
mockObjectPromise, worker,
mockChainedPromise, topic,
mockDomainObjects, mutationTopic,
mockCapability, ROOTS,
mockCapabilityPromise, provider;
mockWorkerService,
mockWorker,
mockTopic,
mockMutationTopic,
mockRoots = ['root1', 'root2'],
mockThrottledFn,
throttledCallCount,
provider,
mockProviderResults;
function resolveObjectPromises() {
var i;
for (i = 0; i < mockObjectPromise.then.calls.length; i += 1) {
mockChainedPromise.then.calls[i].args[0](
mockObjectPromise.then.calls[i]
.args[0](mockDomainObjects)
);
}
}
function resolveThrottledFn() {
if (mockThrottledFn.calls.length > throttledCallCount) {
mockThrottle.mostRecentCall.args[0]();
throttledCallCount = mockThrottledFn.calls.length;
}
}
function resolveAsyncTasks() {
resolveThrottledFn();
resolveObjectPromises();
}
beforeEach(function () { beforeEach(function () {
mockQ = jasmine.createSpyObj( $q = jasmine.createSpyObj(
"$q", '$q',
[ "defer" ] ['defer']
); );
mockLog = jasmine.createSpyObj( $log = jasmine.createSpyObj(
"$log", '$log',
[ "error", "warn", "info", "debug" ] ['warn']
); );
mockDeferred = jasmine.createSpyObj( models = {};
"deferred", modelService = jasmine.createSpyObj(
[ "resolve", "reject"] 'modelService',
['getModels']
); );
mockDeferred.promise = "mock promise"; modelService.getModels.andReturn(Promise.resolve(models));
mockQ.defer.andReturn(mockDeferred); workerService = jasmine.createSpyObj(
'workerService',
mockThrottle = jasmine.createSpy("throttle"); ['run']
mockThrottledFn = jasmine.createSpy("throttledFn");
throttledCallCount = 0;
mockObjectService = jasmine.createSpyObj(
"objectService",
[ "getObjects" ]
); );
mockObjectPromise = jasmine.createSpyObj( worker = jasmine.createSpyObj(
"promise", 'worker',
[ "then", "catch" ]
);
mockChainedPromise = jasmine.createSpyObj(
"chainedPromise",
[ "then" ]
);
mockObjectService.getObjects.andReturn(mockObjectPromise);
mockTopic = jasmine.createSpy('topic');
mockWorkerService = jasmine.createSpyObj(
"workerService",
[ "run" ]
);
mockWorker = jasmine.createSpyObj(
"worker",
[ "postMessage" ]
);
mockWorkerService.run.andReturn(mockWorker);
mockCapabilityPromise = jasmine.createSpyObj(
"promise",
[ "then", "catch" ]
);
mockDomainObjects = {};
['a', 'root1', 'root2'].forEach(function (id) {
mockDomainObjects[id] = (
jasmine.createSpyObj(
"domainObject",
[ [
"getId", 'postMessage',
"getModel", 'addEventListener'
"hasCapability",
"getCapability",
"useCapability"
] ]
)
); );
mockDomainObjects[id].getId.andReturn(id); workerService.run.andReturn(worker);
mockDomainObjects[id].getCapability.andReturn(mockCapability); topic = jasmine.createSpy('topic');
mockDomainObjects[id].useCapability.andReturn(mockCapabilityPromise); mutationTopic = jasmine.createSpyObj(
mockDomainObjects[id].getModel.andReturn({});
});
mockCapability = jasmine.createSpyObj(
"capability",
[ "invoke", "listen" ]
);
mockCapability.invoke.andReturn(mockCapabilityPromise);
mockDomainObjects.a.getCapability.andReturn(mockCapability);
mockMutationTopic = jasmine.createSpyObj(
'mutationTopic', 'mutationTopic',
[ 'listen' ] ['listen']
); );
mockTopic.andCallFake(function (key) { topic.andReturn(mutationTopic);
return key === 'mutation' && mockMutationTopic; ROOTS = [
}); 'mine'
mockThrottle.andReturn(mockThrottledFn); ];
mockObjectPromise.then.andReturn(mockChainedPromise);
spyOn(GenericSearchProvider.prototype, 'scheduleForIndexing');
provider = new GenericSearchProvider( provider = new GenericSearchProvider(
mockQ, $q,
mockLog, $log,
mockThrottle, modelService,
mockObjectService, workerService,
mockWorkerService, topic,
mockTopic, ROOTS
mockRoots
); );
}); });
it("indexes tree on initialization", function () { it('listens for general mutation', function () {
var i; expect(topic).toHaveBeenCalledWith('mutation');
expect(mutationTopic.listen)
resolveThrottledFn();
expect(mockObjectService.getObjects).toHaveBeenCalled();
expect(mockObjectPromise.then).toHaveBeenCalled();
// Call through the root-getting part
resolveObjectPromises();
mockRoots.forEach(function (id) {
expect(mockWorker.postMessage).toHaveBeenCalledWith({
request: 'index',
model: mockDomainObjects[id].getModel(),
id: id
});
});
});
it("indexes members of composition", function () {
mockDomainObjects.root1.getModel.andReturn({
composition: ['a']
});
resolveAsyncTasks();
resolveAsyncTasks();
expect(mockWorker.postMessage).toHaveBeenCalledWith({
request: 'index',
model: mockDomainObjects.a.getModel(),
id: 'a'
});
});
it("listens for changes to mutation", function () {
expect(mockMutationTopic.listen)
.toHaveBeenCalledWith(jasmine.any(Function)); .toHaveBeenCalledWith(jasmine.any(Function));
mockMutationTopic.listen.mostRecentCall });
.args[0](mockDomainObjects.a);
resolveAsyncTasks(); it('reschedules indexing when mutation occurs', function () {
var mockDomainObject =
jasmine.createSpyObj('domainObj', ['getId']);
mockDomainObject.getId.andReturn("some-id");
mutationTopic.listen.mostRecentCall.args[0](mockDomainObject);
expect(provider.scheduleForIndexing).toHaveBeenCalledWith('some-id');
});
expect(mockWorker.postMessage).toHaveBeenCalledWith({ it('starts indexing roots', function () {
expect(provider.scheduleForIndexing).toHaveBeenCalledWith('mine');
});
it('runs a worker', function () {
expect(workerService.run)
.toHaveBeenCalledWith('genericSearchWorker');
});
it('listens for messages from worker', function () {
expect(worker.addEventListener)
.toHaveBeenCalledWith('message', jasmine.any(Function));
spyOn(provider, 'onWorkerMessage');
worker.addEventListener.mostRecentCall.args[1]('mymessage');
expect(provider.onWorkerMessage).toHaveBeenCalledWith('mymessage');
});
it('has a maximum number of concurrent requests', function () {
expect(provider.MAX_CONCURRENT_REQUESTS).toBe(100);
});
describe('scheduleForIndexing', function () {
beforeEach(function () {
provider.scheduleForIndexing.andCallThrough();
spyOn(provider, 'keepIndexing');
});
it('tracks ids to index', function () {
expect(provider.indexedIds.a).not.toBeDefined();
expect(provider.pendingIndex.a).not.toBeDefined();
expect(provider.idsToIndex).not.toContain('a');
provider.scheduleForIndexing('a');
expect(provider.indexedIds.a).toBeDefined();
expect(provider.pendingIndex.a).toBeDefined();
expect(provider.idsToIndex).toContain('a');
});
it('calls keep indexing', function () {
provider.scheduleForIndexing('a');
expect(provider.keepIndexing).toHaveBeenCalled();
});
});
describe('keepIndexing', function () {
it('calls beginIndexRequest until at maximum', function () {
spyOn(provider, 'beginIndexRequest').andCallThrough();
provider.pendingRequests = 9;
provider.idsToIndex = ['a', 'b', 'c'];
provider.MAX_CONCURRENT_REQUESTS = 10;
provider.keepIndexing();
expect(provider.beginIndexRequest).toHaveBeenCalled();
expect(provider.beginIndexRequest.calls.length).toBe(1);
});
it('calls beginIndexRequest for all ids to index', function () {
spyOn(provider, 'beginIndexRequest').andCallThrough();
provider.pendingRequests = 0;
provider.idsToIndex = ['a', 'b', 'c'];
provider.MAX_CONCURRENT_REQUESTS = 10;
provider.keepIndexing();
expect(provider.beginIndexRequest).toHaveBeenCalled();
expect(provider.beginIndexRequest.calls.length).toBe(3);
});
it('does not index when at capacity', function () {
spyOn(provider, 'beginIndexRequest');
provider.pendingRequests = 10;
provider.idsToIndex.push('a');
provider.MAX_CONCURRENT_REQUESTS = 10;
provider.keepIndexing();
expect(provider.beginIndexRequest).not.toHaveBeenCalled();
});
it('does not index when no ids to index', function () {
spyOn(provider, 'beginIndexRequest');
provider.pendingRequests = 0;
provider.MAX_CONCURRENT_REQUESTS = 10;
provider.keepIndexing();
expect(provider.beginIndexRequest).not.toHaveBeenCalled();
});
});
describe('index', function () {
it('sends index message to worker', function () {
var id = 'anId',
model = {};
provider.index(id, model);
expect(worker.postMessage).toHaveBeenCalledWith({
request: 'index', request: 'index',
model: mockDomainObjects.a.getModel(), id: id,
id: mockDomainObjects.a.getId() model: model
}); });
}); });
it("sends search queries to the worker", function () { it('schedules composed ids for indexing', function () {
var timestamp = Date.now(); var id = 'anId',
provider.query(' test "query" ', timestamp, 1, 2); model = {composition: ['abc', 'def']};
expect(mockWorker.postMessage).toHaveBeenCalledWith({
request: "search", provider.index(id, model);
input: ' test "query" ', expect(provider.scheduleForIndexing)
timestamp: timestamp, .toHaveBeenCalledWith('abc');
maxNumber: 1, expect(provider.scheduleForIndexing)
timeout: 2 .toHaveBeenCalledWith('def');
}); });
}); });
it("gives an empty result for an empty query", function () { describe('beginIndexRequest', function () {
var timestamp = Date.now(),
queryOutput;
queryOutput = provider.query('', timestamp, 1, 2); beforeEach(function () {
expect(queryOutput.hits).toEqual([]); provider.pendingRequests = 0;
expect(queryOutput.total).toEqual(0); provider.pendingIds = {'abc': true};
provider.idsToIndex = ['abc'];
queryOutput = provider.query(); models.abc = {};
expect(queryOutput.hits).toEqual([]); spyOn(provider, 'index');
expect(queryOutput.total).toEqual(0);
}); });
it("handles responses from the worker", function () { it('removes items from queue', function () {
var timestamp = Date.now(), provider.beginIndexRequest();
event = { expect(provider.idsToIndex.length).toBe(0);
data: {
request: "search",
results: {
1: 1,
2: 2
},
total: 2,
timedOut: false,
timestamp: timestamp
}
};
provider.query(' test "query" ', timestamp);
mockWorker.onmessage(event);
mockObjectPromise.then.mostRecentCall.args[0](mockDomainObjects);
expect(mockDeferred.resolve).toHaveBeenCalled();
}); });
it("warns when objects are unavailable", function () { it('tracks number of pending requests', function () {
resolveAsyncTasks(); provider.beginIndexRequest();
expect(mockLog.warn).not.toHaveBeenCalled(); expect(provider.pendingRequests).toBe(1);
mockChainedPromise.then.mostRecentCall.args[0]( waitsFor(function () {
mockObjectPromise.then.mostRecentCall.args[1]() return provider.pendingRequests === 0;
});
runs(function () {
expect(provider.pendingRequests).toBe(0);
});
});
it('indexes objects', function () {
provider.beginIndexRequest();
waitsFor(function () {
return provider.pendingRequests === 0;
});
runs(function () {
expect(provider.index)
.toHaveBeenCalledWith('abc', models.abc);
});
});
});
it('can dispatch searches to worker', function () {
spyOn(provider, 'makeQueryId').andReturn(428);
expect(provider.dispatchSearch('searchTerm', 100))
.toBe(428);
expect(worker.postMessage).toHaveBeenCalledWith({
request: 'search',
input: 'searchTerm',
maxResults: 100,
queryId: 428
});
});
it('can generate queryIds', function () {
expect(provider.makeQueryId()).toEqual(jasmine.any(Number));
});
it('can query for terms', function () {
var deferred = {promise: {}};
spyOn(provider, 'dispatchSearch').andReturn(303);
$q.defer.andReturn(deferred);
expect(provider.query('someTerm', 100)).toBe(deferred.promise);
expect(provider.pendingQueries[303]).toBe(deferred);
});
describe('onWorkerMessage', function () {
var pendingQuery;
beforeEach(function () {
pendingQuery = jasmine.createSpyObj(
'pendingQuery',
['resolve']
); );
expect(mockLog.warn).toHaveBeenCalled(); provider.pendingQueries[143] = pendingQuery;
}); });
it("throttles the loading of objects to index", function () { it('resolves pending searches', function () {
expect(mockObjectService.getObjects).not.toHaveBeenCalled(); provider.onWorkerMessage({
resolveThrottledFn(); data: {
expect(mockObjectService.getObjects).toHaveBeenCalled(); request: 'search',
}); total: 2,
results: [
it("logs when all objects have been processed", function () { {
expect(mockLog.info).not.toHaveBeenCalled(); item: {
resolveAsyncTasks(); id: 'abc',
resolveThrottledFn(); model: {id: 'abc'}
expect(mockLog.info).toHaveBeenCalled(); },
}); matchCount: 4
},
}); {
item: {
id: 'def',
model: {id: 'def'}
},
matchCount: 2
} }
); ],
queryId: 143
}
});
expect(pendingQuery.resolve)
.toHaveBeenCalledWith({
total: 2,
hits: [{
id: 'abc',
model: {id: 'abc'},
score: 4
}, {
id: 'def',
model: {id: 'def'},
score: 2
}]
});
expect(provider.pendingQueries[143]).not.toBeDefined();
});
});
});
});

View File

@ -4,12 +4,12 @@
* Administration. All rights reserved. * Administration. All rights reserved.
* *
* Open MCT Web is licensed under the Apache License, Version 2.0 (the * 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. * 'License'); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0. * http://www.apache.org/licenses/LICENSE-2.0.
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations * License for the specific language governing permissions and limitations
* under the License. * under the License.
@ -19,114 +19,205 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
/*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker,require*/ /*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker,
require,afterEach*/
/** /**
* SearchSpec. Created by shale on 07/31/2015. * SearchSpec. Created by shale on 07/31/2015.
*/ */
define( define([
[],
function () {
"use strict";
describe("The generic search worker ", function () { ], function (
) {
'use strict';
describe('GenericSearchWorker', function () {
// If this test fails, make sure this path is correct // If this test fails, make sure this path is correct
var worker = new Worker(require.toUrl('platform/search/src/services/GenericSearchWorker.js')), var worker,
numObjects = 5; objectX,
objectY,
objectZ,
itemsToIndex,
onMessage,
data,
waitForResult;
beforeEach(function () { beforeEach(function () {
var i; worker = new Worker(
for (i = 0; i < numObjects; i += 1) { require.toUrl('platform/search/src/services/GenericSearchWorker.js')
worker.postMessage(
{
request: "index",
id: i,
model: {
name: "object " + i,
id: i,
type: "something"
}
}
); );
}
});
it("searches can reach all objects", function () { objectX = {
var flag = false, id: 'x',
workerOutput, model: {name: 'object xx'}
resultsLength = 0;
// Search something that should return all objects
runs(function () {
worker.postMessage(
{
request: "search",
input: "object",
maxNumber: 100,
timestamp: Date.now(),
timeout: 1000
}
);
});
worker.onmessage = function (event) {
var id;
workerOutput = event.data;
for (id in workerOutput.results) {
resultsLength += 1;
}
flag = true;
}; };
objectY = {
waitsFor(function () { id: 'y',
return flag; model: {name: 'object yy'}
}, "The worker should be searching", 1000);
runs(function () {
expect(workerOutput).toBeDefined();
expect(resultsLength).toEqual(numObjects);
});
});
it("searches return only matches", function () {
var flag = false,
workerOutput,
resultsLength = 0;
// Search something that should return 1 object
runs(function () {
worker.postMessage(
{
request: "search",
input: "2",
maxNumber: 100,
timestamp: Date.now(),
timeout: 1000
}
);
});
worker.onmessage = function (event) {
var id;
workerOutput = event.data;
for (id in workerOutput.results) {
resultsLength += 1;
}
flag = true;
}; };
objectZ = {
id: 'z',
model: {name: 'object zz'}
};
itemsToIndex = [
objectX,
objectY,
objectZ
];
itemsToIndex.forEach(function (item) {
worker.postMessage({
request: 'index',
id: item.id,
model: item.model
});
});
onMessage = jasmine.createSpy('onMessage');
worker.addEventListener('message', onMessage);
waitForResult = function () {
waitsFor(function () { waitsFor(function () {
return flag; if (onMessage.calls.length > 0) {
}, "The worker should be searching", 1000); data = onMessage.calls[0].args[0].data;
return true;
}
return false;
});
};
});
afterEach(function () {
worker.terminate();
});
it('returns search results for partial term matches', function () {
worker.postMessage({
request: 'search',
input: 'obj',
maxResults: 100,
queryId: 123
});
waitForResult();
runs(function () { runs(function () {
expect(workerOutput).toBeDefined(); expect(onMessage).toHaveBeenCalled();
expect(resultsLength).toEqual(1);
expect(workerOutput.results[2]).toBeDefined(); expect(data.request).toBe('search');
expect(data.total).toBe(3);
expect(data.queryId).toBe(123);
expect(data.results.length).toBe(3);
expect(data.results[0].item.id).toBe('x');
expect(data.results[0].item.model).toEqual(objectX.model);
expect(data.results[0].matchCount).toBe(1);
expect(data.results[1].item.id).toBe('y');
expect(data.results[1].item.model).toEqual(objectY.model);
expect(data.results[1].matchCount).toBe(1);
expect(data.results[2].item.id).toBe('z');
expect(data.results[2].item.model).toEqual(objectZ.model);
expect(data.results[2].matchCount).toBe(1);
});
});
it('scores exact term matches higher', function () {
worker.postMessage({
request: 'search',
input: 'object',
maxResults: 100,
queryId: 234
});
waitForResult();
runs(function () {
expect(data.queryId).toBe(234);
expect(data.results.length).toBe(3);
expect(data.results[0].item.id).toBe('x');
expect(data.results[0].matchCount).toBe(1.5);
});
});
it('can find partial term matches', function () {
worker.postMessage({
request: 'search',
input: 'x',
maxResults: 100,
queryId: 345
});
waitForResult();
runs(function () {
expect(data.queryId).toBe(345);
expect(data.results.length).toBe(1);
expect(data.results[0].item.id).toBe('x');
expect(data.results[0].matchCount).toBe(1);
});
});
it('matches individual terms', function () {
worker.postMessage({
request: 'search',
input: 'x y z',
maxResults: 100,
queryId: 456
});
waitForResult();
runs(function () {
expect(data.queryId).toBe(456);
expect(data.results.length).toBe(3);
expect(data.results[0].item.id).toBe('x');
expect(data.results[0].matchCount).toBe(1);
expect(data.results[1].item.id).toBe('y');
expect(data.results[1].matchCount).toBe(1);
expect(data.results[2].item.id).toBe('z');
expect(data.results[1].matchCount).toBe(1);
});
});
it('scores exact matches highest', function () {
worker.postMessage({
request: 'search',
input: 'object xx',
maxResults: 100,
queryId: 567
});
waitForResult();
runs(function () {
expect(data.queryId).toBe(567);
expect(data.results.length).toBe(3);
expect(data.results[0].item.id).toBe('x');
expect(data.results[0].matchCount).toBe(103);
expect(data.results[1].matchCount).toBe(1.5);
expect(data.results[2].matchCount).toBe(1.5);
});
});
it('scores multiple term match above single match', function () {
worker.postMessage({
request: 'search',
input: 'obj x',
maxResults: 100,
queryId: 678
});
waitForResult();
runs(function () {
expect(data.queryId).toBe(678);
expect(data.results.length).toBe(3);
expect(data.results[0].item.id).toBe('x');
expect(data.results[0].matchCount).toBe(2);
expect(data.results[1].matchCount).toBe(1);
expect(data.results[2].matchCount).toBe(1);
}); });
}); });
}); });
} });
);

View File

@ -19,83 +19,244 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
/*global define,describe,it,expect,beforeEach,jasmine*/ /*global define,describe,it,expect,beforeEach,jasmine,Promise,waitsFor,spyOn*/
/** /**
* SearchSpec. Created by shale on 07/31/2015. * SearchSpec. Created by shale on 07/31/2015.
*/ */
define( define([
["../../src/services/SearchAggregator"], "../../src/services/SearchAggregator"
function (SearchAggregator) { ], function (SearchAggregator) {
"use strict"; "use strict";
describe("The search aggregator ", function () { describe("SearchAggregator", function () {
var mockQ, var $q,
mockPromise, objectService,
mockProviders = [], providers,
aggregator, aggregator;
mockProviderResults = [],
mockAggregatorResults,
i;
beforeEach(function () { beforeEach(function () {
mockQ = jasmine.createSpyObj( $q = jasmine.createSpyObj(
"$q", '$q',
[ "all" ] ['all']
); );
mockPromise = jasmine.createSpyObj( $q.all.andReturn(Promise.resolve([]));
"promise", objectService = jasmine.createSpyObj(
[ "then" ] 'objectService',
['getObjects']
); );
for (i = 0; i < 3; i += 1) { providers = [];
mockProviders.push( aggregator = new SearchAggregator($q, objectService, providers);
jasmine.createSpyObj( });
"mockProvider" + i,
[ "query" ]
)
);
mockProviders[i].query.andReturn(mockPromise);
}
mockQ.all.andReturn(mockPromise);
aggregator = new SearchAggregator(mockQ, mockProviders); it("has a fudge factor", function () {
aggregator.query(); expect(aggregator.FUDGE_FACTOR).toBe(5);
});
for (i = 0; i < mockProviders.length; i += 1) { it("has default max results", function () {
mockProviderResults.push({ expect(aggregator.DEFAULT_MAX_RESULTS).toBe(100);
});
it("can order model results by score", function () {
var modelResults = {
hits: [ hits: [
{score: 1},
{score: 23},
{score: 11}
]
},
sorted = aggregator.orderByScore(modelResults);
expect(sorted.hits).toEqual([
{score: 23},
{score: 11},
{score: 1}
]);
});
it('filters results without a function', function () {
var modelResults = {
hits: [
{thing: 1},
{thing: 2}
],
total: 2
},
filtered = aggregator.applyFilter(modelResults);
expect(filtered.hits).toEqual([
{thing: 1},
{thing: 2}
]);
expect(filtered.total).toBe(2);
});
it('filters results with a function', function () {
var modelResults = {
hits: [
{model: {thing: 1}},
{model: {thing: 2}},
{model: {thing: 3}}
],
total: 3
},
filterFunc = function (model) {
return model.thing < 2;
},
filtered = aggregator.applyFilter(modelResults, filterFunc);
expect(filtered.hits).toEqual([
{model: {thing: 1}}
]);
expect(filtered.total).toBe(1);
});
it('can remove duplicates', function () {
var modelResults = {
hits: [
{id: 15},
{id: 23},
{id: 14},
{id: 23}
],
total: 4
},
deduped = aggregator.removeDuplicates(modelResults);
expect(deduped.hits).toEqual([
{id: 15},
{id: 23},
{id: 14}
]);
expect(deduped.total).toBe(3);
});
it('can convert model results to object results', function () {
var modelResults = {
hits: [
{id: 123, score: 5},
{id: 234, score: 1}
],
total: 2
},
objects = {
123: '123-object-hey',
234: '234-object-hello'
},
promiseChainComplete = false;
objectService.getObjects.andReturn(Promise.resolve(objects));
aggregator
.asObjectResults(modelResults)
.then(function (objectResults) {
expect(objectResults).toEqual({
hits: [
{id: 123, score: 5, object: '123-object-hey'},
{id: 234, score: 1, object: '234-object-hello'}
],
total: 2
});
})
.then(function () {
promiseChainComplete = true;
});
waitsFor(function () {
return promiseChainComplete;
});
});
it('can send queries to providers', function () {
var provider = jasmine.createSpyObj(
'provider',
['query']
);
provider.query.andReturn('i prooomise!');
providers.push(provider);
aggregator.query('find me', 123, 'filter');
expect(provider.query)
.toHaveBeenCalledWith(
'find me',
123 * aggregator.FUDGE_FACTOR
);
expect($q.all).toHaveBeenCalledWith(['i prooomise!']);
});
it('supplies max results when none is provided', function () {
var provider = jasmine.createSpyObj(
'provider',
['query']
);
providers.push(provider);
aggregator.query('find me');
expect(provider.query).toHaveBeenCalledWith(
'find me',
aggregator.DEFAULT_MAX_RESULTS * aggregator.FUDGE_FACTOR
);
});
it('can combine responses from multiple providers', function () {
var providerResponses = [
{ {
id: i, hits: [
score: 42 - i 'oneHit',
'twoHit'
],
total: 2
}, },
{ {
id: i + 1, hits: [
score: 42 - (2 * i) 'redHit',
'blueHit',
'by',
'Pete'
],
total: 4
} }
] ],
promiseChainResolved = false;
$q.all.andReturn(Promise.resolve(providerResponses));
spyOn(aggregator, 'orderByScore').andReturn('orderedByScore!');
spyOn(aggregator, 'applyFilter').andReturn('filterApplied!');
spyOn(aggregator, 'removeDuplicates')
.andReturn('duplicatesRemoved!');
spyOn(aggregator, 'asObjectResults').andReturn('objectResults');
aggregator
.query('something', 10, 'filter')
.then(function (objectResults) {
expect(aggregator.orderByScore).toHaveBeenCalledWith({
hits: [
'oneHit',
'twoHit',
'redHit',
'blueHit',
'by',
'Pete'
],
total: 6
}); });
} expect(aggregator.applyFilter)
mockAggregatorResults = mockPromise.then.mostRecentCall.args[0](mockProviderResults); .toHaveBeenCalledWith('orderedByScore!', 'filter');
expect(aggregator.removeDuplicates)
.toHaveBeenCalledWith('filterApplied!');
expect(aggregator.asObjectResults)
.toHaveBeenCalledWith('duplicatesRemoved!');
expect(objectResults).toBe('objectResults');
})
.then(function () {
promiseChainResolved = true;
}); });
it("sends queries to all providers", function () { waitsFor(function () {
for (i = 0; i < mockProviders.length; i += 1) { return promiseChainResolved;
expect(mockProviders[i].query).toHaveBeenCalled();
}
}); });
it("filters out duplicate objects", function () {
expect(mockAggregatorResults.hits.length).toEqual(mockProviders.length + 1);
expect(mockAggregatorResults.total).not.toBeLessThan(mockAggregatorResults.hits.length);
});
it("orders results by score", function () {
for (i = 1; i < mockAggregatorResults.hits.length; i += 1) {
expect(mockAggregatorResults.hits[i].score)
.not.toBeGreaterThan(mockAggregatorResults.hits[i - 1].score);
}
}); });
}); });
} });
);

View File

@ -44,7 +44,8 @@ require.config({
paths: { paths: {
'es6-promise': 'platform/framework/lib/es6-promise-2.0.0.min', 'es6-promise': 'platform/framework/lib/es6-promise-2.0.0.min',
'moment-duration-format': 'warp/clock/lib/moment-duration-format' 'moment-duration-format': 'warp/clock/lib/moment-duration-format',
'uuid': 'platform/commonUI/browse/lib/uuid'
}, },
// dynamically load all test files // dynamically load all test files