mirror of
https://github.com/nasa/openmct.git
synced 2024-12-19 13:17:53 +00:00
Merge pull request #193 from nasa/search-performance
Search performance
This commit is contained in:
commit
59f094763b
@ -13,7 +13,7 @@
|
||||
"provides": "searchService",
|
||||
"type": "provider",
|
||||
"implementation": "ElasticSearchProvider.js",
|
||||
"depends": [ "$http", "objectService", "ELASTIC_ROOT" ]
|
||||
"depends": [ "$http", "ELASTIC_ROOT" ]
|
||||
}
|
||||
],
|
||||
"constants": [
|
||||
|
@ -24,190 +24,122 @@
|
||||
/**
|
||||
* Module defining ElasticSearchProvider. Created by shale on 07/16/2015.
|
||||
*/
|
||||
define(
|
||||
[],
|
||||
function () {
|
||||
"use strict";
|
||||
define([
|
||||
|
||||
// JSLint doesn't like underscore-prefixed properties,
|
||||
// so hide them here.
|
||||
var ID = "_id",
|
||||
SCORE = "_score",
|
||||
DEFAULT_MAX_RESULTS = 100;
|
||||
|
||||
/**
|
||||
* A search service which searches through domain objects in
|
||||
* the filetree using ElasticSearch.
|
||||
*
|
||||
* @constructor
|
||||
* @param $http Angular's $http service, for working with urls.
|
||||
* @param {ObjectService} objectService the service from which
|
||||
* domain objects can be gotten.
|
||||
* @param ROOT the constant `ELASTIC_ROOT` which allows us to
|
||||
* interact with ElasticSearch.
|
||||
*/
|
||||
function ElasticSearchProvider($http, objectService, ROOT) {
|
||||
this.$http = $http;
|
||||
this.objectService = objectService;
|
||||
this.root = ROOT;
|
||||
}
|
||||
], function (
|
||||
|
||||
/**
|
||||
* Searches through the filetree for domain objects using a search
|
||||
* term. This is done through querying elasticsearch. Returns a
|
||||
* promise for a result object that has the format
|
||||
* {hits: searchResult[], total: number, timedOut: boolean}
|
||||
* where a searchResult has the format
|
||||
* {id: string, object: domainObject, score: number}
|
||||
*
|
||||
* Notes:
|
||||
* * The order of the results is from highest to lowest score,
|
||||
* as elsaticsearch determines them to be.
|
||||
* * Uses the fuzziness operator to get more results.
|
||||
* * More about this search's behavior at
|
||||
* https://www.elastic.co/guide/en/elasticsearch/reference/current/search-uri-request.html
|
||||
*
|
||||
* @param searchTerm The text input that is the query.
|
||||
* @param timestamp The time at which this function was called.
|
||||
* This timestamp is used as a unique identifier for this
|
||||
* query and the corresponding results.
|
||||
* @param maxResults (optional) The maximum number of results
|
||||
* that this function should return.
|
||||
* @param timeout (optional) The time after which the search should
|
||||
* stop calculations and return partial results. Elasticsearch
|
||||
* does not guarentee that this timeout will be strictly followed.
|
||||
*/
|
||||
ElasticSearchProvider.prototype.query = function query(searchTerm, timestamp, maxResults, timeout) {
|
||||
var $http = this.$http,
|
||||
objectService = this.objectService,
|
||||
root = this.root,
|
||||
esQuery;
|
||||
|
||||
function addFuzziness(searchTerm, editDistance) {
|
||||
if (!editDistance) {
|
||||
editDistance = '';
|
||||
}
|
||||
) {
|
||||
"use strict";
|
||||
|
||||
return searchTerm.split(' ').map(function (s) {
|
||||
// Don't add fuzziness for quoted strings
|
||||
if (s.indexOf('"') !== -1) {
|
||||
return s;
|
||||
} else {
|
||||
return s + '~' + editDistance;
|
||||
}
|
||||
}).join(' ');
|
||||
}
|
||||
var ID_PROPERTY = '_id',
|
||||
SOURCE_PROPERTY = '_source',
|
||||
SCORE_PROPERTY = '_score';
|
||||
|
||||
// Currently specific to elasticsearch
|
||||
function processSearchTerm(searchTerm) {
|
||||
var spaceIndex;
|
||||
|
||||
// Cut out any extra spaces
|
||||
while (searchTerm.substr(0, 1) === ' ') {
|
||||
searchTerm = searchTerm.substring(1, searchTerm.length);
|
||||
}
|
||||
while (searchTerm.substr(searchTerm.length - 1, 1) === ' ') {
|
||||
searchTerm = searchTerm.substring(0, searchTerm.length - 1);
|
||||
}
|
||||
spaceIndex = searchTerm.indexOf(' ');
|
||||
while (spaceIndex !== -1) {
|
||||
searchTerm = searchTerm.substring(0, spaceIndex) +
|
||||
searchTerm.substring(spaceIndex + 1, searchTerm.length);
|
||||
spaceIndex = searchTerm.indexOf(' ');
|
||||
}
|
||||
|
||||
// Add fuzziness for completeness
|
||||
searchTerm = addFuzziness(searchTerm);
|
||||
|
||||
return searchTerm;
|
||||
}
|
||||
|
||||
// Processes results from the format that elasticsearch returns to
|
||||
// a list of searchResult objects, then returns a result object
|
||||
// (See documentation for query for object descriptions)
|
||||
function processResults(rawResults, timestamp) {
|
||||
var results = rawResults.data.hits.hits,
|
||||
resultsLength = results.length,
|
||||
ids = [],
|
||||
scores = {},
|
||||
searchResults = [],
|
||||
i;
|
||||
|
||||
// Get the result objects' IDs
|
||||
for (i = 0; i < resultsLength; i += 1) {
|
||||
ids.push(results[i][ID]);
|
||||
}
|
||||
|
||||
// Get the result objects' scores
|
||||
for (i = 0; i < resultsLength; i += 1) {
|
||||
scores[ids[i]] = results[i][SCORE];
|
||||
}
|
||||
|
||||
// Get the domain objects from their IDs
|
||||
return objectService.getObjects(ids).then(function (objects) {
|
||||
var j,
|
||||
id;
|
||||
|
||||
for (j = 0; j < resultsLength; j += 1) {
|
||||
id = ids[j];
|
||||
|
||||
// Include items we can get models for
|
||||
if (objects[id].getModel) {
|
||||
// Format the results as searchResult objects
|
||||
searchResults.push({
|
||||
id: id,
|
||||
object: objects[id],
|
||||
score: scores[id]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hits: searchResults,
|
||||
total: rawResults.data.hits.total,
|
||||
timedOut: rawResults.data.timed_out
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Check to see if the user provided a maximum
|
||||
// number of results to display
|
||||
if (!maxResults) {
|
||||
// Else, we provide a default value.
|
||||
maxResults = DEFAULT_MAX_RESULTS;
|
||||
}
|
||||
|
||||
// If the user input is empty, we want to have no search results.
|
||||
if (searchTerm !== '' && searchTerm !== undefined) {
|
||||
// Process the search term
|
||||
searchTerm = processSearchTerm(searchTerm);
|
||||
|
||||
// Create the query to elasticsearch
|
||||
esQuery = root + "/_search/?q=" + searchTerm +
|
||||
"&size=" + maxResults;
|
||||
if (timeout) {
|
||||
esQuery += "&timeout=" + timeout;
|
||||
}
|
||||
|
||||
// Get the data...
|
||||
return this.$http({
|
||||
method: "GET",
|
||||
url: esQuery
|
||||
}).then(function (rawResults) {
|
||||
// ...then process the data
|
||||
return processResults(rawResults, timestamp);
|
||||
}, function (err) {
|
||||
// In case of error, return nothing. (To prevent
|
||||
// infinite loading time.)
|
||||
return {hits: [], total: 0};
|
||||
});
|
||||
} else {
|
||||
return {hits: [], total: 0};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return ElasticSearchProvider;
|
||||
/**
|
||||
* A search service which searches through domain objects in
|
||||
* the filetree using ElasticSearch.
|
||||
*
|
||||
* @constructor
|
||||
* @param $http Angular's $http service, for working with urls.
|
||||
* @param ROOT the constant `ELASTIC_ROOT` which allows us to
|
||||
* interact with ElasticSearch.
|
||||
*/
|
||||
function ElasticSearchProvider($http, ROOT) {
|
||||
this.$http = $http;
|
||||
this.root = ROOT;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Search for domain objects using elasticsearch as a search provider.
|
||||
*
|
||||
* @param {String} searchTerm the term to search by.
|
||||
* @param {Number} [maxResults] the max numer of results to return.
|
||||
* @returns {Promise} promise for a modelResults object.
|
||||
*/
|
||||
ElasticSearchProvider.prototype.query = function (searchTerm, maxResults) {
|
||||
var searchUrl = this.root + '/_search/',
|
||||
params = {},
|
||||
provider = this;
|
||||
|
||||
searchTerm = this.cleanTerm(searchTerm);
|
||||
searchTerm = this.fuzzyMatchUnquotedTerms(searchTerm);
|
||||
|
||||
params.q = searchTerm;
|
||||
params.size = maxResults;
|
||||
|
||||
return this
|
||||
.$http({
|
||||
method: "GET",
|
||||
url: searchUrl,
|
||||
params: params
|
||||
})
|
||||
.then(function success(succesResponse) {
|
||||
return provider.parseResponse(succesResponse);
|
||||
}, function error(errorResponse) {
|
||||
// Gracefully fail.
|
||||
return {
|
||||
hits: [],
|
||||
total: 0
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clean excess whitespace from a search term and return the cleaned
|
||||
* version.
|
||||
*
|
||||
* @private
|
||||
* @param {string} the search term to clean.
|
||||
* @returns {string} search terms cleaned of excess whitespace.
|
||||
*/
|
||||
ElasticSearchProvider.prototype.cleanTerm = function (term) {
|
||||
return term.trim().replace(/ +/g, ' ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Add fuzzy matching markup to search terms that are not quoted.
|
||||
*
|
||||
* The following:
|
||||
* hello welcome "to quoted village" have fun
|
||||
* will become
|
||||
* hello~ welcome~ "to quoted village" have~ fun~
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
ElasticSearchProvider.prototype.fuzzyMatchUnquotedTerms = function (query) {
|
||||
var matchUnquotedSpaces = '\\s+(?=([^"]*"[^"]*")*[^"]*$)',
|
||||
matcher = new RegExp(matchUnquotedSpaces, 'g');
|
||||
|
||||
return query
|
||||
.replace(matcher, '~ ')
|
||||
.replace(/$/, '~')
|
||||
.replace(/"~+/, '"');
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the response from ElasticSearch and convert it to a
|
||||
* modelResults object.
|
||||
*
|
||||
* @private
|
||||
* @param response a ES response object from $http
|
||||
* @returns modelResults
|
||||
*/
|
||||
ElasticSearchProvider.prototype.parseResponse = function (response) {
|
||||
var results = response.data.hits.hits,
|
||||
searchResults = results.map(function (result) {
|
||||
return {
|
||||
id: result[ID_PROPERTY],
|
||||
model: result[SOURCE_PROPERTY],
|
||||
score: result[SCORE_PROPERTY]
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
hits: searchResults,
|
||||
total: response.data.hits.total
|
||||
};
|
||||
};
|
||||
|
||||
return ElasticSearchProvider;
|
||||
});
|
||||
|
@ -19,97 +19,151 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
/*global define,describe,it,expect,beforeEach,jasmine*/
|
||||
/*global define,describe,it,expect,beforeEach,jasmine,spyOn,Promise,waitsFor*/
|
||||
|
||||
/**
|
||||
* SearchSpec. Created by shale on 07/31/2015.
|
||||
*/
|
||||
define(
|
||||
["../src/ElasticSearchProvider"],
|
||||
function (ElasticSearchProvider) {
|
||||
"use strict";
|
||||
define([
|
||||
'../src/ElasticSearchProvider'
|
||||
], function (
|
||||
ElasticSearchProvider
|
||||
) {
|
||||
'use strict';
|
||||
|
||||
// JSLint doesn't like underscore-prefixed properties,
|
||||
// so hide them here.
|
||||
var ID = "_id",
|
||||
SCORE = "_score";
|
||||
|
||||
describe("The ElasticSearch search provider ", function () {
|
||||
var mockHttp,
|
||||
mockHttpPromise,
|
||||
mockObjectPromise,
|
||||
mockObjectService,
|
||||
mockDomainObject,
|
||||
provider,
|
||||
mockProviderResults;
|
||||
describe('ElasticSearchProvider', function () {
|
||||
var $http,
|
||||
ROOT,
|
||||
provider;
|
||||
|
||||
beforeEach(function () {
|
||||
$http = jasmine.createSpy('$http');
|
||||
ROOT = 'http://localhost:9200';
|
||||
|
||||
provider = new ElasticSearchProvider($http, ROOT);
|
||||
});
|
||||
|
||||
describe('query', function () {
|
||||
beforeEach(function () {
|
||||
mockHttp = jasmine.createSpy("$http");
|
||||
mockHttpPromise = jasmine.createSpyObj(
|
||||
"promise",
|
||||
[ "then" ]
|
||||
);
|
||||
mockHttp.andReturn(mockHttpPromise);
|
||||
// allow chaining of promise.then().catch();
|
||||
mockHttpPromise.then.andReturn(mockHttpPromise);
|
||||
|
||||
mockObjectService = jasmine.createSpyObj(
|
||||
"objectService",
|
||||
[ "getObjects" ]
|
||||
);
|
||||
mockObjectPromise = jasmine.createSpyObj(
|
||||
"promise",
|
||||
[ "then" ]
|
||||
);
|
||||
mockObjectService.getObjects.andReturn(mockObjectPromise);
|
||||
|
||||
mockDomainObject = jasmine.createSpyObj(
|
||||
"domainObject",
|
||||
[ "getId", "getModel" ]
|
||||
);
|
||||
|
||||
provider = new ElasticSearchProvider(mockHttp, mockObjectService, "");
|
||||
provider.query(' test "query" ', 0, undefined, 1000);
|
||||
spyOn(provider, 'cleanTerm').andReturn('cleanedTerm');
|
||||
spyOn(provider, 'fuzzyMatchUnquotedTerms').andReturn('fuzzy');
|
||||
spyOn(provider, 'parseResponse').andReturn('parsedResponse');
|
||||
$http.andReturn(Promise.resolve({}));
|
||||
});
|
||||
|
||||
it("sends a query to ElasticSearch", function () {
|
||||
expect(mockHttp).toHaveBeenCalled();
|
||||
|
||||
it('cleans terms and adds fuzzyness', function () {
|
||||
provider.query('hello', 10);
|
||||
expect(provider.cleanTerm).toHaveBeenCalledWith('hello');
|
||||
expect(provider.fuzzyMatchUnquotedTerms)
|
||||
.toHaveBeenCalledWith('cleanedTerm');
|
||||
});
|
||||
|
||||
it("gets data from ElasticSearch", function () {
|
||||
var data = {
|
||||
hits: {
|
||||
hits: [
|
||||
{},
|
||||
{}
|
||||
],
|
||||
total: 0
|
||||
|
||||
it('calls through to $http', function () {
|
||||
provider.query('hello', 10);
|
||||
expect($http).toHaveBeenCalledWith({
|
||||
method: 'GET',
|
||||
params: {
|
||||
q: 'fuzzy',
|
||||
size: 10
|
||||
},
|
||||
timed_out: false
|
||||
};
|
||||
data.hits.hits[0][ID] = 1;
|
||||
data.hits.hits[0][SCORE] = 1;
|
||||
data.hits.hits[1][ID] = 2;
|
||||
data.hits.hits[1][SCORE] = 2;
|
||||
|
||||
mockProviderResults = mockHttpPromise.then.mostRecentCall.args[0]({data: data});
|
||||
|
||||
expect(
|
||||
mockObjectPromise.then.mostRecentCall.args[0]({
|
||||
1: mockDomainObject,
|
||||
2: mockDomainObject
|
||||
}).hits.length
|
||||
).toEqual(2);
|
||||
url: 'http://localhost:9200/_search/'
|
||||
});
|
||||
});
|
||||
|
||||
it("returns nothing for an empty string query", function () {
|
||||
expect(provider.query("").hits).toEqual([]);
|
||||
|
||||
it('gracefully fails when http fails', function () {
|
||||
var promiseChainResolved = false;
|
||||
$http.andReturn(Promise.reject());
|
||||
|
||||
provider
|
||||
.query('hello', 10)
|
||||
.then(function (results) {
|
||||
expect(results).toEqual({
|
||||
hits: [],
|
||||
total: 0
|
||||
});
|
||||
promiseChainResolved = true;
|
||||
});
|
||||
|
||||
waitsFor(function () {
|
||||
return promiseChainResolved;
|
||||
});
|
||||
});
|
||||
|
||||
it("returns something when there is an ElasticSearch error", function () {
|
||||
mockProviderResults = mockHttpPromise.then.mostRecentCall.args[1]();
|
||||
expect(mockProviderResults).toBeDefined();
|
||||
|
||||
it('parses and returns when http succeeds', function () {
|
||||
var promiseChainResolved = false;
|
||||
$http.andReturn(Promise.resolve('successResponse'));
|
||||
|
||||
provider
|
||||
.query('hello', 10)
|
||||
.then(function (results) {
|
||||
expect(provider.parseResponse)
|
||||
.toHaveBeenCalledWith('successResponse');
|
||||
expect(results).toBe('parsedResponse');
|
||||
promiseChainResolved = true;
|
||||
});
|
||||
|
||||
waitsFor(function () {
|
||||
return promiseChainResolved;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('can clean terms', function () {
|
||||
expect(provider.cleanTerm(' asdhs ')).toBe('asdhs');
|
||||
expect(provider.cleanTerm(' and some words'))
|
||||
.toBe('and some words');
|
||||
expect(provider.cleanTerm('Nice input')).toBe('Nice input');
|
||||
});
|
||||
|
||||
it('can create fuzzy term matchers', function () {
|
||||
expect(provider.fuzzyMatchUnquotedTerms('pwr dvc 43'))
|
||||
.toBe('pwr~ dvc~ 43~');
|
||||
|
||||
expect(provider.fuzzyMatchUnquotedTerms(
|
||||
'hello welcome "to quoted village" have fun'
|
||||
)).toBe(
|
||||
'hello~ welcome~ "to quoted village" have~ fun~'
|
||||
);
|
||||
});
|
||||
|
||||
it('can parse responses', function () {
|
||||
var elasticSearchResponse = {
|
||||
data: {
|
||||
hits: {
|
||||
total: 2,
|
||||
hits: [
|
||||
{
|
||||
'_id': 'hit1Id',
|
||||
'_source': 'hit1Model',
|
||||
'_score': 0.56
|
||||
},
|
||||
{
|
||||
'_id': 'hit2Id',
|
||||
'_source': 'hit2Model',
|
||||
'_score': 0.34
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(provider.parseResponse(elasticSearchResponse))
|
||||
.toEqual({
|
||||
hits: [
|
||||
{
|
||||
id: 'hit1Id',
|
||||
model: 'hit1Model',
|
||||
score: 0.56
|
||||
},
|
||||
{
|
||||
id: 'hit2Id',
|
||||
model: 'hit2Model',
|
||||
score: 0.34
|
||||
}
|
||||
],
|
||||
total: 2
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -48,8 +48,7 @@
|
||||
"depends": [
|
||||
"$q",
|
||||
"$log",
|
||||
"throttle",
|
||||
"objectService",
|
||||
"modelService",
|
||||
"workerService",
|
||||
"topic",
|
||||
"GENERIC_SEARCH_ROOTS"
|
||||
@ -59,7 +58,7 @@
|
||||
"provides": "searchService",
|
||||
"type": "aggregator",
|
||||
"implementation": "services/SearchAggregator.js",
|
||||
"depends": [ "$q" ]
|
||||
"depends": [ "$q", "objectService" ]
|
||||
}
|
||||
],
|
||||
"workers": [
|
||||
|
@ -21,21 +21,16 @@
|
||||
-->
|
||||
<div class="search"
|
||||
ng-controller="SearchController as controller">
|
||||
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="search-bar"
|
||||
ng-controller="ClickAwayController as toggle">
|
||||
|
||||
|
||||
<!-- Input field -->
|
||||
<input class="search-input"
|
||||
type="text"
|
||||
ng-model="ngModel.input"
|
||||
ng-keyup="controller.search()" />
|
||||
<!--mct-control key="'textfield'"
|
||||
class="search-input"
|
||||
ng-model="ngModel.input"
|
||||
ng-keyup="controller.search()">
|
||||
</mct-control-->
|
||||
|
||||
<!-- Search icon -->
|
||||
<!-- ui symbols for search are 'd' and 'M' -->
|
||||
@ -43,20 +38,20 @@
|
||||
ng-class="{content: !(ngModel.input === '' || ngModel.input === undefined)}">
|
||||
M
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Clear icon/button 'x' -->
|
||||
<a class="ui-symbol clear-icon"
|
||||
ng-class="{content: !(ngModel.input === '' || ngModel.input === undefined)}"
|
||||
ng-click="ngModel.input = ''; controller.search()">
|
||||

|
||||
</a>
|
||||
|
||||
|
||||
<!-- Menu icon/button 'v' -->
|
||||
<a class="ui-symbol menu-icon"
|
||||
ng-click="toggle.toggle()">
|
||||
v
|
||||
</a>
|
||||
|
||||
|
||||
<!-- Menu -->
|
||||
<mct-representation key="'search-menu'"
|
||||
class="menu-element search-menu-holder"
|
||||
@ -65,27 +60,24 @@
|
||||
ng-click="toggle.setState(true)">
|
||||
</mct-representation>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Active filter display -->
|
||||
<div class="active-filter-display"
|
||||
ng-class="{off: ngModel.filtersString === '' || ngModel.filtersString === undefined || !ngModel.search}"
|
||||
ng-controller="SearchMenuController as menuController">
|
||||
|
||||
|
||||
<a class="ui-symbol clear-filters-icon"
|
||||
ng-click="ngModel.checkAll = true; menuController.checkAll()">
|
||||

|
||||
</a>
|
||||
|
||||
Filtered by: {{ ngModel.filtersString }}
|
||||
|
||||
<!--div class="filter-options">
|
||||
Filtered by: {{ ngModel.filtersString }}
|
||||
</div-->
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- This div exists to determine scroll bar location -->
|
||||
<div class="search-scroll abs">
|
||||
|
||||
|
||||
<!-- Results list -->
|
||||
<div class="results">
|
||||
<mct-representation key="'search-item'"
|
||||
@ -103,14 +95,14 @@
|
||||
<span class="title-label">Loading...</span>
|
||||
</div>
|
||||
|
||||
<!-- Load more button -->
|
||||
<!-- Load more button -->
|
||||
<div ng-if="controller.areMore()">
|
||||
<a class="load-more-button s-btn vsm"
|
||||
ng-click="controller.loadMore()">
|
||||
ng-click="controller.loadMore()">
|
||||
More Results
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -26,146 +26,155 @@
|
||||
*/
|
||||
define(function () {
|
||||
"use strict";
|
||||
|
||||
var INITIAL_LOAD_NUMBER = 20,
|
||||
LOAD_INCREMENT = 20;
|
||||
|
||||
|
||||
/**
|
||||
* Controller for search in Tree View.
|
||||
*
|
||||
* Filtering is currently buggy; it filters after receiving results from
|
||||
* search providers, the downside of this is that it requires search
|
||||
* providers to provide objects for all possible results, which is
|
||||
* potentially a hit to persistence, thus can be very very slow.
|
||||
*
|
||||
* Ideally, filtering should be handled before loading objects from the persistence
|
||||
* store, the downside to this is that filters must be applied to object
|
||||
* models, not object instances.
|
||||
*
|
||||
* @constructor
|
||||
* @param $scope
|
||||
* @param searchService
|
||||
*/
|
||||
function SearchController($scope, searchService) {
|
||||
// numResults is the amount of results to display. Will get increased.
|
||||
// fullResults holds the most recent complete searchService response object
|
||||
var numResults = INITIAL_LOAD_NUMBER,
|
||||
fullResults = {hits: []};
|
||||
|
||||
// Scope variables are:
|
||||
// Variables used only in SearchController:
|
||||
// results, an array of searchResult objects
|
||||
// loading, whether search() is loading
|
||||
// ngModel.input, the text of the search query
|
||||
// ngModel.search, a boolean of whether to display search or the tree
|
||||
// Variables used also in SearchMenuController:
|
||||
// ngModel.filter, the function filter defined below
|
||||
// ngModel.types, an array of type objects
|
||||
// ngModel.checked, a dictionary of which type filter options are checked
|
||||
// ngModel.checkAll, a boolean of whether to search all types
|
||||
// ngModel.filtersString, a string list of what filters on the results are active
|
||||
$scope.results = [];
|
||||
$scope.loading = false;
|
||||
|
||||
|
||||
// Filters searchResult objects by type. Allows types that are
|
||||
// checked. (ngModel.checked['typekey'] === true)
|
||||
// If hits is not provided, will use fullResults.hits
|
||||
function filter(hits) {
|
||||
var newResults = [],
|
||||
i = 0;
|
||||
|
||||
if (!hits) {
|
||||
hits = fullResults.hits;
|
||||
}
|
||||
|
||||
// If checkAll is checked, search everything no matter what the other
|
||||
// checkboxes' statuses are. Otherwise filter the search by types.
|
||||
if ($scope.ngModel.checkAll) {
|
||||
newResults = fullResults.hits.slice(0, numResults);
|
||||
} else {
|
||||
while (newResults.length < numResults && i < hits.length) {
|
||||
// If this is of an acceptable type, add it to the list
|
||||
if ($scope.ngModel.checked[hits[i].object.getModel().type]) {
|
||||
newResults.push(fullResults.hits[i]);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.results = newResults;
|
||||
return newResults;
|
||||
}
|
||||
|
||||
// Make function accessible from SearchMenuController
|
||||
$scope.ngModel.filter = filter;
|
||||
|
||||
// For documentation, see search below
|
||||
function search(maxResults) {
|
||||
var inputText = $scope.ngModel.input;
|
||||
|
||||
if (inputText !== '' && inputText !== undefined) {
|
||||
// We are starting to load.
|
||||
$scope.loading = true;
|
||||
|
||||
// Update whether the file tree should be displayed
|
||||
// Hide tree only when starting search
|
||||
$scope.ngModel.search = true;
|
||||
}
|
||||
|
||||
if (!maxResults) {
|
||||
// Reset 'load more'
|
||||
numResults = INITIAL_LOAD_NUMBER;
|
||||
}
|
||||
|
||||
// Send the query
|
||||
searchService.query(inputText, maxResults).then(function (result) {
|
||||
// Store all the results before splicing off the front, so that
|
||||
// we can load more to display later.
|
||||
fullResults = result;
|
||||
$scope.results = filter(result.hits);
|
||||
|
||||
// Update whether the file tree should be displayed
|
||||
// Reveal tree only when finishing search
|
||||
if (inputText === '' || inputText === undefined) {
|
||||
$scope.ngModel.search = false;
|
||||
}
|
||||
|
||||
// Now we are done loading.
|
||||
$scope.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Search the filetree. Assumes that any search text will
|
||||
* be in ngModel.input
|
||||
*
|
||||
* @param maxResults (optional) The maximum number of results
|
||||
* that this function should return. If not provided, search
|
||||
* service default will be used.
|
||||
*/
|
||||
search: search,
|
||||
|
||||
/**
|
||||
* Checks to see if there are more search results to display. If the answer is
|
||||
* unclear, this function will err toward saying that there are more results.
|
||||
*/
|
||||
areMore: function () {
|
||||
var i;
|
||||
|
||||
// Check to see if any of the not displayed results are of an allowed type
|
||||
for (i = numResults; i < fullResults.hits.length; i += 1) {
|
||||
if ($scope.ngModel.checkAll || $scope.ngModel.checked[fullResults.hits[i].object.getModel().type]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If none of the ones at hand are correct, there still may be more if we
|
||||
// re-search with a larger maxResults
|
||||
return fullResults.hits.length < fullResults.total;
|
||||
},
|
||||
|
||||
/**
|
||||
* Increases the number of search results to display, and then
|
||||
* loads them, adding to the displayed results.
|
||||
*/
|
||||
loadMore: function () {
|
||||
numResults += LOAD_INCREMENT;
|
||||
|
||||
if (numResults > fullResults.hits.length && fullResults.hits.length < fullResults.total) {
|
||||
// Resend the query if we are out of items to display, but there are more to get
|
||||
search(numResults);
|
||||
} else {
|
||||
// Otherwise just take from what we already have
|
||||
$scope.results = filter(fullResults.hits);
|
||||
}
|
||||
}
|
||||
var controller = this;
|
||||
this.$scope = $scope;
|
||||
this.searchService = searchService;
|
||||
this.numberToDisplay = this.RESULTS_PER_PAGE;
|
||||
this.availabileResults = 0;
|
||||
this.$scope.results = [];
|
||||
this.$scope.loading = false;
|
||||
this.pendingQuery = undefined;
|
||||
this.$scope.ngModel.filter = function () {
|
||||
return controller.onFilterChange.apply(controller, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
SearchController.prototype.RESULTS_PER_PAGE = 20;
|
||||
|
||||
/**
|
||||
* Returns true if there are more results than currently displayed for the
|
||||
* for the current query and filters.
|
||||
*/
|
||||
SearchController.prototype.areMore = function () {
|
||||
return this.$scope.results.length < this.availableResults;
|
||||
};
|
||||
|
||||
/**
|
||||
* Display more results for the currently displayed query and filters.
|
||||
*/
|
||||
SearchController.prototype.loadMore = function () {
|
||||
this.numberToDisplay += this.RESULTS_PER_PAGE;
|
||||
this.dispatchSearch();
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset search results, then search for the query string specified in
|
||||
* scope.
|
||||
*/
|
||||
SearchController.prototype.search = function () {
|
||||
var inputText = this.$scope.ngModel.input;
|
||||
|
||||
this.clearResults();
|
||||
|
||||
if (inputText) {
|
||||
this.$scope.loading = true;
|
||||
this.$scope.ngModel.search = true;
|
||||
} else {
|
||||
this.pendingQuery = undefined;
|
||||
this.$scope.ngModel.search = false;
|
||||
this.$scope.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatchSearch();
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatch a search to the search service if it hasn't already been
|
||||
* dispatched.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
SearchController.prototype.dispatchSearch = function () {
|
||||
var inputText = this.$scope.ngModel.input,
|
||||
controller = this,
|
||||
queryId = inputText + this.numberToDisplay;
|
||||
|
||||
if (this.pendingQuery === queryId) {
|
||||
return; // don't issue multiple queries for the same term.
|
||||
}
|
||||
|
||||
this.pendingQuery = queryId;
|
||||
|
||||
this
|
||||
.searchService
|
||||
.query(inputText, this.numberToDisplay, this.filterPredicate())
|
||||
.then(function (results) {
|
||||
if (controller.pendingQuery !== queryId) {
|
||||
return; // another query in progress, so skip this one.
|
||||
}
|
||||
controller.onSearchComplete(results);
|
||||
});
|
||||
};
|
||||
|
||||
SearchController.prototype.filter = SearchController.prototype.onFilterChange;
|
||||
|
||||
/**
|
||||
* Refilter results and update visible results when filters have changed.
|
||||
*/
|
||||
SearchController.prototype.onFilterChange = function () {
|
||||
this.pendingQuery = undefined;
|
||||
this.search();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a predicate function that can be used to filter object models.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
SearchController.prototype.filterPredicate = function () {
|
||||
if (this.$scope.ngModel.checkAll) {
|
||||
return function () {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
var includeTypes = this.$scope.ngModel.checked;
|
||||
return function (model) {
|
||||
return !!includeTypes[model.type];
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the search results.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
SearchController.prototype.clearResults = function () {
|
||||
this.$scope.results = [];
|
||||
this.availableResults = 0;
|
||||
this.numberToDisplay = this.RESULTS_PER_PAGE;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Update search results from given `results`.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
SearchController.prototype.onSearchComplete = function (results) {
|
||||
this.availableResults = results.total;
|
||||
this.$scope.results = results.hits;
|
||||
this.$scope.loading = false;
|
||||
this.pendingQuery = undefined;
|
||||
};
|
||||
|
||||
return SearchController;
|
||||
});
|
||||
|
@ -19,234 +19,262 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
/*global define*/
|
||||
/*global define,setTimeout*/
|
||||
|
||||
/**
|
||||
* Module defining GenericSearchProvider. Created by shale on 07/16/2015.
|
||||
*/
|
||||
define(
|
||||
[],
|
||||
function () {
|
||||
"use strict";
|
||||
define([
|
||||
|
||||
var DEFAULT_MAX_RESULTS = 100,
|
||||
DEFAULT_TIMEOUT = 1000,
|
||||
MAX_CONCURRENT_REQUESTS = 100,
|
||||
FLUSH_INTERVAL = 0,
|
||||
stopTime;
|
||||
], function (
|
||||
|
||||
/**
|
||||
* A search service which searches through domain objects in
|
||||
* the filetree without using external search implementations.
|
||||
*
|
||||
* @constructor
|
||||
* @param $q Angular's $q, for promise consolidation.
|
||||
* @param $log Anglar's $log, for logging.
|
||||
* @param {Function} throttle a function to throttle function invocations
|
||||
* @param {ObjectService} objectService The service from which
|
||||
* domain objects can be gotten.
|
||||
* @param {WorkerService} workerService The service which allows
|
||||
* more easy creation of web workers.
|
||||
* @param {GENERIC_SEARCH_ROOTS} ROOTS An array of the root
|
||||
* domain objects' IDs.
|
||||
*/
|
||||
function GenericSearchProvider($q, $log, throttle, objectService, workerService, topic, ROOTS) {
|
||||
var indexed = {},
|
||||
pendingIndex = {},
|
||||
pendingQueries = {},
|
||||
toRequest = [],
|
||||
worker = workerService.run('genericSearchWorker'),
|
||||
mutationTopic = topic("mutation"),
|
||||
indexingStarted = Date.now(),
|
||||
pendingRequests = 0,
|
||||
scheduleFlush;
|
||||
) {
|
||||
"use strict";
|
||||
|
||||
this.worker = worker;
|
||||
this.pendingQueries = pendingQueries;
|
||||
this.$q = $q;
|
||||
// pendingQueries is a dictionary with the key value pairs st
|
||||
// the key is the timestamp and the value is the promise
|
||||
/**
|
||||
* A search service which searches through domain objects in
|
||||
* the filetree without using external search implementations.
|
||||
*
|
||||
* @constructor
|
||||
* @param $q Angular's $q, for promise consolidation.
|
||||
* @param $log Anglar's $log, for logging.
|
||||
* @param {ModelService} modelService the model service.
|
||||
* @param {WorkerService} workerService the workerService.
|
||||
* @param {TopicService} topic the topic service.
|
||||
* @param {Array} ROOTS An array of object Ids to begin indexing.
|
||||
*/
|
||||
function GenericSearchProvider($q, $log, modelService, workerService, topic, ROOTS) {
|
||||
var provider = this;
|
||||
this.$q = $q;
|
||||
this.$log = $log;
|
||||
this.modelService = modelService;
|
||||
|
||||
function scheduleIdsForIndexing(ids) {
|
||||
ids.forEach(function (id) {
|
||||
if (!indexed[id] && !pendingIndex[id]) {
|
||||
indexed[id] = true;
|
||||
pendingIndex[id] = true;
|
||||
toRequest.push(id);
|
||||
}
|
||||
});
|
||||
scheduleFlush();
|
||||
}
|
||||
this.indexedIds = {};
|
||||
this.idsToIndex = [];
|
||||
this.pendingIndex = {};
|
||||
this.pendingRequests = 0;
|
||||
|
||||
// Tell the web worker to add a domain object's model to its list of items.
|
||||
function indexItem(domainObject) {
|
||||
var model = domainObject.getModel();
|
||||
this.pendingQueries = {};
|
||||
|
||||
worker.postMessage({
|
||||
request: 'index',
|
||||
model: model,
|
||||
id: domainObject.getId()
|
||||
});
|
||||
this.worker = this.startWorker(workerService);
|
||||
this.indexOnMutation(topic);
|
||||
|
||||
if (Array.isArray(model.composition)) {
|
||||
scheduleIdsForIndexing(model.composition);
|
||||
}
|
||||
}
|
||||
ROOTS.forEach(function indexRoot(rootId) {
|
||||
provider.scheduleForIndexing(rootId);
|
||||
});
|
||||
|
||||
// Handles responses from the web worker. Namely, the results of
|
||||
// a search request.
|
||||
function handleResponse(event) {
|
||||
var ids = [],
|
||||
id;
|
||||
|
||||
// If we have the results from a search
|
||||
if (event.data.request === 'search') {
|
||||
// Convert the ids given from the web worker into domain objects
|
||||
for (id in event.data.results) {
|
||||
ids.push(id);
|
||||
}
|
||||
objectService.getObjects(ids).then(function (objects) {
|
||||
var searchResults = [],
|
||||
id;
|
||||
}
|
||||
|
||||
// Create searchResult objects
|
||||
for (id in objects) {
|
||||
searchResults.push({
|
||||
object: objects[id],
|
||||
id: id,
|
||||
score: event.data.results[id]
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Maximum number of concurrent index requests to allow.
|
||||
*/
|
||||
GenericSearchProvider.prototype.MAX_CONCURRENT_REQUESTS = 100;
|
||||
|
||||
// Resove the promise corresponding to this
|
||||
pendingQueries[event.data.timestamp].resolve({
|
||||
hits: searchResults,
|
||||
total: event.data.total,
|
||||
timedOut: event.data.timedOut
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Query the search provider for results.
|
||||
*
|
||||
* @param {String} input the string to search by.
|
||||
* @param {Number} maxResults max number of results to return.
|
||||
* @returns {Promise} a promise for a modelResults object.
|
||||
*/
|
||||
GenericSearchProvider.prototype.query = function (
|
||||
input,
|
||||
maxResults
|
||||
) {
|
||||
|
||||
function requestAndIndex(id) {
|
||||
pendingRequests += 1;
|
||||
objectService.getObjects([id]).then(function (objects) {
|
||||
delete pendingIndex[id];
|
||||
if (objects[id]) {
|
||||
indexItem(objects[id]);
|
||||
}
|
||||
}, function () {
|
||||
$log.warn("Failed to index domain object " + id);
|
||||
}).then(function () {
|
||||
pendingRequests -= 1;
|
||||
scheduleFlush();
|
||||
});
|
||||
}
|
||||
var queryId = this.dispatchSearch(input, maxResults),
|
||||
pendingQuery = this.$q.defer();
|
||||
|
||||
scheduleFlush = throttle(function flush() {
|
||||
var batchSize =
|
||||
Math.max(MAX_CONCURRENT_REQUESTS - pendingRequests, 0);
|
||||
this.pendingQueries[queryId] = pendingQuery;
|
||||
|
||||
if (toRequest.length + pendingRequests < 1) {
|
||||
$log.info([
|
||||
'GenericSearch finished indexing after ',
|
||||
((Date.now() - indexingStarted) / 1000).toFixed(2),
|
||||
' seconds.'
|
||||
].join(''));
|
||||
} else {
|
||||
toRequest.splice(-batchSize, batchSize)
|
||||
.forEach(requestAndIndex);
|
||||
}
|
||||
}, FLUSH_INTERVAL);
|
||||
return pendingQuery.promise;
|
||||
};
|
||||
|
||||
worker.onmessage = handleResponse;
|
||||
/**
|
||||
* Creates a search worker and attaches handlers.
|
||||
*
|
||||
* @private
|
||||
* @param workerService
|
||||
* @returns worker the created search worker.
|
||||
*/
|
||||
GenericSearchProvider.prototype.startWorker = function (workerService) {
|
||||
var worker = workerService.run('genericSearchWorker'),
|
||||
provider = this;
|
||||
|
||||
// Index the tree's contents once at the beginning
|
||||
scheduleIdsForIndexing(ROOTS);
|
||||
worker.addEventListener('message', function (messageEvent) {
|
||||
provider.onWorkerMessage(messageEvent);
|
||||
});
|
||||
|
||||
// Re-index items when they are mutated
|
||||
mutationTopic.listen(function (domainObject) {
|
||||
var id = domainObject.getId();
|
||||
indexed[id] = false;
|
||||
scheduleIdsForIndexing([id]);
|
||||
return worker;
|
||||
};
|
||||
|
||||
/**
|
||||
* Listen to the mutation topic and re-index objects when they are
|
||||
* mutated.
|
||||
*
|
||||
* @private
|
||||
* @param topic the topicService.
|
||||
*/
|
||||
GenericSearchProvider.prototype.indexOnMutation = function (topic) {
|
||||
var mutationTopic = topic('mutation'),
|
||||
provider = this;
|
||||
|
||||
mutationTopic.listen(function (mutatedObject) {
|
||||
var id = mutatedObject.getId();
|
||||
provider.indexed[id] = false;
|
||||
provider.scheduleForIndexing(id);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedule an id to be indexed at a later date. If there are less
|
||||
* pending requests then allowed, will kick off an indexing request.
|
||||
*
|
||||
* @private
|
||||
* @param {String} id to be indexed.
|
||||
*/
|
||||
GenericSearchProvider.prototype.scheduleForIndexing = function (id) {
|
||||
if (!this.indexedIds[id] && !this.pendingIndex[id]) {
|
||||
this.indexedIds[id] = true;
|
||||
this.pendingIndex[id] = true;
|
||||
this.idsToIndex.push(id);
|
||||
}
|
||||
this.keepIndexing();
|
||||
};
|
||||
|
||||
/**
|
||||
* If there are less pending requests than concurrent requests, keep
|
||||
* firing requests.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
GenericSearchProvider.prototype.keepIndexing = function () {
|
||||
while (this.pendingRequests < this.MAX_CONCURRENT_REQUESTS &&
|
||||
this.idsToIndex.length
|
||||
) {
|
||||
this.beginIndexRequest();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pass an id and model to the worker to be indexed. If the model has
|
||||
* composition, schedule those ids for later indexing.
|
||||
*
|
||||
* @private
|
||||
* @param id a model id
|
||||
* @param model a model
|
||||
*/
|
||||
GenericSearchProvider.prototype.index = function (id, model) {
|
||||
var provider = this;
|
||||
|
||||
this.worker.postMessage({
|
||||
request: 'index',
|
||||
model: model,
|
||||
id: id
|
||||
});
|
||||
|
||||
if (Array.isArray(model.composition)) {
|
||||
model.composition.forEach(function (id) {
|
||||
provider.scheduleForIndexing(id);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches through the filetree for domain objects which match
|
||||
* the search term. This function is to be used as a fallback
|
||||
* in the case where other search services are not avaliable.
|
||||
* Returns a promise for a result object that has the format
|
||||
* {hits: searchResult[], total: number, timedOut: boolean}
|
||||
* where a searchResult has the format
|
||||
* {id: string, object: domainObject, score: number}
|
||||
*
|
||||
* Notes:
|
||||
* * The order of the results is not guarenteed.
|
||||
* * A domain object qualifies as a match for a search input if
|
||||
* the object's name property contains any of the search terms
|
||||
* (which are generated by splitting the input at spaces).
|
||||
* * Scores are higher for matches that have more of the terms
|
||||
* as substrings.
|
||||
*
|
||||
* @param input The text input that is the query.
|
||||
* @param timestamp The time at which this function was called.
|
||||
* This timestamp is used as a unique identifier for this
|
||||
* query and the corresponding results.
|
||||
* @param maxResults (optional) The maximum number of results
|
||||
* that this function should return.
|
||||
* @param timeout (optional) The time after which the search should
|
||||
* stop calculations and return partial results.
|
||||
*/
|
||||
GenericSearchProvider.prototype.query = function query(input, timestamp, maxResults, timeout) {
|
||||
var terms = [],
|
||||
searchResults = [],
|
||||
pendingQueries = this.pendingQueries,
|
||||
worker = this.worker,
|
||||
defer = this.$q.defer();
|
||||
/**
|
||||
* Pulls an id from the indexing queue, loads it from the model service,
|
||||
* and indexes it. Upon completion, tells the provider to keep
|
||||
* indexing.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
GenericSearchProvider.prototype.beginIndexRequest = function () {
|
||||
var idToIndex = this.idsToIndex.shift(),
|
||||
provider = this;
|
||||
|
||||
// Tell the worker to search for items it has that match this searchInput.
|
||||
// Takes the searchInput, as well as a max number of results (will return
|
||||
// less than that if there are fewer matches).
|
||||
function workerSearch(searchInput, maxResults, timestamp, timeout) {
|
||||
var message = {
|
||||
request: 'search',
|
||||
input: searchInput,
|
||||
maxNumber: maxResults,
|
||||
timestamp: timestamp,
|
||||
timeout: timeout
|
||||
};
|
||||
worker.postMessage(message);
|
||||
}
|
||||
|
||||
// If the input is nonempty, do a search
|
||||
if (input !== '' && input !== undefined) {
|
||||
|
||||
// Allow us to access this promise later to resolve it later
|
||||
pendingQueries[timestamp] = defer;
|
||||
|
||||
// Check to see if the user provided a maximum
|
||||
// number of results to display
|
||||
if (!maxResults) {
|
||||
// Else, we provide a default value
|
||||
maxResults = DEFAULT_MAX_RESULTS;
|
||||
}
|
||||
// Similarly, check if timeout was provided
|
||||
if (!timeout) {
|
||||
timeout = DEFAULT_TIMEOUT;
|
||||
this.pendingRequests += 1;
|
||||
this.modelService
|
||||
.getModels([idToIndex])
|
||||
.then(function (models) {
|
||||
delete provider.pendingIndex[idToIndex];
|
||||
if (models[idToIndex]) {
|
||||
provider.index(idToIndex, models[idToIndex]);
|
||||
}
|
||||
}, function () {
|
||||
provider
|
||||
.$log
|
||||
.warn('Failed to index domain object ' + idToIndex);
|
||||
})
|
||||
.then(function () {
|
||||
setTimeout(function () {
|
||||
provider.pendingRequests -= 1;
|
||||
provider.keepIndexing();
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
// Send the query to the worker
|
||||
workerSearch(input, maxResults, timestamp, timeout);
|
||||
/**
|
||||
* Handle messages from the worker. Only really knows how to handle search
|
||||
* results, which are parsed, transformed into a modelResult object, which
|
||||
* is used to resolve the corresponding promise.
|
||||
* @private
|
||||
*/
|
||||
GenericSearchProvider.prototype.onWorkerMessage = function (event) {
|
||||
if (event.data.request !== 'search') {
|
||||
return;
|
||||
}
|
||||
|
||||
return defer.promise;
|
||||
} else {
|
||||
// Otherwise return an empty result
|
||||
return { hits: [], total: 0 };
|
||||
}
|
||||
};
|
||||
var pendingQuery = this.pendingQueries[event.data.queryId],
|
||||
modelResults = {
|
||||
total: event.data.total
|
||||
};
|
||||
|
||||
modelResults.hits = event.data.results.map(function (hit) {
|
||||
return {
|
||||
id: hit.item.id,
|
||||
model: hit.item.model,
|
||||
score: hit.matchCount
|
||||
};
|
||||
});
|
||||
|
||||
pendingQuery.resolve(modelResults);
|
||||
delete this.pendingQueries[event.data.queryId];
|
||||
};
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {Number} a unique, unusued query Id.
|
||||
*/
|
||||
GenericSearchProvider.prototype.makeQueryId = function () {
|
||||
var queryId = Math.ceil(Math.random() * 100000);
|
||||
while (this.pendingQueries[queryId]) {
|
||||
queryId = Math.ceil(Math.random() * 100000);
|
||||
}
|
||||
return queryId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatch a search query to the worker and return a queryId.
|
||||
*
|
||||
* @private
|
||||
* @returns {Number} a unique query Id for the query.
|
||||
*/
|
||||
GenericSearchProvider.prototype.dispatchSearch = function (
|
||||
searchInput,
|
||||
maxResults
|
||||
) {
|
||||
var queryId = this.makeQueryId();
|
||||
|
||||
this.worker.postMessage({
|
||||
request: 'search',
|
||||
input: searchInput,
|
||||
maxResults: maxResults,
|
||||
queryId: queryId
|
||||
});
|
||||
|
||||
return queryId;
|
||||
};
|
||||
|
||||
|
||||
return GenericSearchProvider;
|
||||
}
|
||||
);
|
||||
return GenericSearchProvider;
|
||||
});
|
||||
|
@ -26,133 +26,132 @@
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
|
||||
// An array of objects composed of domain object IDs and models
|
||||
// {id: domainObject's ID, model: domainObject's model}
|
||||
var indexedItems = [];
|
||||
|
||||
// Helper function for serach()
|
||||
function convertToTerms(input) {
|
||||
var terms = input;
|
||||
// Shave any spaces off of the ends of the input
|
||||
while (terms.substr(0, 1) === ' ') {
|
||||
terms = terms.substring(1, terms.length);
|
||||
}
|
||||
while (terms.substr(terms.length - 1, 1) === ' ') {
|
||||
terms = terms.substring(0, terms.length - 1);
|
||||
}
|
||||
|
||||
// Then split it at spaces and asterisks
|
||||
terms = terms.split(/ |\*/);
|
||||
|
||||
// Remove any empty strings from the terms
|
||||
while (terms.indexOf('') !== -1) {
|
||||
terms.splice(terms.indexOf(''), 1);
|
||||
}
|
||||
|
||||
return terms;
|
||||
var indexedItems = [],
|
||||
TERM_SPLITTER = /[ _\*]/;
|
||||
|
||||
function indexItem(id, model) {
|
||||
var vector = {
|
||||
name: model.name
|
||||
};
|
||||
vector.cleanName = model.name.trim();
|
||||
vector.lowerCaseName = vector.cleanName.toLocaleLowerCase();
|
||||
vector.terms = vector.lowerCaseName.split(TERM_SPLITTER);
|
||||
|
||||
indexedItems.push({
|
||||
id: id,
|
||||
vector: vector,
|
||||
model: model
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Helper function for search()
|
||||
function scoreItem(item, input, terms) {
|
||||
var name = item.model.name.toLocaleLowerCase(),
|
||||
weight = 0.65,
|
||||
score = 0.0,
|
||||
i;
|
||||
|
||||
// Make the score really big if the item name and
|
||||
// the original search input are the same
|
||||
if (name === input) {
|
||||
score = 42;
|
||||
}
|
||||
|
||||
for (i = 0; i < terms.length; i += 1) {
|
||||
// Increase the score if the term is in the item name
|
||||
if (name.indexOf(terms[i]) !== -1) {
|
||||
score += 1;
|
||||
|
||||
// Add extra to the score if the search term exists
|
||||
// as its own term within the items
|
||||
if (name.split(' ').indexOf(terms[i]) !== -1) {
|
||||
score += 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score * weight;
|
||||
function convertToTerms(input) {
|
||||
var query = {
|
||||
exactInput: input
|
||||
};
|
||||
query.inputClean = input.trim();
|
||||
query.inputLowerCase = query.inputClean.toLocaleLowerCase();
|
||||
query.terms = query.inputLowerCase.split(TERM_SPLITTER);
|
||||
query.exactTerms = query.inputClean.split(TERM_SPLITTER);
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
/**
|
||||
* Gets search results from the indexedItems based on provided search
|
||||
* input. Returns matching results from indexedItems, as well as the
|
||||
* timestamp that was passed to it.
|
||||
*
|
||||
* input. Returns matching results from indexedItems
|
||||
*
|
||||
* @param data An object which contains:
|
||||
* * input: The original string which we are searching with
|
||||
* * maxNumber: The maximum number of search results desired
|
||||
* * timestamp: The time identifier from when the query was made
|
||||
* * maxResults: The maximum number of search results desired
|
||||
* * queryId: an id identifying this query, will be returned.
|
||||
*/
|
||||
function search(data) {
|
||||
// This results dictionary will have domain object ID keys which
|
||||
// point to the value the domain object's score.
|
||||
var results = {},
|
||||
input = data.input.toLocaleLowerCase(),
|
||||
terms = convertToTerms(input),
|
||||
// This results dictionary will have domain object ID keys which
|
||||
// point to the value the domain object's score.
|
||||
var results,
|
||||
input = data.input,
|
||||
query = convertToTerms(input),
|
||||
message = {
|
||||
request: 'search',
|
||||
results: {},
|
||||
total: 0,
|
||||
timestamp: data.timestamp,
|
||||
timedOut: false
|
||||
queryId: data.queryId
|
||||
},
|
||||
score,
|
||||
i,
|
||||
id;
|
||||
|
||||
// If the user input is empty, we want to have no search results.
|
||||
if (input !== '') {
|
||||
for (i = 0; i < indexedItems.length; i += 1) {
|
||||
// If this is taking too long, then stop
|
||||
if (Date.now() > data.timestamp + data.timeout) {
|
||||
message.timedOut = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Score and add items
|
||||
score = scoreItem(indexedItems[i], input, terms);
|
||||
if (score > 0) {
|
||||
results[indexedItems[i].id] = score;
|
||||
message.total += 1;
|
||||
}
|
||||
}
|
||||
matches = {};
|
||||
|
||||
if (!query.inputClean) {
|
||||
// No search terms, no results;
|
||||
return message;
|
||||
}
|
||||
|
||||
// Truncate results if there are more than maxResults
|
||||
if (message.total > data.maxResults) {
|
||||
i = 0;
|
||||
for (id in results) {
|
||||
message.results[id] = results[id];
|
||||
i += 1;
|
||||
if (i >= data.maxResults) {
|
||||
break;
|
||||
|
||||
// Two phases: find matches, then score matches.
|
||||
// Idea being that match finding should be fast, so that future scoring
|
||||
// operations process fewer objects.
|
||||
|
||||
query.terms.forEach(function findMatchingItems(term) {
|
||||
indexedItems
|
||||
.filter(function matchesItem(item) {
|
||||
return item.vector.lowerCaseName.indexOf(term) !== -1;
|
||||
})
|
||||
.forEach(function trackMatch(matchedItem) {
|
||||
if (!matches[matchedItem.id]) {
|
||||
matches[matchedItem.id] = {
|
||||
matchCount: 0,
|
||||
item: matchedItem
|
||||
};
|
||||
}
|
||||
matches[matchedItem.id].matchCount += 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Then, score matching items.
|
||||
results = Object
|
||||
.keys(matches)
|
||||
.map(function asMatches(matchId) {
|
||||
return matches[matchId];
|
||||
})
|
||||
.map(function prioritizeExactMatches(match) {
|
||||
if (match.item.vector.name === query.exactInput) {
|
||||
match.matchCount += 100;
|
||||
} else if (match.item.vector.lowerCaseName ===
|
||||
query.inputLowerCase) {
|
||||
match.matchCount += 50;
|
||||
}
|
||||
}
|
||||
// TODO: This seems inefficient.
|
||||
} else {
|
||||
message.results = results;
|
||||
}
|
||||
|
||||
return match;
|
||||
})
|
||||
.map(function prioritizeCompleteTermMatches(match) {
|
||||
match.item.vector.terms.forEach(function (term) {
|
||||
if (query.terms.indexOf(term) !== -1) {
|
||||
match.matchCount += 0.5;
|
||||
}
|
||||
});
|
||||
return match;
|
||||
})
|
||||
.sort(function compare(a, b) {
|
||||
if (a.matchCount > b.matchCount) {
|
||||
return -1;
|
||||
}
|
||||
if (a.matchCount < b.matchCount) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
message.total = results.length;
|
||||
message.results = results
|
||||
.slice(0, data.maxResults);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
self.onmessage = function (event) {
|
||||
if (event.data.request === 'index') {
|
||||
indexedItems.push({
|
||||
id: event.data.id,
|
||||
model: event.data.model
|
||||
});
|
||||
indexItem(event.data.id, event.data.model);
|
||||
} else if (event.data.request === 'search') {
|
||||
self.postMessage(search(event.data));
|
||||
}
|
||||
};
|
||||
}());
|
||||
}());
|
||||
|
@ -24,122 +24,201 @@
|
||||
/**
|
||||
* Module defining SearchAggregator. Created by shale on 07/16/2015.
|
||||
*/
|
||||
define(
|
||||
[],
|
||||
function () {
|
||||
"use strict";
|
||||
define([
|
||||
|
||||
var DEFUALT_TIMEOUT = 1000,
|
||||
DEFAULT_MAX_RESULTS = 100;
|
||||
|
||||
/**
|
||||
* Allows multiple services which provide search functionality
|
||||
* to be treated as one.
|
||||
*
|
||||
* @constructor
|
||||
* @param $q Angular's $q, for promise consolidation.
|
||||
* @param {SearchProvider[]} providers The search providers to be
|
||||
* aggregated.
|
||||
*/
|
||||
function SearchAggregator($q, providers) {
|
||||
this.$q = $q;
|
||||
this.providers = providers;
|
||||
], function (
|
||||
|
||||
) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Aggregates multiple search providers as a singular search provider.
|
||||
* Search providers are expected to implement a `query` method which returns
|
||||
* a promise for a `modelResults` object.
|
||||
*
|
||||
* The search aggregator combines the results from multiple providers,
|
||||
* removes aggregates, and converts the results to domain objects.
|
||||
*
|
||||
* @constructor
|
||||
* @param $q Angular's $q, for promise consolidation.
|
||||
* @param objectService
|
||||
* @param {SearchProvider[]} providers The search providers to be
|
||||
* aggregated.
|
||||
*/
|
||||
function SearchAggregator($q, objectService, providers) {
|
||||
this.$q = $q;
|
||||
this.objectService = objectService;
|
||||
this.providers = providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* If max results is not specified in query, use this as default.
|
||||
*/
|
||||
SearchAggregator.prototype.DEFAULT_MAX_RESULTS = 100;
|
||||
|
||||
/**
|
||||
* Because filtering isn't implemented inside each provider, the fudge
|
||||
* factor is a multiplier on the number of results returned-- more results
|
||||
* than requested will be fetched, and then will be filtered. This helps
|
||||
* provide more predictable pagination when large numbers of results are
|
||||
* returned but very few results match filters.
|
||||
*
|
||||
* If a provider level filter implementation is implemented in the future,
|
||||
* remove this.
|
||||
*/
|
||||
SearchAggregator.prototype.FUDGE_FACTOR = 5;
|
||||
|
||||
/**
|
||||
* Sends a query to each of the providers. Returns a promise for
|
||||
* a result object that has the format
|
||||
* {hits: searchResult[], total: number}
|
||||
* where a searchResult has the format
|
||||
* {id: string, object: domainObject, score: number}
|
||||
*
|
||||
* @param {String} inputText The text input that is the query.
|
||||
* @param {Number} maxResults (optional) The maximum number of results
|
||||
* that this function should return. If not provided, a
|
||||
* default of 100 will be used.
|
||||
* @param {Function} [filter] if provided, will be called for every
|
||||
* potential modelResult. If it returns false, the model result will be
|
||||
* excluded from the search results.
|
||||
* @returns {Promise} A Promise for a search result object.
|
||||
*/
|
||||
SearchAggregator.prototype.query = function (
|
||||
inputText,
|
||||
maxResults,
|
||||
filter
|
||||
) {
|
||||
|
||||
var aggregator = this,
|
||||
resultPromises;
|
||||
|
||||
if (!maxResults) {
|
||||
maxResults = this.DEFAULT_MAX_RESULTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a query to each of the providers. Returns a promise for
|
||||
* a result object that has the format
|
||||
* {hits: searchResult[], total: number, timedOut: boolean}
|
||||
* where a searchResult has the format
|
||||
* {id: string, object: domainObject, score: number}
|
||||
*
|
||||
* @param inputText The text input that is the query.
|
||||
* @param maxResults (optional) The maximum number of results
|
||||
* that this function should return. If not provided, a
|
||||
* default of 100 will be used.
|
||||
*/
|
||||
SearchAggregator.prototype.query = function queryAll(inputText, maxResults) {
|
||||
var $q = this.$q,
|
||||
providers = this.providers,
|
||||
i,
|
||||
timestamp = Date.now(),
|
||||
resultPromises = [];
|
||||
resultPromises = this.providers.map(function (provider) {
|
||||
return provider.query(
|
||||
inputText,
|
||||
maxResults * aggregator.FUDGE_FACTOR
|
||||
);
|
||||
});
|
||||
|
||||
// Remove duplicate objects that have the same ID. Modifies the passed
|
||||
// array, and returns the number that were removed.
|
||||
function filterDuplicates(results, total) {
|
||||
var ids = {},
|
||||
numRemoved = 0,
|
||||
i;
|
||||
return this.$q
|
||||
.all(resultPromises)
|
||||
.then(function (providerResults) {
|
||||
var modelResults = {
|
||||
hits: [],
|
||||
total: 0
|
||||
};
|
||||
|
||||
for (i = 0; i < results.length; i += 1) {
|
||||
if (ids[results[i].id]) {
|
||||
// If this result's ID is already there, remove the object
|
||||
results.splice(i, 1);
|
||||
numRemoved += 1;
|
||||
|
||||
// Reduce loop index because we shortened the array
|
||||
i -= 1;
|
||||
} else {
|
||||
// Otherwise add the ID to the list of the ones we have seen
|
||||
ids[results[i].id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return numRemoved;
|
||||
}
|
||||
|
||||
// Order the objects from highest to lowest score in the array.
|
||||
// Modifies the passed array, as well as returns the modified array.
|
||||
function orderByScore(results) {
|
||||
results.sort(function (a, b) {
|
||||
if (a.score > b.score) {
|
||||
return -1;
|
||||
} else if (b.score > a.score) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
providerResults.forEach(function (providerResult) {
|
||||
modelResults.hits =
|
||||
modelResults.hits.concat(providerResult.hits);
|
||||
modelResults.total += providerResult.total;
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
if (!maxResults) {
|
||||
maxResults = DEFAULT_MAX_RESULTS;
|
||||
}
|
||||
modelResults = aggregator.orderByScore(modelResults);
|
||||
modelResults = aggregator.applyFilter(modelResults, filter);
|
||||
modelResults = aggregator.removeDuplicates(modelResults);
|
||||
|
||||
// Send the query to all the providers
|
||||
for (i = 0; i < providers.length; i += 1) {
|
||||
resultPromises.push(
|
||||
providers[i].query(inputText, timestamp, maxResults, DEFUALT_TIMEOUT)
|
||||
);
|
||||
}
|
||||
|
||||
// Get promises for results arrays
|
||||
return $q.all(resultPromises).then(function (resultObjects) {
|
||||
var results = [],
|
||||
totalSum = 0,
|
||||
i;
|
||||
|
||||
// Merge results
|
||||
for (i = 0; i < resultObjects.length; i += 1) {
|
||||
results = results.concat(resultObjects[i].hits);
|
||||
totalSum += resultObjects[i].total;
|
||||
}
|
||||
// Order by score first, so that when removing repeats we keep the higher scored ones
|
||||
orderByScore(results);
|
||||
totalSum -= filterDuplicates(results, totalSum);
|
||||
|
||||
return {
|
||||
hits: results,
|
||||
total: totalSum,
|
||||
timedOut: resultObjects.some(function (obj) {
|
||||
return obj.timedOut;
|
||||
})
|
||||
};
|
||||
return aggregator.asObjectResults(modelResults);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
return SearchAggregator;
|
||||
}
|
||||
);
|
||||
/**
|
||||
* Order model results by score descending and return them.
|
||||
*/
|
||||
SearchAggregator.prototype.orderByScore = function (modelResults) {
|
||||
modelResults.hits.sort(function (a, b) {
|
||||
if (a.score > b.score) {
|
||||
return -1;
|
||||
} else if (b.score > a.score) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return modelResults;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a filter to each model result, removing it from search results
|
||||
* if it does not match.
|
||||
*/
|
||||
SearchAggregator.prototype.applyFilter = function (modelResults, filter) {
|
||||
if (!filter) {
|
||||
return modelResults;
|
||||
}
|
||||
var initialLength = modelResults.hits.length,
|
||||
finalLength,
|
||||
removedByFilter;
|
||||
|
||||
modelResults.hits = modelResults.hits.filter(function (hit) {
|
||||
return filter(hit.model);
|
||||
});
|
||||
|
||||
finalLength = modelResults.hits.length;
|
||||
removedByFilter = initialLength - finalLength;
|
||||
modelResults.total -= removedByFilter;
|
||||
|
||||
return modelResults;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove duplicate hits in a modelResults object, and decrement `total`
|
||||
* each time a duplicate is removed.
|
||||
*/
|
||||
SearchAggregator.prototype.removeDuplicates = function (modelResults) {
|
||||
var includedIds = {};
|
||||
|
||||
modelResults.hits = modelResults
|
||||
.hits
|
||||
.filter(function alreadyInResults(hit) {
|
||||
if (includedIds[hit.id]) {
|
||||
modelResults.total -= 1;
|
||||
return false;
|
||||
}
|
||||
includedIds[hit.id] = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
return modelResults;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert modelResults to objectResults by fetching them from the object
|
||||
* service.
|
||||
*
|
||||
* @returns {Promise} for an objectResults object.
|
||||
*/
|
||||
SearchAggregator.prototype.asObjectResults = function (modelResults) {
|
||||
var objectIds = modelResults.hits.map(function (modelResult) {
|
||||
return modelResult.id;
|
||||
});
|
||||
|
||||
return this
|
||||
.objectService
|
||||
.getObjects(objectIds)
|
||||
.then(function (objects) {
|
||||
|
||||
var objectResults = {
|
||||
total: modelResults.total
|
||||
};
|
||||
|
||||
objectResults.hits = modelResults
|
||||
.hits
|
||||
.map(function asObjectResult(hit) {
|
||||
return {
|
||||
id: hit.id,
|
||||
object: objects[hit.id],
|
||||
score: hit.score
|
||||
};
|
||||
});
|
||||
|
||||
return objectResults;
|
||||
});
|
||||
};
|
||||
|
||||
return SearchAggregator;
|
||||
});
|
||||
|
@ -4,12 +4,12 @@
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* 'License'); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
@ -24,185 +24,162 @@
|
||||
/**
|
||||
* SearchSpec. Created by shale on 07/31/2015.
|
||||
*/
|
||||
define(
|
||||
["../../src/controllers/SearchController"],
|
||||
function (SearchController) {
|
||||
"use strict";
|
||||
define([
|
||||
'../../src/controllers/SearchController'
|
||||
], function (
|
||||
SearchController
|
||||
) {
|
||||
'use strict';
|
||||
|
||||
// These should be the same as the ones on the top of the search controller
|
||||
var INITIAL_LOAD_NUMBER = 20,
|
||||
LOAD_INCREMENT = 20;
|
||||
|
||||
describe("The search controller", function () {
|
||||
var mockScope,
|
||||
mockSearchService,
|
||||
mockPromise,
|
||||
mockSearchResult,
|
||||
mockDomainObject,
|
||||
mockTypes,
|
||||
controller;
|
||||
describe('The search controller', function () {
|
||||
var mockScope,
|
||||
mockSearchService,
|
||||
mockPromise,
|
||||
mockSearchResult,
|
||||
mockDomainObject,
|
||||
mockTypes,
|
||||
controller;
|
||||
|
||||
function bigArray(size) {
|
||||
var array = [],
|
||||
i;
|
||||
for (i = 0; i < size; i += 1) {
|
||||
array.push(mockSearchResult);
|
||||
}
|
||||
return array;
|
||||
function bigArray(size) {
|
||||
var array = [],
|
||||
i;
|
||||
for (i = 0; i < size; i += 1) {
|
||||
array.push(mockSearchResult);
|
||||
}
|
||||
|
||||
|
||||
beforeEach(function () {
|
||||
mockScope = jasmine.createSpyObj(
|
||||
"$scope",
|
||||
[ "$watch" ]
|
||||
);
|
||||
mockScope.ngModel = {};
|
||||
mockScope.ngModel.input = "test input";
|
||||
mockScope.ngModel.checked = {};
|
||||
mockScope.ngModel.checked['mock.type'] = true;
|
||||
return array;
|
||||
}
|
||||
|
||||
|
||||
beforeEach(function () {
|
||||
mockScope = jasmine.createSpyObj(
|
||||
'$scope',
|
||||
[ '$watch' ]
|
||||
);
|
||||
mockScope.ngModel = {};
|
||||
mockScope.ngModel.input = 'test input';
|
||||
mockScope.ngModel.checked = {};
|
||||
mockScope.ngModel.checked['mock.type'] = true;
|
||||
mockScope.ngModel.checkAll = true;
|
||||
|
||||
mockSearchService = jasmine.createSpyObj(
|
||||
'searchService',
|
||||
[ 'query' ]
|
||||
);
|
||||
mockPromise = jasmine.createSpyObj(
|
||||
'promise',
|
||||
[ 'then' ]
|
||||
);
|
||||
mockSearchService.query.andReturn(mockPromise);
|
||||
|
||||
mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}];
|
||||
|
||||
mockSearchResult = jasmine.createSpyObj(
|
||||
'searchResult',
|
||||
[ '' ]
|
||||
);
|
||||
mockDomainObject = jasmine.createSpyObj(
|
||||
'domainObject',
|
||||
[ 'getModel' ]
|
||||
);
|
||||
mockSearchResult.object = mockDomainObject;
|
||||
mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'});
|
||||
|
||||
controller = new SearchController(mockScope, mockSearchService, mockTypes);
|
||||
controller.search();
|
||||
});
|
||||
|
||||
it('has a default number of results per page', function () {
|
||||
expect(controller.RESULTS_PER_PAGE).toBe(20);
|
||||
});
|
||||
|
||||
it('sends queries to the search service', function () {
|
||||
expect(mockSearchService.query).toHaveBeenCalledWith(
|
||||
'test input',
|
||||
controller.RESULTS_PER_PAGE,
|
||||
jasmine.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
describe('filter query function', function () {
|
||||
it('returns true when all types allowed', function () {
|
||||
mockScope.ngModel.checkAll = true;
|
||||
|
||||
mockSearchService = jasmine.createSpyObj(
|
||||
"searchService",
|
||||
[ "query" ]
|
||||
);
|
||||
mockPromise = jasmine.createSpyObj(
|
||||
"promise",
|
||||
[ "then" ]
|
||||
);
|
||||
mockSearchService.query.andReturn(mockPromise);
|
||||
|
||||
mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}];
|
||||
|
||||
mockSearchResult = jasmine.createSpyObj(
|
||||
"searchResult",
|
||||
[ "" ]
|
||||
);
|
||||
mockDomainObject = jasmine.createSpyObj(
|
||||
"domainObject",
|
||||
[ "getModel" ]
|
||||
);
|
||||
mockSearchResult.object = mockDomainObject;
|
||||
mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'});
|
||||
|
||||
controller = new SearchController(mockScope, mockSearchService, mockTypes);
|
||||
controller.search();
|
||||
});
|
||||
|
||||
it("sends queries to the search service", function () {
|
||||
expect(mockSearchService.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("populates the results with results from the search service", function () {
|
||||
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
|
||||
mockPromise.then.mostRecentCall.args[0]({hits: []});
|
||||
|
||||
expect(mockScope.results).toBeDefined();
|
||||
});
|
||||
|
||||
it("is loading until the service's promise fufills", function () {
|
||||
// Send query
|
||||
controller.search();
|
||||
expect(mockScope.loading).toBeTruthy();
|
||||
|
||||
// Then resolve the promises
|
||||
mockPromise.then.mostRecentCall.args[0]({hits: []});
|
||||
expect(mockScope.loading).toBeFalsy();
|
||||
controller.onFilterChange();
|
||||
var filterFn = mockSearchService.query.mostRecentCall.args[2];
|
||||
expect(filterFn('askbfa')).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it("displays only some results when there are many", function () {
|
||||
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
|
||||
mockPromise.then.mostRecentCall.args[0]({hits: bigArray(100)});
|
||||
|
||||
expect(mockScope.results).toBeDefined();
|
||||
expect(mockScope.results.length).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("detects when there are more results", function () {
|
||||
it('returns true only for matching checked types', function () {
|
||||
mockScope.ngModel.checkAll = false;
|
||||
|
||||
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
|
||||
mockPromise.then.mostRecentCall.args[0]({
|
||||
hits: bigArray(INITIAL_LOAD_NUMBER + 5),
|
||||
total: INITIAL_LOAD_NUMBER + 5
|
||||
});
|
||||
// bigArray gives searchResults of type 'mock.type'
|
||||
mockScope.ngModel.checked['mock.type'] = false;
|
||||
mockScope.ngModel.checked['mock.type.2'] = true;
|
||||
|
||||
expect(controller.areMore()).toBeFalsy();
|
||||
|
||||
mockScope.ngModel.checked['mock.type'] = true;
|
||||
|
||||
expect(controller.areMore()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("can load more results", function () {
|
||||
var oldSize;
|
||||
|
||||
expect(mockPromise.then).toHaveBeenCalled();
|
||||
mockPromise.then.mostRecentCall.args[0]({
|
||||
hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1),
|
||||
total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1
|
||||
});
|
||||
// These hits and total lengths are the case where the controller
|
||||
// DOES NOT have to re-search to load more results
|
||||
oldSize = mockScope.results.length;
|
||||
|
||||
expect(controller.areMore()).toBeTruthy();
|
||||
|
||||
controller.loadMore();
|
||||
expect(mockScope.results.length).toBeGreaterThan(oldSize);
|
||||
});
|
||||
|
||||
it("can re-search to load more results", function () {
|
||||
var oldSize,
|
||||
oldCallCount;
|
||||
|
||||
expect(mockPromise.then).toHaveBeenCalled();
|
||||
mockPromise.then.mostRecentCall.args[0]({
|
||||
hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT - 1),
|
||||
total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1
|
||||
});
|
||||
// These hits and total lengths are the case where the controller
|
||||
// DOES have to re-search to load more results
|
||||
oldSize = mockScope.results.length;
|
||||
oldCallCount = mockPromise.then.callCount;
|
||||
expect(controller.areMore()).toBeTruthy();
|
||||
|
||||
controller.loadMore();
|
||||
expect(mockPromise.then).toHaveBeenCalled();
|
||||
// Make sure that a NEW call to search has been made
|
||||
expect(oldCallCount).toBeLessThan(mockPromise.then.callCount);
|
||||
mockPromise.then.mostRecentCall.args[0]({
|
||||
hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1),
|
||||
total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1
|
||||
});
|
||||
expect(mockScope.results.length).toBeGreaterThan(oldSize);
|
||||
});
|
||||
|
||||
it("sets the ngModel.search flag", function () {
|
||||
// Flag should be true with nonempty input
|
||||
expect(mockScope.ngModel.search).toEqual(true);
|
||||
|
||||
// Flag should be flase with empty input
|
||||
mockScope.ngModel.input = "";
|
||||
controller.search();
|
||||
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
|
||||
expect(mockScope.ngModel.search).toEqual(false);
|
||||
|
||||
// Both the empty string and undefined should be 'empty input'
|
||||
mockScope.ngModel.input = undefined;
|
||||
controller.search();
|
||||
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
|
||||
expect(mockScope.ngModel.search).toEqual(false);
|
||||
});
|
||||
|
||||
it("has a default results list to filter from", function () {
|
||||
expect(mockScope.ngModel.filter()).toBeDefined();
|
||||
controller.onFilterChange();
|
||||
var filterFn = mockSearchService.query.mostRecentCall.args[2];
|
||||
expect(filterFn({type: 'mock.type'})).toBe(true);
|
||||
expect(filterFn({type: 'other.type'})).toBe(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('populates the results with results from the search service', function () {
|
||||
expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function));
|
||||
mockPromise.then.mostRecentCall.args[0]({hits: ['a']});
|
||||
|
||||
expect(mockScope.results.length).toBe(1);
|
||||
expect(mockScope.results).toContain('a');
|
||||
});
|
||||
|
||||
it('is loading until the service\'s promise fufills', function () {
|
||||
expect(mockScope.loading).toBeTruthy();
|
||||
|
||||
// Then resolve the promises
|
||||
mockPromise.then.mostRecentCall.args[0]({hits: []});
|
||||
expect(mockScope.loading).toBeFalsy();
|
||||
});
|
||||
|
||||
it('detects when there are more results', function () {
|
||||
mockPromise.then.mostRecentCall.args[0]({
|
||||
hits: bigArray(controller.RESULTS_PER_PAGE),
|
||||
total: controller.RESULTS_PER_PAGE + 5
|
||||
});
|
||||
|
||||
expect(mockScope.results.length).toBe(controller.RESULTS_PER_PAGE);
|
||||
expect(controller.areMore()).toBeTruthy();
|
||||
|
||||
controller.loadMore();
|
||||
|
||||
expect(mockSearchService.query).toHaveBeenCalledWith(
|
||||
'test input',
|
||||
controller.RESULTS_PER_PAGE * 2,
|
||||
jasmine.any(Function)
|
||||
);
|
||||
|
||||
mockPromise.then.mostRecentCall.args[0]({
|
||||
hits: bigArray(controller.RESULTS_PER_PAGE + 5),
|
||||
total: controller.RESULTS_PER_PAGE + 5
|
||||
});
|
||||
|
||||
expect(mockScope.results.length)
|
||||
.toBe(controller.RESULTS_PER_PAGE + 5);
|
||||
|
||||
expect(controller.areMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('sets the ngModel.search flag', function () {
|
||||
// Flag should be true with nonempty input
|
||||
expect(mockScope.ngModel.search).toEqual(true);
|
||||
|
||||
// Flag should be flase with empty input
|
||||
mockScope.ngModel.input = '';
|
||||
controller.search();
|
||||
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
|
||||
expect(mockScope.ngModel.search).toEqual(false);
|
||||
|
||||
// Both the empty string and undefined should be 'empty input'
|
||||
mockScope.ngModel.input = undefined;
|
||||
controller.search();
|
||||
mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0});
|
||||
expect(mockScope.ngModel.search).toEqual(false);
|
||||
});
|
||||
|
||||
it('attaches a filter function to scope', function () {
|
||||
expect(mockScope.ngModel.filter).toEqual(jasmine.any(Function));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -19,275 +19,313 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
/*global define,describe,it,expect,beforeEach,jasmine*/
|
||||
/*global define,describe,it,expect,beforeEach,jasmine,Promise,spyOn,waitsFor,
|
||||
runs*/
|
||||
|
||||
/**
|
||||
* SearchSpec. Created by shale on 07/31/2015.
|
||||
*/
|
||||
define(
|
||||
["../../src/services/GenericSearchProvider"],
|
||||
function (GenericSearchProvider) {
|
||||
"use strict";
|
||||
define([
|
||||
"../../src/services/GenericSearchProvider"
|
||||
], function (
|
||||
GenericSearchProvider
|
||||
) {
|
||||
"use strict";
|
||||
|
||||
describe("The generic search provider ", function () {
|
||||
var mockQ,
|
||||
mockLog,
|
||||
mockThrottle,
|
||||
mockDeferred,
|
||||
mockObjectService,
|
||||
mockObjectPromise,
|
||||
mockChainedPromise,
|
||||
mockDomainObjects,
|
||||
mockCapability,
|
||||
mockCapabilityPromise,
|
||||
mockWorkerService,
|
||||
mockWorker,
|
||||
mockTopic,
|
||||
mockMutationTopic,
|
||||
mockRoots = ['root1', 'root2'],
|
||||
mockThrottledFn,
|
||||
throttledCallCount,
|
||||
provider,
|
||||
mockProviderResults;
|
||||
describe('GenericSearchProvider', function () {
|
||||
var $q,
|
||||
$log,
|
||||
modelService,
|
||||
models,
|
||||
workerService,
|
||||
worker,
|
||||
topic,
|
||||
mutationTopic,
|
||||
ROOTS,
|
||||
provider;
|
||||
|
||||
function resolveObjectPromises() {
|
||||
var i;
|
||||
for (i = 0; i < mockObjectPromise.then.calls.length; i += 1) {
|
||||
mockChainedPromise.then.calls[i].args[0](
|
||||
mockObjectPromise.then.calls[i]
|
||||
.args[0](mockDomainObjects)
|
||||
);
|
||||
}
|
||||
}
|
||||
beforeEach(function () {
|
||||
$q = jasmine.createSpyObj(
|
||||
'$q',
|
||||
['defer']
|
||||
);
|
||||
$log = jasmine.createSpyObj(
|
||||
'$log',
|
||||
['warn']
|
||||
);
|
||||
models = {};
|
||||
modelService = jasmine.createSpyObj(
|
||||
'modelService',
|
||||
['getModels']
|
||||
);
|
||||
modelService.getModels.andReturn(Promise.resolve(models));
|
||||
workerService = jasmine.createSpyObj(
|
||||
'workerService',
|
||||
['run']
|
||||
);
|
||||
worker = jasmine.createSpyObj(
|
||||
'worker',
|
||||
[
|
||||
'postMessage',
|
||||
'addEventListener'
|
||||
]
|
||||
);
|
||||
workerService.run.andReturn(worker);
|
||||
topic = jasmine.createSpy('topic');
|
||||
mutationTopic = jasmine.createSpyObj(
|
||||
'mutationTopic',
|
||||
['listen']
|
||||
);
|
||||
topic.andReturn(mutationTopic);
|
||||
ROOTS = [
|
||||
'mine'
|
||||
];
|
||||
|
||||
function resolveThrottledFn() {
|
||||
if (mockThrottledFn.calls.length > throttledCallCount) {
|
||||
mockThrottle.mostRecentCall.args[0]();
|
||||
throttledCallCount = mockThrottledFn.calls.length;
|
||||
}
|
||||
}
|
||||
spyOn(GenericSearchProvider.prototype, 'scheduleForIndexing');
|
||||
|
||||
function resolveAsyncTasks() {
|
||||
resolveThrottledFn();
|
||||
resolveObjectPromises();
|
||||
}
|
||||
provider = new GenericSearchProvider(
|
||||
$q,
|
||||
$log,
|
||||
modelService,
|
||||
workerService,
|
||||
topic,
|
||||
ROOTS
|
||||
);
|
||||
});
|
||||
|
||||
it('listens for general mutation', function () {
|
||||
expect(topic).toHaveBeenCalledWith('mutation');
|
||||
expect(mutationTopic.listen)
|
||||
.toHaveBeenCalledWith(jasmine.any(Function));
|
||||
});
|
||||
|
||||
it('starts indexing roots', function () {
|
||||
expect(provider.scheduleForIndexing).toHaveBeenCalledWith('mine');
|
||||
});
|
||||
|
||||
it('runs a worker', function () {
|
||||
expect(workerService.run)
|
||||
.toHaveBeenCalledWith('genericSearchWorker');
|
||||
});
|
||||
|
||||
it('listens for messages from worker', function () {
|
||||
expect(worker.addEventListener)
|
||||
.toHaveBeenCalledWith('message', jasmine.any(Function));
|
||||
spyOn(provider, 'onWorkerMessage');
|
||||
worker.addEventListener.mostRecentCall.args[1]('mymessage');
|
||||
expect(provider.onWorkerMessage).toHaveBeenCalledWith('mymessage');
|
||||
});
|
||||
|
||||
it('has a maximum number of concurrent requests', function () {
|
||||
expect(provider.MAX_CONCURRENT_REQUESTS).toBe(100);
|
||||
});
|
||||
|
||||
describe('scheduleForIndexing', function () {
|
||||
beforeEach(function () {
|
||||
provider.scheduleForIndexing.andCallThrough();
|
||||
spyOn(provider, 'keepIndexing');
|
||||
});
|
||||
|
||||
it('tracks ids to index', function () {
|
||||
expect(provider.indexedIds.a).not.toBeDefined();
|
||||
expect(provider.pendingIndex.a).not.toBeDefined();
|
||||
expect(provider.idsToIndex).not.toContain('a');
|
||||
provider.scheduleForIndexing('a');
|
||||
expect(provider.indexedIds.a).toBeDefined();
|
||||
expect(provider.pendingIndex.a).toBeDefined();
|
||||
expect(provider.idsToIndex).toContain('a');
|
||||
});
|
||||
|
||||
it('calls keep indexing', function () {
|
||||
provider.scheduleForIndexing('a');
|
||||
expect(provider.keepIndexing).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepIndexing', function () {
|
||||
it('calls beginIndexRequest until at maximum', function () {
|
||||
spyOn(provider, 'beginIndexRequest').andCallThrough();
|
||||
provider.pendingRequests = 9;
|
||||
provider.idsToIndex = ['a', 'b', 'c'];
|
||||
provider.MAX_CONCURRENT_REQUESTS = 10;
|
||||
provider.keepIndexing();
|
||||
expect(provider.beginIndexRequest).toHaveBeenCalled();
|
||||
expect(provider.beginIndexRequest.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('calls beginIndexRequest for all ids to index', function () {
|
||||
spyOn(provider, 'beginIndexRequest').andCallThrough();
|
||||
provider.pendingRequests = 0;
|
||||
provider.idsToIndex = ['a', 'b', 'c'];
|
||||
provider.MAX_CONCURRENT_REQUESTS = 10;
|
||||
provider.keepIndexing();
|
||||
expect(provider.beginIndexRequest).toHaveBeenCalled();
|
||||
expect(provider.beginIndexRequest.calls.length).toBe(3);
|
||||
});
|
||||
|
||||
it('does not index when at capacity', function () {
|
||||
spyOn(provider, 'beginIndexRequest');
|
||||
provider.pendingRequests = 10;
|
||||
provider.idsToIndex.push('a');
|
||||
provider.MAX_CONCURRENT_REQUESTS = 10;
|
||||
provider.keepIndexing();
|
||||
expect(provider.beginIndexRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not index when no ids to index', function () {
|
||||
spyOn(provider, 'beginIndexRequest');
|
||||
provider.pendingRequests = 0;
|
||||
provider.MAX_CONCURRENT_REQUESTS = 10;
|
||||
provider.keepIndexing();
|
||||
expect(provider.beginIndexRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('index', function () {
|
||||
it('sends index message to worker', function () {
|
||||
var id = 'anId',
|
||||
model = {};
|
||||
|
||||
provider.index(id, model);
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({
|
||||
request: 'index',
|
||||
id: id,
|
||||
model: model
|
||||
});
|
||||
});
|
||||
|
||||
it('schedules composed ids for indexing', function () {
|
||||
var id = 'anId',
|
||||
model = {composition: ['abc', 'def']};
|
||||
|
||||
provider.index(id, model);
|
||||
expect(provider.scheduleForIndexing)
|
||||
.toHaveBeenCalledWith('abc');
|
||||
expect(provider.scheduleForIndexing)
|
||||
.toHaveBeenCalledWith('def');
|
||||
});
|
||||
});
|
||||
|
||||
describe('beginIndexRequest', function () {
|
||||
|
||||
beforeEach(function () {
|
||||
mockQ = jasmine.createSpyObj(
|
||||
"$q",
|
||||
[ "defer" ]
|
||||
);
|
||||
mockLog = jasmine.createSpyObj(
|
||||
"$log",
|
||||
[ "error", "warn", "info", "debug" ]
|
||||
);
|
||||
mockDeferred = jasmine.createSpyObj(
|
||||
"deferred",
|
||||
[ "resolve", "reject"]
|
||||
);
|
||||
mockDeferred.promise = "mock promise";
|
||||
mockQ.defer.andReturn(mockDeferred);
|
||||
|
||||
mockThrottle = jasmine.createSpy("throttle");
|
||||
mockThrottledFn = jasmine.createSpy("throttledFn");
|
||||
throttledCallCount = 0;
|
||||
|
||||
mockObjectService = jasmine.createSpyObj(
|
||||
"objectService",
|
||||
[ "getObjects" ]
|
||||
);
|
||||
mockObjectPromise = jasmine.createSpyObj(
|
||||
"promise",
|
||||
[ "then", "catch" ]
|
||||
);
|
||||
mockChainedPromise = jasmine.createSpyObj(
|
||||
"chainedPromise",
|
||||
[ "then" ]
|
||||
);
|
||||
mockObjectService.getObjects.andReturn(mockObjectPromise);
|
||||
|
||||
mockTopic = jasmine.createSpy('topic');
|
||||
|
||||
mockWorkerService = jasmine.createSpyObj(
|
||||
"workerService",
|
||||
[ "run" ]
|
||||
);
|
||||
mockWorker = jasmine.createSpyObj(
|
||||
"worker",
|
||||
[ "postMessage" ]
|
||||
);
|
||||
mockWorkerService.run.andReturn(mockWorker);
|
||||
|
||||
mockCapabilityPromise = jasmine.createSpyObj(
|
||||
"promise",
|
||||
[ "then", "catch" ]
|
||||
);
|
||||
|
||||
mockDomainObjects = {};
|
||||
['a', 'root1', 'root2'].forEach(function (id) {
|
||||
mockDomainObjects[id] = (
|
||||
jasmine.createSpyObj(
|
||||
"domainObject",
|
||||
[
|
||||
"getId",
|
||||
"getModel",
|
||||
"hasCapability",
|
||||
"getCapability",
|
||||
"useCapability"
|
||||
]
|
||||
)
|
||||
);
|
||||
mockDomainObjects[id].getId.andReturn(id);
|
||||
mockDomainObjects[id].getCapability.andReturn(mockCapability);
|
||||
mockDomainObjects[id].useCapability.andReturn(mockCapabilityPromise);
|
||||
mockDomainObjects[id].getModel.andReturn({});
|
||||
});
|
||||
|
||||
mockCapability = jasmine.createSpyObj(
|
||||
"capability",
|
||||
[ "invoke", "listen" ]
|
||||
);
|
||||
mockCapability.invoke.andReturn(mockCapabilityPromise);
|
||||
mockDomainObjects.a.getCapability.andReturn(mockCapability);
|
||||
mockMutationTopic = jasmine.createSpyObj(
|
||||
'mutationTopic',
|
||||
[ 'listen' ]
|
||||
);
|
||||
mockTopic.andCallFake(function (key) {
|
||||
return key === 'mutation' && mockMutationTopic;
|
||||
});
|
||||
mockThrottle.andReturn(mockThrottledFn);
|
||||
mockObjectPromise.then.andReturn(mockChainedPromise);
|
||||
|
||||
provider = new GenericSearchProvider(
|
||||
mockQ,
|
||||
mockLog,
|
||||
mockThrottle,
|
||||
mockObjectService,
|
||||
mockWorkerService,
|
||||
mockTopic,
|
||||
mockRoots
|
||||
);
|
||||
provider.pendingRequests = 0;
|
||||
provider.pendingIds = {'abc': true};
|
||||
provider.idsToIndex = ['abc'];
|
||||
models.abc = {};
|
||||
spyOn(provider, 'index');
|
||||
});
|
||||
|
||||
it("indexes tree on initialization", function () {
|
||||
var i;
|
||||
it('removes items from queue', function () {
|
||||
provider.beginIndexRequest();
|
||||
expect(provider.idsToIndex.length).toBe(0);
|
||||
});
|
||||
|
||||
resolveThrottledFn();
|
||||
|
||||
expect(mockObjectService.getObjects).toHaveBeenCalled();
|
||||
expect(mockObjectPromise.then).toHaveBeenCalled();
|
||||
|
||||
// Call through the root-getting part
|
||||
resolveObjectPromises();
|
||||
|
||||
mockRoots.forEach(function (id) {
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith({
|
||||
request: 'index',
|
||||
model: mockDomainObjects[id].getModel(),
|
||||
id: id
|
||||
});
|
||||
it('tracks number of pending requests', function () {
|
||||
provider.beginIndexRequest();
|
||||
expect(provider.pendingRequests).toBe(1);
|
||||
waitsFor(function () {
|
||||
return provider.pendingRequests === 0;
|
||||
});
|
||||
runs(function () {
|
||||
expect(provider.pendingRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("indexes members of composition", function () {
|
||||
mockDomainObjects.root1.getModel.andReturn({
|
||||
composition: ['a']
|
||||
it('indexes objects', function () {
|
||||
provider.beginIndexRequest();
|
||||
waitsFor(function () {
|
||||
return provider.pendingRequests === 0;
|
||||
});
|
||||
|
||||
resolveAsyncTasks();
|
||||
resolveAsyncTasks();
|
||||
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith({
|
||||
request: 'index',
|
||||
model: mockDomainObjects.a.getModel(),
|
||||
id: 'a'
|
||||
runs(function () {
|
||||
expect(provider.index)
|
||||
.toHaveBeenCalledWith('abc', models.abc);
|
||||
});
|
||||
});
|
||||
|
||||
it("listens for changes to mutation", function () {
|
||||
expect(mockMutationTopic.listen)
|
||||
.toHaveBeenCalledWith(jasmine.any(Function));
|
||||
mockMutationTopic.listen.mostRecentCall
|
||||
.args[0](mockDomainObjects.a);
|
||||
|
||||
resolveAsyncTasks();
|
||||
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith({
|
||||
request: 'index',
|
||||
model: mockDomainObjects.a.getModel(),
|
||||
id: mockDomainObjects.a.getId()
|
||||
});
|
||||
});
|
||||
|
||||
it("sends search queries to the worker", function () {
|
||||
var timestamp = Date.now();
|
||||
provider.query(' test "query" ', timestamp, 1, 2);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith({
|
||||
request: "search",
|
||||
input: ' test "query" ',
|
||||
timestamp: timestamp,
|
||||
maxNumber: 1,
|
||||
timeout: 2
|
||||
});
|
||||
});
|
||||
|
||||
it("gives an empty result for an empty query", function () {
|
||||
var timestamp = Date.now(),
|
||||
queryOutput;
|
||||
|
||||
queryOutput = provider.query('', timestamp, 1, 2);
|
||||
expect(queryOutput.hits).toEqual([]);
|
||||
expect(queryOutput.total).toEqual(0);
|
||||
|
||||
queryOutput = provider.query();
|
||||
expect(queryOutput.hits).toEqual([]);
|
||||
expect(queryOutput.total).toEqual(0);
|
||||
});
|
||||
|
||||
it("handles responses from the worker", function () {
|
||||
var timestamp = Date.now(),
|
||||
event = {
|
||||
data: {
|
||||
request: "search",
|
||||
results: {
|
||||
1: 1,
|
||||
2: 2
|
||||
},
|
||||
total: 2,
|
||||
timedOut: false,
|
||||
timestamp: timestamp
|
||||
}
|
||||
};
|
||||
|
||||
provider.query(' test "query" ', timestamp);
|
||||
mockWorker.onmessage(event);
|
||||
mockObjectPromise.then.mostRecentCall.args[0](mockDomainObjects);
|
||||
expect(mockDeferred.resolve).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when objects are unavailable", function () {
|
||||
resolveAsyncTasks();
|
||||
expect(mockLog.warn).not.toHaveBeenCalled();
|
||||
mockChainedPromise.then.mostRecentCall.args[0](
|
||||
mockObjectPromise.then.mostRecentCall.args[1]()
|
||||
);
|
||||
expect(mockLog.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throttles the loading of objects to index", function () {
|
||||
expect(mockObjectService.getObjects).not.toHaveBeenCalled();
|
||||
resolveThrottledFn();
|
||||
expect(mockObjectService.getObjects).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs when all objects have been processed", function () {
|
||||
expect(mockLog.info).not.toHaveBeenCalled();
|
||||
resolveAsyncTasks();
|
||||
resolveThrottledFn();
|
||||
expect(mockLog.info).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
it('can dispatch searches to worker', function () {
|
||||
spyOn(provider, 'makeQueryId').andReturn(428);
|
||||
expect(provider.dispatchSearch('searchTerm', 100))
|
||||
.toBe(428);
|
||||
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({
|
||||
request: 'search',
|
||||
input: 'searchTerm',
|
||||
maxResults: 100,
|
||||
queryId: 428
|
||||
});
|
||||
});
|
||||
|
||||
it('can generate queryIds', function () {
|
||||
expect(provider.makeQueryId()).toEqual(jasmine.any(Number));
|
||||
});
|
||||
|
||||
it('can query for terms', function () {
|
||||
var deferred = {promise: {}};
|
||||
spyOn(provider, 'dispatchSearch').andReturn(303);
|
||||
$q.defer.andReturn(deferred);
|
||||
|
||||
expect(provider.query('someTerm', 100)).toBe(deferred.promise);
|
||||
expect(provider.pendingQueries[303]).toBe(deferred);
|
||||
});
|
||||
|
||||
describe('onWorkerMessage', function () {
|
||||
var pendingQuery;
|
||||
beforeEach(function () {
|
||||
pendingQuery = jasmine.createSpyObj(
|
||||
'pendingQuery',
|
||||
['resolve']
|
||||
);
|
||||
provider.pendingQueries[143] = pendingQuery;
|
||||
});
|
||||
|
||||
it('resolves pending searches', function () {
|
||||
provider.onWorkerMessage({
|
||||
data: {
|
||||
request: 'search',
|
||||
total: 2,
|
||||
results: [
|
||||
{
|
||||
item: {
|
||||
id: 'abc',
|
||||
model: {id: 'abc'}
|
||||
},
|
||||
matchCount: 4
|
||||
},
|
||||
{
|
||||
item: {
|
||||
id: 'def',
|
||||
model: {id: 'def'}
|
||||
},
|
||||
matchCount: 2
|
||||
}
|
||||
],
|
||||
queryId: 143
|
||||
}
|
||||
});
|
||||
|
||||
expect(pendingQuery.resolve)
|
||||
.toHaveBeenCalledWith({
|
||||
total: 2,
|
||||
hits: [{
|
||||
id: 'abc',
|
||||
model: {id: 'abc'},
|
||||
score: 4
|
||||
}, {
|
||||
id: 'def',
|
||||
model: {id: 'def'},
|
||||
score: 2
|
||||
}]
|
||||
});
|
||||
|
||||
expect(provider.pendingQueries[143]).not.toBeDefined();
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
@ -4,12 +4,12 @@
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* 'License'); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
@ -19,114 +19,205 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
/*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker,require*/
|
||||
/*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker,
|
||||
require,afterEach*/
|
||||
|
||||
/**
|
||||
* SearchSpec. Created by shale on 07/31/2015.
|
||||
*/
|
||||
define(
|
||||
[],
|
||||
function () {
|
||||
"use strict";
|
||||
define([
|
||||
|
||||
describe("The generic search worker ", function () {
|
||||
// If this test fails, make sure this path is correct
|
||||
var worker = new Worker(require.toUrl('platform/search/src/services/GenericSearchWorker.js')),
|
||||
numObjects = 5;
|
||||
|
||||
beforeEach(function () {
|
||||
var i;
|
||||
for (i = 0; i < numObjects; i += 1) {
|
||||
worker.postMessage(
|
||||
{
|
||||
request: "index",
|
||||
id: i,
|
||||
model: {
|
||||
name: "object " + i,
|
||||
id: i,
|
||||
type: "something"
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("searches can reach all objects", function () {
|
||||
var flag = false,
|
||||
workerOutput,
|
||||
resultsLength = 0;
|
||||
|
||||
// Search something that should return all objects
|
||||
runs(function () {
|
||||
worker.postMessage(
|
||||
{
|
||||
request: "search",
|
||||
input: "object",
|
||||
maxNumber: 100,
|
||||
timestamp: Date.now(),
|
||||
timeout: 1000
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
worker.onmessage = function (event) {
|
||||
var id;
|
||||
|
||||
workerOutput = event.data;
|
||||
for (id in workerOutput.results) {
|
||||
resultsLength += 1;
|
||||
}
|
||||
flag = true;
|
||||
};
|
||||
|
||||
waitsFor(function () {
|
||||
return flag;
|
||||
}, "The worker should be searching", 1000);
|
||||
|
||||
runs(function () {
|
||||
expect(workerOutput).toBeDefined();
|
||||
expect(resultsLength).toEqual(numObjects);
|
||||
], function (
|
||||
|
||||
) {
|
||||
'use strict';
|
||||
|
||||
describe('GenericSearchWorker', function () {
|
||||
// If this test fails, make sure this path is correct
|
||||
var worker,
|
||||
objectX,
|
||||
objectY,
|
||||
objectZ,
|
||||
itemsToIndex,
|
||||
onMessage,
|
||||
data,
|
||||
waitForResult;
|
||||
|
||||
beforeEach(function () {
|
||||
worker = new Worker(
|
||||
require.toUrl('platform/search/src/services/GenericSearchWorker.js')
|
||||
);
|
||||
|
||||
objectX = {
|
||||
id: 'x',
|
||||
model: {name: 'object xx'}
|
||||
};
|
||||
objectY = {
|
||||
id: 'y',
|
||||
model: {name: 'object yy'}
|
||||
};
|
||||
objectZ = {
|
||||
id: 'z',
|
||||
model: {name: 'object zz'}
|
||||
};
|
||||
itemsToIndex = [
|
||||
objectX,
|
||||
objectY,
|
||||
objectZ
|
||||
];
|
||||
|
||||
itemsToIndex.forEach(function (item) {
|
||||
worker.postMessage({
|
||||
request: 'index',
|
||||
id: item.id,
|
||||
model: item.model
|
||||
});
|
||||
});
|
||||
|
||||
it("searches return only matches", function () {
|
||||
var flag = false,
|
||||
workerOutput,
|
||||
resultsLength = 0;
|
||||
|
||||
// Search something that should return 1 object
|
||||
runs(function () {
|
||||
worker.postMessage(
|
||||
{
|
||||
request: "search",
|
||||
input: "2",
|
||||
maxNumber: 100,
|
||||
timestamp: Date.now(),
|
||||
timeout: 1000
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
worker.onmessage = function (event) {
|
||||
var id;
|
||||
|
||||
workerOutput = event.data;
|
||||
for (id in workerOutput.results) {
|
||||
resultsLength += 1;
|
||||
}
|
||||
flag = true;
|
||||
};
|
||||
|
||||
|
||||
onMessage = jasmine.createSpy('onMessage');
|
||||
worker.addEventListener('message', onMessage);
|
||||
|
||||
waitForResult = function () {
|
||||
waitsFor(function () {
|
||||
return flag;
|
||||
}, "The worker should be searching", 1000);
|
||||
|
||||
runs(function () {
|
||||
expect(workerOutput).toBeDefined();
|
||||
expect(resultsLength).toEqual(1);
|
||||
expect(workerOutput.results[2]).toBeDefined();
|
||||
if (onMessage.calls.length > 0) {
|
||||
data = onMessage.calls[0].args[0].data;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
worker.terminate();
|
||||
});
|
||||
|
||||
it('returns search results for partial term matches', function () {
|
||||
|
||||
worker.postMessage({
|
||||
request: 'search',
|
||||
input: 'obj',
|
||||
maxResults: 100,
|
||||
queryId: 123
|
||||
});
|
||||
|
||||
waitForResult();
|
||||
|
||||
runs(function () {
|
||||
expect(onMessage).toHaveBeenCalled();
|
||||
|
||||
expect(data.request).toBe('search');
|
||||
expect(data.total).toBe(3);
|
||||
expect(data.queryId).toBe(123);
|
||||
expect(data.results.length).toBe(3);
|
||||
expect(data.results[0].item.id).toBe('x');
|
||||
expect(data.results[0].item.model).toEqual(objectX.model);
|
||||
expect(data.results[0].matchCount).toBe(1);
|
||||
expect(data.results[1].item.id).toBe('y');
|
||||
expect(data.results[1].item.model).toEqual(objectY.model);
|
||||
expect(data.results[1].matchCount).toBe(1);
|
||||
expect(data.results[2].item.id).toBe('z');
|
||||
expect(data.results[2].item.model).toEqual(objectZ.model);
|
||||
expect(data.results[2].matchCount).toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('scores exact term matches higher', function () {
|
||||
worker.postMessage({
|
||||
request: 'search',
|
||||
input: 'object',
|
||||
maxResults: 100,
|
||||
queryId: 234
|
||||
});
|
||||
|
||||
waitForResult();
|
||||
|
||||
runs(function () {
|
||||
expect(data.queryId).toBe(234);
|
||||
expect(data.results.length).toBe(3);
|
||||
expect(data.results[0].item.id).toBe('x');
|
||||
expect(data.results[0].matchCount).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
it('can find partial term matches', function () {
|
||||
worker.postMessage({
|
||||
request: 'search',
|
||||
input: 'x',
|
||||
maxResults: 100,
|
||||
queryId: 345
|
||||
});
|
||||
|
||||
waitForResult();
|
||||
|
||||
runs(function () {
|
||||
expect(data.queryId).toBe(345);
|
||||
expect(data.results.length).toBe(1);
|
||||
expect(data.results[0].item.id).toBe('x');
|
||||
expect(data.results[0].matchCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('matches individual terms', function () {
|
||||
worker.postMessage({
|
||||
request: 'search',
|
||||
input: 'x y z',
|
||||
maxResults: 100,
|
||||
queryId: 456
|
||||
});
|
||||
|
||||
waitForResult();
|
||||
|
||||
runs(function () {
|
||||
expect(data.queryId).toBe(456);
|
||||
expect(data.results.length).toBe(3);
|
||||
expect(data.results[0].item.id).toBe('x');
|
||||
expect(data.results[0].matchCount).toBe(1);
|
||||
expect(data.results[1].item.id).toBe('y');
|
||||
expect(data.results[1].matchCount).toBe(1);
|
||||
expect(data.results[2].item.id).toBe('z');
|
||||
expect(data.results[1].matchCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('scores exact matches highest', function () {
|
||||
worker.postMessage({
|
||||
request: 'search',
|
||||
input: 'object xx',
|
||||
maxResults: 100,
|
||||
queryId: 567
|
||||
});
|
||||
|
||||
waitForResult();
|
||||
|
||||
runs(function () {
|
||||
expect(data.queryId).toBe(567);
|
||||
expect(data.results.length).toBe(3);
|
||||
expect(data.results[0].item.id).toBe('x');
|
||||
expect(data.results[0].matchCount).toBe(103);
|
||||
expect(data.results[1].matchCount).toBe(1.5);
|
||||
expect(data.results[2].matchCount).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
it('scores multiple term match above single match', function () {
|
||||
worker.postMessage({
|
||||
request: 'search',
|
||||
input: 'obj x',
|
||||
maxResults: 100,
|
||||
queryId: 678
|
||||
});
|
||||
|
||||
waitForResult();
|
||||
|
||||
runs(function () {
|
||||
expect(data.queryId).toBe(678);
|
||||
expect(data.results.length).toBe(3);
|
||||
expect(data.results[0].item.id).toBe('x');
|
||||
expect(data.results[0].matchCount).toBe(2);
|
||||
expect(data.results[1].matchCount).toBe(1);
|
||||
expect(data.results[2].matchCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -19,83 +19,244 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
/*global define,describe,it,expect,beforeEach,jasmine*/
|
||||
/*global define,describe,it,expect,beforeEach,jasmine,Promise,waitsFor,spyOn*/
|
||||
|
||||
/**
|
||||
* SearchSpec. Created by shale on 07/31/2015.
|
||||
*/
|
||||
define(
|
||||
["../../src/services/SearchAggregator"],
|
||||
function (SearchAggregator) {
|
||||
"use strict";
|
||||
define([
|
||||
"../../src/services/SearchAggregator"
|
||||
], function (SearchAggregator) {
|
||||
"use strict";
|
||||
|
||||
describe("The search aggregator ", function () {
|
||||
var mockQ,
|
||||
mockPromise,
|
||||
mockProviders = [],
|
||||
aggregator,
|
||||
mockProviderResults = [],
|
||||
mockAggregatorResults,
|
||||
i;
|
||||
describe("SearchAggregator", function () {
|
||||
var $q,
|
||||
objectService,
|
||||
providers,
|
||||
aggregator;
|
||||
|
||||
beforeEach(function () {
|
||||
mockQ = jasmine.createSpyObj(
|
||||
"$q",
|
||||
[ "all" ]
|
||||
);
|
||||
mockPromise = jasmine.createSpyObj(
|
||||
"promise",
|
||||
[ "then" ]
|
||||
);
|
||||
for (i = 0; i < 3; i += 1) {
|
||||
mockProviders.push(
|
||||
jasmine.createSpyObj(
|
||||
"mockProvider" + i,
|
||||
[ "query" ]
|
||||
)
|
||||
);
|
||||
mockProviders[i].query.andReturn(mockPromise);
|
||||
}
|
||||
mockQ.all.andReturn(mockPromise);
|
||||
|
||||
aggregator = new SearchAggregator(mockQ, mockProviders);
|
||||
aggregator.query();
|
||||
|
||||
for (i = 0; i < mockProviders.length; i += 1) {
|
||||
mockProviderResults.push({
|
||||
hits: [
|
||||
{
|
||||
id: i,
|
||||
score: 42 - i
|
||||
},
|
||||
{
|
||||
id: i + 1,
|
||||
score: 42 - (2 * i)
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
mockAggregatorResults = mockPromise.then.mostRecentCall.args[0](mockProviderResults);
|
||||
});
|
||||
|
||||
it("sends queries to all providers", function () {
|
||||
for (i = 0; i < mockProviders.length; i += 1) {
|
||||
expect(mockProviders[i].query).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("filters out duplicate objects", function () {
|
||||
expect(mockAggregatorResults.hits.length).toEqual(mockProviders.length + 1);
|
||||
expect(mockAggregatorResults.total).not.toBeLessThan(mockAggregatorResults.hits.length);
|
||||
});
|
||||
|
||||
it("orders results by score", function () {
|
||||
for (i = 1; i < mockAggregatorResults.hits.length; i += 1) {
|
||||
expect(mockAggregatorResults.hits[i].score)
|
||||
.not.toBeGreaterThan(mockAggregatorResults.hits[i - 1].score);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
$q = jasmine.createSpyObj(
|
||||
'$q',
|
||||
['all']
|
||||
);
|
||||
$q.all.andReturn(Promise.resolve([]));
|
||||
objectService = jasmine.createSpyObj(
|
||||
'objectService',
|
||||
['getObjects']
|
||||
);
|
||||
providers = [];
|
||||
aggregator = new SearchAggregator($q, objectService, providers);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it("has a fudge factor", function () {
|
||||
expect(aggregator.FUDGE_FACTOR).toBe(5);
|
||||
});
|
||||
|
||||
it("has default max results", function () {
|
||||
expect(aggregator.DEFAULT_MAX_RESULTS).toBe(100);
|
||||
});
|
||||
|
||||
it("can order model results by score", function () {
|
||||
var modelResults = {
|
||||
hits: [
|
||||
{score: 1},
|
||||
{score: 23},
|
||||
{score: 11}
|
||||
]
|
||||
},
|
||||
sorted = aggregator.orderByScore(modelResults);
|
||||
|
||||
expect(sorted.hits).toEqual([
|
||||
{score: 23},
|
||||
{score: 11},
|
||||
{score: 1}
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters results without a function', function () {
|
||||
var modelResults = {
|
||||
hits: [
|
||||
{thing: 1},
|
||||
{thing: 2}
|
||||
],
|
||||
total: 2
|
||||
},
|
||||
filtered = aggregator.applyFilter(modelResults);
|
||||
|
||||
expect(filtered.hits).toEqual([
|
||||
{thing: 1},
|
||||
{thing: 2}
|
||||
]);
|
||||
|
||||
expect(filtered.total).toBe(2);
|
||||
});
|
||||
|
||||
it('filters results with a function', function () {
|
||||
var modelResults = {
|
||||
hits: [
|
||||
{model: {thing: 1}},
|
||||
{model: {thing: 2}},
|
||||
{model: {thing: 3}}
|
||||
],
|
||||
total: 3
|
||||
},
|
||||
filterFunc = function (model) {
|
||||
return model.thing < 2;
|
||||
},
|
||||
filtered = aggregator.applyFilter(modelResults, filterFunc);
|
||||
|
||||
expect(filtered.hits).toEqual([
|
||||
{model: {thing: 1}}
|
||||
]);
|
||||
expect(filtered.total).toBe(1);
|
||||
});
|
||||
|
||||
it('can remove duplicates', function () {
|
||||
var modelResults = {
|
||||
hits: [
|
||||
{id: 15},
|
||||
{id: 23},
|
||||
{id: 14},
|
||||
{id: 23}
|
||||
],
|
||||
total: 4
|
||||
},
|
||||
deduped = aggregator.removeDuplicates(modelResults);
|
||||
|
||||
expect(deduped.hits).toEqual([
|
||||
{id: 15},
|
||||
{id: 23},
|
||||
{id: 14}
|
||||
]);
|
||||
expect(deduped.total).toBe(3);
|
||||
});
|
||||
|
||||
it('can convert model results to object results', function () {
|
||||
var modelResults = {
|
||||
hits: [
|
||||
{id: 123, score: 5},
|
||||
{id: 234, score: 1}
|
||||
],
|
||||
total: 2
|
||||
},
|
||||
objects = {
|
||||
123: '123-object-hey',
|
||||
234: '234-object-hello'
|
||||
},
|
||||
promiseChainComplete = false;
|
||||
|
||||
objectService.getObjects.andReturn(Promise.resolve(objects));
|
||||
|
||||
aggregator
|
||||
.asObjectResults(modelResults)
|
||||
.then(function (objectResults) {
|
||||
expect(objectResults).toEqual({
|
||||
hits: [
|
||||
{id: 123, score: 5, object: '123-object-hey'},
|
||||
{id: 234, score: 1, object: '234-object-hello'}
|
||||
],
|
||||
total: 2
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
promiseChainComplete = true;
|
||||
});
|
||||
|
||||
waitsFor(function () {
|
||||
return promiseChainComplete;
|
||||
});
|
||||
});
|
||||
|
||||
it('can send queries to providers', function () {
|
||||
var provider = jasmine.createSpyObj(
|
||||
'provider',
|
||||
['query']
|
||||
);
|
||||
provider.query.andReturn('i prooomise!');
|
||||
providers.push(provider);
|
||||
|
||||
aggregator.query('find me', 123, 'filter');
|
||||
expect(provider.query)
|
||||
.toHaveBeenCalledWith(
|
||||
'find me',
|
||||
123 * aggregator.FUDGE_FACTOR
|
||||
);
|
||||
expect($q.all).toHaveBeenCalledWith(['i prooomise!']);
|
||||
});
|
||||
|
||||
it('supplies max results when none is provided', function () {
|
||||
var provider = jasmine.createSpyObj(
|
||||
'provider',
|
||||
['query']
|
||||
);
|
||||
providers.push(provider);
|
||||
aggregator.query('find me');
|
||||
expect(provider.query).toHaveBeenCalledWith(
|
||||
'find me',
|
||||
aggregator.DEFAULT_MAX_RESULTS * aggregator.FUDGE_FACTOR
|
||||
);
|
||||
});
|
||||
|
||||
it('can combine responses from multiple providers', function () {
|
||||
var providerResponses = [
|
||||
{
|
||||
hits: [
|
||||
'oneHit',
|
||||
'twoHit'
|
||||
],
|
||||
total: 2
|
||||
},
|
||||
{
|
||||
hits: [
|
||||
'redHit',
|
||||
'blueHit',
|
||||
'by',
|
||||
'Pete'
|
||||
],
|
||||
total: 4
|
||||
}
|
||||
],
|
||||
promiseChainResolved = false;
|
||||
|
||||
$q.all.andReturn(Promise.resolve(providerResponses));
|
||||
spyOn(aggregator, 'orderByScore').andReturn('orderedByScore!');
|
||||
spyOn(aggregator, 'applyFilter').andReturn('filterApplied!');
|
||||
spyOn(aggregator, 'removeDuplicates')
|
||||
.andReturn('duplicatesRemoved!');
|
||||
spyOn(aggregator, 'asObjectResults').andReturn('objectResults');
|
||||
|
||||
aggregator
|
||||
.query('something', 10, 'filter')
|
||||
.then(function (objectResults) {
|
||||
expect(aggregator.orderByScore).toHaveBeenCalledWith({
|
||||
hits: [
|
||||
'oneHit',
|
||||
'twoHit',
|
||||
'redHit',
|
||||
'blueHit',
|
||||
'by',
|
||||
'Pete'
|
||||
],
|
||||
total: 6
|
||||
});
|
||||
expect(aggregator.applyFilter)
|
||||
.toHaveBeenCalledWith('orderedByScore!', 'filter');
|
||||
expect(aggregator.removeDuplicates)
|
||||
.toHaveBeenCalledWith('filterApplied!');
|
||||
expect(aggregator.asObjectResults)
|
||||
.toHaveBeenCalledWith('duplicatesRemoved!');
|
||||
|
||||
expect(objectResults).toBe('objectResults');
|
||||
})
|
||||
.then(function () {
|
||||
promiseChainResolved = true;
|
||||
});
|
||||
|
||||
waitsFor(function () {
|
||||
return promiseChainResolved;
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user