Merge remote-tracking branch 'github/master' into open182
Merge latest from master branch into topic branch for nasa/openmctweb#182
2
Procfile
@ -1 +1 @@
|
|||||||
web: node app.js --port $PORT --include example/localstorage
|
web: node app.js --port $PORT
|
||||||
|
@ -30,7 +30,8 @@
|
|||||||
var CONSTANTS = {
|
var CONSTANTS = {
|
||||||
DIAGRAM_WIDTH: 800,
|
DIAGRAM_WIDTH: 800,
|
||||||
DIAGRAM_HEIGHT: 500
|
DIAGRAM_HEIGHT: 500
|
||||||
};
|
},
|
||||||
|
TOC_HEAD = "# Table of Contents";
|
||||||
|
|
||||||
GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be defined
|
GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be defined
|
||||||
(function () {
|
(function () {
|
||||||
@ -44,6 +45,7 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
|
|||||||
split = require("split"),
|
split = require("split"),
|
||||||
stream = require("stream"),
|
stream = require("stream"),
|
||||||
nomnoml = require('nomnoml'),
|
nomnoml = require('nomnoml'),
|
||||||
|
toc = require("markdown-toc"),
|
||||||
Canvas = require('canvas'),
|
Canvas = require('canvas'),
|
||||||
options = require("minimist")(process.argv.slice(2));
|
options = require("minimist")(process.argv.slice(2));
|
||||||
|
|
||||||
@ -110,6 +112,9 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
|
|||||||
done();
|
done();
|
||||||
};
|
};
|
||||||
transform._flush = function (done) {
|
transform._flush = function (done) {
|
||||||
|
// Prepend table of contents
|
||||||
|
markdown =
|
||||||
|
[ TOC_HEAD, toc(markdown).content, "", markdown ].join("\n");
|
||||||
this.push("<html><body>\n");
|
this.push("<html><body>\n");
|
||||||
this.push(marked(markdown));
|
this.push(marked(markdown));
|
||||||
this.push("\n</body></html>\n");
|
this.push("\n</body></html>\n");
|
||||||
@ -179,13 +184,17 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
|
|||||||
glob(options['in'] + "/**/*.@(html|css|png)", {}, function (err, files) {
|
glob(options['in'] + "/**/*.@(html|css|png)", {}, function (err, files) {
|
||||||
files.forEach(function (file) {
|
files.forEach(function (file) {
|
||||||
var destination = file.replace(options['in'], options.out),
|
var destination = file.replace(options['in'], options.out),
|
||||||
destPath = path.dirname(destination);
|
destPath = path.dirname(destination),
|
||||||
|
streamOptions = {};
|
||||||
|
if (file.match(/png$/)){
|
||||||
|
streamOptions.encoding = 'binary';
|
||||||
|
} else {
|
||||||
|
streamOptions.encoding = 'utf8';
|
||||||
|
}
|
||||||
|
|
||||||
mkdirp(destPath, function (err) {
|
mkdirp(destPath, function (err) {
|
||||||
fs.createReadStream(file, { encoding: 'utf8' })
|
fs.createReadStream(file, streamOptions)
|
||||||
.pipe(fs.createWriteStream(destination, {
|
.pipe(fs.createWriteStream(destination, streamOptions));
|
||||||
encoding: 'utf8'
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -35,16 +35,26 @@ in __any of these tiers__.
|
|||||||
* _DOM_: The rendered HTML document, composed from HTML templates which
|
* _DOM_: The rendered HTML document, composed from HTML templates which
|
||||||
have been processed by AngularJS and will be updated by AngularJS
|
have been processed by AngularJS and will be updated by AngularJS
|
||||||
to reflect changes from the presentation layer. User interactions
|
to reflect changes from the presentation layer. User interactions
|
||||||
are initiated from here and invoke behavior in the presentation layer.
|
are initiated from here and invoke behavior in the presentation layer. HTML
|
||||||
|
templates are written in Angular’s 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 lookandfeel of the rendered templates) belong
|
||||||
|
in this grouping as well.
|
||||||
* [_Presentation layer_](#presentation-layer): The presentation layer
|
* [_Presentation layer_](#presentation-layer): The presentation layer
|
||||||
is responsible for updating (and providing information to update)
|
is responsible for updating (and providing information to update)
|
||||||
the displayed state of the application. The presentation layer consists
|
the displayed state of the application. The presentation layer consists
|
||||||
primarily of _controllers_ and _directives_. The presentation layer is
|
primarily of _controllers_ and _directives_. The presentation layer is
|
||||||
concerned with inspecting the information model and preparing it for
|
concerned with inspecting the information model and preparing it for
|
||||||
display.
|
display.
|
||||||
* [_Information model_](#information-model): The information model
|
* [_Information model_](#information-model): Provides a common (within Open MCT
|
||||||
describes the state and behavior of the objects with which the user
|
Web) set of interfaces for dealing with “things” domain objects within the
|
||||||
interacts.
|
system. Userfacing concerns in a Open MCT Web application are expressed as
|
||||||
|
domain objects; examples include folders (used to organize other domain
|
||||||
|
objects), layouts (used to build displays), or telemetry points (used as
|
||||||
|
handles for streams of remote measurements.) These domain objects expose a
|
||||||
|
common set of interfaces to allow reusable user interfaces to be built in the
|
||||||
|
presentation and template tiers; the specifics of these behaviors are then
|
||||||
|
mapped to interactions with underlying services.
|
||||||
* [_Service infrastructure_](#service-infrastructure): The service
|
* [_Service infrastructure_](#service-infrastructure): The service
|
||||||
infrastructure is responsible for providing the underlying general
|
infrastructure is responsible for providing the underlying general
|
||||||
functionality needed to support the information model. This includes
|
functionality needed to support the information model. This includes
|
||||||
@ -52,7 +62,9 @@ in __any of these tiers__.
|
|||||||
back-end.
|
back-end.
|
||||||
* _Back-end_: The back-end is out of the scope of Open MCT Web, except
|
* _Back-end_: The back-end is out of the scope of Open MCT Web, except
|
||||||
for the interfaces which are utilized by adapters participating in the
|
for the interfaces which are utilized by adapters participating in the
|
||||||
service infrastructure.
|
service infrastructure. Includes the underlying persistence stores, telemetry
|
||||||
|
streams, and so forth which the Open MCT Web client is being used to interact
|
||||||
|
with.
|
||||||
|
|
||||||
## Application Start-up
|
## Application Start-up
|
||||||
|
|
||||||
|
@ -29,8 +29,9 @@
|
|||||||
Sections:
|
Sections:
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="api/">API</a></li>
|
<li><a href="api/">API</a></li>
|
||||||
<li><a href="guide/">Developer Guide</a></li>
|
|
||||||
<li><a href="architecture/">Architecture Overview</a></li>
|
<li><a href="architecture/">Architecture Overview</a></li>
|
||||||
|
<li><a href="guide/">Developer Guide</a></li>
|
||||||
|
<li><a href="tutorials/">Tutorials</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
BIN
docs/src/tutorials/images/add-task.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
docs/src/tutorials/images/bar-plot-2.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
docs/src/tutorials/images/bar-plot-3.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
docs/src/tutorials/images/bar-plot-4.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
docs/src/tutorials/images/bar-plot.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
docs/src/tutorials/images/chrome.png
Normal file
After Width: | Height: | Size: 140 KiB |
BIN
docs/src/tutorials/images/remove-task.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
docs/src/tutorials/images/telemetry-1.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
docs/src/tutorials/images/telemetry-2.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
docs/src/tutorials/images/telemetry-3.png
Normal file
After Width: | Height: | Size: 51 KiB |
BIN
docs/src/tutorials/images/todo-edit.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
docs/src/tutorials/images/todo-list.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
docs/src/tutorials/images/todo-restyled.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
docs/src/tutorials/images/todo-selection.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
docs/src/tutorials/images/todo.png
Normal file
After Width: | Height: | Size: 43 KiB |
3055
docs/src/tutorials/index.md
Normal file
@ -22,7 +22,8 @@
|
|||||||
"split": "^1.0.0",
|
"split": "^1.0.0",
|
||||||
"mkdirp": "^0.5.1",
|
"mkdirp": "^0.5.1",
|
||||||
"nomnoml": "^0.0.3",
|
"nomnoml": "^0.0.3",
|
||||||
"canvas": "^1.2.7"
|
"canvas": "^1.2.7",
|
||||||
|
"markdown-toc": "^0.11.7"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node app.js",
|
"start": "node app.js",
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
{
|
{
|
||||||
|
"configuration": {
|
||||||
|
"paths": {
|
||||||
|
"uuid": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
* Module defining CreateService. Created by vwoeltje on 11/10/14.
|
* Module defining CreateService. Created by vwoeltje on 11/10/14.
|
||||||
*/
|
*/
|
||||||
define(
|
define(
|
||||||
["../../lib/uuid"],
|
["uuid"],
|
||||||
function (uuid) {
|
function (uuid) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
@ -30,6 +30,14 @@
|
|||||||
"category": "contextual",
|
"category": "contextual",
|
||||||
"implementation": "actions/LinkAction.js",
|
"implementation": "actions/LinkAction.js",
|
||||||
"depends": ["locationService", "linkService"]
|
"depends": ["locationService", "linkService"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "follow",
|
||||||
|
"name": "Go To Original",
|
||||||
|
"description": "Go to the original, un-linked instance of this object.",
|
||||||
|
"glyph": "\u00F4",
|
||||||
|
"category": "contextual",
|
||||||
|
"implementation": "actions/GoToOriginalAction.js"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
@ -52,7 +60,8 @@
|
|||||||
"key": "location",
|
"key": "location",
|
||||||
"name": "Location Capability",
|
"name": "Location Capability",
|
||||||
"description": "Provides a capability for retrieving the location of an object based upon it's context.",
|
"description": "Provides a capability for retrieving the location of an object based upon it's context.",
|
||||||
"implementation": "capabilities/LocationCapability"
|
"implementation": "capabilities/LocationCapability",
|
||||||
|
"depends": [ "$q", "$injector" ]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"services": [
|
"services": [
|
||||||
|
62
platform/entanglement/src/actions/GoToOriginalAction.js
Normal 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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -1,3 +1,25 @@
|
|||||||
|
/*****************************************************************************
|
||||||
|
* Open MCT Web, Copyright (c) 2014-2015, United States Government
|
||||||
|
* as represented by the Administrator of the National Aeronautics and Space
|
||||||
|
* Administration. All rights reserved.
|
||||||
|
*
|
||||||
|
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*
|
||||||
|
* Open MCT Web includes source code licensed under additional open source
|
||||||
|
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||||
|
* this source code distribution or the Licensing information page available
|
||||||
|
* at runtime from the About dialog for additional information.
|
||||||
|
*****************************************************************************/
|
||||||
|
|
||||||
/*global define */
|
/*global define */
|
||||||
|
|
||||||
define(
|
define(
|
||||||
@ -12,11 +34,41 @@ define(
|
|||||||
*
|
*
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
function LocationCapability(domainObject) {
|
function LocationCapability($q, $injector, domainObject) {
|
||||||
this.domainObject = domainObject;
|
this.domainObject = domainObject;
|
||||||
|
this.$q = $q;
|
||||||
|
this.$injector = $injector;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an instance of this domain object in its original location.
|
||||||
|
*
|
||||||
|
* @returns {Promise.<DomainObject>} a promise for the original
|
||||||
|
* instance of this domain object
|
||||||
|
*/
|
||||||
|
LocationCapability.prototype.getOriginal = function () {
|
||||||
|
var id;
|
||||||
|
|
||||||
|
if (this.isOriginal()) {
|
||||||
|
return this.$q.when(this.domainObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
id = this.domainObject.getId();
|
||||||
|
|
||||||
|
this.objectService =
|
||||||
|
this.objectService || this.$injector.get("objectService");
|
||||||
|
|
||||||
|
// Assume that an object will be correctly contextualized when
|
||||||
|
// loaded directly from the object service; this is true
|
||||||
|
// so long as LocatingObjectDecorator is present, and that
|
||||||
|
// decorator is also contained in this bundle.
|
||||||
|
return this.objectService.getObjects([id])
|
||||||
|
.then(function (objects) {
|
||||||
|
return objects[id];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the primary location (the parent id) of the current domain
|
* Set the primary location (the parent id) of the current domain
|
||||||
* object.
|
* object.
|
||||||
@ -78,10 +130,6 @@ define(
|
|||||||
return !this.isLink();
|
return !this.isLink();
|
||||||
};
|
};
|
||||||
|
|
||||||
function createLocationCapability(domainObject) {
|
return LocationCapability;
|
||||||
return new LocationCapability(domainObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
return createLocationCapability;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
95
platform/entanglement/test/actions/GoToOriginalActionSpec.js
Normal 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -1,3 +1,25 @@
|
|||||||
|
/*****************************************************************************
|
||||||
|
* Open MCT Web, Copyright (c) 2014-2015, United States Government
|
||||||
|
* as represented by the Administrator of the National Aeronautics and Space
|
||||||
|
* Administration. All rights reserved.
|
||||||
|
*
|
||||||
|
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*
|
||||||
|
* Open MCT Web includes source code licensed under additional open source
|
||||||
|
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||||
|
* this source code distribution or the Licensing information page available
|
||||||
|
* at runtime from the About dialog for additional information.
|
||||||
|
*****************************************************************************/
|
||||||
|
|
||||||
/*global define,describe,it,expect,beforeEach,jasmine */
|
/*global define,describe,it,expect,beforeEach,jasmine */
|
||||||
|
|
||||||
define(
|
define(
|
||||||
@ -7,6 +29,7 @@ define(
|
|||||||
'../ControlledPromise'
|
'../ControlledPromise'
|
||||||
],
|
],
|
||||||
function (LocationCapability, domainObjectFactory, ControlledPromise) {
|
function (LocationCapability, domainObjectFactory, ControlledPromise) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
describe("LocationCapability", function () {
|
describe("LocationCapability", function () {
|
||||||
|
|
||||||
@ -14,10 +37,14 @@ define(
|
|||||||
var locationCapability,
|
var locationCapability,
|
||||||
persistencePromise,
|
persistencePromise,
|
||||||
mutationPromise,
|
mutationPromise,
|
||||||
|
mockQ,
|
||||||
|
mockInjector,
|
||||||
|
mockObjectService,
|
||||||
domainObject;
|
domainObject;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
domainObject = domainObjectFactory({
|
domainObject = domainObjectFactory({
|
||||||
|
id: "testObject",
|
||||||
capabilities: {
|
capabilities: {
|
||||||
context: {
|
context: {
|
||||||
getParent: function () {
|
getParent: function () {
|
||||||
@ -35,6 +62,11 @@ define(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mockQ = jasmine.createSpyObj("$q", ["when"]);
|
||||||
|
mockInjector = jasmine.createSpyObj("$injector", ["get"]);
|
||||||
|
mockObjectService =
|
||||||
|
jasmine.createSpyObj("objectService", ["getObjects"]);
|
||||||
|
|
||||||
persistencePromise = new ControlledPromise();
|
persistencePromise = new ControlledPromise();
|
||||||
domainObject.capabilities.persistence.persist.andReturn(
|
domainObject.capabilities.persistence.persist.andReturn(
|
||||||
persistencePromise
|
persistencePromise
|
||||||
@ -49,7 +81,11 @@ define(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
locationCapability = new LocationCapability(domainObject);
|
locationCapability = new LocationCapability(
|
||||||
|
mockQ,
|
||||||
|
mockInjector,
|
||||||
|
domainObject
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns contextual location", function () {
|
it("returns contextual location", function () {
|
||||||
@ -88,6 +124,57 @@ define(
|
|||||||
expect(whenComplete).toHaveBeenCalled();
|
expect(whenComplete).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("when used to load an original instance", function () {
|
||||||
|
var objectPromise,
|
||||||
|
qPromise,
|
||||||
|
originalObjects,
|
||||||
|
mockCallback;
|
||||||
|
|
||||||
|
function resolvePromises() {
|
||||||
|
if (mockQ.when.calls.length > 0) {
|
||||||
|
qPromise.resolve(mockQ.when.mostRecentCall.args[0]);
|
||||||
|
}
|
||||||
|
if (mockObjectService.getObjects.calls.length > 0) {
|
||||||
|
objectPromise.resolve(originalObjects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
objectPromise = new ControlledPromise();
|
||||||
|
qPromise = new ControlledPromise();
|
||||||
|
originalObjects = {
|
||||||
|
testObject: domainObjectFactory()
|
||||||
|
};
|
||||||
|
|
||||||
|
mockInjector.get.andCallFake(function (key) {
|
||||||
|
return key === 'objectService' && mockObjectService;
|
||||||
|
});
|
||||||
|
mockObjectService.getObjects.andReturn(objectPromise);
|
||||||
|
mockQ.when.andReturn(qPromise);
|
||||||
|
|
||||||
|
mockCallback = jasmine.createSpy('callback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides originals directly", function () {
|
||||||
|
domainObject.model.location = 'root';
|
||||||
|
locationCapability.getOriginal().then(mockCallback);
|
||||||
|
expect(mockCallback).not.toHaveBeenCalled();
|
||||||
|
resolvePromises();
|
||||||
|
expect(mockCallback)
|
||||||
|
.toHaveBeenCalledWith(domainObject);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads from the object service for links", function () {
|
||||||
|
domainObject.model.location = 'some-other-root';
|
||||||
|
locationCapability.getOriginal().then(mockCallback);
|
||||||
|
expect(mockCallback).not.toHaveBeenCalled();
|
||||||
|
resolvePromises();
|
||||||
|
expect(mockCallback)
|
||||||
|
.toHaveBeenCalledWith(originalObjects.testObject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
[
|
[
|
||||||
"actions/AbstractComposeAction",
|
"actions/AbstractComposeAction",
|
||||||
|
"actions/CopyAction",
|
||||||
|
"actions/GoToOriginalAction",
|
||||||
|
"actions/LinkAction",
|
||||||
|
"actions/MoveAction",
|
||||||
"services/CopyService",
|
"services/CopyService",
|
||||||
"services/LinkService",
|
"services/LinkService",
|
||||||
"services/MoveService",
|
"services/MoveService",
|
||||||
|
@ -159,7 +159,9 @@ define(
|
|||||||
|
|
||||||
// Update dimensions and origin based on extrema of plots
|
// Update dimensions and origin based on extrema of plots
|
||||||
PlotUpdater.prototype.updateBounds = function () {
|
PlotUpdater.prototype.updateBounds = function () {
|
||||||
var bufferArray = this.bufferArray,
|
var bufferArray = this.bufferArray.filter(function (lineBuffer) {
|
||||||
|
return lineBuffer.getLength() > 0; // Ignore empty lines
|
||||||
|
}),
|
||||||
priorDomainOrigin = this.origin[0],
|
priorDomainOrigin = this.origin[0],
|
||||||
priorDomainDimensions = this.dimensions[0];
|
priorDomainDimensions = this.dimensions[0];
|
||||||
|
|
||||||
|
@ -202,6 +202,38 @@ define(
|
|||||||
expect(updater.getDimensions()[1]).toBeGreaterThan(20);
|
expect(updater.getDimensions()[1]).toBeGreaterThan(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("when no data is initially available", function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
testDomainValues = {};
|
||||||
|
testRangeValues = {};
|
||||||
|
updater = new PlotUpdater(
|
||||||
|
mockSubscription,
|
||||||
|
testDomain,
|
||||||
|
testRange,
|
||||||
|
1350 // Smaller max size for easier testing
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no line data", function () {
|
||||||
|
// Either no lines, or empty lines are fine
|
||||||
|
expect(updater.getLineBuffers().map(function (lineBuffer) {
|
||||||
|
return lineBuffer.getLength();
|
||||||
|
}).reduce(function (a, b) {
|
||||||
|
return a + b;
|
||||||
|
}, 0)).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("determines initial domain bounds from first available data", function () {
|
||||||
|
testDomainValues.a = 123;
|
||||||
|
testRangeValues.a = 456;
|
||||||
|
updater.update();
|
||||||
|
expect(updater.getOrigin()[0]).toEqual(jasmine.any(Number));
|
||||||
|
expect(updater.getOrigin()[1]).toEqual(jasmine.any(Number));
|
||||||
|
expect(isNaN(updater.getOrigin()[0])).toBeFalsy();
|
||||||
|
expect(isNaN(updater.getOrigin()[1])).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
"provides": "searchService",
|
"provides": "searchService",
|
||||||
"type": "provider",
|
"type": "provider",
|
||||||
"implementation": "ElasticSearchProvider.js",
|
"implementation": "ElasticSearchProvider.js",
|
||||||
"depends": [ "$http", "objectService", "ELASTIC_ROOT" ]
|
"depends": [ "$http", "ELASTIC_ROOT" ]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"constants": [
|
"constants": [
|
||||||
|
@ -24,16 +24,16 @@
|
|||||||
/**
|
/**
|
||||||
* Module defining ElasticSearchProvider. Created by shale on 07/16/2015.
|
* Module defining ElasticSearchProvider. Created by shale on 07/16/2015.
|
||||||
*/
|
*/
|
||||||
define(
|
define([
|
||||||
[],
|
|
||||||
function () {
|
], function (
|
||||||
|
|
||||||
|
) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// JSLint doesn't like underscore-prefixed properties,
|
var ID_PROPERTY = '_id',
|
||||||
// so hide them here.
|
SOURCE_PROPERTY = '_source',
|
||||||
var ID = "_id",
|
SCORE_PROPERTY = '_score';
|
||||||
SCORE = "_score",
|
|
||||||
DEFAULT_MAX_RESULTS = 100;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A search service which searches through domain objects in
|
* A search service which searches through domain objects in
|
||||||
@ -41,173 +41,105 @@ define(
|
|||||||
*
|
*
|
||||||
* @constructor
|
* @constructor
|
||||||
* @param $http Angular's $http service, for working with urls.
|
* @param $http Angular's $http service, for working with urls.
|
||||||
* @param {ObjectService} objectService the service from which
|
|
||||||
* domain objects can be gotten.
|
|
||||||
* @param ROOT the constant `ELASTIC_ROOT` which allows us to
|
* @param ROOT the constant `ELASTIC_ROOT` which allows us to
|
||||||
* interact with ElasticSearch.
|
* interact with ElasticSearch.
|
||||||
*/
|
*/
|
||||||
function ElasticSearchProvider($http, objectService, ROOT) {
|
function ElasticSearchProvider($http, ROOT) {
|
||||||
this.$http = $http;
|
this.$http = $http;
|
||||||
this.objectService = objectService;
|
|
||||||
this.root = ROOT;
|
this.root = ROOT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Searches through the filetree for domain objects using a search
|
* Search for domain objects using elasticsearch as a search provider.
|
||||||
* term. This is done through querying elasticsearch. Returns a
|
|
||||||
* promise for a result object that has the format
|
|
||||||
* {hits: searchResult[], total: number, timedOut: boolean}
|
|
||||||
* where a searchResult has the format
|
|
||||||
* {id: string, object: domainObject, score: number}
|
|
||||||
*
|
*
|
||||||
* Notes:
|
* @param {String} searchTerm the term to search by.
|
||||||
* * The order of the results is from highest to lowest score,
|
* @param {Number} [maxResults] the max numer of results to return.
|
||||||
* as elsaticsearch determines them to be.
|
* @returns {Promise} promise for a modelResults object.
|
||||||
* * Uses the fuzziness operator to get more results.
|
|
||||||
* * More about this search's behavior at
|
|
||||||
* https://www.elastic.co/guide/en/elasticsearch/reference/current/search-uri-request.html
|
|
||||||
*
|
|
||||||
* @param searchTerm The text input that is the query.
|
|
||||||
* @param timestamp The time at which this function was called.
|
|
||||||
* This timestamp is used as a unique identifier for this
|
|
||||||
* query and the corresponding results.
|
|
||||||
* @param maxResults (optional) The maximum number of results
|
|
||||||
* that this function should return.
|
|
||||||
* @param timeout (optional) The time after which the search should
|
|
||||||
* stop calculations and return partial results. Elasticsearch
|
|
||||||
* does not guarentee that this timeout will be strictly followed.
|
|
||||||
*/
|
*/
|
||||||
ElasticSearchProvider.prototype.query = function query(searchTerm, timestamp, maxResults, timeout) {
|
ElasticSearchProvider.prototype.query = function (searchTerm, maxResults) {
|
||||||
var $http = this.$http,
|
var searchUrl = this.root + '/_search/',
|
||||||
objectService = this.objectService,
|
params = {},
|
||||||
root = this.root,
|
provider = this;
|
||||||
esQuery;
|
|
||||||
|
|
||||||
function addFuzziness(searchTerm, editDistance) {
|
searchTerm = this.cleanTerm(searchTerm);
|
||||||
if (!editDistance) {
|
searchTerm = this.fuzzyMatchUnquotedTerms(searchTerm);
|
||||||
editDistance = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchTerm.split(' ').map(function (s) {
|
params.q = searchTerm;
|
||||||
// Don't add fuzziness for quoted strings
|
params.size = maxResults;
|
||||||
if (s.indexOf('"') !== -1) {
|
|
||||||
return s;
|
|
||||||
} else {
|
|
||||||
return s + '~' + editDistance;
|
|
||||||
}
|
|
||||||
}).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Currently specific to elasticsearch
|
return this
|
||||||
function processSearchTerm(searchTerm) {
|
.$http({
|
||||||
var spaceIndex;
|
method: "GET",
|
||||||
|
url: searchUrl,
|
||||||
// Cut out any extra spaces
|
params: params
|
||||||
while (searchTerm.substr(0, 1) === ' ') {
|
})
|
||||||
searchTerm = searchTerm.substring(1, searchTerm.length);
|
.then(function success(succesResponse) {
|
||||||
}
|
return provider.parseResponse(succesResponse);
|
||||||
while (searchTerm.substr(searchTerm.length - 1, 1) === ' ') {
|
}, function error(errorResponse) {
|
||||||
searchTerm = searchTerm.substring(0, searchTerm.length - 1);
|
// Gracefully fail.
|
||||||
}
|
return {
|
||||||
spaceIndex = searchTerm.indexOf(' ');
|
hits: [],
|
||||||
while (spaceIndex !== -1) {
|
total: 0
|
||||||
searchTerm = searchTerm.substring(0, spaceIndex) +
|
};
|
||||||
searchTerm.substring(spaceIndex + 1, searchTerm.length);
|
});
|
||||||
spaceIndex = searchTerm.indexOf(' ');
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Add fuzziness for completeness
|
/**
|
||||||
searchTerm = addFuzziness(searchTerm);
|
* Clean excess whitespace from a search term and return the cleaned
|
||||||
|
* version.
|
||||||
return searchTerm;
|
*
|
||||||
}
|
* @private
|
||||||
|
* @param {string} the search term to clean.
|
||||||
// Processes results from the format that elasticsearch returns to
|
* @returns {string} search terms cleaned of excess whitespace.
|
||||||
// a list of searchResult objects, then returns a result object
|
*/
|
||||||
// (See documentation for query for object descriptions)
|
ElasticSearchProvider.prototype.cleanTerm = function (term) {
|
||||||
function processResults(rawResults, timestamp) {
|
return term.trim().replace(/ +/g, ' ');
|
||||||
var results = rawResults.data.hits.hits,
|
};
|
||||||
resultsLength = results.length,
|
|
||||||
ids = [],
|
/**
|
||||||
scores = {},
|
* Add fuzzy matching markup to search terms that are not quoted.
|
||||||
searchResults = [],
|
*
|
||||||
i;
|
* The following:
|
||||||
|
* hello welcome "to quoted village" have fun
|
||||||
// Get the result objects' IDs
|
* will become
|
||||||
for (i = 0; i < resultsLength; i += 1) {
|
* hello~ welcome~ "to quoted village" have~ fun~
|
||||||
ids.push(results[i][ID]);
|
*
|
||||||
}
|
* @private
|
||||||
|
*/
|
||||||
// Get the result objects' scores
|
ElasticSearchProvider.prototype.fuzzyMatchUnquotedTerms = function (query) {
|
||||||
for (i = 0; i < resultsLength; i += 1) {
|
var matchUnquotedSpaces = '\\s+(?=([^"]*"[^"]*")*[^"]*$)',
|
||||||
scores[ids[i]] = results[i][SCORE];
|
matcher = new RegExp(matchUnquotedSpaces, 'g');
|
||||||
}
|
|
||||||
|
return query
|
||||||
// Get the domain objects from their IDs
|
.replace(matcher, '~ ')
|
||||||
return objectService.getObjects(ids).then(function (objects) {
|
.replace(/$/, '~')
|
||||||
var j,
|
.replace(/"~+/, '"');
|
||||||
id;
|
};
|
||||||
|
|
||||||
for (j = 0; j < resultsLength; j += 1) {
|
/**
|
||||||
id = ids[j];
|
* Parse the response from ElasticSearch and convert it to a
|
||||||
|
* modelResults object.
|
||||||
// Include items we can get models for
|
*
|
||||||
if (objects[id].getModel) {
|
* @private
|
||||||
// Format the results as searchResult objects
|
* @param response a ES response object from $http
|
||||||
searchResults.push({
|
* @returns modelResults
|
||||||
id: id,
|
*/
|
||||||
object: objects[id],
|
ElasticSearchProvider.prototype.parseResponse = function (response) {
|
||||||
score: scores[id]
|
var results = response.data.hits.hits,
|
||||||
|
searchResults = results.map(function (result) {
|
||||||
|
return {
|
||||||
|
id: result[ID_PROPERTY],
|
||||||
|
model: result[SOURCE_PROPERTY],
|
||||||
|
score: result[SCORE_PROPERTY]
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hits: searchResults,
|
hits: searchResults,
|
||||||
total: rawResults.data.hits.total,
|
total: response.data.hits.total
|
||||||
timedOut: rawResults.data.timed_out
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Check to see if the user provided a maximum
|
|
||||||
// number of results to display
|
|
||||||
if (!maxResults) {
|
|
||||||
// Else, we provide a default value.
|
|
||||||
maxResults = DEFAULT_MAX_RESULTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user input is empty, we want to have no search results.
|
|
||||||
if (searchTerm !== '' && searchTerm !== undefined) {
|
|
||||||
// Process the search term
|
|
||||||
searchTerm = processSearchTerm(searchTerm);
|
|
||||||
|
|
||||||
// Create the query to elasticsearch
|
|
||||||
esQuery = root + "/_search/?q=" + searchTerm +
|
|
||||||
"&size=" + maxResults;
|
|
||||||
if (timeout) {
|
|
||||||
esQuery += "&timeout=" + timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the data...
|
|
||||||
return this.$http({
|
|
||||||
method: "GET",
|
|
||||||
url: esQuery
|
|
||||||
}).then(function (rawResults) {
|
|
||||||
// ...then process the data
|
|
||||||
return processResults(rawResults, timestamp);
|
|
||||||
}, function (err) {
|
|
||||||
// In case of error, return nothing. (To prevent
|
|
||||||
// infinite loading time.)
|
|
||||||
return {hits: [], total: 0};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return {hits: [], total: 0};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return ElasticSearchProvider;
|
return ElasticSearchProvider;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
@ -19,97 +19,151 @@
|
|||||||
* this source code distribution or the Licensing information page available
|
* this source code distribution or the Licensing information page available
|
||||||
* at runtime from the About dialog for additional information.
|
* at runtime from the About dialog for additional information.
|
||||||
*****************************************************************************/
|
*****************************************************************************/
|
||||||
/*global define,describe,it,expect,beforeEach,jasmine*/
|
/*global define,describe,it,expect,beforeEach,jasmine,spyOn,Promise,waitsFor*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SearchSpec. Created by shale on 07/31/2015.
|
* SearchSpec. Created by shale on 07/31/2015.
|
||||||
*/
|
*/
|
||||||
define(
|
define([
|
||||||
["../src/ElasticSearchProvider"],
|
'../src/ElasticSearchProvider'
|
||||||
function (ElasticSearchProvider) {
|
], function (
|
||||||
"use strict";
|
ElasticSearchProvider
|
||||||
|
) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
// JSLint doesn't like underscore-prefixed properties,
|
describe('ElasticSearchProvider', function () {
|
||||||
// so hide them here.
|
var $http,
|
||||||
var ID = "_id",
|
ROOT,
|
||||||
SCORE = "_score";
|
provider;
|
||||||
|
|
||||||
describe("The ElasticSearch search provider ", function () {
|
|
||||||
var mockHttp,
|
|
||||||
mockHttpPromise,
|
|
||||||
mockObjectPromise,
|
|
||||||
mockObjectService,
|
|
||||||
mockDomainObject,
|
|
||||||
provider,
|
|
||||||
mockProviderResults;
|
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
mockHttp = jasmine.createSpy("$http");
|
$http = jasmine.createSpy('$http');
|
||||||
mockHttpPromise = jasmine.createSpyObj(
|
ROOT = 'http://localhost:9200';
|
||||||
"promise",
|
|
||||||
[ "then" ]
|
|
||||||
);
|
|
||||||
mockHttp.andReturn(mockHttpPromise);
|
|
||||||
// allow chaining of promise.then().catch();
|
|
||||||
mockHttpPromise.then.andReturn(mockHttpPromise);
|
|
||||||
|
|
||||||
mockObjectService = jasmine.createSpyObj(
|
provider = new ElasticSearchProvider($http, ROOT);
|
||||||
"objectService",
|
|
||||||
[ "getObjects" ]
|
|
||||||
);
|
|
||||||
mockObjectPromise = jasmine.createSpyObj(
|
|
||||||
"promise",
|
|
||||||
[ "then" ]
|
|
||||||
);
|
|
||||||
mockObjectService.getObjects.andReturn(mockObjectPromise);
|
|
||||||
|
|
||||||
mockDomainObject = jasmine.createSpyObj(
|
|
||||||
"domainObject",
|
|
||||||
[ "getId", "getModel" ]
|
|
||||||
);
|
|
||||||
|
|
||||||
provider = new ElasticSearchProvider(mockHttp, mockObjectService, "");
|
|
||||||
provider.query(' test "query" ', 0, undefined, 1000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends a query to ElasticSearch", function () {
|
describe('query', function () {
|
||||||
expect(mockHttp).toHaveBeenCalled();
|
beforeEach(function () {
|
||||||
|
spyOn(provider, 'cleanTerm').andReturn('cleanedTerm');
|
||||||
|
spyOn(provider, 'fuzzyMatchUnquotedTerms').andReturn('fuzzy');
|
||||||
|
spyOn(provider, 'parseResponse').andReturn('parsedResponse');
|
||||||
|
$http.andReturn(Promise.resolve({}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gets data from ElasticSearch", function () {
|
it('cleans terms and adds fuzzyness', function () {
|
||||||
var data = {
|
provider.query('hello', 10);
|
||||||
hits: {
|
expect(provider.cleanTerm).toHaveBeenCalledWith('hello');
|
||||||
hits: [
|
expect(provider.fuzzyMatchUnquotedTerms)
|
||||||
{},
|
.toHaveBeenCalledWith('cleanedTerm');
|
||||||
{}
|
});
|
||||||
],
|
|
||||||
total: 0
|
it('calls through to $http', function () {
|
||||||
|
provider.query('hello', 10);
|
||||||
|
expect($http).toHaveBeenCalledWith({
|
||||||
|
method: 'GET',
|
||||||
|
params: {
|
||||||
|
q: 'fuzzy',
|
||||||
|
size: 10
|
||||||
},
|
},
|
||||||
timed_out: false
|
url: 'http://localhost:9200/_search/'
|
||||||
};
|
|
||||||
data.hits.hits[0][ID] = 1;
|
|
||||||
data.hits.hits[0][SCORE] = 1;
|
|
||||||
data.hits.hits[1][ID] = 2;
|
|
||||||
data.hits.hits[1][SCORE] = 2;
|
|
||||||
|
|
||||||
mockProviderResults = mockHttpPromise.then.mostRecentCall.args[0]({data: data});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
mockObjectPromise.then.mostRecentCall.args[0]({
|
|
||||||
1: mockDomainObject,
|
|
||||||
2: mockDomainObject
|
|
||||||
}).hits.length
|
|
||||||
).toEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns nothing for an empty string query", function () {
|
|
||||||
expect(provider.query("").hits).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns something when there is an ElasticSearch error", function () {
|
|
||||||
mockProviderResults = mockHttpPromise.then.mostRecentCall.args[1]();
|
|
||||||
expect(mockProviderResults).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
it('gracefully fails when http fails', function () {
|
||||||
|
var promiseChainResolved = false;
|
||||||
|
$http.andReturn(Promise.reject());
|
||||||
|
|
||||||
|
provider
|
||||||
|
.query('hello', 10)
|
||||||
|
.then(function (results) {
|
||||||
|
expect(results).toEqual({
|
||||||
|
hits: [],
|
||||||
|
total: 0
|
||||||
|
});
|
||||||
|
promiseChainResolved = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
waitsFor(function () {
|
||||||
|
return promiseChainResolved;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses and returns when http succeeds', function () {
|
||||||
|
var promiseChainResolved = false;
|
||||||
|
$http.andReturn(Promise.resolve('successResponse'));
|
||||||
|
|
||||||
|
provider
|
||||||
|
.query('hello', 10)
|
||||||
|
.then(function (results) {
|
||||||
|
expect(provider.parseResponse)
|
||||||
|
.toHaveBeenCalledWith('successResponse');
|
||||||
|
expect(results).toBe('parsedResponse');
|
||||||
|
promiseChainResolved = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
waitsFor(function () {
|
||||||
|
return promiseChainResolved;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can clean terms', function () {
|
||||||
|
expect(provider.cleanTerm(' asdhs ')).toBe('asdhs');
|
||||||
|
expect(provider.cleanTerm(' and some words'))
|
||||||
|
.toBe('and some words');
|
||||||
|
expect(provider.cleanTerm('Nice input')).toBe('Nice input');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create fuzzy term matchers', function () {
|
||||||
|
expect(provider.fuzzyMatchUnquotedTerms('pwr dvc 43'))
|
||||||
|
.toBe('pwr~ dvc~ 43~');
|
||||||
|
|
||||||
|
expect(provider.fuzzyMatchUnquotedTerms(
|
||||||
|
'hello welcome "to quoted village" have fun'
|
||||||
|
)).toBe(
|
||||||
|
'hello~ welcome~ "to quoted village" have~ fun~'
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can parse responses', function () {
|
||||||
|
var elasticSearchResponse = {
|
||||||
|
data: {
|
||||||
|
hits: {
|
||||||
|
total: 2,
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
'_id': 'hit1Id',
|
||||||
|
'_source': 'hit1Model',
|
||||||
|
'_score': 0.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'_id': 'hit2Id',
|
||||||
|
'_source': 'hit2Model',
|
||||||
|
'_score': 0.34
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(provider.parseResponse(elasticSearchResponse))
|
||||||
|
.toEqual({
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
id: 'hit1Id',
|
||||||
|
model: 'hit1Model',
|
||||||
|
score: 0.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hit2Id',
|
||||||
|
model: 'hit2Model',
|
||||||
|
score: 0.34
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
@ -48,8 +48,7 @@
|
|||||||
"depends": [
|
"depends": [
|
||||||
"$q",
|
"$q",
|
||||||
"$log",
|
"$log",
|
||||||
"throttle",
|
"modelService",
|
||||||
"objectService",
|
|
||||||
"workerService",
|
"workerService",
|
||||||
"topic",
|
"topic",
|
||||||
"GENERIC_SEARCH_ROOTS"
|
"GENERIC_SEARCH_ROOTS"
|
||||||
@ -59,7 +58,7 @@
|
|||||||
"provides": "searchService",
|
"provides": "searchService",
|
||||||
"type": "aggregator",
|
"type": "aggregator",
|
||||||
"implementation": "services/SearchAggregator.js",
|
"implementation": "services/SearchAggregator.js",
|
||||||
"depends": [ "$q" ]
|
"depends": [ "$q", "objectService" ]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"workers": [
|
"workers": [
|
||||||
|
@ -31,11 +31,6 @@
|
|||||||
type="text"
|
type="text"
|
||||||
ng-model="ngModel.input"
|
ng-model="ngModel.input"
|
||||||
ng-keyup="controller.search()" />
|
ng-keyup="controller.search()" />
|
||||||
<!--mct-control key="'textfield'"
|
|
||||||
class="search-input"
|
|
||||||
ng-model="ngModel.input"
|
|
||||||
ng-keyup="controller.search()">
|
|
||||||
</mct-control-->
|
|
||||||
|
|
||||||
<!-- Search icon -->
|
<!-- Search icon -->
|
||||||
<!-- ui symbols for search are 'd' and 'M' -->
|
<!-- ui symbols for search are 'd' and 'M' -->
|
||||||
@ -78,9 +73,6 @@
|
|||||||
|
|
||||||
Filtered by: {{ ngModel.filtersString }}
|
Filtered by: {{ ngModel.filtersString }}
|
||||||
|
|
||||||
<!--div class="filter-options">
|
|
||||||
Filtered by: {{ ngModel.filtersString }}
|
|
||||||
</div-->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- This div exists to determine scroll bar location -->
|
<!-- This div exists to determine scroll bar location -->
|
||||||
|
@ -27,145 +27,154 @@
|
|||||||
define(function () {
|
define(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var INITIAL_LOAD_NUMBER = 20,
|
|
||||||
LOAD_INCREMENT = 20;
|
|
||||||
|
|
||||||
function SearchController($scope, searchService) {
|
|
||||||
// numResults is the amount of results to display. Will get increased.
|
|
||||||
// fullResults holds the most recent complete searchService response object
|
|
||||||
var numResults = INITIAL_LOAD_NUMBER,
|
|
||||||
fullResults = {hits: []};
|
|
||||||
|
|
||||||
// Scope variables are:
|
|
||||||
// Variables used only in SearchController:
|
|
||||||
// results, an array of searchResult objects
|
|
||||||
// loading, whether search() is loading
|
|
||||||
// ngModel.input, the text of the search query
|
|
||||||
// ngModel.search, a boolean of whether to display search or the tree
|
|
||||||
// Variables used also in SearchMenuController:
|
|
||||||
// ngModel.filter, the function filter defined below
|
|
||||||
// ngModel.types, an array of type objects
|
|
||||||
// ngModel.checked, a dictionary of which type filter options are checked
|
|
||||||
// ngModel.checkAll, a boolean of whether to search all types
|
|
||||||
// ngModel.filtersString, a string list of what filters on the results are active
|
|
||||||
$scope.results = [];
|
|
||||||
$scope.loading = false;
|
|
||||||
|
|
||||||
|
|
||||||
// Filters searchResult objects by type. Allows types that are
|
|
||||||
// checked. (ngModel.checked['typekey'] === true)
|
|
||||||
// If hits is not provided, will use fullResults.hits
|
|
||||||
function filter(hits) {
|
|
||||||
var newResults = [],
|
|
||||||
i = 0;
|
|
||||||
|
|
||||||
if (!hits) {
|
|
||||||
hits = fullResults.hits;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If checkAll is checked, search everything no matter what the other
|
|
||||||
// checkboxes' statuses are. Otherwise filter the search by types.
|
|
||||||
if ($scope.ngModel.checkAll) {
|
|
||||||
newResults = fullResults.hits.slice(0, numResults);
|
|
||||||
} else {
|
|
||||||
while (newResults.length < numResults && i < hits.length) {
|
|
||||||
// If this is of an acceptable type, add it to the list
|
|
||||||
if ($scope.ngModel.checked[hits[i].object.getModel().type]) {
|
|
||||||
newResults.push(fullResults.hits[i]);
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.results = newResults;
|
|
||||||
return newResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make function accessible from SearchMenuController
|
|
||||||
$scope.ngModel.filter = filter;
|
|
||||||
|
|
||||||
// For documentation, see search below
|
|
||||||
function search(maxResults) {
|
|
||||||
var inputText = $scope.ngModel.input;
|
|
||||||
|
|
||||||
if (inputText !== '' && inputText !== undefined) {
|
|
||||||
// We are starting to load.
|
|
||||||
$scope.loading = true;
|
|
||||||
|
|
||||||
// Update whether the file tree should be displayed
|
|
||||||
// Hide tree only when starting search
|
|
||||||
$scope.ngModel.search = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!maxResults) {
|
|
||||||
// Reset 'load more'
|
|
||||||
numResults = INITIAL_LOAD_NUMBER;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the query
|
|
||||||
searchService.query(inputText, maxResults).then(function (result) {
|
|
||||||
// Store all the results before splicing off the front, so that
|
|
||||||
// we can load more to display later.
|
|
||||||
fullResults = result;
|
|
||||||
$scope.results = filter(result.hits);
|
|
||||||
|
|
||||||
// Update whether the file tree should be displayed
|
|
||||||
// Reveal tree only when finishing search
|
|
||||||
if (inputText === '' || inputText === undefined) {
|
|
||||||
$scope.ngModel.search = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now we are done loading.
|
|
||||||
$scope.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
/**
|
||||||
* Search the filetree. Assumes that any search text will
|
* Controller for search in Tree View.
|
||||||
* be in ngModel.input
|
|
||||||
*
|
*
|
||||||
* @param maxResults (optional) The maximum number of results
|
* Filtering is currently buggy; it filters after receiving results from
|
||||||
* that this function should return. If not provided, search
|
* search providers, the downside of this is that it requires search
|
||||||
* service default will be used.
|
* providers to provide objects for all possible results, which is
|
||||||
|
* potentially a hit to persistence, thus can be very very slow.
|
||||||
|
*
|
||||||
|
* Ideally, filtering should be handled before loading objects from the persistence
|
||||||
|
* store, the downside to this is that filters must be applied to object
|
||||||
|
* models, not object instances.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param $scope
|
||||||
|
* @param searchService
|
||||||
*/
|
*/
|
||||||
search: search,
|
function SearchController($scope, searchService) {
|
||||||
|
var controller = this;
|
||||||
/**
|
this.$scope = $scope;
|
||||||
* Checks to see if there are more search results to display. If the answer is
|
this.searchService = searchService;
|
||||||
* unclear, this function will err toward saying that there are more results.
|
this.numberToDisplay = this.RESULTS_PER_PAGE;
|
||||||
*/
|
this.availabileResults = 0;
|
||||||
areMore: function () {
|
this.$scope.results = [];
|
||||||
var i;
|
this.$scope.loading = false;
|
||||||
|
this.pendingQuery = undefined;
|
||||||
// Check to see if any of the not displayed results are of an allowed type
|
this.$scope.ngModel.filter = function () {
|
||||||
for (i = numResults; i < fullResults.hits.length; i += 1) {
|
return controller.onFilterChange.apply(controller, arguments);
|
||||||
if ($scope.ngModel.checkAll || $scope.ngModel.checked[fullResults.hits[i].object.getModel().type]) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If none of the ones at hand are correct, there still may be more if we
|
|
||||||
// re-search with a larger maxResults
|
|
||||||
return fullResults.hits.length < fullResults.total;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Increases the number of search results to display, and then
|
|
||||||
* loads them, adding to the displayed results.
|
|
||||||
*/
|
|
||||||
loadMore: function () {
|
|
||||||
numResults += LOAD_INCREMENT;
|
|
||||||
|
|
||||||
if (numResults > fullResults.hits.length && fullResults.hits.length < fullResults.total) {
|
|
||||||
// Resend the query if we are out of items to display, but there are more to get
|
|
||||||
search(numResults);
|
|
||||||
} else {
|
|
||||||
// Otherwise just take from what we already have
|
|
||||||
$scope.results = filter(fullResults.hits);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SearchController.prototype.RESULTS_PER_PAGE = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if there are more results than currently displayed for the
|
||||||
|
* for the current query and filters.
|
||||||
|
*/
|
||||||
|
SearchController.prototype.areMore = function () {
|
||||||
|
return this.$scope.results.length < this.availableResults;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display more results for the currently displayed query and filters.
|
||||||
|
*/
|
||||||
|
SearchController.prototype.loadMore = function () {
|
||||||
|
this.numberToDisplay += this.RESULTS_PER_PAGE;
|
||||||
|
this.dispatchSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset search results, then search for the query string specified in
|
||||||
|
* scope.
|
||||||
|
*/
|
||||||
|
SearchController.prototype.search = function () {
|
||||||
|
var inputText = this.$scope.ngModel.input;
|
||||||
|
|
||||||
|
this.clearResults();
|
||||||
|
|
||||||
|
if (inputText) {
|
||||||
|
this.$scope.loading = true;
|
||||||
|
this.$scope.ngModel.search = true;
|
||||||
|
} else {
|
||||||
|
this.pendingQuery = undefined;
|
||||||
|
this.$scope.ngModel.search = false;
|
||||||
|
this.$scope.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a search to the search service if it hasn't already been
|
||||||
|
* dispatched.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
SearchController.prototype.dispatchSearch = function () {
|
||||||
|
var inputText = this.$scope.ngModel.input,
|
||||||
|
controller = this,
|
||||||
|
queryId = inputText + this.numberToDisplay;
|
||||||
|
|
||||||
|
if (this.pendingQuery === queryId) {
|
||||||
|
return; // don't issue multiple queries for the same term.
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingQuery = queryId;
|
||||||
|
|
||||||
|
this
|
||||||
|
.searchService
|
||||||
|
.query(inputText, this.numberToDisplay, this.filterPredicate())
|
||||||
|
.then(function (results) {
|
||||||
|
if (controller.pendingQuery !== queryId) {
|
||||||
|
return; // another query in progress, so skip this one.
|
||||||
|
}
|
||||||
|
controller.onSearchComplete(results);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
SearchController.prototype.filter = SearchController.prototype.onFilterChange;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refilter results and update visible results when filters have changed.
|
||||||
|
*/
|
||||||
|
SearchController.prototype.onFilterChange = function () {
|
||||||
|
this.pendingQuery = undefined;
|
||||||
|
this.search();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a predicate function that can be used to filter object models.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
SearchController.prototype.filterPredicate = function () {
|
||||||
|
if (this.$scope.ngModel.checkAll) {
|
||||||
|
return function () {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var includeTypes = this.$scope.ngModel.checked;
|
||||||
|
return function (model) {
|
||||||
|
return !!includeTypes[model.type];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search results.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
SearchController.prototype.clearResults = function () {
|
||||||
|
this.$scope.results = [];
|
||||||
|
this.availableResults = 0;
|
||||||
|
this.numberToDisplay = this.RESULTS_PER_PAGE;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update search results from given `results`.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
SearchController.prototype.onSearchComplete = function (results) {
|
||||||
|
this.availableResults = results.total;
|
||||||
|
this.$scope.results = results.hits;
|
||||||
|
this.$scope.loading = false;
|
||||||
|
this.pendingQuery = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return SearchController;
|
return SearchController;
|
||||||
});
|
});
|
||||||
|
@ -19,21 +19,17 @@
|
|||||||
* this source code distribution or the Licensing information page available
|
* this source code distribution or the Licensing information page available
|
||||||
* at runtime from the About dialog for additional information.
|
* at runtime from the About dialog for additional information.
|
||||||
*****************************************************************************/
|
*****************************************************************************/
|
||||||
/*global define*/
|
/*global define,setTimeout*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module defining GenericSearchProvider. Created by shale on 07/16/2015.
|
* Module defining GenericSearchProvider. Created by shale on 07/16/2015.
|
||||||
*/
|
*/
|
||||||
define(
|
define([
|
||||||
[],
|
|
||||||
function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
var DEFAULT_MAX_RESULTS = 100,
|
], function (
|
||||||
DEFAULT_TIMEOUT = 1000,
|
|
||||||
MAX_CONCURRENT_REQUESTS = 100,
|
) {
|
||||||
FLUSH_INTERVAL = 0,
|
"use strict";
|
||||||
stopTime;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A search service which searches through domain objects in
|
* A search service which searches through domain objects in
|
||||||
@ -42,211 +38,243 @@ define(
|
|||||||
* @constructor
|
* @constructor
|
||||||
* @param $q Angular's $q, for promise consolidation.
|
* @param $q Angular's $q, for promise consolidation.
|
||||||
* @param $log Anglar's $log, for logging.
|
* @param $log Anglar's $log, for logging.
|
||||||
* @param {Function} throttle a function to throttle function invocations
|
* @param {ModelService} modelService the model service.
|
||||||
* @param {ObjectService} objectService The service from which
|
* @param {WorkerService} workerService the workerService.
|
||||||
* domain objects can be gotten.
|
* @param {TopicService} topic the topic service.
|
||||||
* @param {WorkerService} workerService The service which allows
|
* @param {Array} ROOTS An array of object Ids to begin indexing.
|
||||||
* more easy creation of web workers.
|
|
||||||
* @param {GENERIC_SEARCH_ROOTS} ROOTS An array of the root
|
|
||||||
* domain objects' IDs.
|
|
||||||
*/
|
*/
|
||||||
function GenericSearchProvider($q, $log, throttle, objectService, workerService, topic, ROOTS) {
|
function GenericSearchProvider($q, $log, modelService, workerService, topic, ROOTS) {
|
||||||
var indexed = {},
|
var provider = this;
|
||||||
pendingIndex = {},
|
|
||||||
pendingQueries = {},
|
|
||||||
toRequest = [],
|
|
||||||
worker = workerService.run('genericSearchWorker'),
|
|
||||||
mutationTopic = topic("mutation"),
|
|
||||||
indexingStarted = Date.now(),
|
|
||||||
pendingRequests = 0,
|
|
||||||
scheduleFlush;
|
|
||||||
|
|
||||||
this.worker = worker;
|
|
||||||
this.pendingQueries = pendingQueries;
|
|
||||||
this.$q = $q;
|
this.$q = $q;
|
||||||
// pendingQueries is a dictionary with the key value pairs st
|
this.$log = $log;
|
||||||
// the key is the timestamp and the value is the promise
|
this.modelService = modelService;
|
||||||
|
|
||||||
function scheduleIdsForIndexing(ids) {
|
this.indexedIds = {};
|
||||||
ids.forEach(function (id) {
|
this.idsToIndex = [];
|
||||||
if (!indexed[id] && !pendingIndex[id]) {
|
this.pendingIndex = {};
|
||||||
indexed[id] = true;
|
this.pendingRequests = 0;
|
||||||
pendingIndex[id] = true;
|
|
||||||
toRequest.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
scheduleFlush();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tell the web worker to add a domain object's model to its list of items.
|
this.pendingQueries = {};
|
||||||
function indexItem(domainObject) {
|
|
||||||
var model = domainObject.getModel();
|
|
||||||
|
|
||||||
worker.postMessage({
|
this.worker = this.startWorker(workerService);
|
||||||
request: 'index',
|
this.indexOnMutation(topic);
|
||||||
model: model,
|
|
||||||
id: domainObject.getId()
|
ROOTS.forEach(function indexRoot(rootId) {
|
||||||
|
provider.scheduleForIndexing(rootId);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(model.composition)) {
|
|
||||||
scheduleIdsForIndexing(model.composition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handles responses from the web worker. Namely, the results of
|
|
||||||
// a search request.
|
|
||||||
function handleResponse(event) {
|
|
||||||
var ids = [],
|
|
||||||
id;
|
|
||||||
|
|
||||||
// If we have the results from a search
|
|
||||||
if (event.data.request === 'search') {
|
|
||||||
// Convert the ids given from the web worker into domain objects
|
|
||||||
for (id in event.data.results) {
|
|
||||||
ids.push(id);
|
|
||||||
}
|
|
||||||
objectService.getObjects(ids).then(function (objects) {
|
|
||||||
var searchResults = [],
|
|
||||||
id;
|
|
||||||
|
|
||||||
// Create searchResult objects
|
|
||||||
for (id in objects) {
|
|
||||||
searchResults.push({
|
|
||||||
object: objects[id],
|
|
||||||
id: id,
|
|
||||||
score: event.data.results[id]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resove the promise corresponding to this
|
|
||||||
pendingQueries[event.data.timestamp].resolve({
|
|
||||||
hits: searchResults,
|
|
||||||
total: event.data.total,
|
|
||||||
timedOut: event.data.timedOut
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestAndIndex(id) {
|
|
||||||
pendingRequests += 1;
|
|
||||||
objectService.getObjects([id]).then(function (objects) {
|
|
||||||
delete pendingIndex[id];
|
|
||||||
if (objects[id]) {
|
|
||||||
indexItem(objects[id]);
|
|
||||||
}
|
|
||||||
}, function () {
|
|
||||||
$log.warn("Failed to index domain object " + id);
|
|
||||||
}).then(function () {
|
|
||||||
pendingRequests -= 1;
|
|
||||||
scheduleFlush();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleFlush = throttle(function flush() {
|
|
||||||
var batchSize =
|
|
||||||
Math.max(MAX_CONCURRENT_REQUESTS - pendingRequests, 0);
|
|
||||||
|
|
||||||
if (toRequest.length + pendingRequests < 1) {
|
|
||||||
$log.info([
|
|
||||||
'GenericSearch finished indexing after ',
|
|
||||||
((Date.now() - indexingStarted) / 1000).toFixed(2),
|
|
||||||
' seconds.'
|
|
||||||
].join(''));
|
|
||||||
} else {
|
|
||||||
toRequest.splice(-batchSize, batchSize)
|
|
||||||
.forEach(requestAndIndex);
|
|
||||||
}
|
|
||||||
}, FLUSH_INTERVAL);
|
|
||||||
|
|
||||||
worker.onmessage = handleResponse;
|
|
||||||
|
|
||||||
// Index the tree's contents once at the beginning
|
|
||||||
scheduleIdsForIndexing(ROOTS);
|
|
||||||
|
|
||||||
// Re-index items when they are mutated
|
|
||||||
mutationTopic.listen(function (domainObject) {
|
|
||||||
var id = domainObject.getId();
|
|
||||||
indexed[id] = false;
|
|
||||||
scheduleIdsForIndexing([id]);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Searches through the filetree for domain objects which match
|
* Maximum number of concurrent index requests to allow.
|
||||||
* the search term. This function is to be used as a fallback
|
|
||||||
* in the case where other search services are not avaliable.
|
|
||||||
* Returns a promise for a result object that has the format
|
|
||||||
* {hits: searchResult[], total: number, timedOut: boolean}
|
|
||||||
* where a searchResult has the format
|
|
||||||
* {id: string, object: domainObject, score: number}
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* * The order of the results is not guarenteed.
|
|
||||||
* * A domain object qualifies as a match for a search input if
|
|
||||||
* the object's name property contains any of the search terms
|
|
||||||
* (which are generated by splitting the input at spaces).
|
|
||||||
* * Scores are higher for matches that have more of the terms
|
|
||||||
* as substrings.
|
|
||||||
*
|
|
||||||
* @param input The text input that is the query.
|
|
||||||
* @param timestamp The time at which this function was called.
|
|
||||||
* This timestamp is used as a unique identifier for this
|
|
||||||
* query and the corresponding results.
|
|
||||||
* @param maxResults (optional) The maximum number of results
|
|
||||||
* that this function should return.
|
|
||||||
* @param timeout (optional) The time after which the search should
|
|
||||||
* stop calculations and return partial results.
|
|
||||||
*/
|
*/
|
||||||
GenericSearchProvider.prototype.query = function query(input, timestamp, maxResults, timeout) {
|
GenericSearchProvider.prototype.MAX_CONCURRENT_REQUESTS = 100;
|
||||||
var terms = [],
|
|
||||||
searchResults = [],
|
|
||||||
pendingQueries = this.pendingQueries,
|
|
||||||
worker = this.worker,
|
|
||||||
defer = this.$q.defer();
|
|
||||||
|
|
||||||
// Tell the worker to search for items it has that match this searchInput.
|
/**
|
||||||
// Takes the searchInput, as well as a max number of results (will return
|
* Query the search provider for results.
|
||||||
// less than that if there are fewer matches).
|
*
|
||||||
function workerSearch(searchInput, maxResults, timestamp, timeout) {
|
* @param {String} input the string to search by.
|
||||||
var message = {
|
* @param {Number} maxResults max number of results to return.
|
||||||
|
* @returns {Promise} a promise for a modelResults object.
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.query = function (
|
||||||
|
input,
|
||||||
|
maxResults
|
||||||
|
) {
|
||||||
|
|
||||||
|
var queryId = this.dispatchSearch(input, maxResults),
|
||||||
|
pendingQuery = this.$q.defer();
|
||||||
|
|
||||||
|
this.pendingQueries[queryId] = pendingQuery;
|
||||||
|
|
||||||
|
return pendingQuery.promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a search worker and attaches handlers.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param workerService
|
||||||
|
* @returns worker the created search worker.
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.startWorker = function (workerService) {
|
||||||
|
var worker = workerService.run('genericSearchWorker'),
|
||||||
|
provider = this;
|
||||||
|
|
||||||
|
worker.addEventListener('message', function (messageEvent) {
|
||||||
|
provider.onWorkerMessage(messageEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
return worker;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to the mutation topic and re-index objects when they are
|
||||||
|
* mutated.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param topic the topicService.
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.indexOnMutation = function (topic) {
|
||||||
|
var mutationTopic = topic('mutation'),
|
||||||
|
provider = this;
|
||||||
|
|
||||||
|
mutationTopic.listen(function (mutatedObject) {
|
||||||
|
var id = mutatedObject.getId();
|
||||||
|
provider.indexedIds[id] = false;
|
||||||
|
provider.scheduleForIndexing(id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule an id to be indexed at a later date. If there are less
|
||||||
|
* pending requests then allowed, will kick off an indexing request.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {String} id to be indexed.
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.scheduleForIndexing = function (id) {
|
||||||
|
if (!this.indexedIds[id] && !this.pendingIndex[id]) {
|
||||||
|
this.indexedIds[id] = true;
|
||||||
|
this.pendingIndex[id] = true;
|
||||||
|
this.idsToIndex.push(id);
|
||||||
|
}
|
||||||
|
this.keepIndexing();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there are less pending requests than concurrent requests, keep
|
||||||
|
* firing requests.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.keepIndexing = function () {
|
||||||
|
while (this.pendingRequests < this.MAX_CONCURRENT_REQUESTS &&
|
||||||
|
this.idsToIndex.length
|
||||||
|
) {
|
||||||
|
this.beginIndexRequest();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass an id and model to the worker to be indexed. If the model has
|
||||||
|
* composition, schedule those ids for later indexing.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param id a model id
|
||||||
|
* @param model a model
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.index = function (id, model) {
|
||||||
|
var provider = this;
|
||||||
|
|
||||||
|
this.worker.postMessage({
|
||||||
|
request: 'index',
|
||||||
|
model: model,
|
||||||
|
id: id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(model.composition)) {
|
||||||
|
model.composition.forEach(function (id) {
|
||||||
|
provider.scheduleForIndexing(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls an id from the indexing queue, loads it from the model service,
|
||||||
|
* and indexes it. Upon completion, tells the provider to keep
|
||||||
|
* indexing.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.beginIndexRequest = function () {
|
||||||
|
var idToIndex = this.idsToIndex.shift(),
|
||||||
|
provider = this;
|
||||||
|
|
||||||
|
this.pendingRequests += 1;
|
||||||
|
this.modelService
|
||||||
|
.getModels([idToIndex])
|
||||||
|
.then(function (models) {
|
||||||
|
delete provider.pendingIndex[idToIndex];
|
||||||
|
if (models[idToIndex]) {
|
||||||
|
provider.index(idToIndex, models[idToIndex]);
|
||||||
|
}
|
||||||
|
}, function () {
|
||||||
|
provider
|
||||||
|
.$log
|
||||||
|
.warn('Failed to index domain object ' + idToIndex);
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
setTimeout(function () {
|
||||||
|
provider.pendingRequests -= 1;
|
||||||
|
provider.keepIndexing();
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle messages from the worker. Only really knows how to handle search
|
||||||
|
* results, which are parsed, transformed into a modelResult object, which
|
||||||
|
* is used to resolve the corresponding promise.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.onWorkerMessage = function (event) {
|
||||||
|
if (event.data.request !== 'search') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingQuery = this.pendingQueries[event.data.queryId],
|
||||||
|
modelResults = {
|
||||||
|
total: event.data.total
|
||||||
|
};
|
||||||
|
|
||||||
|
modelResults.hits = event.data.results.map(function (hit) {
|
||||||
|
return {
|
||||||
|
id: hit.item.id,
|
||||||
|
model: hit.item.model,
|
||||||
|
score: hit.matchCount
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingQuery.resolve(modelResults);
|
||||||
|
delete this.pendingQueries[event.data.queryId];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @returns {Number} a unique, unusued query Id.
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.makeQueryId = function () {
|
||||||
|
var queryId = Math.ceil(Math.random() * 100000);
|
||||||
|
while (this.pendingQueries[queryId]) {
|
||||||
|
queryId = Math.ceil(Math.random() * 100000);
|
||||||
|
}
|
||||||
|
return queryId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a search query to the worker and return a queryId.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @returns {Number} a unique query Id for the query.
|
||||||
|
*/
|
||||||
|
GenericSearchProvider.prototype.dispatchSearch = function (
|
||||||
|
searchInput,
|
||||||
|
maxResults
|
||||||
|
) {
|
||||||
|
var queryId = this.makeQueryId();
|
||||||
|
|
||||||
|
this.worker.postMessage({
|
||||||
request: 'search',
|
request: 'search',
|
||||||
input: searchInput,
|
input: searchInput,
|
||||||
maxNumber: maxResults,
|
maxResults: maxResults,
|
||||||
timestamp: timestamp,
|
queryId: queryId
|
||||||
timeout: timeout
|
});
|
||||||
};
|
|
||||||
worker.postMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the input is nonempty, do a search
|
return queryId;
|
||||||
if (input !== '' && input !== undefined) {
|
|
||||||
|
|
||||||
// Allow us to access this promise later to resolve it later
|
|
||||||
pendingQueries[timestamp] = defer;
|
|
||||||
|
|
||||||
// Check to see if the user provided a maximum
|
|
||||||
// number of results to display
|
|
||||||
if (!maxResults) {
|
|
||||||
// Else, we provide a default value
|
|
||||||
maxResults = DEFAULT_MAX_RESULTS;
|
|
||||||
}
|
|
||||||
// Similarly, check if timeout was provided
|
|
||||||
if (!timeout) {
|
|
||||||
timeout = DEFAULT_TIMEOUT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the query to the worker
|
|
||||||
workerSearch(input, maxResults, timestamp, timeout);
|
|
||||||
|
|
||||||
return defer.promise;
|
|
||||||
} else {
|
|
||||||
// Otherwise return an empty result
|
|
||||||
return { hits: [], total: 0 };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return GenericSearchProvider;
|
return GenericSearchProvider;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
@ -29,128 +29,127 @@
|
|||||||
|
|
||||||
// An array of objects composed of domain object IDs and models
|
// An array of objects composed of domain object IDs and models
|
||||||
// {id: domainObject's ID, model: domainObject's model}
|
// {id: domainObject's ID, model: domainObject's model}
|
||||||
var indexedItems = [];
|
var indexedItems = [],
|
||||||
|
TERM_SPLITTER = /[ _\*]/;
|
||||||
|
|
||||||
// Helper function for serach()
|
function indexItem(id, model) {
|
||||||
function convertToTerms(input) {
|
var vector = {
|
||||||
var terms = input;
|
name: model.name
|
||||||
// Shave any spaces off of the ends of the input
|
};
|
||||||
while (terms.substr(0, 1) === ' ') {
|
vector.cleanName = model.name.trim();
|
||||||
terms = terms.substring(1, terms.length);
|
vector.lowerCaseName = vector.cleanName.toLocaleLowerCase();
|
||||||
}
|
vector.terms = vector.lowerCaseName.split(TERM_SPLITTER);
|
||||||
while (terms.substr(terms.length - 1, 1) === ' ') {
|
|
||||||
terms = terms.substring(0, terms.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then split it at spaces and asterisks
|
indexedItems.push({
|
||||||
terms = terms.split(/ |\*/);
|
id: id,
|
||||||
|
vector: vector,
|
||||||
// Remove any empty strings from the terms
|
model: model
|
||||||
while (terms.indexOf('') !== -1) {
|
});
|
||||||
terms.splice(terms.indexOf(''), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return terms;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function for search()
|
// Helper function for search()
|
||||||
function scoreItem(item, input, terms) {
|
function convertToTerms(input) {
|
||||||
var name = item.model.name.toLocaleLowerCase(),
|
var query = {
|
||||||
weight = 0.65,
|
exactInput: input
|
||||||
score = 0.0,
|
};
|
||||||
i;
|
query.inputClean = input.trim();
|
||||||
|
query.inputLowerCase = query.inputClean.toLocaleLowerCase();
|
||||||
// Make the score really big if the item name and
|
query.terms = query.inputLowerCase.split(TERM_SPLITTER);
|
||||||
// the original search input are the same
|
query.exactTerms = query.inputClean.split(TERM_SPLITTER);
|
||||||
if (name === input) {
|
return query;
|
||||||
score = 42;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i = 0; i < terms.length; i += 1) {
|
|
||||||
// Increase the score if the term is in the item name
|
|
||||||
if (name.indexOf(terms[i]) !== -1) {
|
|
||||||
score += 1;
|
|
||||||
|
|
||||||
// Add extra to the score if the search term exists
|
|
||||||
// as its own term within the items
|
|
||||||
if (name.split(' ').indexOf(terms[i]) !== -1) {
|
|
||||||
score += 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return score * weight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets search results from the indexedItems based on provided search
|
* Gets search results from the indexedItems based on provided search
|
||||||
* input. Returns matching results from indexedItems, as well as the
|
* input. Returns matching results from indexedItems
|
||||||
* timestamp that was passed to it.
|
|
||||||
*
|
*
|
||||||
* @param data An object which contains:
|
* @param data An object which contains:
|
||||||
* * input: The original string which we are searching with
|
* * input: The original string which we are searching with
|
||||||
* * maxNumber: The maximum number of search results desired
|
* * maxResults: The maximum number of search results desired
|
||||||
* * timestamp: The time identifier from when the query was made
|
* * queryId: an id identifying this query, will be returned.
|
||||||
*/
|
*/
|
||||||
function search(data) {
|
function search(data) {
|
||||||
// This results dictionary will have domain object ID keys which
|
// This results dictionary will have domain object ID keys which
|
||||||
// point to the value the domain object's score.
|
// point to the value the domain object's score.
|
||||||
var results = {},
|
var results,
|
||||||
input = data.input.toLocaleLowerCase(),
|
input = data.input,
|
||||||
terms = convertToTerms(input),
|
query = convertToTerms(input),
|
||||||
message = {
|
message = {
|
||||||
request: 'search',
|
request: 'search',
|
||||||
results: {},
|
results: {},
|
||||||
total: 0,
|
total: 0,
|
||||||
timestamp: data.timestamp,
|
queryId: data.queryId
|
||||||
timedOut: false
|
|
||||||
},
|
},
|
||||||
score,
|
matches = {};
|
||||||
i,
|
|
||||||
id;
|
|
||||||
|
|
||||||
// If the user input is empty, we want to have no search results.
|
if (!query.inputClean) {
|
||||||
if (input !== '') {
|
// No search terms, no results;
|
||||||
for (i = 0; i < indexedItems.length; i += 1) {
|
return message;
|
||||||
// If this is taking too long, then stop
|
|
||||||
if (Date.now() > data.timestamp + data.timeout) {
|
|
||||||
message.timedOut = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score and add items
|
// Two phases: find matches, then score matches.
|
||||||
score = scoreItem(indexedItems[i], input, terms);
|
// Idea being that match finding should be fast, so that future scoring
|
||||||
if (score > 0) {
|
// operations process fewer objects.
|
||||||
results[indexedItems[i].id] = score;
|
|
||||||
message.total += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate results if there are more than maxResults
|
query.terms.forEach(function findMatchingItems(term) {
|
||||||
if (message.total > data.maxResults) {
|
indexedItems
|
||||||
i = 0;
|
.filter(function matchesItem(item) {
|
||||||
for (id in results) {
|
return item.vector.lowerCaseName.indexOf(term) !== -1;
|
||||||
message.results[id] = results[id];
|
})
|
||||||
i += 1;
|
.forEach(function trackMatch(matchedItem) {
|
||||||
if (i >= data.maxResults) {
|
if (!matches[matchedItem.id]) {
|
||||||
break;
|
matches[matchedItem.id] = {
|
||||||
|
matchCount: 0,
|
||||||
|
item: matchedItem
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
matches[matchedItem.id].matchCount += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then, score matching items.
|
||||||
|
results = Object
|
||||||
|
.keys(matches)
|
||||||
|
.map(function asMatches(matchId) {
|
||||||
|
return matches[matchId];
|
||||||
|
})
|
||||||
|
.map(function prioritizeExactMatches(match) {
|
||||||
|
if (match.item.vector.name === query.exactInput) {
|
||||||
|
match.matchCount += 100;
|
||||||
|
} else if (match.item.vector.lowerCaseName ===
|
||||||
|
query.inputLowerCase) {
|
||||||
|
match.matchCount += 50;
|
||||||
}
|
}
|
||||||
// TODO: This seems inefficient.
|
return match;
|
||||||
} else {
|
})
|
||||||
message.results = results;
|
.map(function prioritizeCompleteTermMatches(match) {
|
||||||
|
match.item.vector.terms.forEach(function (term) {
|
||||||
|
if (query.terms.indexOf(term) !== -1) {
|
||||||
|
match.matchCount += 0.5;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
return match;
|
||||||
|
})
|
||||||
|
.sort(function compare(a, b) {
|
||||||
|
if (a.matchCount > b.matchCount) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.matchCount < b.matchCount) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
message.total = results.length;
|
||||||
|
message.results = results
|
||||||
|
.slice(0, data.maxResults);
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.onmessage = function (event) {
|
self.onmessage = function (event) {
|
||||||
if (event.data.request === 'index') {
|
if (event.data.request === 'index') {
|
||||||
indexedItems.push({
|
indexItem(event.data.id, event.data.model);
|
||||||
id: event.data.id,
|
|
||||||
model: event.data.model
|
|
||||||
});
|
|
||||||
} else if (event.data.request === 'search') {
|
} else if (event.data.request === 'search') {
|
||||||
self.postMessage(search(event.data));
|
self.postMessage(search(event.data));
|
||||||
}
|
}
|
||||||
|
@ -24,75 +24,113 @@
|
|||||||
/**
|
/**
|
||||||
* Module defining SearchAggregator. Created by shale on 07/16/2015.
|
* Module defining SearchAggregator. Created by shale on 07/16/2015.
|
||||||
*/
|
*/
|
||||||
define(
|
define([
|
||||||
[],
|
|
||||||
function () {
|
], function (
|
||||||
|
|
||||||
|
) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var DEFUALT_TIMEOUT = 1000,
|
|
||||||
DEFAULT_MAX_RESULTS = 100;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows multiple services which provide search functionality
|
* Aggregates multiple search providers as a singular search provider.
|
||||||
* to be treated as one.
|
* Search providers are expected to implement a `query` method which returns
|
||||||
|
* a promise for a `modelResults` object.
|
||||||
|
*
|
||||||
|
* The search aggregator combines the results from multiple providers,
|
||||||
|
* removes aggregates, and converts the results to domain objects.
|
||||||
*
|
*
|
||||||
* @constructor
|
* @constructor
|
||||||
* @param $q Angular's $q, for promise consolidation.
|
* @param $q Angular's $q, for promise consolidation.
|
||||||
|
* @param objectService
|
||||||
* @param {SearchProvider[]} providers The search providers to be
|
* @param {SearchProvider[]} providers The search providers to be
|
||||||
* aggregated.
|
* aggregated.
|
||||||
*/
|
*/
|
||||||
function SearchAggregator($q, providers) {
|
function SearchAggregator($q, objectService, providers) {
|
||||||
this.$q = $q;
|
this.$q = $q;
|
||||||
|
this.objectService = objectService;
|
||||||
this.providers = providers;
|
this.providers = providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If max results is not specified in query, use this as default.
|
||||||
|
*/
|
||||||
|
SearchAggregator.prototype.DEFAULT_MAX_RESULTS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Because filtering isn't implemented inside each provider, the fudge
|
||||||
|
* factor is a multiplier on the number of results returned-- more results
|
||||||
|
* than requested will be fetched, and then will be filtered. This helps
|
||||||
|
* provide more predictable pagination when large numbers of results are
|
||||||
|
* returned but very few results match filters.
|
||||||
|
*
|
||||||
|
* If a provider level filter implementation is implemented in the future,
|
||||||
|
* remove this.
|
||||||
|
*/
|
||||||
|
SearchAggregator.prototype.FUDGE_FACTOR = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a query to each of the providers. Returns a promise for
|
* Sends a query to each of the providers. Returns a promise for
|
||||||
* a result object that has the format
|
* a result object that has the format
|
||||||
* {hits: searchResult[], total: number, timedOut: boolean}
|
* {hits: searchResult[], total: number}
|
||||||
* where a searchResult has the format
|
* where a searchResult has the format
|
||||||
* {id: string, object: domainObject, score: number}
|
* {id: string, object: domainObject, score: number}
|
||||||
*
|
*
|
||||||
* @param inputText The text input that is the query.
|
* @param {String} inputText The text input that is the query.
|
||||||
* @param maxResults (optional) The maximum number of results
|
* @param {Number} maxResults (optional) The maximum number of results
|
||||||
* that this function should return. If not provided, a
|
* that this function should return. If not provided, a
|
||||||
* default of 100 will be used.
|
* default of 100 will be used.
|
||||||
|
* @param {Function} [filter] if provided, will be called for every
|
||||||
|
* potential modelResult. If it returns false, the model result will be
|
||||||
|
* excluded from the search results.
|
||||||
|
* @returns {Promise} A Promise for a search result object.
|
||||||
*/
|
*/
|
||||||
SearchAggregator.prototype.query = function queryAll(inputText, maxResults) {
|
SearchAggregator.prototype.query = function (
|
||||||
var $q = this.$q,
|
inputText,
|
||||||
providers = this.providers,
|
maxResults,
|
||||||
i,
|
filter
|
||||||
timestamp = Date.now(),
|
) {
|
||||||
resultPromises = [];
|
|
||||||
|
|
||||||
// Remove duplicate objects that have the same ID. Modifies the passed
|
var aggregator = this,
|
||||||
// array, and returns the number that were removed.
|
resultPromises;
|
||||||
function filterDuplicates(results, total) {
|
|
||||||
var ids = {},
|
|
||||||
numRemoved = 0,
|
|
||||||
i;
|
|
||||||
|
|
||||||
for (i = 0; i < results.length; i += 1) {
|
if (!maxResults) {
|
||||||
if (ids[results[i].id]) {
|
maxResults = this.DEFAULT_MAX_RESULTS;
|
||||||
// If this result's ID is already there, remove the object
|
|
||||||
results.splice(i, 1);
|
|
||||||
numRemoved += 1;
|
|
||||||
|
|
||||||
// Reduce loop index because we shortened the array
|
|
||||||
i -= 1;
|
|
||||||
} else {
|
|
||||||
// Otherwise add the ID to the list of the ones we have seen
|
|
||||||
ids[results[i].id] = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return numRemoved;
|
resultPromises = this.providers.map(function (provider) {
|
||||||
}
|
return provider.query(
|
||||||
|
inputText,
|
||||||
|
maxResults * aggregator.FUDGE_FACTOR
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Order the objects from highest to lowest score in the array.
|
return this.$q
|
||||||
// Modifies the passed array, as well as returns the modified array.
|
.all(resultPromises)
|
||||||
function orderByScore(results) {
|
.then(function (providerResults) {
|
||||||
results.sort(function (a, b) {
|
var modelResults = {
|
||||||
|
hits: [],
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
providerResults.forEach(function (providerResult) {
|
||||||
|
modelResults.hits =
|
||||||
|
modelResults.hits.concat(providerResult.hits);
|
||||||
|
modelResults.total += providerResult.total;
|
||||||
|
});
|
||||||
|
|
||||||
|
modelResults = aggregator.orderByScore(modelResults);
|
||||||
|
modelResults = aggregator.applyFilter(modelResults, filter);
|
||||||
|
modelResults = aggregator.removeDuplicates(modelResults);
|
||||||
|
|
||||||
|
return aggregator.asObjectResults(modelResults);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order model results by score descending and return them.
|
||||||
|
*/
|
||||||
|
SearchAggregator.prototype.orderByScore = function (modelResults) {
|
||||||
|
modelResults.hits.sort(function (a, b) {
|
||||||
if (a.score > b.score) {
|
if (a.score > b.score) {
|
||||||
return -1;
|
return -1;
|
||||||
} else if (b.score > a.score) {
|
} else if (b.score > a.score) {
|
||||||
@ -101,45 +139,86 @@ define(
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return results;
|
return modelResults;
|
||||||
}
|
|
||||||
|
|
||||||
if (!maxResults) {
|
|
||||||
maxResults = DEFAULT_MAX_RESULTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the query to all the providers
|
|
||||||
for (i = 0; i < providers.length; i += 1) {
|
|
||||||
resultPromises.push(
|
|
||||||
providers[i].query(inputText, timestamp, maxResults, DEFUALT_TIMEOUT)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get promises for results arrays
|
|
||||||
return $q.all(resultPromises).then(function (resultObjects) {
|
|
||||||
var results = [],
|
|
||||||
totalSum = 0,
|
|
||||||
i;
|
|
||||||
|
|
||||||
// Merge results
|
|
||||||
for (i = 0; i < resultObjects.length; i += 1) {
|
|
||||||
results = results.concat(resultObjects[i].hits);
|
|
||||||
totalSum += resultObjects[i].total;
|
|
||||||
}
|
|
||||||
// Order by score first, so that when removing repeats we keep the higher scored ones
|
|
||||||
orderByScore(results);
|
|
||||||
totalSum -= filterDuplicates(results, totalSum);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hits: results,
|
|
||||||
total: totalSum,
|
|
||||||
timedOut: resultObjects.some(function (obj) {
|
|
||||||
return obj.timedOut;
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a filter to each model result, removing it from search results
|
||||||
|
* if it does not match.
|
||||||
|
*/
|
||||||
|
SearchAggregator.prototype.applyFilter = function (modelResults, filter) {
|
||||||
|
if (!filter) {
|
||||||
|
return modelResults;
|
||||||
|
}
|
||||||
|
var initialLength = modelResults.hits.length,
|
||||||
|
finalLength,
|
||||||
|
removedByFilter;
|
||||||
|
|
||||||
|
modelResults.hits = modelResults.hits.filter(function (hit) {
|
||||||
|
return filter(hit.model);
|
||||||
|
});
|
||||||
|
|
||||||
|
finalLength = modelResults.hits.length;
|
||||||
|
removedByFilter = initialLength - finalLength;
|
||||||
|
modelResults.total -= removedByFilter;
|
||||||
|
|
||||||
|
return modelResults;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove duplicate hits in a modelResults object, and decrement `total`
|
||||||
|
* each time a duplicate is removed.
|
||||||
|
*/
|
||||||
|
SearchAggregator.prototype.removeDuplicates = function (modelResults) {
|
||||||
|
var includedIds = {};
|
||||||
|
|
||||||
|
modelResults.hits = modelResults
|
||||||
|
.hits
|
||||||
|
.filter(function alreadyInResults(hit) {
|
||||||
|
if (includedIds[hit.id]) {
|
||||||
|
modelResults.total -= 1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
includedIds[hit.id] = true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return modelResults;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert modelResults to objectResults by fetching them from the object
|
||||||
|
* service.
|
||||||
|
*
|
||||||
|
* @returns {Promise} for an objectResults object.
|
||||||
|
*/
|
||||||
|
SearchAggregator.prototype.asObjectResults = function (modelResults) {
|
||||||
|
var objectIds = modelResults.hits.map(function (modelResult) {
|
||||||
|
return modelResult.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this
|
||||||
|
.objectService
|
||||||
|
.getObjects(objectIds)
|
||||||
|
.then(function (objects) {
|
||||||
|
|
||||||
|
var objectResults = {
|
||||||
|
total: modelResults.total
|
||||||
|
};
|
||||||
|
|
||||||
|
objectResults.hits = modelResults
|
||||||
|
.hits
|
||||||
|
.map(function asObjectResult(hit) {
|
||||||
|
return {
|
||||||
|
id: hit.id,
|
||||||
|
object: objects[hit.id],
|
||||||
|
score: hit.score
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return objectResults;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return SearchAggregator;
|
return SearchAggregator;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
@ -4,12 +4,12 @@
|
|||||||
* Administration. All rights reserved.
|
* Administration. All rights reserved.
|
||||||
*
|
*
|
||||||
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
|
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
|
||||||
* "License"); you may not use this file except in compliance with the License.
|
* 'License'); you may not use this file except in compliance with the License.
|
||||||
* You may obtain a copy of the License at
|
* You may obtain a copy of the License at
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||||
*
|
*
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
* distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
|
||||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
* License for the specific language governing permissions and limitations
|
* License for the specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
@ -24,16 +24,14 @@
|
|||||||
/**
|
/**
|
||||||
* SearchSpec. Created by shale on 07/31/2015.
|
* SearchSpec. Created by shale on 07/31/2015.
|
||||||
*/
|
*/
|
||||||
define(
|
define([
|
||||||
["../../src/controllers/SearchController"],
|
'../../src/controllers/SearchController'
|
||||||
function (SearchController) {
|
], function (
|
||||||
"use strict";
|
SearchController
|
||||||
|
) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
// These should be the same as the ones on the top of the search controller
|
describe('The search controller', function () {
|
||||||
var INITIAL_LOAD_NUMBER = 20,
|
|
||||||
LOAD_INCREMENT = 20;
|
|
||||||
|
|
||||||
describe("The search controller", function () {
|
|
||||||
var mockScope,
|
var mockScope,
|
||||||
mockSearchService,
|
mockSearchService,
|
||||||
mockPromise,
|
mockPromise,
|
||||||
@ -54,34 +52,34 @@ define(
|
|||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
mockScope = jasmine.createSpyObj(
|
mockScope = jasmine.createSpyObj(
|
||||||
"$scope",
|
'$scope',
|
||||||
[ "$watch" ]
|
[ '$watch' ]
|
||||||
);
|
);
|
||||||
mockScope.ngModel = {};
|
mockScope.ngModel = {};
|
||||||
mockScope.ngModel.input = "test input";
|
mockScope.ngModel.input = 'test input';
|
||||||
mockScope.ngModel.checked = {};
|
mockScope.ngModel.checked = {};
|
||||||
mockScope.ngModel.checked['mock.type'] = true;
|
mockScope.ngModel.checked['mock.type'] = true;
|
||||||
mockScope.ngModel.checkAll = true;
|
mockScope.ngModel.checkAll = true;
|
||||||
|
|
||||||
mockSearchService = jasmine.createSpyObj(
|
mockSearchService = jasmine.createSpyObj(
|
||||||
"searchService",
|
'searchService',
|
||||||
[ "query" ]
|
[ 'query' ]
|
||||||
);
|
);
|
||||||
mockPromise = jasmine.createSpyObj(
|
mockPromise = jasmine.createSpyObj(
|
||||||
"promise",
|
'promise',
|
||||||
[ "then" ]
|
[ 'then' ]
|
||||||
);
|
);
|
||||||
mockSearchService.query.andReturn(mockPromise);
|
mockSearchService.query.andReturn(mockPromise);
|
||||||
|
|
||||||
mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}];
|
mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}];
|
||||||
|
|
||||||
mockSearchResult = jasmine.createSpyObj(
|
mockSearchResult = jasmine.createSpyObj(
|
||||||
"searchResult",
|
'searchResult',
|
||||||
[ "" ]
|
[ '' ]
|
||||||
);
|
);
|
||||||
mockDomainObject = jasmine.createSpyObj(
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
"domainObject",
|
'domainObject',
|
||||||
[ "getModel" ]
|
[ 'getModel' ]
|
||||||
);
|
);
|
||||||
mockSearchResult.object = mockDomainObject;
|
mockSearchResult.object = mockDomainObject;
|
||||||
mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'});
|
mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'});
|
||||||
@ -90,20 +88,44 @@ define(
|
|||||||
controller.search();
|
controller.search();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends queries to the search service", function () {
|
it('has a default number of results per page', function () {
|
||||||
expect(mockSearchService.query).toHaveBeenCalled();
|
expect(controller.RESULTS_PER_PAGE).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("populates the results with results from the search service", function () {
|
it('sends queries to the search service', function () {
|
||||||
|
expect(mockSearchService.query).toHaveBeenCalledWith(
|
||||||
|
'test input',
|
||||||
|
controller.RESULTS_PER_PAGE,
|
||||||
|
jasmine.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filter query function', function () {
|
||||||
|
it('returns true when all types allowed', function () {
|
||||||
|
mockScope.ngModel.checkAll = true;
|
||||||
|
controller.onFilterChange();
|
||||||
|
var filterFn = mockSearchService.query.mostRecentCall.args[2];
|
||||||
|
expect(filterFn('askbfa')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true only for matching checked types', function () {
|
||||||
|
mockScope.ngModel.checkAll = false;
|
||||||
|
controller.onFilterChange();
|
||||||
|
var filterFn = mockSearchService.query.mostRecentCall.args[2];
|
||||||
|
expect(filterFn({type: 'mock.type'})).toBe(true);
|
||||||
|
expect(filterFn({type: 'other.type'})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates the results with results from the search service', function () {
|
||||||
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
|
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
|
||||||
mockPromise.then.mostRecentCall.args[0]({hits: []});
|
mockPromise.then.mostRecentCall.args[0]({hits: ['a']});
|
||||||
|
|
||||||
expect(mockScope.results).toBeDefined();
|
expect(mockScope.results.length).toBe(1);
|
||||||
|
expect(mockScope.results).toContain('a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is loading until the service's promise fufills", function () {
|
it('is loading until the service\'s promise fufills', function () {
|
||||||
// Send query
|
|
||||||
controller.search();
|
|
||||||
expect(mockScope.loading).toBeTruthy();
|
expect(mockScope.loading).toBeTruthy();
|
||||||
|
|
||||||
// Then resolve the promises
|
// Then resolve the promises
|
||||||
@ -111,84 +133,40 @@ define(
|
|||||||
expect(mockScope.loading).toBeFalsy();
|
expect(mockScope.loading).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects when there are more results', function () {
|
||||||
it("displays only some results when there are many", function () {
|
|
||||||
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
|
|
||||||
mockPromise.then.mostRecentCall.args[0]({hits: bigArray(100)});
|
|
||||||
|
|
||||||
expect(mockScope.results).toBeDefined();
|
|
||||||
expect(mockScope.results.length).toBeLessThan(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects when there are more results", function () {
|
|
||||||
mockScope.ngModel.checkAll = false;
|
|
||||||
|
|
||||||
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
|
|
||||||
mockPromise.then.mostRecentCall.args[0]({
|
mockPromise.then.mostRecentCall.args[0]({
|
||||||
hits: bigArray(INITIAL_LOAD_NUMBER + 5),
|
hits: bigArray(controller.RESULTS_PER_PAGE),
|
||||||
total: INITIAL_LOAD_NUMBER + 5
|
total: controller.RESULTS_PER_PAGE + 5
|
||||||
});
|
|
||||||
// bigArray gives searchResults of type 'mock.type'
|
|
||||||
mockScope.ngModel.checked['mock.type'] = false;
|
|
||||||
mockScope.ngModel.checked['mock.type.2'] = true;
|
|
||||||
|
|
||||||
expect(controller.areMore()).toBeFalsy();
|
|
||||||
|
|
||||||
mockScope.ngModel.checked['mock.type'] = true;
|
|
||||||
|
|
||||||
expect(controller.areMore()).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can load more results", function () {
|
expect(mockScope.results.length).toBe(controller.RESULTS_PER_PAGE);
|
||||||
var oldSize;
|
|
||||||
|
|
||||||
expect(mockPromise.then).toHaveBeenCalled();
|
|
||||||
mockPromise.then.mostRecentCall.args[0]({
|
|
||||||
hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1),
|
|
||||||
total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1
|
|
||||||
});
|
|
||||||
// These hits and total lengths are the case where the controller
|
|
||||||
// DOES NOT have to re-search to load more results
|
|
||||||
oldSize = mockScope.results.length;
|
|
||||||
|
|
||||||
expect(controller.areMore()).toBeTruthy();
|
expect(controller.areMore()).toBeTruthy();
|
||||||
|
|
||||||
controller.loadMore();
|
controller.loadMore();
|
||||||
expect(mockScope.results.length).toBeGreaterThan(oldSize);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can re-search to load more results", function () {
|
expect(mockSearchService.query).toHaveBeenCalledWith(
|
||||||
var oldSize,
|
'test input',
|
||||||
oldCallCount;
|
controller.RESULTS_PER_PAGE * 2,
|
||||||
|
jasmine.any(Function)
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockPromise.then).toHaveBeenCalled();
|
|
||||||
mockPromise.then.mostRecentCall.args[0]({
|
mockPromise.then.mostRecentCall.args[0]({
|
||||||
hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT - 1),
|
hits: bigArray(controller.RESULTS_PER_PAGE + 5),
|
||||||
total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1
|
total: controller.RESULTS_PER_PAGE + 5
|
||||||
});
|
|
||||||
// These hits and total lengths are the case where the controller
|
|
||||||
// DOES have to re-search to load more results
|
|
||||||
oldSize = mockScope.results.length;
|
|
||||||
oldCallCount = mockPromise.then.callCount;
|
|
||||||
expect(controller.areMore()).toBeTruthy();
|
|
||||||
|
|
||||||
controller.loadMore();
|
|
||||||
expect(mockPromise.then).toHaveBeenCalled();
|
|
||||||
// Make sure that a NEW call to search has been made
|
|
||||||
expect(oldCallCount).toBeLessThan(mockPromise.then.callCount);
|
|
||||||
mockPromise.then.mostRecentCall.args[0]({
|
|
||||||
hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1),
|
|
||||||
total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1
|
|
||||||
});
|
|
||||||
expect(mockScope.results.length).toBeGreaterThan(oldSize);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets the ngModel.search flag", function () {
|
expect(mockScope.results.length)
|
||||||
|
.toBe(controller.RESULTS_PER_PAGE + 5);
|
||||||
|
|
||||||
|
expect(controller.areMore()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the ngModel.search flag', function () {
|
||||||
// Flag should be true with nonempty input
|
// Flag should be true with nonempty input
|
||||||
expect(mockScope.ngModel.search).toEqual(true);
|
expect(mockScope.ngModel.search).toEqual(true);
|
||||||
|
|
||||||
// Flag should be flase with empty input
|
// Flag should be flase with empty input
|
||||||
mockScope.ngModel.input = "";
|
mockScope.ngModel.input = '';
|
||||||
controller.search();
|
controller.search();
|
||||||
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
|
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
|
||||||
expect(mockScope.ngModel.search).toEqual(false);
|
expect(mockScope.ngModel.search).toEqual(false);
|
||||||
@ -200,9 +178,8 @@ define(
|
|||||||
expect(mockScope.ngModel.search).toEqual(false);
|
expect(mockScope.ngModel.search).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has a default results list to filter from", function () {
|
it('attaches a filter function to scope', function () {
|
||||||
expect(mockScope.ngModel.filter()).toBeDefined();
|
expect(mockScope.ngModel.filter).toEqual(jasmine.any(Function));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
);
|
|
@ -19,275 +19,321 @@
|
|||||||
* this source code distribution or the Licensing information page available
|
* this source code distribution or the Licensing information page available
|
||||||
* at runtime from the About dialog for additional information.
|
* at runtime from the About dialog for additional information.
|
||||||
*****************************************************************************/
|
*****************************************************************************/
|
||||||
/*global define,describe,it,expect,beforeEach,jasmine*/
|
/*global define,describe,it,expect,beforeEach,jasmine,Promise,spyOn,waitsFor,
|
||||||
|
runs*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SearchSpec. Created by shale on 07/31/2015.
|
* SearchSpec. Created by shale on 07/31/2015.
|
||||||
*/
|
*/
|
||||||
define(
|
define([
|
||||||
["../../src/services/GenericSearchProvider"],
|
"../../src/services/GenericSearchProvider"
|
||||||
function (GenericSearchProvider) {
|
], function (
|
||||||
|
GenericSearchProvider
|
||||||
|
) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
describe("The generic search provider ", function () {
|
describe('GenericSearchProvider', function () {
|
||||||
var mockQ,
|
var $q,
|
||||||
mockLog,
|
$log,
|
||||||
mockThrottle,
|
modelService,
|
||||||
mockDeferred,
|
models,
|
||||||
mockObjectService,
|
workerService,
|
||||||
mockObjectPromise,
|
worker,
|
||||||
mockChainedPromise,
|
topic,
|
||||||
mockDomainObjects,
|
mutationTopic,
|
||||||
mockCapability,
|
ROOTS,
|
||||||
mockCapabilityPromise,
|
provider;
|
||||||
mockWorkerService,
|
|
||||||
mockWorker,
|
|
||||||
mockTopic,
|
|
||||||
mockMutationTopic,
|
|
||||||
mockRoots = ['root1', 'root2'],
|
|
||||||
mockThrottledFn,
|
|
||||||
throttledCallCount,
|
|
||||||
provider,
|
|
||||||
mockProviderResults;
|
|
||||||
|
|
||||||
function resolveObjectPromises() {
|
|
||||||
var i;
|
|
||||||
for (i = 0; i < mockObjectPromise.then.calls.length; i += 1) {
|
|
||||||
mockChainedPromise.then.calls[i].args[0](
|
|
||||||
mockObjectPromise.then.calls[i]
|
|
||||||
.args[0](mockDomainObjects)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveThrottledFn() {
|
|
||||||
if (mockThrottledFn.calls.length > throttledCallCount) {
|
|
||||||
mockThrottle.mostRecentCall.args[0]();
|
|
||||||
throttledCallCount = mockThrottledFn.calls.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAsyncTasks() {
|
|
||||||
resolveThrottledFn();
|
|
||||||
resolveObjectPromises();
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
mockQ = jasmine.createSpyObj(
|
$q = jasmine.createSpyObj(
|
||||||
"$q",
|
'$q',
|
||||||
[ "defer" ]
|
['defer']
|
||||||
);
|
);
|
||||||
mockLog = jasmine.createSpyObj(
|
$log = jasmine.createSpyObj(
|
||||||
"$log",
|
'$log',
|
||||||
[ "error", "warn", "info", "debug" ]
|
['warn']
|
||||||
);
|
);
|
||||||
mockDeferred = jasmine.createSpyObj(
|
models = {};
|
||||||
"deferred",
|
modelService = jasmine.createSpyObj(
|
||||||
[ "resolve", "reject"]
|
'modelService',
|
||||||
|
['getModels']
|
||||||
);
|
);
|
||||||
mockDeferred.promise = "mock promise";
|
modelService.getModels.andReturn(Promise.resolve(models));
|
||||||
mockQ.defer.andReturn(mockDeferred);
|
workerService = jasmine.createSpyObj(
|
||||||
|
'workerService',
|
||||||
mockThrottle = jasmine.createSpy("throttle");
|
['run']
|
||||||
mockThrottledFn = jasmine.createSpy("throttledFn");
|
|
||||||
throttledCallCount = 0;
|
|
||||||
|
|
||||||
mockObjectService = jasmine.createSpyObj(
|
|
||||||
"objectService",
|
|
||||||
[ "getObjects" ]
|
|
||||||
);
|
);
|
||||||
mockObjectPromise = jasmine.createSpyObj(
|
worker = jasmine.createSpyObj(
|
||||||
"promise",
|
'worker',
|
||||||
[ "then", "catch" ]
|
|
||||||
);
|
|
||||||
mockChainedPromise = jasmine.createSpyObj(
|
|
||||||
"chainedPromise",
|
|
||||||
[ "then" ]
|
|
||||||
);
|
|
||||||
mockObjectService.getObjects.andReturn(mockObjectPromise);
|
|
||||||
|
|
||||||
mockTopic = jasmine.createSpy('topic');
|
|
||||||
|
|
||||||
mockWorkerService = jasmine.createSpyObj(
|
|
||||||
"workerService",
|
|
||||||
[ "run" ]
|
|
||||||
);
|
|
||||||
mockWorker = jasmine.createSpyObj(
|
|
||||||
"worker",
|
|
||||||
[ "postMessage" ]
|
|
||||||
);
|
|
||||||
mockWorkerService.run.andReturn(mockWorker);
|
|
||||||
|
|
||||||
mockCapabilityPromise = jasmine.createSpyObj(
|
|
||||||
"promise",
|
|
||||||
[ "then", "catch" ]
|
|
||||||
);
|
|
||||||
|
|
||||||
mockDomainObjects = {};
|
|
||||||
['a', 'root1', 'root2'].forEach(function (id) {
|
|
||||||
mockDomainObjects[id] = (
|
|
||||||
jasmine.createSpyObj(
|
|
||||||
"domainObject",
|
|
||||||
[
|
[
|
||||||
"getId",
|
'postMessage',
|
||||||
"getModel",
|
'addEventListener'
|
||||||
"hasCapability",
|
|
||||||
"getCapability",
|
|
||||||
"useCapability"
|
|
||||||
]
|
]
|
||||||
)
|
|
||||||
);
|
);
|
||||||
mockDomainObjects[id].getId.andReturn(id);
|
workerService.run.andReturn(worker);
|
||||||
mockDomainObjects[id].getCapability.andReturn(mockCapability);
|
topic = jasmine.createSpy('topic');
|
||||||
mockDomainObjects[id].useCapability.andReturn(mockCapabilityPromise);
|
mutationTopic = jasmine.createSpyObj(
|
||||||
mockDomainObjects[id].getModel.andReturn({});
|
|
||||||
});
|
|
||||||
|
|
||||||
mockCapability = jasmine.createSpyObj(
|
|
||||||
"capability",
|
|
||||||
[ "invoke", "listen" ]
|
|
||||||
);
|
|
||||||
mockCapability.invoke.andReturn(mockCapabilityPromise);
|
|
||||||
mockDomainObjects.a.getCapability.andReturn(mockCapability);
|
|
||||||
mockMutationTopic = jasmine.createSpyObj(
|
|
||||||
'mutationTopic',
|
'mutationTopic',
|
||||||
['listen']
|
['listen']
|
||||||
);
|
);
|
||||||
mockTopic.andCallFake(function (key) {
|
topic.andReturn(mutationTopic);
|
||||||
return key === 'mutation' && mockMutationTopic;
|
ROOTS = [
|
||||||
});
|
'mine'
|
||||||
mockThrottle.andReturn(mockThrottledFn);
|
];
|
||||||
mockObjectPromise.then.andReturn(mockChainedPromise);
|
|
||||||
|
spyOn(GenericSearchProvider.prototype, 'scheduleForIndexing');
|
||||||
|
|
||||||
provider = new GenericSearchProvider(
|
provider = new GenericSearchProvider(
|
||||||
mockQ,
|
$q,
|
||||||
mockLog,
|
$log,
|
||||||
mockThrottle,
|
modelService,
|
||||||
mockObjectService,
|
workerService,
|
||||||
mockWorkerService,
|
topic,
|
||||||
mockTopic,
|
ROOTS
|
||||||
mockRoots
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("indexes tree on initialization", function () {
|
it('listens for general mutation', function () {
|
||||||
var i;
|
expect(topic).toHaveBeenCalledWith('mutation');
|
||||||
|
expect(mutationTopic.listen)
|
||||||
resolveThrottledFn();
|
|
||||||
|
|
||||||
expect(mockObjectService.getObjects).toHaveBeenCalled();
|
|
||||||
expect(mockObjectPromise.then).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Call through the root-getting part
|
|
||||||
resolveObjectPromises();
|
|
||||||
|
|
||||||
mockRoots.forEach(function (id) {
|
|
||||||
expect(mockWorker.postMessage).toHaveBeenCalledWith({
|
|
||||||
request: 'index',
|
|
||||||
model: mockDomainObjects[id].getModel(),
|
|
||||||
id: id
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("indexes members of composition", function () {
|
|
||||||
mockDomainObjects.root1.getModel.andReturn({
|
|
||||||
composition: ['a']
|
|
||||||
});
|
|
||||||
|
|
||||||
resolveAsyncTasks();
|
|
||||||
resolveAsyncTasks();
|
|
||||||
|
|
||||||
expect(mockWorker.postMessage).toHaveBeenCalledWith({
|
|
||||||
request: 'index',
|
|
||||||
model: mockDomainObjects.a.getModel(),
|
|
||||||
id: 'a'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("listens for changes to mutation", function () {
|
|
||||||
expect(mockMutationTopic.listen)
|
|
||||||
.toHaveBeenCalledWith(jasmine.any(Function));
|
.toHaveBeenCalledWith(jasmine.any(Function));
|
||||||
mockMutationTopic.listen.mostRecentCall
|
});
|
||||||
.args[0](mockDomainObjects.a);
|
|
||||||
|
|
||||||
resolveAsyncTasks();
|
it('reschedules indexing when mutation occurs', function () {
|
||||||
|
var mockDomainObject =
|
||||||
|
jasmine.createSpyObj('domainObj', ['getId']);
|
||||||
|
mockDomainObject.getId.andReturn("some-id");
|
||||||
|
mutationTopic.listen.mostRecentCall.args[0](mockDomainObject);
|
||||||
|
expect(provider.scheduleForIndexing).toHaveBeenCalledWith('some-id');
|
||||||
|
});
|
||||||
|
|
||||||
expect(mockWorker.postMessage).toHaveBeenCalledWith({
|
it('starts indexing roots', function () {
|
||||||
|
expect(provider.scheduleForIndexing).toHaveBeenCalledWith('mine');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs a worker', function () {
|
||||||
|
expect(workerService.run)
|
||||||
|
.toHaveBeenCalledWith('genericSearchWorker');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens for messages from worker', function () {
|
||||||
|
expect(worker.addEventListener)
|
||||||
|
.toHaveBeenCalledWith('message', jasmine.any(Function));
|
||||||
|
spyOn(provider, 'onWorkerMessage');
|
||||||
|
worker.addEventListener.mostRecentCall.args[1]('mymessage');
|
||||||
|
expect(provider.onWorkerMessage).toHaveBeenCalledWith('mymessage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a maximum number of concurrent requests', function () {
|
||||||
|
expect(provider.MAX_CONCURRENT_REQUESTS).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scheduleForIndexing', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
provider.scheduleForIndexing.andCallThrough();
|
||||||
|
spyOn(provider, 'keepIndexing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks ids to index', function () {
|
||||||
|
expect(provider.indexedIds.a).not.toBeDefined();
|
||||||
|
expect(provider.pendingIndex.a).not.toBeDefined();
|
||||||
|
expect(provider.idsToIndex).not.toContain('a');
|
||||||
|
provider.scheduleForIndexing('a');
|
||||||
|
expect(provider.indexedIds.a).toBeDefined();
|
||||||
|
expect(provider.pendingIndex.a).toBeDefined();
|
||||||
|
expect(provider.idsToIndex).toContain('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls keep indexing', function () {
|
||||||
|
provider.scheduleForIndexing('a');
|
||||||
|
expect(provider.keepIndexing).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keepIndexing', function () {
|
||||||
|
it('calls beginIndexRequest until at maximum', function () {
|
||||||
|
spyOn(provider, 'beginIndexRequest').andCallThrough();
|
||||||
|
provider.pendingRequests = 9;
|
||||||
|
provider.idsToIndex = ['a', 'b', 'c'];
|
||||||
|
provider.MAX_CONCURRENT_REQUESTS = 10;
|
||||||
|
provider.keepIndexing();
|
||||||
|
expect(provider.beginIndexRequest).toHaveBeenCalled();
|
||||||
|
expect(provider.beginIndexRequest.calls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls beginIndexRequest for all ids to index', function () {
|
||||||
|
spyOn(provider, 'beginIndexRequest').andCallThrough();
|
||||||
|
provider.pendingRequests = 0;
|
||||||
|
provider.idsToIndex = ['a', 'b', 'c'];
|
||||||
|
provider.MAX_CONCURRENT_REQUESTS = 10;
|
||||||
|
provider.keepIndexing();
|
||||||
|
expect(provider.beginIndexRequest).toHaveBeenCalled();
|
||||||
|
expect(provider.beginIndexRequest.calls.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not index when at capacity', function () {
|
||||||
|
spyOn(provider, 'beginIndexRequest');
|
||||||
|
provider.pendingRequests = 10;
|
||||||
|
provider.idsToIndex.push('a');
|
||||||
|
provider.MAX_CONCURRENT_REQUESTS = 10;
|
||||||
|
provider.keepIndexing();
|
||||||
|
expect(provider.beginIndexRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not index when no ids to index', function () {
|
||||||
|
spyOn(provider, 'beginIndexRequest');
|
||||||
|
provider.pendingRequests = 0;
|
||||||
|
provider.MAX_CONCURRENT_REQUESTS = 10;
|
||||||
|
provider.keepIndexing();
|
||||||
|
expect(provider.beginIndexRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('index', function () {
|
||||||
|
it('sends index message to worker', function () {
|
||||||
|
var id = 'anId',
|
||||||
|
model = {};
|
||||||
|
|
||||||
|
provider.index(id, model);
|
||||||
|
expect(worker.postMessage).toHaveBeenCalledWith({
|
||||||
request: 'index',
|
request: 'index',
|
||||||
model: mockDomainObjects.a.getModel(),
|
id: id,
|
||||||
id: mockDomainObjects.a.getId()
|
model: model
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends search queries to the worker", function () {
|
it('schedules composed ids for indexing', function () {
|
||||||
var timestamp = Date.now();
|
var id = 'anId',
|
||||||
provider.query(' test "query" ', timestamp, 1, 2);
|
model = {composition: ['abc', 'def']};
|
||||||
expect(mockWorker.postMessage).toHaveBeenCalledWith({
|
|
||||||
request: "search",
|
provider.index(id, model);
|
||||||
input: ' test "query" ',
|
expect(provider.scheduleForIndexing)
|
||||||
timestamp: timestamp,
|
.toHaveBeenCalledWith('abc');
|
||||||
maxNumber: 1,
|
expect(provider.scheduleForIndexing)
|
||||||
timeout: 2
|
.toHaveBeenCalledWith('def');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives an empty result for an empty query", function () {
|
describe('beginIndexRequest', function () {
|
||||||
var timestamp = Date.now(),
|
|
||||||
queryOutput;
|
|
||||||
|
|
||||||
queryOutput = provider.query('', timestamp, 1, 2);
|
beforeEach(function () {
|
||||||
expect(queryOutput.hits).toEqual([]);
|
provider.pendingRequests = 0;
|
||||||
expect(queryOutput.total).toEqual(0);
|
provider.pendingIds = {'abc': true};
|
||||||
|
provider.idsToIndex = ['abc'];
|
||||||
queryOutput = provider.query();
|
models.abc = {};
|
||||||
expect(queryOutput.hits).toEqual([]);
|
spyOn(provider, 'index');
|
||||||
expect(queryOutput.total).toEqual(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles responses from the worker", function () {
|
it('removes items from queue', function () {
|
||||||
var timestamp = Date.now(),
|
provider.beginIndexRequest();
|
||||||
event = {
|
expect(provider.idsToIndex.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 objects', function () {
|
||||||
|
provider.beginIndexRequest();
|
||||||
|
waitsFor(function () {
|
||||||
|
return provider.pendingRequests === 0;
|
||||||
|
});
|
||||||
|
runs(function () {
|
||||||
|
expect(provider.index)
|
||||||
|
.toHaveBeenCalledWith('abc', models.abc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('can dispatch searches to worker', function () {
|
||||||
|
spyOn(provider, 'makeQueryId').andReturn(428);
|
||||||
|
expect(provider.dispatchSearch('searchTerm', 100))
|
||||||
|
.toBe(428);
|
||||||
|
|
||||||
|
expect(worker.postMessage).toHaveBeenCalledWith({
|
||||||
|
request: 'search',
|
||||||
|
input: 'searchTerm',
|
||||||
|
maxResults: 100,
|
||||||
|
queryId: 428
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can generate queryIds', function () {
|
||||||
|
expect(provider.makeQueryId()).toEqual(jasmine.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can query for terms', function () {
|
||||||
|
var deferred = {promise: {}};
|
||||||
|
spyOn(provider, 'dispatchSearch').andReturn(303);
|
||||||
|
$q.defer.andReturn(deferred);
|
||||||
|
|
||||||
|
expect(provider.query('someTerm', 100)).toBe(deferred.promise);
|
||||||
|
expect(provider.pendingQueries[303]).toBe(deferred);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onWorkerMessage', function () {
|
||||||
|
var pendingQuery;
|
||||||
|
beforeEach(function () {
|
||||||
|
pendingQuery = jasmine.createSpyObj(
|
||||||
|
'pendingQuery',
|
||||||
|
['resolve']
|
||||||
|
);
|
||||||
|
provider.pendingQueries[143] = pendingQuery;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves pending searches', function () {
|
||||||
|
provider.onWorkerMessage({
|
||||||
data: {
|
data: {
|
||||||
request: "search",
|
request: 'search',
|
||||||
results: {
|
|
||||||
1: 1,
|
|
||||||
2: 2
|
|
||||||
},
|
|
||||||
total: 2,
|
total: 2,
|
||||||
timedOut: false,
|
results: [
|
||||||
timestamp: timestamp
|
{
|
||||||
|
item: {
|
||||||
|
id: 'abc',
|
||||||
|
model: {id: 'abc'}
|
||||||
|
},
|
||||||
|
matchCount: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item: {
|
||||||
|
id: 'def',
|
||||||
|
model: {id: 'def'}
|
||||||
|
},
|
||||||
|
matchCount: 2
|
||||||
}
|
}
|
||||||
};
|
],
|
||||||
|
queryId: 143
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -4,12 +4,12 @@
|
|||||||
* Administration. All rights reserved.
|
* Administration. All rights reserved.
|
||||||
*
|
*
|
||||||
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
|
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
|
||||||
* "License"); you may not use this file except in compliance with the License.
|
* 'License'); you may not use this file except in compliance with the License.
|
||||||
* You may obtain a copy of the License at
|
* You may obtain a copy of the License at
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||||
*
|
*
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
* distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
|
||||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
* License for the specific language governing permissions and limitations
|
* License for the specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
@ -19,114 +19,205 @@
|
|||||||
* this source code distribution or the Licensing information page available
|
* this source code distribution or the Licensing information page available
|
||||||
* at runtime from the About dialog for additional information.
|
* at runtime from the About dialog for additional information.
|
||||||
*****************************************************************************/
|
*****************************************************************************/
|
||||||
/*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker,require*/
|
/*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker,
|
||||||
|
require,afterEach*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SearchSpec. Created by shale on 07/31/2015.
|
* SearchSpec. Created by shale on 07/31/2015.
|
||||||
*/
|
*/
|
||||||
define(
|
define([
|
||||||
[],
|
|
||||||
function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
describe("The generic search worker ", function () {
|
], function (
|
||||||
|
|
||||||
|
) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe('GenericSearchWorker', function () {
|
||||||
// If this test fails, make sure this path is correct
|
// If this test fails, make sure this path is correct
|
||||||
var worker = new Worker(require.toUrl('platform/search/src/services/GenericSearchWorker.js')),
|
var worker,
|
||||||
numObjects = 5;
|
objectX,
|
||||||
|
objectY,
|
||||||
|
objectZ,
|
||||||
|
itemsToIndex,
|
||||||
|
onMessage,
|
||||||
|
data,
|
||||||
|
waitForResult;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
var i;
|
worker = new Worker(
|
||||||
for (i = 0; i < numObjects; i += 1) {
|
require.toUrl('platform/search/src/services/GenericSearchWorker.js')
|
||||||
worker.postMessage(
|
|
||||||
{
|
|
||||||
request: "index",
|
|
||||||
id: i,
|
|
||||||
model: {
|
|
||||||
name: "object " + i,
|
|
||||||
id: i,
|
|
||||||
type: "something"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("searches can reach all objects", function () {
|
objectX = {
|
||||||
var flag = false,
|
id: 'x',
|
||||||
workerOutput,
|
model: {name: 'object xx'}
|
||||||
resultsLength = 0;
|
|
||||||
|
|
||||||
// Search something that should return all objects
|
|
||||||
runs(function () {
|
|
||||||
worker.postMessage(
|
|
||||||
{
|
|
||||||
request: "search",
|
|
||||||
input: "object",
|
|
||||||
maxNumber: 100,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
timeout: 1000
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
worker.onmessage = function (event) {
|
|
||||||
var id;
|
|
||||||
|
|
||||||
workerOutput = event.data;
|
|
||||||
for (id in workerOutput.results) {
|
|
||||||
resultsLength += 1;
|
|
||||||
}
|
|
||||||
flag = true;
|
|
||||||
};
|
};
|
||||||
|
objectY = {
|
||||||
waitsFor(function () {
|
id: 'y',
|
||||||
return flag;
|
model: {name: 'object yy'}
|
||||||
}, "The worker should be searching", 1000);
|
|
||||||
|
|
||||||
runs(function () {
|
|
||||||
expect(workerOutput).toBeDefined();
|
|
||||||
expect(resultsLength).toEqual(numObjects);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("searches return only matches", function () {
|
|
||||||
var flag = false,
|
|
||||||
workerOutput,
|
|
||||||
resultsLength = 0;
|
|
||||||
|
|
||||||
// Search something that should return 1 object
|
|
||||||
runs(function () {
|
|
||||||
worker.postMessage(
|
|
||||||
{
|
|
||||||
request: "search",
|
|
||||||
input: "2",
|
|
||||||
maxNumber: 100,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
timeout: 1000
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
worker.onmessage = function (event) {
|
|
||||||
var id;
|
|
||||||
|
|
||||||
workerOutput = event.data;
|
|
||||||
for (id in workerOutput.results) {
|
|
||||||
resultsLength += 1;
|
|
||||||
}
|
|
||||||
flag = true;
|
|
||||||
};
|
};
|
||||||
|
objectZ = {
|
||||||
|
id: 'z',
|
||||||
|
model: {name: 'object zz'}
|
||||||
|
};
|
||||||
|
itemsToIndex = [
|
||||||
|
objectX,
|
||||||
|
objectY,
|
||||||
|
objectZ
|
||||||
|
];
|
||||||
|
|
||||||
|
itemsToIndex.forEach(function (item) {
|
||||||
|
worker.postMessage({
|
||||||
|
request: 'index',
|
||||||
|
id: item.id,
|
||||||
|
model: item.model
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage = jasmine.createSpy('onMessage');
|
||||||
|
worker.addEventListener('message', onMessage);
|
||||||
|
|
||||||
|
waitForResult = function () {
|
||||||
waitsFor(function () {
|
waitsFor(function () {
|
||||||
return flag;
|
if (onMessage.calls.length > 0) {
|
||||||
}, "The worker should be searching", 1000);
|
data = onMessage.calls[0].args[0].data;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
worker.terminate();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns search results for partial term matches', function () {
|
||||||
|
|
||||||
|
worker.postMessage({
|
||||||
|
request: 'search',
|
||||||
|
input: 'obj',
|
||||||
|
maxResults: 100,
|
||||||
|
queryId: 123
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForResult();
|
||||||
|
|
||||||
runs(function () {
|
runs(function () {
|
||||||
expect(workerOutput).toBeDefined();
|
expect(onMessage).toHaveBeenCalled();
|
||||||
expect(resultsLength).toEqual(1);
|
|
||||||
expect(workerOutput.results[2]).toBeDefined();
|
expect(data.request).toBe('search');
|
||||||
|
expect(data.total).toBe(3);
|
||||||
|
expect(data.queryId).toBe(123);
|
||||||
|
expect(data.results.length).toBe(3);
|
||||||
|
expect(data.results[0].item.id).toBe('x');
|
||||||
|
expect(data.results[0].item.model).toEqual(objectX.model);
|
||||||
|
expect(data.results[0].matchCount).toBe(1);
|
||||||
|
expect(data.results[1].item.id).toBe('y');
|
||||||
|
expect(data.results[1].item.model).toEqual(objectY.model);
|
||||||
|
expect(data.results[1].matchCount).toBe(1);
|
||||||
|
expect(data.results[2].item.id).toBe('z');
|
||||||
|
expect(data.results[2].item.model).toEqual(objectZ.model);
|
||||||
|
expect(data.results[2].matchCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scores exact term matches higher', function () {
|
||||||
|
worker.postMessage({
|
||||||
|
request: 'search',
|
||||||
|
input: 'object',
|
||||||
|
maxResults: 100,
|
||||||
|
queryId: 234
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForResult();
|
||||||
|
|
||||||
|
runs(function () {
|
||||||
|
expect(data.queryId).toBe(234);
|
||||||
|
expect(data.results.length).toBe(3);
|
||||||
|
expect(data.results[0].item.id).toBe('x');
|
||||||
|
expect(data.results[0].matchCount).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can find partial term matches', function () {
|
||||||
|
worker.postMessage({
|
||||||
|
request: 'search',
|
||||||
|
input: 'x',
|
||||||
|
maxResults: 100,
|
||||||
|
queryId: 345
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForResult();
|
||||||
|
|
||||||
|
runs(function () {
|
||||||
|
expect(data.queryId).toBe(345);
|
||||||
|
expect(data.results.length).toBe(1);
|
||||||
|
expect(data.results[0].item.id).toBe('x');
|
||||||
|
expect(data.results[0].matchCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches individual terms', function () {
|
||||||
|
worker.postMessage({
|
||||||
|
request: 'search',
|
||||||
|
input: 'x y z',
|
||||||
|
maxResults: 100,
|
||||||
|
queryId: 456
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForResult();
|
||||||
|
|
||||||
|
runs(function () {
|
||||||
|
expect(data.queryId).toBe(456);
|
||||||
|
expect(data.results.length).toBe(3);
|
||||||
|
expect(data.results[0].item.id).toBe('x');
|
||||||
|
expect(data.results[0].matchCount).toBe(1);
|
||||||
|
expect(data.results[1].item.id).toBe('y');
|
||||||
|
expect(data.results[1].matchCount).toBe(1);
|
||||||
|
expect(data.results[2].item.id).toBe('z');
|
||||||
|
expect(data.results[1].matchCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scores exact matches highest', function () {
|
||||||
|
worker.postMessage({
|
||||||
|
request: 'search',
|
||||||
|
input: 'object xx',
|
||||||
|
maxResults: 100,
|
||||||
|
queryId: 567
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForResult();
|
||||||
|
|
||||||
|
runs(function () {
|
||||||
|
expect(data.queryId).toBe(567);
|
||||||
|
expect(data.results.length).toBe(3);
|
||||||
|
expect(data.results[0].item.id).toBe('x');
|
||||||
|
expect(data.results[0].matchCount).toBe(103);
|
||||||
|
expect(data.results[1].matchCount).toBe(1.5);
|
||||||
|
expect(data.results[2].matchCount).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scores multiple term match above single match', function () {
|
||||||
|
worker.postMessage({
|
||||||
|
request: 'search',
|
||||||
|
input: 'obj x',
|
||||||
|
maxResults: 100,
|
||||||
|
queryId: 678
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForResult();
|
||||||
|
|
||||||
|
runs(function () {
|
||||||
|
expect(data.queryId).toBe(678);
|
||||||
|
expect(data.results.length).toBe(3);
|
||||||
|
expect(data.results[0].item.id).toBe('x');
|
||||||
|
expect(data.results[0].matchCount).toBe(2);
|
||||||
|
expect(data.results[1].matchCount).toBe(1);
|
||||||
|
expect(data.results[2].matchCount).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
);
|
|
@ -19,83 +19,244 @@
|
|||||||
* this source code distribution or the Licensing information page available
|
* this source code distribution or the Licensing information page available
|
||||||
* at runtime from the About dialog for additional information.
|
* at runtime from the About dialog for additional information.
|
||||||
*****************************************************************************/
|
*****************************************************************************/
|
||||||
/*global define,describe,it,expect,beforeEach,jasmine*/
|
/*global define,describe,it,expect,beforeEach,jasmine,Promise,waitsFor,spyOn*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SearchSpec. Created by shale on 07/31/2015.
|
* SearchSpec. Created by shale on 07/31/2015.
|
||||||
*/
|
*/
|
||||||
define(
|
define([
|
||||||
["../../src/services/SearchAggregator"],
|
"../../src/services/SearchAggregator"
|
||||||
function (SearchAggregator) {
|
], function (SearchAggregator) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
describe("The search aggregator ", function () {
|
describe("SearchAggregator", function () {
|
||||||
var mockQ,
|
var $q,
|
||||||
mockPromise,
|
objectService,
|
||||||
mockProviders = [],
|
providers,
|
||||||
aggregator,
|
aggregator;
|
||||||
mockProviderResults = [],
|
|
||||||
mockAggregatorResults,
|
|
||||||
i;
|
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
mockQ = jasmine.createSpyObj(
|
$q = jasmine.createSpyObj(
|
||||||
"$q",
|
'$q',
|
||||||
[ "all" ]
|
['all']
|
||||||
);
|
);
|
||||||
mockPromise = jasmine.createSpyObj(
|
$q.all.andReturn(Promise.resolve([]));
|
||||||
"promise",
|
objectService = jasmine.createSpyObj(
|
||||||
[ "then" ]
|
'objectService',
|
||||||
|
['getObjects']
|
||||||
);
|
);
|
||||||
for (i = 0; i < 3; i += 1) {
|
providers = [];
|
||||||
mockProviders.push(
|
aggregator = new SearchAggregator($q, objectService, providers);
|
||||||
jasmine.createSpyObj(
|
});
|
||||||
"mockProvider" + i,
|
|
||||||
[ "query" ]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
mockProviders[i].query.andReturn(mockPromise);
|
|
||||||
}
|
|
||||||
mockQ.all.andReturn(mockPromise);
|
|
||||||
|
|
||||||
aggregator = new SearchAggregator(mockQ, mockProviders);
|
it("has a fudge factor", function () {
|
||||||
aggregator.query();
|
expect(aggregator.FUDGE_FACTOR).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
for (i = 0; i < mockProviders.length; i += 1) {
|
it("has default max results", function () {
|
||||||
mockProviderResults.push({
|
expect(aggregator.DEFAULT_MAX_RESULTS).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can order model results by score", function () {
|
||||||
|
var modelResults = {
|
||||||
hits: [
|
hits: [
|
||||||
|
{score: 1},
|
||||||
|
{score: 23},
|
||||||
|
{score: 11}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
sorted = aggregator.orderByScore(modelResults);
|
||||||
|
|
||||||
|
expect(sorted.hits).toEqual([
|
||||||
|
{score: 23},
|
||||||
|
{score: 11},
|
||||||
|
{score: 1}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters results without a function', function () {
|
||||||
|
var modelResults = {
|
||||||
|
hits: [
|
||||||
|
{thing: 1},
|
||||||
|
{thing: 2}
|
||||||
|
],
|
||||||
|
total: 2
|
||||||
|
},
|
||||||
|
filtered = aggregator.applyFilter(modelResults);
|
||||||
|
|
||||||
|
expect(filtered.hits).toEqual([
|
||||||
|
{thing: 1},
|
||||||
|
{thing: 2}
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(filtered.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters results with a function', function () {
|
||||||
|
var modelResults = {
|
||||||
|
hits: [
|
||||||
|
{model: {thing: 1}},
|
||||||
|
{model: {thing: 2}},
|
||||||
|
{model: {thing: 3}}
|
||||||
|
],
|
||||||
|
total: 3
|
||||||
|
},
|
||||||
|
filterFunc = function (model) {
|
||||||
|
return model.thing < 2;
|
||||||
|
},
|
||||||
|
filtered = aggregator.applyFilter(modelResults, filterFunc);
|
||||||
|
|
||||||
|
expect(filtered.hits).toEqual([
|
||||||
|
{model: {thing: 1}}
|
||||||
|
]);
|
||||||
|
expect(filtered.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can remove duplicates', function () {
|
||||||
|
var modelResults = {
|
||||||
|
hits: [
|
||||||
|
{id: 15},
|
||||||
|
{id: 23},
|
||||||
|
{id: 14},
|
||||||
|
{id: 23}
|
||||||
|
],
|
||||||
|
total: 4
|
||||||
|
},
|
||||||
|
deduped = aggregator.removeDuplicates(modelResults);
|
||||||
|
|
||||||
|
expect(deduped.hits).toEqual([
|
||||||
|
{id: 15},
|
||||||
|
{id: 23},
|
||||||
|
{id: 14}
|
||||||
|
]);
|
||||||
|
expect(deduped.total).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can convert model results to object results', function () {
|
||||||
|
var modelResults = {
|
||||||
|
hits: [
|
||||||
|
{id: 123, score: 5},
|
||||||
|
{id: 234, score: 1}
|
||||||
|
],
|
||||||
|
total: 2
|
||||||
|
},
|
||||||
|
objects = {
|
||||||
|
123: '123-object-hey',
|
||||||
|
234: '234-object-hello'
|
||||||
|
},
|
||||||
|
promiseChainComplete = false;
|
||||||
|
|
||||||
|
objectService.getObjects.andReturn(Promise.resolve(objects));
|
||||||
|
|
||||||
|
aggregator
|
||||||
|
.asObjectResults(modelResults)
|
||||||
|
.then(function (objectResults) {
|
||||||
|
expect(objectResults).toEqual({
|
||||||
|
hits: [
|
||||||
|
{id: 123, score: 5, object: '123-object-hey'},
|
||||||
|
{id: 234, score: 1, object: '234-object-hello'}
|
||||||
|
],
|
||||||
|
total: 2
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
promiseChainComplete = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
waitsFor(function () {
|
||||||
|
return promiseChainComplete;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can send queries to providers', function () {
|
||||||
|
var provider = jasmine.createSpyObj(
|
||||||
|
'provider',
|
||||||
|
['query']
|
||||||
|
);
|
||||||
|
provider.query.andReturn('i prooomise!');
|
||||||
|
providers.push(provider);
|
||||||
|
|
||||||
|
aggregator.query('find me', 123, 'filter');
|
||||||
|
expect(provider.query)
|
||||||
|
.toHaveBeenCalledWith(
|
||||||
|
'find me',
|
||||||
|
123 * aggregator.FUDGE_FACTOR
|
||||||
|
);
|
||||||
|
expect($q.all).toHaveBeenCalledWith(['i prooomise!']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supplies max results when none is provided', function () {
|
||||||
|
var provider = jasmine.createSpyObj(
|
||||||
|
'provider',
|
||||||
|
['query']
|
||||||
|
);
|
||||||
|
providers.push(provider);
|
||||||
|
aggregator.query('find me');
|
||||||
|
expect(provider.query).toHaveBeenCalledWith(
|
||||||
|
'find me',
|
||||||
|
aggregator.DEFAULT_MAX_RESULTS * aggregator.FUDGE_FACTOR
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can combine responses from multiple providers', function () {
|
||||||
|
var providerResponses = [
|
||||||
{
|
{
|
||||||
id: i,
|
hits: [
|
||||||
score: 42 - i
|
'oneHit',
|
||||||
|
'twoHit'
|
||||||
|
],
|
||||||
|
total: 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: i + 1,
|
hits: [
|
||||||
score: 42 - (2 * i)
|
'redHit',
|
||||||
|
'blueHit',
|
||||||
|
'by',
|
||||||
|
'Pete'
|
||||||
|
],
|
||||||
|
total: 4
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
promiseChainResolved = false;
|
||||||
|
|
||||||
|
$q.all.andReturn(Promise.resolve(providerResponses));
|
||||||
|
spyOn(aggregator, 'orderByScore').andReturn('orderedByScore!');
|
||||||
|
spyOn(aggregator, 'applyFilter').andReturn('filterApplied!');
|
||||||
|
spyOn(aggregator, 'removeDuplicates')
|
||||||
|
.andReturn('duplicatesRemoved!');
|
||||||
|
spyOn(aggregator, 'asObjectResults').andReturn('objectResults');
|
||||||
|
|
||||||
|
aggregator
|
||||||
|
.query('something', 10, 'filter')
|
||||||
|
.then(function (objectResults) {
|
||||||
|
expect(aggregator.orderByScore).toHaveBeenCalledWith({
|
||||||
|
hits: [
|
||||||
|
'oneHit',
|
||||||
|
'twoHit',
|
||||||
|
'redHit',
|
||||||
|
'blueHit',
|
||||||
|
'by',
|
||||||
|
'Pete'
|
||||||
|
],
|
||||||
|
total: 6
|
||||||
});
|
});
|
||||||
}
|
expect(aggregator.applyFilter)
|
||||||
mockAggregatorResults = mockPromise.then.mostRecentCall.args[0](mockProviderResults);
|
.toHaveBeenCalledWith('orderedByScore!', 'filter');
|
||||||
|
expect(aggregator.removeDuplicates)
|
||||||
|
.toHaveBeenCalledWith('filterApplied!');
|
||||||
|
expect(aggregator.asObjectResults)
|
||||||
|
.toHaveBeenCalledWith('duplicatesRemoved!');
|
||||||
|
|
||||||
|
expect(objectResults).toBe('objectResults');
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
promiseChainResolved = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends queries to all providers", function () {
|
waitsFor(function () {
|
||||||
for (i = 0; i < mockProviders.length; i += 1) {
|
return promiseChainResolved;
|
||||||
expect(mockProviders[i].query).toHaveBeenCalled();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters out duplicate objects", function () {
|
|
||||||
expect(mockAggregatorResults.hits.length).toEqual(mockProviders.length + 1);
|
|
||||||
expect(mockAggregatorResults.total).not.toBeLessThan(mockAggregatorResults.hits.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("orders results by score", function () {
|
|
||||||
for (i = 1; i < mockAggregatorResults.hits.length; i += 1) {
|
|
||||||
expect(mockAggregatorResults.hits[i].score)
|
|
||||||
.not.toBeGreaterThan(mockAggregatorResults.hits[i - 1].score);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
@ -44,7 +44,8 @@ require.config({
|
|||||||
|
|
||||||
paths: {
|
paths: {
|
||||||
'es6-promise': 'platform/framework/lib/es6-promise-2.0.0.min',
|
'es6-promise': 'platform/framework/lib/es6-promise-2.0.0.min',
|
||||||
'moment-duration-format': 'warp/clock/lib/moment-duration-format'
|
'moment-duration-format': 'warp/clock/lib/moment-duration-format',
|
||||||
|
'uuid': 'platform/commonUI/browse/lib/uuid'
|
||||||
},
|
},
|
||||||
|
|
||||||
// dynamically load all test files
|
// dynamically load all test files
|
||||||
|