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
commit 7bd0e279b0
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 = {
DIAGRAM_WIDTH: 800,
DIAGRAM_HEIGHT: 500
};
},
TOC_HEAD = "# Table of Contents";
GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be defined
(function () {
@ -44,6 +45,7 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
split = require("split"),
stream = require("stream"),
nomnoml = require('nomnoml'),
toc = require("markdown-toc"),
Canvas = require('canvas'),
options = require("minimist")(process.argv.slice(2));
@ -110,6 +112,9 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
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(marked(markdown));
this.push("\n</body></html>\n");
@ -133,8 +138,8 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
customRenderer.link = function (href, title, text) {
// ...but only if they look like relative paths
return (href || "").indexOf(":") === -1 && href[0] !== "/" ?
renderer.link(href.replace(/\.md/, ".html"), title, text) :
renderer.link.apply(renderer, arguments);
renderer.link(href.replace(/\.md/, ".html"), title, text) :
renderer.link.apply(renderer, arguments);
};
return customRenderer;
}
@ -179,13 +184,17 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
glob(options['in'] + "/**/*.@(html|css|png)", {}, function (err, files) {
files.forEach(function (file) {
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) {
fs.createReadStream(file, { encoding: 'utf8' })
.pipe(fs.createWriteStream(destination, {
encoding: 'utf8'
}));
fs.createReadStream(file, streamOptions)
.pipe(fs.createWriteStream(destination, streamOptions));
});
});
});

View File

@ -35,16 +35,26 @@ in __any of these tiers__.
* _DOM_: The rendered HTML document, composed from HTML templates which
have been processed by AngularJS and will be updated by AngularJS
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
is responsible for updating (and providing information to update)
the displayed state of the application. The presentation layer consists
primarily of _controllers_ and _directives_. The presentation layer is
concerned with inspecting the information model and preparing it for
display.
* [_Information model_](#information-model): The information model
describes the state and behavior of the objects with which the user
interacts.
* [_Information model_](#information-model): Provides a common (within Open MCT 
Web) set of interfaces for dealing with “things” ­ domain objects ­ within the 
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
infrastructure is responsible for providing the underlying general
functionality needed to support the information model. This includes
@ -52,7 +62,9 @@ in __any of these tiers__.
back-end.
* _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
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

File diff suppressed because it is too large Load Diff

View File

@ -29,8 +29,9 @@
Sections:
<ul>
<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="guide/">Developer Guide</a></li>
<li><a href="tutorials/">Tutorials</a></li>
</ul>
</body>
</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",
"mkdirp": "^0.5.1",
"nomnoml": "^0.0.3",
"canvas": "^1.2.7"
"canvas": "^1.2.7",
"markdown-toc": "^0.11.7"
},
"scripts": {
"start": "node app.js",

View File

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

View File

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

View File

@ -30,6 +30,14 @@
"category": "contextual",
"implementation": "actions/LinkAction.js",
"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": [
@ -52,7 +60,8 @@
"key": "location",
"name": "Location Capability",
"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": [

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 */
define(
@ -12,11 +34,41 @@ define(
*
* @constructor
*/
function LocationCapability(domainObject) {
function LocationCapability($q, $injector, domainObject) {
this.domainObject = domainObject;
this.$q = $q;
this.$injector = $injector;
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
* object.
@ -78,10 +130,6 @@ define(
return !this.isLink();
};
function createLocationCapability(domainObject) {
return new LocationCapability(domainObject);
}
return createLocationCapability;
return LocationCapability;
}
);

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 */
define(
@ -7,6 +29,7 @@ define(
'../ControlledPromise'
],
function (LocationCapability, domainObjectFactory, ControlledPromise) {
'use strict';
describe("LocationCapability", function () {
@ -14,13 +37,17 @@ define(
var locationCapability,
persistencePromise,
mutationPromise,
mockQ,
mockInjector,
mockObjectService,
domainObject;
beforeEach(function () {
domainObject = domainObjectFactory({
id: "testObject",
capabilities: {
context: {
getParent: function() {
getParent: function () {
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();
domainObject.capabilities.persistence.persist.andReturn(
persistencePromise
@ -49,7 +81,11 @@ define(
}
);
locationCapability = new LocationCapability(domainObject);
locationCapability = new LocationCapability(
mockQ,
mockInjector,
domainObject
);
});
it("returns contextual location", function () {
@ -88,6 +124,57 @@ define(
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/CopyAction",
"actions/GoToOriginalAction",
"actions/LinkAction",
"actions/MoveAction",
"services/CopyService",
"services/LinkService",
"services/MoveService",

View File

@ -159,7 +159,9 @@ define(
// Update dimensions and origin based on extrema of plots
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],
priorDomainDimensions = this.dimensions[0];

View File

@ -202,6 +202,38 @@ define(
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",
"type": "provider",
"implementation": "ElasticSearchProvider.js",
"depends": [ "$http", "objectService", "ELASTIC_ROOT" ]
"depends": [ "$http", "ELASTIC_ROOT" ]
}
],
"constants": [

View File

@ -24,190 +24,122 @@
/**
* Module defining ElasticSearchProvider. Created by shale on 07/16/2015.
*/
define(
[],
function () {
"use strict";
define([
// JSLint doesn't like underscore-prefixed properties,
// so hide them here.
var ID = "_id",
SCORE = "_score",
DEFAULT_MAX_RESULTS = 100;
/**
* A search service which searches through domain objects in
* the filetree using ElasticSearch.
*
* @constructor
* @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
* interact with ElasticSearch.
*/
function ElasticSearchProvider($http, objectService, ROOT) {
this.$http = $http;
this.objectService = objectService;
this.root = ROOT;
}
], function (
/**
* Searches through the filetree for domain objects using a search
* 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:
* * The order of the results is from highest to lowest score,
* as elsaticsearch determines them to be.
* * 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) {
var $http = this.$http,
objectService = this.objectService,
root = this.root,
esQuery;
function addFuzziness(searchTerm, editDistance) {
if (!editDistance) {
editDistance = '';
}
) {
"use strict";
return searchTerm.split(' ').map(function (s) {
// Don't add fuzziness for quoted strings
if (s.indexOf('"') !== -1) {
return s;
} else {
return s + '~' + editDistance;
}
}).join(' ');
}
var ID_PROPERTY = '_id',
SOURCE_PROPERTY = '_source',
SCORE_PROPERTY = '_score';
// Currently specific to elasticsearch
function processSearchTerm(searchTerm) {
var spaceIndex;
// Cut out any extra spaces
while (searchTerm.substr(0, 1) === ' ') {
searchTerm = searchTerm.substring(1, searchTerm.length);
}
while (searchTerm.substr(searchTerm.length - 1, 1) === ' ') {
searchTerm = searchTerm.substring(0, searchTerm.length - 1);
}
spaceIndex = searchTerm.indexOf(' ');
while (spaceIndex !== -1) {
searchTerm = searchTerm.substring(0, spaceIndex) +
searchTerm.substring(spaceIndex + 1, searchTerm.length);
spaceIndex = searchTerm.indexOf(' ');
}
// Add fuzziness for completeness
searchTerm = addFuzziness(searchTerm);
return searchTerm;
}
// Processes results from the format that elasticsearch returns to
// a list of searchResult objects, then returns a result object
// (See documentation for query for object descriptions)
function processResults(rawResults, timestamp) {
var results = rawResults.data.hits.hits,
resultsLength = results.length,
ids = [],
scores = {},
searchResults = [],
i;
// Get the result objects' IDs
for (i = 0; i < resultsLength; i += 1) {
ids.push(results[i][ID]);
}
// Get the result objects' scores
for (i = 0; i < resultsLength; i += 1) {
scores[ids[i]] = results[i][SCORE];
}
// Get the domain objects from their IDs
return objectService.getObjects(ids).then(function (objects) {
var j,
id;
for (j = 0; j < resultsLength; j += 1) {
id = ids[j];
// Include items we can get models for
if (objects[id].getModel) {
// Format the results as searchResult objects
searchResults.push({
id: id,
object: objects[id],
score: scores[id]
});
}
}
return {
hits: searchResults,
total: rawResults.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;
/**
* A search service which searches through domain objects in
* the filetree using ElasticSearch.
*
* @constructor
* @param $http Angular's $http service, for working with urls.
* @param ROOT the constant `ELASTIC_ROOT` which allows us to
* interact with ElasticSearch.
*/
function ElasticSearchProvider($http, ROOT) {
this.$http = $http;
this.root = ROOT;
}
);
/**
* Search for domain objects using elasticsearch as a search provider.
*
* @param {String} searchTerm the term to search by.
* @param {Number} [maxResults] the max numer of results to return.
* @returns {Promise} promise for a modelResults object.
*/
ElasticSearchProvider.prototype.query = function (searchTerm, maxResults) {
var searchUrl = this.root + '/_search/',
params = {},
provider = this;
searchTerm = this.cleanTerm(searchTerm);
searchTerm = this.fuzzyMatchUnquotedTerms(searchTerm);
params.q = searchTerm;
params.size = maxResults;
return this
.$http({
method: "GET",
url: searchUrl,
params: params
})
.then(function success(succesResponse) {
return provider.parseResponse(succesResponse);
}, function error(errorResponse) {
// Gracefully fail.
return {
hits: [],
total: 0
};
});
};
/**
* Clean excess whitespace from a search term and return the cleaned
* version.
*
* @private
* @param {string} the search term to clean.
* @returns {string} search terms cleaned of excess whitespace.
*/
ElasticSearchProvider.prototype.cleanTerm = function (term) {
return term.trim().replace(/ +/g, ' ');
};
/**
* Add fuzzy matching markup to search terms that are not quoted.
*
* The following:
* hello welcome "to quoted village" have fun
* will become
* hello~ welcome~ "to quoted village" have~ fun~
*
* @private
*/
ElasticSearchProvider.prototype.fuzzyMatchUnquotedTerms = function (query) {
var matchUnquotedSpaces = '\\s+(?=([^"]*"[^"]*")*[^"]*$)',
matcher = new RegExp(matchUnquotedSpaces, 'g');
return query
.replace(matcher, '~ ')
.replace(/$/, '~')
.replace(/"~+/, '"');
};
/**
* Parse the response from ElasticSearch and convert it to a
* modelResults object.
*
* @private
* @param response a ES response object from $http
* @returns modelResults
*/
ElasticSearchProvider.prototype.parseResponse = function (response) {
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 {
hits: searchResults,
total: response.data.hits.total
};
};
return ElasticSearchProvider;
});

View File

@ -19,97 +19,151 @@
* 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,spyOn,Promise,waitsFor*/
/**
* SearchSpec. Created by shale on 07/31/2015.
*/
define(
["../src/ElasticSearchProvider"],
function (ElasticSearchProvider) {
"use strict";
define([
'../src/ElasticSearchProvider'
], function (
ElasticSearchProvider
) {
'use strict';
// JSLint doesn't like underscore-prefixed properties,
// so hide them here.
var ID = "_id",
SCORE = "_score";
describe("The ElasticSearch search provider ", function () {
var mockHttp,
mockHttpPromise,
mockObjectPromise,
mockObjectService,
mockDomainObject,
provider,
mockProviderResults;
describe('ElasticSearchProvider', function () {
var $http,
ROOT,
provider;
beforeEach(function () {
$http = jasmine.createSpy('$http');
ROOT = 'http://localhost:9200';
provider = new ElasticSearchProvider($http, ROOT);
});
describe('query', function () {
beforeEach(function () {
mockHttp = jasmine.createSpy("$http");
mockHttpPromise = jasmine.createSpyObj(
"promise",
[ "then" ]
);
mockHttp.andReturn(mockHttpPromise);
// allow chaining of promise.then().catch();
mockHttpPromise.then.andReturn(mockHttpPromise);
mockObjectService = jasmine.createSpyObj(
"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);
spyOn(provider, 'cleanTerm').andReturn('cleanedTerm');
spyOn(provider, 'fuzzyMatchUnquotedTerms').andReturn('fuzzy');
spyOn(provider, 'parseResponse').andReturn('parsedResponse');
$http.andReturn(Promise.resolve({}));
});
it("sends a query to ElasticSearch", function () {
expect(mockHttp).toHaveBeenCalled();
it('cleans terms and adds fuzzyness', function () {
provider.query('hello', 10);
expect(provider.cleanTerm).toHaveBeenCalledWith('hello');
expect(provider.fuzzyMatchUnquotedTerms)
.toHaveBeenCalledWith('cleanedTerm');
});
it("gets data from ElasticSearch", function () {
var data = {
hits: {
hits: [
{},
{}
],
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
};
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);
url: 'http://localhost:9200/_search/'
});
});
it("returns nothing for an empty string query", function () {
expect(provider.query("").hits).toEqual([]);
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("returns something when there is an ElasticSearch error", function () {
mockProviderResults = mockHttpPromise.then.mostRecentCall.args[1]();
expect(mockProviderResults).toBeDefined();
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": [
"$q",
"$log",
"throttle",
"objectService",
"modelService",
"workerService",
"topic",
"GENERIC_SEARCH_ROOTS"
@ -59,7 +58,7 @@
"provides": "searchService",
"type": "aggregator",
"implementation": "services/SearchAggregator.js",
"depends": [ "$q" ]
"depends": [ "$q", "objectService" ]
}
],
"workers": [

View File

@ -21,21 +21,16 @@
-->
<div class="search"
ng-controller="SearchController as controller">
<!-- Search bar -->
<div class="search-bar"
ng-controller="ClickAwayController as toggle">
<!-- Input field -->
<input class="search-input"
type="text"
ng-model="ngModel.input"
ng-keyup="controller.search()" />
<!--mct-control key="'textfield'"
class="search-input"
ng-model="ngModel.input"
ng-keyup="controller.search()">
</mct-control-->
<!-- Search icon -->
<!-- ui symbols for search are 'd' and 'M' -->
@ -43,20 +38,20 @@
ng-class="{content: !(ngModel.input === '' || ngModel.input === undefined)}">
M
</div>
<!-- Clear icon/button 'x' -->
<a class="ui-symbol clear-icon"
ng-class="{content: !(ngModel.input === '' || ngModel.input === undefined)}"
ng-click="ngModel.input = ''; controller.search()">
&#xe607;
</a>
<!-- Menu icon/button 'v' -->
<a class="ui-symbol menu-icon"
ng-click="toggle.toggle()">
v
</a>
<!-- Menu -->
<mct-representation key="'search-menu'"
class="menu-element search-menu-holder"
@ -65,27 +60,24 @@
ng-click="toggle.setState(true)">
</mct-representation>
</div>
<!-- Active filter display -->
<div class="active-filter-display"
ng-class="{off: ngModel.filtersString === '' || ngModel.filtersString === undefined || !ngModel.search}"
ng-controller="SearchMenuController as menuController">
<a class="ui-symbol clear-filters-icon"
ng-click="ngModel.checkAll = true; menuController.checkAll()">
&#xe607;
</a>
Filtered by: {{ ngModel.filtersString }}
<!--div class="filter-options">
Filtered by: {{ ngModel.filtersString }}
</div-->
</div>
<!-- This div exists to determine scroll bar location -->
<div class="search-scroll abs">
<!-- Results list -->
<div class="results">
<mct-representation key="'search-item'"
@ -103,14 +95,14 @@
<span class="title-label">Loading...</span>
</div>
<!-- Load more button -->
<!-- Load more button -->
<div ng-if="controller.areMore()">
<a class="load-more-button s-btn vsm"
ng-click="controller.loadMore()">
ng-click="controller.loadMore()">
More Results
</a>
</div>
</div>
</div>
</div>

View File

@ -26,146 +26,155 @@
*/
define(function () {
"use strict";
var INITIAL_LOAD_NUMBER = 20,
LOAD_INCREMENT = 20;
/**
* Controller for search in Tree View.
*
* Filtering is currently buggy; it filters after receiving results from
* search providers, the downside of this is that it requires search
* 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
*/
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
* be in ngModel.input
*
* @param maxResults (optional) The maximum number of results
* that this function should return. If not provided, search
* service default will be used.
*/
search: search,
/**
* Checks to see if there are more search results to display. If the answer is
* unclear, this function will err toward saying that there are more results.
*/
areMore: function () {
var i;
// Check to see if any of the not displayed results are of an allowed type
for (i = numResults; i < fullResults.hits.length; i += 1) {
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);
}
}
var controller = this;
this.$scope = $scope;
this.searchService = searchService;
this.numberToDisplay = this.RESULTS_PER_PAGE;
this.availabileResults = 0;
this.$scope.results = [];
this.$scope.loading = false;
this.pendingQuery = undefined;
this.$scope.ngModel.filter = function () {
return controller.onFilterChange.apply(controller, arguments);
};
}
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;
});

View File

@ -19,234 +19,262 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*global define*/
/*global define,setTimeout*/
/**
* Module defining GenericSearchProvider. Created by shale on 07/16/2015.
*/
define(
[],
function () {
"use strict";
define([
var DEFAULT_MAX_RESULTS = 100,
DEFAULT_TIMEOUT = 1000,
MAX_CONCURRENT_REQUESTS = 100,
FLUSH_INTERVAL = 0,
stopTime;
], function (
/**
* A search service which searches through domain objects in
* the filetree without using external search implementations.
*
* @constructor
* @param $q Angular's $q, for promise consolidation.
* @param $log Anglar's $log, for logging.
* @param {Function} throttle a function to throttle function invocations
* @param {ObjectService} objectService The service from which
* domain objects can be gotten.
* @param {WorkerService} workerService The service which allows
* 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) {
var indexed = {},
pendingIndex = {},
pendingQueries = {},
toRequest = [],
worker = workerService.run('genericSearchWorker'),
mutationTopic = topic("mutation"),
indexingStarted = Date.now(),
pendingRequests = 0,
scheduleFlush;
) {
"use strict";
this.worker = worker;
this.pendingQueries = pendingQueries;
this.$q = $q;
// pendingQueries is a dictionary with the key value pairs st
// the key is the timestamp and the value is the promise
/**
* A search service which searches through domain objects in
* the filetree without using external search implementations.
*
* @constructor
* @param $q Angular's $q, for promise consolidation.
* @param $log Anglar's $log, for logging.
* @param {ModelService} modelService the model service.
* @param {WorkerService} workerService the workerService.
* @param {TopicService} topic the topic service.
* @param {Array} ROOTS An array of object Ids to begin indexing.
*/
function GenericSearchProvider($q, $log, modelService, workerService, topic, ROOTS) {
var provider = this;
this.$q = $q;
this.$log = $log;
this.modelService = modelService;
function scheduleIdsForIndexing(ids) {
ids.forEach(function (id) {
if (!indexed[id] && !pendingIndex[id]) {
indexed[id] = true;
pendingIndex[id] = true;
toRequest.push(id);
}
});
scheduleFlush();
}
this.indexedIds = {};
this.idsToIndex = [];
this.pendingIndex = {};
this.pendingRequests = 0;
// Tell the web worker to add a domain object's model to its list of items.
function indexItem(domainObject) {
var model = domainObject.getModel();
this.pendingQueries = {};
worker.postMessage({
request: 'index',
model: model,
id: domainObject.getId()
});
this.worker = this.startWorker(workerService);
this.indexOnMutation(topic);
if (Array.isArray(model.composition)) {
scheduleIdsForIndexing(model.composition);
}
}
ROOTS.forEach(function indexRoot(rootId) {
provider.scheduleForIndexing(rootId);
});
// 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]
});
}
/**
* Maximum number of concurrent index requests to allow.
*/
GenericSearchProvider.prototype.MAX_CONCURRENT_REQUESTS = 100;
// Resove the promise corresponding to this
pendingQueries[event.data.timestamp].resolve({
hits: searchResults,
total: event.data.total,
timedOut: event.data.timedOut
});
});
}
}
/**
* Query the search provider for results.
*
* @param {String} input the string to search by.
* @param {Number} maxResults max number of results to return.
* @returns {Promise} a promise for a modelResults object.
*/
GenericSearchProvider.prototype.query = function (
input,
maxResults
) {
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();
});
}
var queryId = this.dispatchSearch(input, maxResults),
pendingQuery = this.$q.defer();
scheduleFlush = throttle(function flush() {
var batchSize =
Math.max(MAX_CONCURRENT_REQUESTS - pendingRequests, 0);
this.pendingQueries[queryId] = pendingQuery;
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);
return pendingQuery.promise;
};
worker.onmessage = handleResponse;
/**
* 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;
// Index the tree's contents once at the beginning
scheduleIdsForIndexing(ROOTS);
worker.addEventListener('message', function (messageEvent) {
provider.onWorkerMessage(messageEvent);
});
// Re-index items when they are mutated
mutationTopic.listen(function (domainObject) {
var id = domainObject.getId();
indexed[id] = false;
scheduleIdsForIndexing([id]);
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);
});
}
};
/**
* Searches through the filetree for domain objects which match
* 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) {
var terms = [],
searchResults = [],
pendingQueries = this.pendingQueries,
worker = this.worker,
defer = this.$q.defer();
/**
* 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;
// 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
// less than that if there are fewer matches).
function workerSearch(searchInput, maxResults, timestamp, timeout) {
var message = {
request: 'search',
input: searchInput,
maxNumber: maxResults,
timestamp: timestamp,
timeout: timeout
};
worker.postMessage(message);
}
// If the input is nonempty, do a search
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;
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);
});
};
// Send the query to the worker
workerSearch(input, maxResults, timestamp, timeout);
/**
* 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;
}
return defer.promise;
} else {
// Otherwise return an empty result
return { hits: [], total: 0 };
}
};
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',
input: searchInput,
maxResults: maxResults,
queryId: queryId
});
return queryId;
};
return GenericSearchProvider;
}
);
return GenericSearchProvider;
});

View File

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

View File

@ -24,122 +24,201 @@
/**
* Module defining SearchAggregator. Created by shale on 07/16/2015.
*/
define(
[],
function () {
"use strict";
define([
var DEFUALT_TIMEOUT = 1000,
DEFAULT_MAX_RESULTS = 100;
/**
* Allows multiple services which provide search functionality
* to be treated as one.
*
* @constructor
* @param $q Angular's $q, for promise consolidation.
* @param {SearchProvider[]} providers The search providers to be
* aggregated.
*/
function SearchAggregator($q, providers) {
this.$q = $q;
this.providers = providers;
], function (
) {
"use strict";
/**
* Aggregates multiple search providers as a singular search provider.
* 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
* @param $q Angular's $q, for promise consolidation.
* @param objectService
* @param {SearchProvider[]} providers The search providers to be
* aggregated.
*/
function SearchAggregator($q, objectService, providers) {
this.$q = $q;
this.objectService = objectService;
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
* a result object that has the format
* {hits: searchResult[], total: number}
* where a searchResult has the format
* {id: string, object: domainObject, score: number}
*
* @param {String} inputText The text input that is the query.
* @param {Number} maxResults (optional) The maximum number of results
* that this function should return. If not provided, a
* 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 (
inputText,
maxResults,
filter
) {
var aggregator = this,
resultPromises;
if (!maxResults) {
maxResults = this.DEFAULT_MAX_RESULTS;
}
/**
* Sends a query to each of the providers. 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}
*
* @param inputText The text input that is the query.
* @param maxResults (optional) The maximum number of results
* that this function should return. If not provided, a
* default of 100 will be used.
*/
SearchAggregator.prototype.query = function queryAll(inputText, maxResults) {
var $q = this.$q,
providers = this.providers,
i,
timestamp = Date.now(),
resultPromises = [];
resultPromises = this.providers.map(function (provider) {
return provider.query(
inputText,
maxResults * aggregator.FUDGE_FACTOR
);
});
// Remove duplicate objects that have the same ID. Modifies the passed
// array, and returns the number that were removed.
function filterDuplicates(results, total) {
var ids = {},
numRemoved = 0,
i;
return this.$q
.all(resultPromises)
.then(function (providerResults) {
var modelResults = {
hits: [],
total: 0
};
for (i = 0; i < results.length; i += 1) {
if (ids[results[i].id]) {
// 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;
}
// Order the objects from highest to lowest score in the array.
// Modifies the passed array, as well as returns the modified array.
function orderByScore(results) {
results.sort(function (a, b) {
if (a.score > b.score) {
return -1;
} else if (b.score > a.score) {
return 1;
} else {
return 0;
}
providerResults.forEach(function (providerResult) {
modelResults.hits =
modelResults.hits.concat(providerResult.hits);
modelResults.total += providerResult.total;
});
return results;
}
if (!maxResults) {
maxResults = DEFAULT_MAX_RESULTS;
}
modelResults = aggregator.orderByScore(modelResults);
modelResults = aggregator.applyFilter(modelResults, filter);
modelResults = aggregator.removeDuplicates(modelResults);
// 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;
})
};
return aggregator.asObjectResults(modelResults);
});
};
};
return SearchAggregator;
}
);
/**
* 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) {
return -1;
} else if (b.score > a.score) {
return 1;
} else {
return 0;
}
});
return modelResults;
};
/**
* 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;
});

View File

@ -4,12 +4,12 @@
* 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.
* '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
* 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.
@ -24,185 +24,162 @@
/**
* SearchSpec. Created by shale on 07/31/2015.
*/
define(
["../../src/controllers/SearchController"],
function (SearchController) {
"use strict";
define([
'../../src/controllers/SearchController'
], function (
SearchController
) {
'use strict';
// These should be the same as the ones on the top of the search controller
var INITIAL_LOAD_NUMBER = 20,
LOAD_INCREMENT = 20;
describe("The search controller", function () {
var mockScope,
mockSearchService,
mockPromise,
mockSearchResult,
mockDomainObject,
mockTypes,
controller;
describe('The search controller', function () {
var mockScope,
mockSearchService,
mockPromise,
mockSearchResult,
mockDomainObject,
mockTypes,
controller;
function bigArray(size) {
var array = [],
i;
for (i = 0; i < size; i += 1) {
array.push(mockSearchResult);
}
return array;
function bigArray(size) {
var array = [],
i;
for (i = 0; i < size; i += 1) {
array.push(mockSearchResult);
}
beforeEach(function () {
mockScope = jasmine.createSpyObj(
"$scope",
[ "$watch" ]
);
mockScope.ngModel = {};
mockScope.ngModel.input = "test input";
mockScope.ngModel.checked = {};
mockScope.ngModel.checked['mock.type'] = true;
return array;
}
beforeEach(function () {
mockScope = jasmine.createSpyObj(
'$scope',
[ '$watch' ]
);
mockScope.ngModel = {};
mockScope.ngModel.input = 'test input';
mockScope.ngModel.checked = {};
mockScope.ngModel.checked['mock.type'] = true;
mockScope.ngModel.checkAll = true;
mockSearchService = jasmine.createSpyObj(
'searchService',
[ 'query' ]
);
mockPromise = jasmine.createSpyObj(
'promise',
[ 'then' ]
);
mockSearchService.query.andReturn(mockPromise);
mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}];
mockSearchResult = jasmine.createSpyObj(
'searchResult',
[ '' ]
);
mockDomainObject = jasmine.createSpyObj(
'domainObject',
[ 'getModel' ]
);
mockSearchResult.object = mockDomainObject;
mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'});
controller = new SearchController(mockScope, mockSearchService, mockTypes);
controller.search();
});
it('has a default number of results per page', function () {
expect(controller.RESULTS_PER_PAGE).toBe(20);
});
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;
mockSearchService = jasmine.createSpyObj(
"searchService",
[ "query" ]
);
mockPromise = jasmine.createSpyObj(
"promise",
[ "then" ]
);
mockSearchService.query.andReturn(mockPromise);
mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}];
mockSearchResult = jasmine.createSpyObj(
"searchResult",
[ "" ]
);
mockDomainObject = jasmine.createSpyObj(
"domainObject",
[ "getModel" ]
);
mockSearchResult.object = mockDomainObject;
mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'});
controller = new SearchController(mockScope, mockSearchService, mockTypes);
controller.search();
});
it("sends queries to the search service", function () {
expect(mockSearchService.query).toHaveBeenCalled();
});
it("populates the results with results from the search service", function () {
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
mockPromise.then.mostRecentCall.args[0]({hits: []});
expect(mockScope.results).toBeDefined();
});
it("is loading until the service's promise fufills", function () {
// Send query
controller.search();
expect(mockScope.loading).toBeTruthy();
// Then resolve the promises
mockPromise.then.mostRecentCall.args[0]({hits: []});
expect(mockScope.loading).toBeFalsy();
controller.onFilterChange();
var filterFn = mockSearchService.query.mostRecentCall.args[2];
expect(filterFn('askbfa')).toBe(true);
});
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 () {
it('returns true only for matching checked types', function () {
mockScope.ngModel.checkAll = false;
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
mockPromise.then.mostRecentCall.args[0]({
hits: bigArray(INITIAL_LOAD_NUMBER + 5),
total: INITIAL_LOAD_NUMBER + 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 () {
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();
controller.loadMore();
expect(mockScope.results.length).toBeGreaterThan(oldSize);
});
it("can re-search to load more results", function () {
var oldSize,
oldCallCount;
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 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 () {
// Flag should be true with nonempty input
expect(mockScope.ngModel.search).toEqual(true);
// Flag should be flase with empty input
mockScope.ngModel.input = "";
controller.search();
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
expect(mockScope.ngModel.search).toEqual(false);
// Both the empty string and undefined should be 'empty input'
mockScope.ngModel.input = undefined;
controller.search();
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
expect(mockScope.ngModel.search).toEqual(false);
});
it("has a default results list to filter from", function () {
expect(mockScope.ngModel.filter()).toBeDefined();
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));
mockPromise.then.mostRecentCall.args[0]({hits: ['a']});
expect(mockScope.results.length).toBe(1);
expect(mockScope.results).toContain('a');
});
it('is loading until the service\'s promise fufills', function () {
expect(mockScope.loading).toBeTruthy();
// Then resolve the promises
mockPromise.then.mostRecentCall.args[0]({hits: []});
expect(mockScope.loading).toBeFalsy();
});
it('detects when there are more results', function () {
mockPromise.then.mostRecentCall.args[0]({
hits: bigArray(controller.RESULTS_PER_PAGE),
total: controller.RESULTS_PER_PAGE + 5
});
expect(mockScope.results.length).toBe(controller.RESULTS_PER_PAGE);
expect(controller.areMore()).toBeTruthy();
controller.loadMore();
expect(mockSearchService.query).toHaveBeenCalledWith(
'test input',
controller.RESULTS_PER_PAGE * 2,
jasmine.any(Function)
);
mockPromise.then.mostRecentCall.args[0]({
hits: bigArray(controller.RESULTS_PER_PAGE + 5),
total: controller.RESULTS_PER_PAGE + 5
});
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
expect(mockScope.ngModel.search).toEqual(true);
// Flag should be flase with empty input
mockScope.ngModel.input = '';
controller.search();
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
expect(mockScope.ngModel.search).toEqual(false);
// Both the empty string and undefined should be 'empty input'
mockScope.ngModel.input = undefined;
controller.search();
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
expect(mockScope.ngModel.search).toEqual(false);
});
it('attaches a filter function to scope', function () {
expect(mockScope.ngModel.filter).toEqual(jasmine.any(Function));
});
});
});

View File

@ -19,275 +19,321 @@
* 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,Promise,spyOn,waitsFor,
runs*/
/**
* SearchSpec. Created by shale on 07/31/2015.
*/
define(
["../../src/services/GenericSearchProvider"],
function (GenericSearchProvider) {
"use strict";
define([
"../../src/services/GenericSearchProvider"
], function (
GenericSearchProvider
) {
"use strict";
describe("The generic search provider ", function () {
var mockQ,
mockLog,
mockThrottle,
mockDeferred,
mockObjectService,
mockObjectPromise,
mockChainedPromise,
mockDomainObjects,
mockCapability,
mockCapabilityPromise,
mockWorkerService,
mockWorker,
mockTopic,
mockMutationTopic,
mockRoots = ['root1', 'root2'],
mockThrottledFn,
throttledCallCount,
provider,
mockProviderResults;
describe('GenericSearchProvider', function () {
var $q,
$log,
modelService,
models,
workerService,
worker,
topic,
mutationTopic,
ROOTS,
provider;
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)
);
}
}
beforeEach(function () {
$q = jasmine.createSpyObj(
'$q',
['defer']
);
$log = jasmine.createSpyObj(
'$log',
['warn']
);
models = {};
modelService = jasmine.createSpyObj(
'modelService',
['getModels']
);
modelService.getModels.andReturn(Promise.resolve(models));
workerService = jasmine.createSpyObj(
'workerService',
['run']
);
worker = jasmine.createSpyObj(
'worker',
[
'postMessage',
'addEventListener'
]
);
workerService.run.andReturn(worker);
topic = jasmine.createSpy('topic');
mutationTopic = jasmine.createSpyObj(
'mutationTopic',
['listen']
);
topic.andReturn(mutationTopic);
ROOTS = [
'mine'
];
function resolveThrottledFn() {
if (mockThrottledFn.calls.length > throttledCallCount) {
mockThrottle.mostRecentCall.args[0]();
throttledCallCount = mockThrottledFn.calls.length;
}
}
spyOn(GenericSearchProvider.prototype, 'scheduleForIndexing');
function resolveAsyncTasks() {
resolveThrottledFn();
resolveObjectPromises();
}
provider = new GenericSearchProvider(
$q,
$log,
modelService,
workerService,
topic,
ROOTS
);
});
it('listens for general mutation', function () {
expect(topic).toHaveBeenCalledWith('mutation');
expect(mutationTopic.listen)
.toHaveBeenCalledWith(jasmine.any(Function));
});
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');
});
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',
id: id,
model: model
});
});
it('schedules composed ids for indexing', function () {
var id = 'anId',
model = {composition: ['abc', 'def']};
provider.index(id, model);
expect(provider.scheduleForIndexing)
.toHaveBeenCalledWith('abc');
expect(provider.scheduleForIndexing)
.toHaveBeenCalledWith('def');
});
});
describe('beginIndexRequest', function () {
beforeEach(function () {
mockQ = jasmine.createSpyObj(
"$q",
[ "defer" ]
);
mockLog = jasmine.createSpyObj(
"$log",
[ "error", "warn", "info", "debug" ]
);
mockDeferred = jasmine.createSpyObj(
"deferred",
[ "resolve", "reject"]
);
mockDeferred.promise = "mock promise";
mockQ.defer.andReturn(mockDeferred);
mockThrottle = jasmine.createSpy("throttle");
mockThrottledFn = jasmine.createSpy("throttledFn");
throttledCallCount = 0;
mockObjectService = jasmine.createSpyObj(
"objectService",
[ "getObjects" ]
);
mockObjectPromise = jasmine.createSpyObj(
"promise",
[ "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",
"getModel",
"hasCapability",
"getCapability",
"useCapability"
]
)
);
mockDomainObjects[id].getId.andReturn(id);
mockDomainObjects[id].getCapability.andReturn(mockCapability);
mockDomainObjects[id].useCapability.andReturn(mockCapabilityPromise);
mockDomainObjects[id].getModel.andReturn({});
});
mockCapability = jasmine.createSpyObj(
"capability",
[ "invoke", "listen" ]
);
mockCapability.invoke.andReturn(mockCapabilityPromise);
mockDomainObjects.a.getCapability.andReturn(mockCapability);
mockMutationTopic = jasmine.createSpyObj(
'mutationTopic',
[ 'listen' ]
);
mockTopic.andCallFake(function (key) {
return key === 'mutation' && mockMutationTopic;
});
mockThrottle.andReturn(mockThrottledFn);
mockObjectPromise.then.andReturn(mockChainedPromise);
provider = new GenericSearchProvider(
mockQ,
mockLog,
mockThrottle,
mockObjectService,
mockWorkerService,
mockTopic,
mockRoots
);
provider.pendingRequests = 0;
provider.pendingIds = {'abc': true};
provider.idsToIndex = ['abc'];
models.abc = {};
spyOn(provider, 'index');
});
it("indexes tree on initialization", function () {
var i;
it('removes items from queue', function () {
provider.beginIndexRequest();
expect(provider.idsToIndex.length).toBe(0);
});
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('tracks number of pending requests', function () {
provider.beginIndexRequest();
expect(provider.pendingRequests).toBe(1);
waitsFor(function () {
return provider.pendingRequests === 0;
});
runs(function () {
expect(provider.pendingRequests).toBe(0);
});
});
it("indexes members of composition", function () {
mockDomainObjects.root1.getModel.andReturn({
composition: ['a']
it('indexes objects', function () {
provider.beginIndexRequest();
waitsFor(function () {
return provider.pendingRequests === 0;
});
resolveAsyncTasks();
resolveAsyncTasks();
expect(mockWorker.postMessage).toHaveBeenCalledWith({
request: 'index',
model: mockDomainObjects.a.getModel(),
id: 'a'
runs(function () {
expect(provider.index)
.toHaveBeenCalledWith('abc', models.abc);
});
});
it("listens for changes to mutation", function () {
expect(mockMutationTopic.listen)
.toHaveBeenCalledWith(jasmine.any(Function));
mockMutationTopic.listen.mostRecentCall
.args[0](mockDomainObjects.a);
resolveAsyncTasks();
expect(mockWorker.postMessage).toHaveBeenCalledWith({
request: 'index',
model: mockDomainObjects.a.getModel(),
id: mockDomainObjects.a.getId()
});
});
it("sends search queries to the worker", function () {
var timestamp = Date.now();
provider.query(' test "query" ', timestamp, 1, 2);
expect(mockWorker.postMessage).toHaveBeenCalledWith({
request: "search",
input: ' test "query" ',
timestamp: timestamp,
maxNumber: 1,
timeout: 2
});
});
it("gives an empty result for an empty query", function () {
var timestamp = Date.now(),
queryOutput;
queryOutput = provider.query('', timestamp, 1, 2);
expect(queryOutput.hits).toEqual([]);
expect(queryOutput.total).toEqual(0);
queryOutput = provider.query();
expect(queryOutput.hits).toEqual([]);
expect(queryOutput.total).toEqual(0);
});
it("handles responses from the worker", function () {
var timestamp = Date.now(),
event = {
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 () {
resolveAsyncTasks();
expect(mockLog.warn).not.toHaveBeenCalled();
mockChainedPromise.then.mostRecentCall.args[0](
mockObjectPromise.then.mostRecentCall.args[1]()
);
expect(mockLog.warn).toHaveBeenCalled();
});
it("throttles the loading of objects to index", function () {
expect(mockObjectService.getObjects).not.toHaveBeenCalled();
resolveThrottledFn();
expect(mockObjectService.getObjects).toHaveBeenCalled();
});
it("logs when all objects have been processed", function () {
expect(mockLog.info).not.toHaveBeenCalled();
resolveAsyncTasks();
resolveThrottledFn();
expect(mockLog.info).toHaveBeenCalled();
});
});
}
);
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']
);
provider.pendingQueries[143] = pendingQuery;
});
it('resolves pending searches', function () {
provider.onWorkerMessage({
data: {
request: 'search',
total: 2,
results: [
{
item: {
id: 'abc',
model: {id: 'abc'}
},
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.
*
* 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
* 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
* 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.
@ -19,114 +19,205 @@
* this source code distribution or the Licensing information page available
* 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.
*/
define(
[],
function () {
"use strict";
define([
describe("The generic search worker ", function () {
// If this test fails, make sure this path is correct
var worker = new Worker(require.toUrl('platform/search/src/services/GenericSearchWorker.js')),
numObjects = 5;
beforeEach(function () {
var i;
for (i = 0; i < numObjects; i += 1) {
worker.postMessage(
{
request: "index",
id: i,
model: {
name: "object " + i,
id: i,
type: "something"
}
}
);
}
});
it("searches can reach all objects", function () {
var flag = false,
workerOutput,
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;
};
waitsFor(function () {
return flag;
}, "The worker should be searching", 1000);
runs(function () {
expect(workerOutput).toBeDefined();
expect(resultsLength).toEqual(numObjects);
], function (
) {
'use strict';
describe('GenericSearchWorker', function () {
// If this test fails, make sure this path is correct
var worker,
objectX,
objectY,
objectZ,
itemsToIndex,
onMessage,
data,
waitForResult;
beforeEach(function () {
worker = new Worker(
require.toUrl('platform/search/src/services/GenericSearchWorker.js')
);
objectX = {
id: 'x',
model: {name: 'object xx'}
};
objectY = {
id: 'y',
model: {name: 'object yy'}
};
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
});
});
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;
};
onMessage = jasmine.createSpy('onMessage');
worker.addEventListener('message', onMessage);
waitForResult = function () {
waitsFor(function () {
return flag;
}, "The worker should be searching", 1000);
runs(function () {
expect(workerOutput).toBeDefined();
expect(resultsLength).toEqual(1);
expect(workerOutput.results[2]).toBeDefined();
if (onMessage.calls.length > 0) {
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 () {
expect(onMessage).toHaveBeenCalled();
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
* 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.
*/
define(
["../../src/services/SearchAggregator"],
function (SearchAggregator) {
"use strict";
define([
"../../src/services/SearchAggregator"
], function (SearchAggregator) {
"use strict";
describe("The search aggregator ", function () {
var mockQ,
mockPromise,
mockProviders = [],
aggregator,
mockProviderResults = [],
mockAggregatorResults,
i;
describe("SearchAggregator", function () {
var $q,
objectService,
providers,
aggregator;
beforeEach(function () {
mockQ = jasmine.createSpyObj(
"$q",
[ "all" ]
);
mockPromise = jasmine.createSpyObj(
"promise",
[ "then" ]
);
for (i = 0; i < 3; i += 1) {
mockProviders.push(
jasmine.createSpyObj(
"mockProvider" + i,
[ "query" ]
)
);
mockProviders[i].query.andReturn(mockPromise);
}
mockQ.all.andReturn(mockPromise);
aggregator = new SearchAggregator(mockQ, mockProviders);
aggregator.query();
for (i = 0; i < mockProviders.length; i += 1) {
mockProviderResults.push({
hits: [
{
id: i,
score: 42 - i
},
{
id: i + 1,
score: 42 - (2 * i)
}
]
});
}
mockAggregatorResults = mockPromise.then.mostRecentCall.args[0](mockProviderResults);
});
it("sends queries to all providers", function () {
for (i = 0; i < mockProviders.length; i += 1) {
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);
}
});
beforeEach(function () {
$q = jasmine.createSpyObj(
'$q',
['all']
);
$q.all.andReturn(Promise.resolve([]));
objectService = jasmine.createSpyObj(
'objectService',
['getObjects']
);
providers = [];
aggregator = new SearchAggregator($q, objectService, providers);
});
}
);
it("has a fudge factor", function () {
expect(aggregator.FUDGE_FACTOR).toBe(5);
});
it("has default max results", function () {
expect(aggregator.DEFAULT_MAX_RESULTS).toBe(100);
});
it("can order model results by score", function () {
var modelResults = {
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 = [
{
hits: [
'oneHit',
'twoHit'
],
total: 2
},
{
hits: [
'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)
.toHaveBeenCalledWith('orderedByScore!', 'filter');
expect(aggregator.removeDuplicates)
.toHaveBeenCalledWith('filterApplied!');
expect(aggregator.asObjectResults)
.toHaveBeenCalledWith('duplicatesRemoved!');
expect(objectResults).toBe('objectResults');
})
.then(function () {
promiseChainResolved = true;
});
waitsFor(function () {
return promiseChainResolved;
});
});
});
});

View File

@ -44,7 +44,8 @@ require.config({
paths: {
'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