Mct4041 - Reimplement in-memory search (#4527)

Re-implements the in-memory search index sans Angular

Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: John Hill <jchill2.spam@gmail.com>
This commit is contained in:
Scott Bell 2021-12-16 01:13:41 +01:00 committed by GitHub
parent e18c7562ae
commit 1f588a2a6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 627 additions and 2409 deletions

View File

@ -54,7 +54,11 @@ module.exports = (config) => {
files: [
'indexTest.js',
{
pattern: 'dist/couchDBChangesFeed.js',
pattern: 'dist/couchDBChangesFeed.js*',
included: false
},
{
pattern: 'dist/inMemorySearchWorker.js*',
included: false
}
],

View File

@ -1,83 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
define([
"./src/controllers/SearchController",
"./src/controllers/SearchMenuController",
"./src/services/GenericSearchProvider",
"./src/services/SearchAggregator",
"./res/templates/search-item.html",
"./res/templates/search.html",
"./res/templates/search-menu.html"
], function (
SearchController,
SearchMenuController,
GenericSearchProvider,
SearchAggregator,
searchItemTemplate,
searchTemplate,
searchMenuTemplate
) {
return {
name: "platform/search",
definition: {
"name": "Search",
"description": "Allows the user to search through the file tree.",
"extensions": {
"constants": [
{
"key": "GENERIC_SEARCH_ROOTS",
"value": [
"ROOT"
],
"priority": "fallback"
}
],
"components": [
{
"provides": "searchService",
"type": "provider",
"implementation": GenericSearchProvider,
"depends": [
"$q",
"$log",
"objectService",
"topic",
"GENERIC_SEARCH_ROOTS",
"openmct"
]
},
{
"provides": "searchService",
"type": "aggregator",
"implementation": SearchAggregator,
"depends": [
"$q",
"objectService"
]
}
]
}
}
};
});

View File

@ -1,31 +0,0 @@
<!--
Open MCT, Copyright (c) 2014-2021, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.
Open MCT is licensed under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Open MCT includes source code licensed under additional open source
licenses. See the Open Source Licenses file (LICENSES.md) included with
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
<div class="search-result-item l-flex-row flex-elem grows"
ng-class="{selected: ngModel.selectedObject.getId() === domainObject.getId()}">
<mct-representation key="'label'"
mct-object="domainObject"
ng-model="ngModel"
ng-click="ngModel.allowSelection(domainObject) && ngModel.onSelection(domainObject)"
class="l-flex-row flex-elem grows">
</mct-representation>
</div>

View File

@ -1,58 +0,0 @@
<!--
Open MCT, Copyright (c) 2014-2021, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.
Open MCT is licensed under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Open MCT includes source code licensed under additional open source
licenses. See the Open Source Licenses file (LICENSES.md) included with
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
<div ng-controller="SearchMenuController as controller">
<div class="menu checkbox-menu"
mct-click-elsewhere="parameters.menuVisible(false)">
<ul>
<!-- First element is special - it's a reset option -->
<li class="search-menu-item special icon-asterisk"
title="Select all filters"
ng-click="ngModel.checkAll = !ngModel.checkAll; controller.checkAll()">
<label class="checkbox custom no-text">
<input type="checkbox"
class="checkbox"
ng-model="ngModel.checkAll"
ng-change="controller.checkAll()" />
<em></em>
</label>
All
</li>
<!-- The filter options, by type -->
<li class="search-menu-item {{ type.cssClass }}"
ng-repeat="type in ngModel.types"
ng-click="ngModel.checked[type.key] = !ngModel.checked[type.key]; controller.updateOptions()">
<label class="checkbox custom no-text">
<input type="checkbox"
class="checkbox"
ng-model="ngModel.checked[type.key]"
ng-change="controller.updateOptions()" />
<em></em>
</label>
{{ type.name }}
</li>
</ul>
</div>
</div>

View File

@ -1,78 +0,0 @@
<!--
Open MCT, Copyright (c) 2014-2021, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.
Open MCT is licensed under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Open MCT includes source code licensed under additional open source
licenses. See the Open Source Licenses file (LICENSES.md) included with
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
<div class="angular-w l-flex-col flex-elem grows holder" ng-controller="SearchController as controller">
<div class="l-flex-col flex-elem grows holder holder-search" ng-controller="SearchMenuController as menuController">
<div class="c-search-btn-wrapper"
ng-controller="ToggleController as toggle"
ng-class="{ holder: !(ngModel.input === '' || ngModel.input === undefined) }">
<div class="c-search">
<input class="c-search__search-input"
type="text" tabindex="10000"
ng-model="ngModel.input"
ng-keyup="controller.search()"/>
<button class="c-search__clear-input clear-icon icon-x-in-circle"
ng-class="{show: !(ngModel.input === '' || ngModel.input === undefined)}"
ng-click="ngModel.input = ''; controller.search()"></button>
<!-- To prevent double triggering of clicks on click away, render
non-clickable version of the button when menu active-->
<a ng-if="!toggle.isActive()" class="menu-icon context-available"
ng-click="toggle.toggle()"></a>
<a ng-if="toggle.isActive()" class="menu-icon context-available"></a>
<mct-include key="'search-menu'"
class="menu-element c-search__search-menu-holder"
ng-class="{invisible: !toggle.isActive()}"
ng-model="ngModel"
parameters="{menuVisible: toggle.setState}">
</mct-include>
</div>
<button class="c-button c-search__btn-cancel"
ng-show="!(ngModel.input === '' || ngModel.input === undefined)"
ng-click="ngModel.input = ''; ngModel.checkAll = true; menuController.checkAll(); controller.search()">
Cancel</button>
</div>
<div class="active-filter-display flex-elem holder"
ng-class="{invisible: ngModel.filtersString === '' || ngModel.filtersString === undefined || !ngModel.search}">
<button class="clear-filters icon-x-in-circle s-icon-button"
ng-click="ngModel.checkAll = true; menuController.checkAll()"></button>Filtered by: {{ ngModel.filtersString }}
</div>
<div class="flex-elem holder results-msg" ng-model="ngModel" ng-show="!loading && ngModel.search">
{{
!results.length > 0? 'No results found':
results.length + ' result' + (results.length > 1? 's':'') + ' found'
}}
</div>
<div class="search-results flex-elem holder grows vscroll"
ng-class="{invisible: !(loading || results.length > 0), loading: loading}">
<mct-representation key="'search-item'"
ng-repeat="result in results"
mct-object="result.object"
ng-model="ngModel"
class="l-flex-row flex-elem grows">
</mct-representation>
<button class="load-more-button s-button vsm" ng-if="controller.areMore()" ng-click="controller.loadMore()">More Results</button>
</div>
</div>
</div>

View File

@ -1,182 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* Module defining SearchController. Created by shale on 07/15/2015.
*/
define(function () {
/**
* 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) {
var controller = this;
this.$scope = $scope;
this.$scope.ngModel = this.$scope.ngModel || {};
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 Boolean(includeTypes[model.type]);
};
};
/**
* Clear the search results.
*
* @private
*/
SearchController.prototype.clearResults = function () {
this.$scope.results = [];
this.availableResults = 0;
this.numberToDisplay = this.RESULTS_PER_PAGE;
};
/**
* Update search results from given `results`.
*
* @private
*/
SearchController.prototype.onSearchComplete = function (results) {
this.availableResults = results.total;
this.$scope.results = results.hits;
this.$scope.loading = false;
this.pendingQuery = undefined;
};
return SearchController;
});

View File

@ -1,127 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* Module defining SearchMenuController. Created by shale on 08/17/2015.
*/
define(function () {
function SearchMenuController($scope, types) {
// Model variables are:
// ngModel.filter, the function filter defined in SearchController
// ngModel.types, an array of type objects
// ngModel.checked, a dictionary of which type filter options are checked
// ngModel.checkAll, a boolean of whether all of the types in ngModel.checked are checked
// ngModel.filtersString, a string list of what filters on the results are active
$scope.ngModel.types = [];
$scope.ngModel.checked = {};
$scope.ngModel.checkAll = true;
$scope.ngModel.filtersString = '';
// On initialization, fill the model's types with type keys
types.forEach(function (type) {
// We only want some types, the ones that are probably human readable
// Manually remove 'root', but not 'unknown'
if (type.key && type.name && type.key !== 'root') {
$scope.ngModel.types.push(type);
$scope.ngModel.checked[type.key] = false;
}
});
// For documentation, see updateOptions below
function updateOptions() {
var type,
i;
// Update all-checked status
if ($scope.ngModel.checkAll) {
for (type in $scope.ngModel.checked) {
if ($scope.ngModel.checked[type]) {
$scope.ngModel.checkAll = false;
}
}
}
// Update the current filters string
$scope.ngModel.filtersString = '';
if (!$scope.ngModel.checkAll) {
for (i = 0; i < $scope.ngModel.types.length; i += 1) {
// If the type key corresponds to a checked option...
if ($scope.ngModel.checked[$scope.ngModel.types[i].key]) {
// ... add it to the string list of current filter options
if ($scope.ngModel.filtersString === '') {
$scope.ngModel.filtersString += $scope.ngModel.types[i].name;
} else {
$scope.ngModel.filtersString += ', ' + $scope.ngModel.types[i].name;
}
}
}
// If there's still nothing in the filters string, there are no
// filters selected
if ($scope.ngModel.filtersString === '') {
$scope.ngModel.checkAll = true;
}
}
// Re-filter results
$scope.ngModel.filter();
}
// For documentation, see checkAll below
function checkAll() {
// Reset all the other options to original/default position
Object.keys($scope.ngModel.checked).forEach(function (type) {
$scope.ngModel.checked[type] = false;
});
// This setting will make the filters display hidden
$scope.ngModel.filtersString = '';
// Do not let checkAll become unchecked when it is the only checked filter
if (!$scope.ngModel.checkAll) {
$scope.ngModel.checkAll = true;
}
// Re-filter results
$scope.ngModel.filter();
}
return {
/**
* Updates the status of the checked options. Updates the filtersString
* with which options are checked. Re-filters the search results after.
* Not intended to be called by checkAll when it is toggled.
*/
updateOptions: updateOptions,
/**
* Handles the search and filter options for when checkAll has been
* toggled. This is a special case, compared to the other search
* menu options, so is intended to be called instead of updateOptions.
*/
checkAll: checkAll
};
}
return SearchMenuController;
});

View File

@ -1,326 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* Module defining GenericSearchProvider. Created by shale on 07/16/2015.
*/
define([
'objectUtils',
'lodash',
'raw-loader!./BareBonesSearchWorker.js'
], function (
objectUtils,
_,
BareBonesSearchWorkerText
) {
/**
* 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 {ObjectService} objectService the object service.
* @param {TopicService} topic the topic service.
* @param {Array} ROOTS An array of object Ids to begin indexing.
*/
function GenericSearchProvider($q, $log, objectService, topic, ROOTS, openmct) {
let provider = this;
this.$q = $q;
this.$log = $log;
this.objectService = objectService;
this.openmct = openmct;
this.indexedIds = {};
this.idsToIndex = [];
this.pendingIndex = {};
this.pendingRequests = 0;
this.pendingQueries = {};
this.worker = this.startWorker();
this.indexOnMutation(topic);
ROOTS.forEach(function indexRoot(rootId) {
provider.scheduleForIndexing(rootId);
});
}
/**
* Maximum number of concurrent index requests to allow.
*/
GenericSearchProvider.prototype.MAX_CONCURRENT_REQUESTS = 100;
/**
* 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
) {
var queryId = this.dispatchSearch(input, maxResults),
pendingQuery = this.$q.defer();
this.pendingQueries[queryId] = pendingQuery;
return pendingQuery.promise;
};
/**
* Creates a search worker and attaches handlers.
*
* @private
* @returns worker the created search worker.
*/
GenericSearchProvider.prototype.startWorker = function () {
let provider = this;
const blob = new Blob(
[BareBonesSearchWorkerText],
{type: 'application/javascript'}
);
const objectUrl = URL.createObjectURL(blob);
const searchWorker = new Worker(objectUrl);
searchWorker.addEventListener('message', function (messageEvent) {
provider.onWorkerMessage(messageEvent);
});
return searchWorker;
};
/**
* Listen to the mutation topic and re-index objects when they are
* mutated.
*
* @private
* @param topic the topicService.
*/
GenericSearchProvider.prototype.indexOnMutation = function (topic) {
let mutationTopic = topic('mutation');
mutationTopic.listen(mutatedObject => {
let editor = mutatedObject.getCapability('editor');
if (!editor || !editor.inEditContext()) {
this.index(
mutatedObject.getId(),
mutatedObject.getModel()
);
}
});
};
/**
* 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) {
const identifier = objectUtils.parseKeyString(id);
const objectProvider = this.openmct.objects.getProvider(identifier);
if (objectProvider === undefined || objectProvider.search === undefined) {
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;
if (id !== 'ROOT') {
this.worker.postMessage({
request: 'index',
model: model,
id: id
});
}
var domainObject = objectUtils.toNewFormat(model, id);
var composition = this.openmct.composition.registry.find(p => {
return p.appliesTo(domainObject);
});
if (!composition) {
return;
}
composition.load(domainObject)
.then(function (children) {
children.forEach(function (child) {
provider.scheduleForIndexing(objectUtils.makeKeyString(child));
});
});
};
/**
* Pulls an id from the indexing queue, loads it from the model service,
* and indexes it. Upon completion, tells the provider to keep
* indexing.
*
* @private
*/
GenericSearchProvider.prototype.beginIndexRequest = function () {
var idToIndex = this.idsToIndex.shift(),
provider = this;
this.pendingRequests += 1;
this.objectService
.getObjects([idToIndex])
.then(function (objects) {
delete provider.pendingIndex[idToIndex];
if (objects[idToIndex]) {
provider.index(idToIndex, objects[idToIndex].model);
}
}, function () {
provider
.$log
.warn('Failed to index domain object ' + idToIndex);
})
.then(function () {
setTimeout(function () {
provider.pendingRequests -= 1;
provider.keepIndexing();
}, 0);
});
};
/**
* Handle messages from the worker. Only really knows how to handle search
* results, which are parsed, transformed into a modelResult object, which
* is used to resolve the corresponding promise.
* @private
*/
GenericSearchProvider.prototype.onWorkerMessage = function (event) {
if (event.data.request !== 'search') {
return;
}
var pendingQuery,
modelResults;
if (this.USE_LEGACY_INDEXER) {
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,
type: hit.item.type,
score: hit.matchCount
};
});
} else {
pendingQuery = this.pendingQueries[event.data.queryId];
modelResults = {
total: event.data.total
};
modelResults.hits = event.data.results.map(function (hit) {
return {
id: hit.id
};
});
}
pendingQuery.resolve(modelResults);
delete this.pendingQueries[event.data.queryId];
};
/**
* @private
* @returns {Number} a unique, unused 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;
});

View File

@ -1,233 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* Module defining SearchAggregator. Created by shale on 07/16/2015.
*/
define([
], function (
) {
/**
* 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.
* @param {AbortController.signal} abortSignal (optional) can pass in an abortSignal to cancel any
* downstream fetch requests.
* @returns {Promise} A Promise for a search result object.
*/
SearchAggregator.prototype.query = function (
inputText,
maxResults,
filter,
abortSignal
) {
var aggregator = this,
resultPromises;
if (!maxResults) {
maxResults = this.DEFAULT_MAX_RESULTS;
}
resultPromises = this.providers.map(function (provider) {
return provider.query(
inputText,
maxResults * aggregator.FUDGE_FACTOR
);
});
return this.$q
.all(resultPromises)
.then(function (providerResults) {
var modelResults = {
hits: [],
total: 0
};
providerResults.forEach(function (providerResult) {
modelResults.hits =
modelResults.hits.concat(providerResult.hits);
modelResults.total += providerResult.total;
});
modelResults = aggregator.orderByScore(modelResults);
modelResults = aggregator.applyFilter(modelResults, filter);
modelResults = aggregator.removeDuplicates(modelResults);
return aggregator.asObjectResults(modelResults, abortSignal);
});
};
/**
* 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.
*
* @param {Object} modelResults an object containing the results from the search
* @param {AbortController.signal} abortSignal (optional) abort signal to cancel any
* downstream fetch requests
* @returns {Promise} for an objectResults object.
*/
SearchAggregator.prototype.asObjectResults = function (modelResults, abortSignal) {
var objectIds = modelResults.hits.map(function (modelResult) {
return modelResult.id;
});
return this
.objectService
.getObjects(objectIds, abortSignal)
.then(function (objects) {
var objectResults = {
total: modelResults.total
};
objectResults.hits = modelResults
.hits
.map(function asObjectResult(hit) {
return {
id: hit.id,
object: objects[hit.id],
score: hit.score
};
});
return objectResults;
});
};
return SearchAggregator;
});

View File

@ -1,196 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* 'License'); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* SearchSpec. Created by shale on 07/31/2015.
*/
define([
'../../src/controllers/SearchController'
], function (
SearchController
) {
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;
}
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.and.returnValue(mockPromise);
mockTypes = [{
key: 'mock.type',
name: 'Mock Type',
cssClass: 'icon-object-unknown'
}];
mockSearchResult = jasmine.createSpyObj(
'searchResult',
['']
);
mockDomainObject = jasmine.createSpyObj(
'domainObject',
['getModel']
);
mockSearchResult.object = mockDomainObject;
mockDomainObject.getModel.and.returnValue({
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;
controller.onFilterChange();
var filterFn = mockSearchService.query.calls.mostRecent().args[2];
expect(filterFn('askbfa')).toBe(true);
});
it('returns true only for matching checked types', function () {
mockScope.ngModel.checkAll = false;
controller.onFilterChange();
var filterFn = mockSearchService.query.calls.mostRecent().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.calls.mostRecent().args[0]({hits: ['a']});
expect(mockScope.results.length).toBe(1);
expect(mockScope.results).toContain('a');
});
it('is loading until the service\'s promise fulfills', function () {
expect(mockScope.loading).toBeTruthy();
// Then resolve the promises
mockPromise.then.calls.mostRecent().args[0]({hits: []});
expect(mockScope.loading).toBeFalsy();
});
it('detects when there are more results', function () {
mockPromise.then.calls.mostRecent().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.calls.mostRecent().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 false with empty input
mockScope.ngModel.input = '';
controller.search();
mockPromise.then.calls.mostRecent().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.calls.mostRecent().args[0]({
hits: [],
total: 0
});
expect(mockScope.ngModel.search).toEqual(false);
});
it('attaches a filter function to scope', function () {
expect(mockScope.ngModel.filter).toEqual(jasmine.any(Function));
});
});
});

View File

@ -1,134 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* SearchSpec. Created by shale on 08/17/2015.
*/
define(
["../../src/controllers/SearchMenuController"],
function (SearchMenuController) {
describe("The search menu controller", function () {
var mockScope,
mockTypes,
controller;
beforeEach(function () {
mockScope = jasmine.createSpyObj(
"$scope",
[""]
);
mockTypes = [
{
key: 'mock.type.1',
name: 'Mock Type 1',
cssClass: 'icon-layout'
},
{
key: 'mock.type.2',
name: 'Mock Type 2',
cssClass: 'icon-telemetry'
}
];
mockScope.ngModel = {};
mockScope.ngModel.checked = {};
mockScope.ngModel.checked['mock.type.1'] = false;
mockScope.ngModel.checked['mock.type.2'] = false;
mockScope.ngModel.checkAll = true;
mockScope.ngModel.filter = jasmine.createSpy('$scope.ngModel.filter');
mockScope.ngModel.filtersString = '';
controller = new SearchMenuController(mockScope, mockTypes);
});
it("gets types on initialization", function () {
expect(mockScope.ngModel.types).toBeDefined();
});
it("refilters results when options are updated", function () {
controller.updateOptions();
expect(mockScope.ngModel.filter).toHaveBeenCalled();
controller.checkAll();
expect(mockScope.ngModel.filter).toHaveBeenCalled();
});
it("updates the filters string when options are updated", function () {
controller.updateOptions();
expect(mockScope.ngModel.filtersString).toEqual('');
mockScope.ngModel.checked['mock.type.1'] = true;
controller.updateOptions();
expect(mockScope.ngModel.filtersString).not.toEqual('');
});
it("changing checkAll status sets checkAll to true", function () {
controller.checkAll();
expect(mockScope.ngModel.checkAll).toEqual(true);
expect(mockScope.ngModel.filtersString).toEqual('');
mockScope.ngModel.checkAll = false;
controller.checkAll();
expect(mockScope.ngModel.checkAll).toEqual(true);
expect(mockScope.ngModel.filtersString).toEqual('');
});
it("checking checkAll option resets other options", function () {
mockScope.ngModel.checked['mock.type.1'] = true;
mockScope.ngModel.checked['mock.type.2'] = true;
controller.checkAll();
Object.keys(mockScope.ngModel.checked).forEach(function (type) {
expect(mockScope.ngModel.checked[type]).toBeFalsy();
});
});
it("checks checkAll when no options are checked", function () {
Object.keys(mockScope.ngModel.checked).forEach(function (type) {
mockScope.ngModel.checked[type] = false;
});
mockScope.ngModel.checkAll = false;
controller.updateOptions();
expect(mockScope.ngModel.filtersString).toEqual('');
expect(mockScope.ngModel.checkAll).toEqual(true);
});
it("tells the user when options are checked", function () {
mockScope.ngModel.checkAll = false;
Object.keys(mockScope.ngModel.checked).forEach(function (type) {
mockScope.ngModel.checked[type] = true;
});
controller.updateOptions();
expect(mockScope.ngModel.filtersString).not.toEqual('');
});
});
}
);

View File

@ -1,126 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* 'License'); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* SearchSpec. Created by shale on 07/31/2015.
*/
define([
"raw-loader!../../src/services/BareBonesSearchWorker.js"
], function (
BareBonesSearchWorkerText
) {
describe('BareBonesSearchWorker', function () {
let blob;
let objectUrl;
let worker;
let objectX;
let objectY;
let objectZ;
let itemsToIndex;
beforeEach(function () {
blob = new Blob(
[BareBonesSearchWorkerText],
{type: 'application/javascript'}
);
objectUrl = URL.createObjectURL(blob);
worker = new Worker(objectUrl);
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
});
});
});
afterEach(function () {
blob = undefined;
objectUrl = undefined;
worker.terminate();
});
it('returns search results for partial term matches', function (done) {
worker.addEventListener('message', function (message) {
let data = message.data;
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].id).toBe('x');
expect(data.results[0].name).toEqual(objectX.model.name);
expect(data.results[1].id).toBe('y');
expect(data.results[1].name).toEqual(objectY.model.name);
expect(data.results[2].id).toBe('z');
expect(data.results[2].name).toEqual(objectZ.model.name);
done();
});
worker.postMessage({
request: 'search',
input: 'obj',
maxResults: 100,
queryId: 123
});
});
it('can find partial term matches', function (done) {
worker.addEventListener('message', function (message) {
let data = message.data;
expect(data.queryId).toBe(345);
expect(data.results.length).toBe(1);
expect(data.results[0].id).toBe('x');
done();
});
worker.postMessage({
request: 'search',
input: 'x',
maxResults: 100,
queryId: 345
});
});
});
});

View File

@ -1,421 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* SearchSpec. Created by shale on 07/31/2015.
*/
define([
"../../src/services/GenericSearchProvider"
], function (
GenericSearchProvider
) {
xdescribe('GenericSearchProvider', function () {
var $q,
$log,
modelService,
models,
workerService,
worker,
topic,
mutationTopic,
ROOTS,
compositionProvider,
openmct,
provider;
beforeEach(function () {
$q = jasmine.createSpyObj(
'$q',
['defer']
);
$log = jasmine.createSpyObj(
'$log',
['warn']
);
models = {};
modelService = jasmine.createSpyObj(
'modelService',
['getModels']
);
modelService.getModels.and.returnValue(Promise.resolve(models));
workerService = jasmine.createSpyObj(
'workerService',
['run']
);
worker = jasmine.createSpyObj(
'worker',
[
'postMessage',
'addEventListener'
]
);
workerService.run.and.returnValue(worker);
topic = jasmine.createSpy('topic');
mutationTopic = jasmine.createSpyObj(
'mutationTopic',
['listen']
);
topic.and.returnValue(mutationTopic);
ROOTS = [
'mine'
];
compositionProvider = jasmine.createSpyObj(
'compositionProvider',
['load', 'appliesTo']
);
compositionProvider.load.and.callFake(function (domainObject) {
return Promise.resolve(domainObject.composition);
});
compositionProvider.appliesTo.and.callFake(function (domainObject) {
return Boolean(domainObject.composition);
});
openmct = {
composition: {
registry: [compositionProvider]
}
};
spyOn(GenericSearchProvider.prototype, 'scheduleForIndexing');
provider = new GenericSearchProvider(
$q,
$log,
modelService,
workerService,
topic,
ROOTS,
openmct
);
});
it('listens for general mutation', function () {
expect(topic).toHaveBeenCalledWith('mutation');
expect(mutationTopic.listen)
.toHaveBeenCalledWith(jasmine.any(Function));
});
it('re-indexes when mutation occurs', function () {
var mockDomainObject =
jasmine.createSpyObj('domainObj', [
'getId',
'getModel',
'getCapability'
]),
testModel = { some: 'model' };
mockDomainObject.getId.and.returnValue("some-id");
mockDomainObject.getModel.and.returnValue(testModel);
spyOn(provider, 'index').and.callThrough();
mutationTopic.listen.calls.mostRecent().args[0](mockDomainObject);
expect(provider.index).toHaveBeenCalledWith('some-id', testModel);
});
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.calls.mostRecent().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.and.callThrough();
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').and.callThrough();
provider.pendingRequests = 9;
provider.idsToIndex = ['a', 'b', 'c'];
provider.MAX_CONCURRENT_REQUESTS = 10;
provider.keepIndexing();
expect(provider.beginIndexRequest).toHaveBeenCalled();
expect(provider.beginIndexRequest.calls.count()).toBe(1);
});
it('calls beginIndexRequest for all ids to index', function () {
spyOn(provider, 'beginIndexRequest').and.callThrough();
provider.pendingRequests = 0;
provider.idsToIndex = ['a', 'b', 'c'];
provider.MAX_CONCURRENT_REQUESTS = 10;
provider.keepIndexing();
expect(provider.beginIndexRequest).toHaveBeenCalled();
expect(provider.beginIndexRequest.calls.count()).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']},
resolve,
promise = new Promise(function (r) {
resolve = r;
});
provider.scheduleForIndexing.and.callFake(resolve);
provider.index(id, model);
expect(compositionProvider.appliesTo).toHaveBeenCalledWith({
identifier: {
key: 'anId',
namespace: ''
},
composition: [jasmine.any(Object), jasmine.any(Object)]
});
expect(compositionProvider.load).toHaveBeenCalledWith({
identifier: {
key: 'anId',
namespace: ''
},
composition: [jasmine.any(Object), jasmine.any(Object)]
});
return promise.then(function () {
expect(provider.scheduleForIndexing)
.toHaveBeenCalledWith('abc');
expect(provider.scheduleForIndexing)
.toHaveBeenCalledWith('def');
});
});
it('does not index ROOT, but checks composition', function () {
var id = 'ROOT',
model = {};
provider.index(id, model);
expect(worker.postMessage).not.toHaveBeenCalled();
expect(compositionProvider.appliesTo).toHaveBeenCalledWith({
identifier: {
key: 'ROOT',
namespace: ''
}
});
});
});
describe('beginIndexRequest', function () {
beforeEach(function () {
provider.pendingRequests = 0;
provider.pendingIds = {'abc': true};
provider.idsToIndex = ['abc'];
models.abc = {};
spyOn(provider, 'index');
});
it('removes items from queue', function () {
provider.beginIndexRequest();
expect(provider.idsToIndex.length).toBe(0);
});
it('tracks number of pending requests', function () {
provider.beginIndexRequest();
expect(provider.pendingRequests).toBe(1);
return waitsFor(function () {
return provider.pendingRequests === 0;
}).then(function () {
expect(provider.pendingRequests).toBe(0);
});
});
it('indexes objects', function () {
provider.beginIndexRequest();
return waitsFor(function () {
return provider.pendingRequests === 0;
}).then(function () {
expect(provider.index)
.toHaveBeenCalledWith('abc', models.abc);
});
});
function waitsFor(latchFunction) {
return new Promise(function (resolve, reject) {
var maxWait = 2000;
var start = Date.now();
checkLatchFunction();
function checkLatchFunction() {
var now = Date.now();
var elapsed = now - start;
if (latchFunction()) {
resolve();
} else if (elapsed >= maxWait) {
reject("Timeout waiting for latch function to be true");
} else {
setTimeout(checkLatchFunction);
}
}
});
}
});
it('can dispatch searches to worker', function () {
spyOn(provider, 'makeQueryId').and.returnValue(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').and.returnValue(303);
$q.defer.and.returnValue(deferred);
expect(provider.query('someTerm', 100)).toBe(deferred.promise);
expect(provider.pendingQueries[303]).toBe(deferred);
});
describe('onWorkerMessage', function () {
var pendingQuery;
beforeEach(function () {
pendingQuery = jasmine.createSpyObj(
'pendingQuery',
['resolve']
);
provider.pendingQueries[143] = pendingQuery;
});
it('resolves pending searches', function () {
provider.onWorkerMessage({
data: {
request: 'search',
total: 2,
results: [
{
item: {
id: 'abc',
model: {id: 'abc'}
},
matchCount: 4
},
{
item: {
id: 'def',
model: {id: 'def'}
},
matchCount: 2
}
],
queryId: 143
}
});
expect(pendingQuery.resolve)
.toHaveBeenCalledWith({
total: 2,
hits: [{
id: 'abc',
model: {id: 'abc'},
score: 4
}, {
id: 'def',
model: {id: 'def'},
score: 2
}]
});
expect(provider.pendingQueries[143]).not.toBeDefined();
});
});
});
});

View File

@ -1,259 +0,0 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* SearchSpec. Created by shale on 07/31/2015.
*/
define([
"../../src/services/SearchAggregator"
], function (SearchAggregator) {
xdescribe("SearchAggregator", function () {
var $q,
objectService,
providers,
aggregator;
beforeEach(function () {
$q = jasmine.createSpyObj(
'$q',
['all']
);
$q.all.and.returnValue(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 () {
const modelResults = {
hits: [
{model: {thing: 1}},
{model: {thing: 2}},
{model: {thing: 3}}
],
total: 3
};
let filtered = aggregator.applyFilter(modelResults, filterFunc);
function filterFunc(model) {
return model.thing < 2;
}
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'
};
objectService.getObjects.and.returnValue(Promise.resolve(objects));
return 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
});
});
});
it('can send queries to providers', function () {
var provider = jasmine.createSpyObj(
'provider',
['query']
);
provider.query.and.returnValue('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
}
];
$q.all.and.returnValue(Promise.resolve(providerResponses));
spyOn(aggregator, 'orderByScore').and.returnValue('orderedByScore!');
spyOn(aggregator, 'applyFilter').and.returnValue('filterApplied!');
spyOn(aggregator, 'removeDuplicates')
.and.returnValue('duplicatesRemoved!');
spyOn(aggregator, 'asObjectResults').and.returnValue('objectResults');
return 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');
});
});
});
});

View File

@ -0,0 +1,352 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import uuid from 'uuid';
class InMemorySearchProvider {
/**
* A search service which searches through domain objects in
* the filetree without using external search implementations.
*
* @constructor
* @param {Object} openmct
*/
constructor(openmct) {
/**
* Maximum number of concurrent index requests to allow.
*/
this.MAX_CONCURRENT_REQUESTS = 100;
/**
* If max results is not specified in query, use this as default.
*/
this.DEFAULT_MAX_RESULTS = 100;
this.openmct = openmct;
this.indexedIds = {};
this.idsToIndex = [];
this.pendingIndex = {};
this.pendingRequests = 0;
this.worker = null;
/**
* If we don't have SharedWorkers available (e.g., iOS)
*/
this.localIndexedItems = {};
this.pendingQueries = {};
this.onWorkerMessage = this.onWorkerMessage.bind(this);
this.onWorkerMessageError = this.onWorkerMessageError.bind(this);
this.onerror = this.onWorkerError.bind(this);
this.startIndexing = this.startIndexing.bind(this);
this.onMutationOfIndexedObject = this.onMutationOfIndexedObject.bind(this);
this.openmct.on('start', this.startIndexing);
this.openmct.on('destroy', () => {
if (this.worker && this.worker.port) {
this.worker.onerror = null;
this.worker.port.onmessage = null;
this.worker.port.onmessageerror = null;
this.worker.port.close();
}
});
}
startIndexing() {
const rootObject = this.openmct.objects.rootProvider.rootObject;
this.scheduleForIndexing(rootObject.identifier);
if (typeof SharedWorker !== 'undefined') {
this.worker = this.startSharedWorker();
} else {
// we must be on iOS
}
}
/**
* @private
*/
getIntermediateResponse() {
let intermediateResponse = {};
intermediateResponse.promise = new Promise(function (resolve, reject) {
intermediateResponse.resolve = resolve;
intermediateResponse.reject = reject;
});
return intermediateResponse;
}
/**
* 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.
*/
query(input, maxResults) {
if (!maxResults) {
maxResults = this.DEFAULT_MAX_RESULTS;
}
const queryId = uuid();
const pendingQuery = this.getIntermediateResponse();
this.pendingQueries[queryId] = pendingQuery;
if (this.worker) {
this.dispatchSearch(queryId, input, maxResults);
} else {
this.localSearch(queryId, input, maxResults);
}
return pendingQuery.promise;
}
/**
* 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
*/
async onWorkerMessage(event) {
if (event.data.request !== 'search') {
return;
}
const pendingQuery = this.pendingQueries[event.data.queryId];
const modelResults = {
total: event.data.total
};
modelResults.hits = await Promise.all(event.data.results.map(async (hit) => {
const identifier = this.openmct.objects.parseKeyString(hit.keyString);
const domainObject = await this.openmct.objects.get(identifier.key);
return domainObject;
}));
pendingQuery.resolve(modelResults);
delete this.pendingQueries[event.data.queryId];
}
/**
* Handle error messages from the worker.
* @private
*/
onWorkerMessageError(event) {
console.error('⚙️ Error message from InMemorySearch worker ⚙️', event);
}
/**
* Handle errors from the worker.
* @private
*/
onWorkerError(event) {
console.error('⚙️ Error with InMemorySearch worker ⚙️', event);
}
/**
* @private
*/
startSharedWorker() {
// eslint-disable-next-line no-undef
const sharedWorkerURL = `${this.openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}inMemorySearchWorker.js`;
const sharedWorker = new SharedWorker(sharedWorkerURL, 'InMemorySearch Shared Worker');
sharedWorker.onerror = this.onWorkerError;
sharedWorker.port.onmessage = this.onWorkerMessage;
sharedWorker.port.onmessageerror = this.onWorkerMessageError;
sharedWorker.port.start();
return sharedWorker;
}
/**
* 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 {identifier} id to be indexed.
*/
scheduleForIndexing(identifier) {
const keyString = this.openmct.objects.makeKeyString(identifier);
const objectProvider = this.openmct.objects.getProvider(identifier.key);
if (objectProvider === undefined || objectProvider.search === undefined) {
if (!this.indexedIds[keyString] && !this.pendingIndex[keyString]) {
this.pendingIndex[keyString] = true;
this.idsToIndex.push(keyString);
}
}
this.keepIndexing();
}
/**
* If there are less pending requests than concurrent requests, keep
* firing requests.
*
* @private
*/
keepIndexing() {
while (this.pendingRequests < this.MAX_CONCURRENT_REQUESTS
&& this.idsToIndex.length
) {
this.beginIndexRequest();
}
}
onMutationOfIndexedObject(domainObject) {
const provider = this;
provider.index(domainObject.identifier, domainObject);
}
/**
* 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
*/
async index(id, domainObject) {
const provider = this;
const keyString = this.openmct.objects.makeKeyString(id);
if (!this.indexedIds[keyString]) {
this.openmct.objects.observe(domainObject, `*`, this.onMutationOfIndexedObject);
}
this.indexedIds[keyString] = true;
if ((id.key !== 'ROOT')) {
if (this.worker) {
this.worker.port.postMessage({
request: 'index',
model: domainObject,
keyString
});
} else {
this.localIndexItem(keyString, domainObject);
}
}
const composition = this.openmct.composition.registry.find(foundComposition => {
return foundComposition.appliesTo(domainObject);
});
if (composition) {
const childIdentifiers = await composition.load(domainObject);
childIdentifiers.forEach(function (childIdentifier) {
provider.scheduleForIndexing(childIdentifier);
});
}
}
/**
* 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
*/
async beginIndexRequest() {
const keyString = this.idsToIndex.shift();
const provider = this;
this.pendingRequests += 1;
const identifier = await this.openmct.objects.parseKeyString(keyString);
const domainObject = await this.openmct.objects.get(identifier.key);
delete provider.pendingIndex[keyString];
try {
if (domainObject) {
await provider.index(identifier, domainObject);
}
} catch (error) {
console.warn('Failed to index domain object ' + keyString, error);
}
setTimeout(function () {
provider.pendingRequests -= 1;
provider.keepIndexing();
}, 0);
}
/**
* Dispatch a search query to the worker and return a queryId.
*
* @private
* @returns {String} a unique query Id for the query.
*/
dispatchSearch(queryId, searchInput, maxResults) {
const message = {
request: 'search',
input: searchInput,
maxResults,
queryId
};
this.worker.port.postMessage(message);
}
/**
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*/
localIndexItem(keyString, model) {
this.localIndexedItems[keyString] = {
type: model.type,
name: model.name,
keyString
};
}
/**
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*
* Gets search results from the indexedItems based on provided search
* input. Returns matching results from indexedItems
*/
localSearch(queryId, searchInput, maxResults) {
// This results dictionary will have domain object ID keys which
// point to the value the domain object's score.
let results;
const input = searchInput.trim().toLowerCase();
const message = {
request: 'search',
results: {},
total: 0,
queryId
};
results = Object.values(this.localIndexedItems).filter((indexedItem) => {
return indexedItem.name.toLowerCase().includes(input);
});
message.total = results.length;
message.results = results
.slice(0, maxResults);
const eventToReturn = {
data: message
};
this.onWorkerMessage(eventToReturn);
}
}
export default InMemorySearchProvider;

View File

@ -21,20 +21,39 @@
*****************************************************************************/
/**
* Module defining BareBonesSearchWorker. Created by deeptailor on 10/03/2019.
* Module defining InMemorySearchWorker. Created by deeptailor on 10/03/2019.
*/
(function () {
// An array of objects composed of domain object IDs and names
// An object composed of domain object IDs and models
// {id: domainObject's ID, name: domainObject's name}
var indexedItems = [];
const indexedItems = {};
function indexItem(id, model) {
indexedItems.push({
id: id,
name: model.name.toLowerCase(),
type: model.type
});
self.onconnect = function (e) {
const port = e.ports[0];
port.onmessage = function (event) {
if (event.data.request === 'index') {
indexItem(event.data.keyString, event.data.model);
} else if (event.data.request === 'search') {
port.postMessage(search(event.data));
}
};
port.start();
};
self.onerror = function (error) {
//do nothing
console.error('Error on feed', error);
};
function indexItem(keyString, model) {
indexedItems[keyString] = {
type: model.type,
name: model.name,
keyString
};
}
/**
@ -49,17 +68,17 @@
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.trim().toLowerCase(),
message = {
request: 'search',
results: {},
total: 0,
queryId: data.queryId
};
let results;
const input = data.input.trim().toLowerCase();
const message = {
request: 'search',
results: {},
total: 0,
queryId: data.queryId
};
results = indexedItems.filter((indexedItem) => {
return indexedItem.name.includes(input);
results = Object.values(indexedItems).filter((indexedItem) => {
return indexedItem.name.toLowerCase().includes(input);
});
message.total = results.length;
@ -68,12 +87,4 @@
return message;
}
self.onmessage = function (event) {
if (event.data.request === 'index') {
indexItem(event.data.id, event.data.model);
} else if (event.data.request === 'search') {
self.postMessage(search(event.data));
}
};
}());

View File

@ -28,6 +28,7 @@ import EventEmitter from 'EventEmitter';
import InterceptorRegistry from './InterceptorRegistry';
import Transaction from './Transaction';
import ConflictError from './ConflictError';
import InMemorySearchProvider from './InMemorySearchProvider';
/**
* Utilities for loading, saving, and manipulating domain objects.
@ -41,6 +42,7 @@ function ObjectAPI(typeRegistry, openmct) {
this.eventEmitter = new EventEmitter();
this.providers = {};
this.rootRegistry = new RootRegistry();
this.inMemorySearchProvider = new InMemorySearchProvider(openmct);
this.injectIdentifierService = function () {
this.identifierService = this.openmct.$injector.get("identifierService");
};
@ -235,7 +237,7 @@ ObjectAPI.prototype.get = function (identifier, abortSignal) {
*
* Object providersSearches and combines results of each object provider search.
* Objects without search provided will have been indexed
* and will be searched using the fallback indexed search.
* and will be searched using the fallback in-memory search.
* Search results are asynchronous and resolve in parallel.
*
* @method search
@ -250,14 +252,11 @@ ObjectAPI.prototype.search = function (query, abortSignal) {
const searchPromises = Object.values(this.providers)
.filter(provider => provider.search !== undefined)
.map(provider => provider.search(query, abortSignal));
searchPromises.push(this.fallbackProvider.superSecretFallbackSearch(query, abortSignal)
// abortSignal doesn't seem to be used in generic search?
searchPromises.push(this.inMemorySearchProvider.query(query, null)
.then(results => results.hits
.map(hit => {
let domainObject = utils.toNewFormat(hit.object.getModel(), hit.object.getId());
domainObject = this.applyGetInterceptors(domainObject.identifier, domainObject);
return domainObject;
return hit;
})));
return searchPromises;

View File

@ -1,119 +1,206 @@
import ObjectAPI from './ObjectAPI.js';
import { createOpenMct, resetApplicationState } from '../../utils/testing';
describe("The Object API Search Function", () => {
const MOCK_PROVIDER_KEY = 'mockProvider';
const ANOTHER_MOCK_PROVIDER_KEY = 'anotherMockProvider';
const MOCK_PROVIDER_SEARCH_DELAY = 15000;
const ANOTHER_MOCK_PROVIDER_SEARCH_DELAY = 20000;
const TOTAL_TIME_ELAPSED = 21000;
const BASE_TIME = new Date(2021, 0, 1);
describe("The infrastructure", () => {
const MOCK_PROVIDER_KEY = 'mockProvider';
const ANOTHER_MOCK_PROVIDER_KEY = 'anotherMockProvider';
const MOCK_PROVIDER_SEARCH_DELAY = 15000;
const ANOTHER_MOCK_PROVIDER_SEARCH_DELAY = 20000;
const TOTAL_TIME_ELAPSED = 21000;
const BASE_TIME = new Date(2021, 0, 1);
let objectAPI;
let mockObjectProvider;
let anotherMockObjectProvider;
let mockFallbackProvider;
let fallbackProviderSearchResults;
let resultsPromises;
let mockObjectProvider;
let anotherMockObjectProvider;
let openmct;
beforeEach(() => {
jasmine.clock().install();
jasmine.clock().mockDate(BASE_TIME);
beforeEach((done) => {
openmct = createOpenMct();
resultsPromises = [];
fallbackProviderSearchResults = {
hits: []
};
mockObjectProvider = jasmine.createSpyObj("mock object provider", [
"search"
]);
anotherMockObjectProvider = jasmine.createSpyObj("another mock object provider", [
"search"
]);
openmct.objects.addProvider('objects', mockObjectProvider);
openmct.objects.addProvider('other-objects', anotherMockObjectProvider);
mockObjectProvider.search.and.callFake(() => {
return new Promise(resolve => {
const mockProviderSearch = {
name: MOCK_PROVIDER_KEY,
start: new Date()
};
objectAPI = new ObjectAPI();
setTimeout(() => {
mockProviderSearch.end = new Date();
mockObjectProvider = jasmine.createSpyObj("mock object provider", [
"search"
]);
anotherMockObjectProvider = jasmine.createSpyObj("another mock object provider", [
"search"
]);
mockFallbackProvider = jasmine.createSpyObj("super secret fallback provider", [
"superSecretFallbackSearch"
]);
objectAPI.addProvider('objects', mockObjectProvider);
objectAPI.addProvider('other-objects', anotherMockObjectProvider);
objectAPI.supersecretSetFallbackProvider(mockFallbackProvider);
return resolve(mockProviderSearch);
}, MOCK_PROVIDER_SEARCH_DELAY);
});
});
anotherMockObjectProvider.search.and.callFake(() => {
return new Promise(resolve => {
const anotherMockProviderSearch = {
name: ANOTHER_MOCK_PROVIDER_KEY,
start: new Date()
};
mockObjectProvider.search.and.callFake(() => {
return new Promise(resolve => {
const mockProviderSearch = {
name: MOCK_PROVIDER_KEY,
start: new Date()
setTimeout(() => {
anotherMockProviderSearch.end = new Date();
return resolve(anotherMockProviderSearch);
}, ANOTHER_MOCK_PROVIDER_SEARCH_DELAY);
});
});
openmct.on('start', () => {
done();
});
openmct.startHeadless();
});
afterEach(async () => {
await resetApplicationState(openmct);
});
it("uses each objects given provider's search function", () => {
openmct.objects.search('foo');
expect(mockObjectProvider.search).toHaveBeenCalled();
});
it("provides each providers results as promises that resolve in parallel", async () => {
jasmine.clock().install();
jasmine.clock().mockDate(BASE_TIME);
const resultsPromises = openmct.objects.search('foo');
jasmine.clock().tick(TOTAL_TIME_ELAPSED);
const results = await Promise.all(resultsPromises);
const mockProviderResults = results.find(
result => result.name === MOCK_PROVIDER_KEY
);
const anotherMockProviderResults = results.find(
result => result.name === ANOTHER_MOCK_PROVIDER_KEY
);
const mockProviderStart = mockProviderResults.start.getTime();
const mockProviderEnd = mockProviderResults.end.getTime();
const anotherMockProviderStart = anotherMockProviderResults.start.getTime();
const anotherMockProviderEnd = anotherMockProviderResults.end.getTime();
const searchElapsedTime = Math.max(mockProviderEnd, anotherMockProviderEnd)
- Math.min(mockProviderEnd, anotherMockProviderEnd);
expect(mockProviderStart).toBeLessThan(anotherMockProviderEnd);
expect(anotherMockProviderStart).toBeLessThan(mockProviderEnd);
expect(searchElapsedTime).toBeLessThan(
MOCK_PROVIDER_SEARCH_DELAY
+ ANOTHER_MOCK_PROVIDER_SEARCH_DELAY
);
jasmine.clock().uninstall();
});
});
describe("The in-memory search indexer", () => {
let openmct;
let mockDomainObject1;
let mockIdentifier1;
let mockDomainObject2;
let mockIdentifier2;
let mockDomainObject3;
let mockIdentifier3;
beforeEach((done) => {
openmct = createOpenMct();
spyOn(openmct.objects.inMemorySearchProvider, "query").and.callThrough();
spyOn(openmct.objects.inMemorySearchProvider, "localSearch").and.callThrough();
openmct.on('start', async () => {
mockIdentifier1 = {
key: 'some-object',
namespace: 'some-namespace'
};
mockDomainObject1 = {
type: 'clock',
name: 'fooRabbit',
identifier: mockIdentifier1
};
mockIdentifier2 = {
key: 'some-other-object',
namespace: 'some-namespace'
};
mockDomainObject2 = {
type: 'clock',
name: 'fooBear',
identifier: mockIdentifier2
};
mockIdentifier3 = {
key: 'yet-another-object',
namespace: 'some-namespace'
};
mockDomainObject3 = {
type: 'clock',
name: 'redBear',
identifier: mockIdentifier3
};
await openmct.objects.inMemorySearchProvider.index(mockIdentifier1, mockDomainObject1);
await openmct.objects.inMemorySearchProvider.index(mockIdentifier2, mockDomainObject2);
await openmct.objects.inMemorySearchProvider.index(mockIdentifier3, mockDomainObject3);
done();
});
openmct.startHeadless();
});
setTimeout(() => {
mockProviderSearch.end = new Date();
afterEach(async () => {
await resetApplicationState(openmct);
});
return resolve(mockProviderSearch);
}, MOCK_PROVIDER_SEARCH_DELAY);
it("can provide indexing without a provider", () => {
openmct.objects.search('foo');
expect(openmct.objects.inMemorySearchProvider.query).toHaveBeenCalled();
});
it("can do partial search", async () => {
const searchPromises = openmct.objects.search('foo');
const searchResults = await Promise.all(searchPromises);
expect(searchResults[0].length).toBe(2);
});
it("returns nothing when appropriate", async () => {
const searchPromises = openmct.objects.search('laser');
const searchResults = await Promise.all(searchPromises);
expect(searchResults[0].length).toBe(0);
});
it("returns exact matches", async () => {
const searchPromises = openmct.objects.search('redBear');
const searchResults = await Promise.all(searchPromises);
expect(searchResults[0].length).toBe(1);
});
describe("Without Shared Workers", () => {
beforeEach(async () => {
openmct.objects.inMemorySearchProvider.worker = null;
// reindex locally
await openmct.objects.inMemorySearchProvider.index(mockIdentifier1, mockDomainObject1);
await openmct.objects.inMemorySearchProvider.index(mockIdentifier2, mockDomainObject2);
await openmct.objects.inMemorySearchProvider.index(mockIdentifier3, mockDomainObject3);
});
it("calls local search", () => {
openmct.objects.search('foo');
expect(openmct.objects.inMemorySearchProvider.localSearch).toHaveBeenCalled();
});
it("can do partial search", async () => {
const searchPromises = openmct.objects.search('foo');
const searchResults = await Promise.all(searchPromises);
expect(searchResults[0].length).toBe(2);
});
it("returns nothing when appropriate", async () => {
const searchPromises = openmct.objects.search('laser');
const searchResults = await Promise.all(searchPromises);
expect(searchResults[0].length).toBe(0);
});
it("returns exact matches", async () => {
const searchPromises = openmct.objects.search('redBear');
const searchResults = await Promise.all(searchPromises);
expect(searchResults[0].length).toBe(1);
});
});
anotherMockObjectProvider.search.and.callFake(() => {
return new Promise(resolve => {
const anotherMockProviderSearch = {
name: ANOTHER_MOCK_PROVIDER_KEY,
start: new Date()
};
setTimeout(() => {
anotherMockProviderSearch.end = new Date();
return resolve(anotherMockProviderSearch);
}, ANOTHER_MOCK_PROVIDER_SEARCH_DELAY);
});
});
mockFallbackProvider.superSecretFallbackSearch.and.callFake(
() => new Promise(
resolve => setTimeout(
() => resolve(fallbackProviderSearchResults),
50
)
)
);
resultsPromises = objectAPI.search('foo');
jasmine.clock().tick(TOTAL_TIME_ELAPSED);
});
afterEach(() => {
jasmine.clock().uninstall();
});
it("uses each objects given provider's search function", () => {
expect(mockObjectProvider.search).toHaveBeenCalled();
expect(anotherMockObjectProvider.search).toHaveBeenCalled();
});
it("uses the fallback indexed search for objects without a search function provided", () => {
expect(mockFallbackProvider.superSecretFallbackSearch).toHaveBeenCalled();
});
it("provides each providers results as promises that resolve in parallel", async () => {
const results = await Promise.all(resultsPromises);
const mockProviderResults = results.find(
result => result.name === MOCK_PROVIDER_KEY
);
const anotherMockProviderResults = results.find(
result => result.name === ANOTHER_MOCK_PROVIDER_KEY
);
const mockProviderStart = mockProviderResults.start.getTime();
const mockProviderEnd = mockProviderResults.end.getTime();
const anotherMockProviderStart = anotherMockProviderResults.start.getTime();
const anotherMockProviderEnd = anotherMockProviderResults.end.getTime();
const searchElapsedTime = Math.max(mockProviderEnd, anotherMockProviderEnd)
- Math.min(mockProviderEnd, anotherMockProviderEnd);
expect(mockProviderStart).toBeLessThan(anotherMockProviderEnd);
expect(anotherMockProviderStart).toBeLessThan(mockProviderEnd);
expect(searchElapsedTime).toBeLessThan(
MOCK_PROVIDER_SEARCH_DELAY
+ ANOTHER_MOCK_PROVIDER_SEARCH_DELAY
);
});
});

View File

@ -1,4 +1,5 @@
import ObjectAPI from './ObjectAPI.js';
import { createOpenMct, resetApplicationState } from '../../utils/testing';
describe("The Object API", () => {
let objectAPI;
@ -9,10 +10,11 @@ describe("The Object API", () => {
const TEST_NAMESPACE = "test-namespace";
const FIFTEEN_MINUTES = 15 * 60 * 1000;
beforeEach(() => {
beforeEach((done) => {
typeRegistry = jasmine.createSpyObj('typeRegistry', [
'get'
]);
openmct = createOpenMct();
openmct.$injector = jasmine.createSpyObj('$injector', ['get']);
mockIdentifierService = jasmine.createSpyObj(
'identifierService',
@ -25,7 +27,7 @@ describe("The Object API", () => {
});
openmct.$injector.get.and.returnValue(mockIdentifierService);
objectAPI = new ObjectAPI(typeRegistry, openmct);
objectAPI = openmct.objects;
openmct.editor = {};
openmct.editor.isEditing = () => false;
@ -38,15 +40,29 @@ describe("The Object API", () => {
name: "test object",
type: "test-type"
};
openmct.on('start', () => {
done();
});
openmct.startHeadless();
});
afterEach(async () => {
await resetApplicationState(openmct);
});
describe("The save function", () => {
it("Rejects if no provider available", () => {
it("Rejects if no provider available", async () => {
let rejected = false;
objectAPI.providers = {};
objectAPI.fallbackProvider = null;
return objectAPI.save(mockDomainObject)
.catch(() => rejected = true)
.then(() => expect(rejected).toBe(true));
try {
await objectAPI.save(mockDomainObject);
} catch (error) {
rejected = true;
}
expect(rejected).toBe(true);
});
describe("when a provider is available", () => {
let mockProvider;

View File

@ -39,7 +39,6 @@ const DEFAULTS = [
'platform/persistence/aggregator',
'platform/policy',
'platform/entanglement',
'platform/search',
'platform/status',
'platform/commonUI/regions'
];
@ -81,7 +80,6 @@ define([
'../platform/persistence/queue/bundle',
'../platform/policy/bundle',
'../platform/representation/bundle',
'../platform/search/bundle',
'../platform/status/bundle',
'../platform/telemetry/bundle'
], function () {

View File

@ -66,7 +66,6 @@ describe('the plugin', () => {
openmct.install(new CouchPlugin(options));
openmct.types.addType('notebook', {creatable: true});
openmct.setAssetPath('/base');
openmct.on('start', done);
openmct.startHeadless();
@ -130,6 +129,7 @@ describe('the plugin', () => {
it('works without Shared Workers', async () => {
let sharedWorkerCallback;
const restoreSharedWorker = window.SharedWorker;
window.SharedWorker = undefined;
const mockEventSource = {
addEventListener: (topic, addedListener) => {
@ -164,7 +164,7 @@ describe('the plugin', () => {
expect(provider.create).toHaveBeenCalled();
expect(provider.startSharedWorker).not.toHaveBeenCalled();
//Set modified timestamp it detects a change and persists the updated model.
mockDomainObject.modified = Date.now();
mockDomainObject.modified = mockDomainObject.persisted + 1;
const updatedResult = await openmct.objects.save(mockDomainObject);
openmct.objects.observe(mockDomainObject, '*', (updatedObject) => {
});
@ -173,6 +173,7 @@ describe('the plugin', () => {
expect(provider.fetchChanges).toHaveBeenCalled();
sharedWorkerCallback(fakeUpdateEvent);
expect(provider.onEventMessage).toHaveBeenCalled();
window.SharedWorker = restoreSharedWorker;
});
});
describe('batches requests', () => {

View File

@ -238,7 +238,6 @@ export default {
methods: {
async initialize() {
this.isLoading = true;
this.openmct.$injector.get('searchService');
this.getSavedOpenItems();
this.treeResizeObserver = new ResizeObserver(this.handleTreeResize);
this.treeResizeObserver.observe(this.$el);
@ -615,12 +614,15 @@ export default {
// to cancel an active searches if necessary
this.abortSearchController = new AbortController();
const abortSignal = this.abortSearchController.signal;
const searchPromises = this.openmct.objects.search(this.searchValue, abortSignal);
const promises = this.openmct.objects.search(this.searchValue, abortSignal)
.map(promise => promise
.then(results => this.aggregateSearchResults(results, abortSignal)));
searchPromises.map(promise => promise
.then(results => {
this.aggregateSearchResults(results, abortSignal);
}
));
Promise.all(promises).catch(reason => {
Promise.all(searchPromises).catch(reason => {
// search aborted
}).finally(() => {
this.searchLoading = false;

View File

@ -37,6 +37,7 @@ export function createOpenMct(timeSystemOptions = DEFAULT_TIME_OPTIONS) {
const openmct = new MCT();
openmct.install(openmct.plugins.LocalStorage());
openmct.install(openmct.plugins.UTCTimeSystem());
openmct.setAssetPath('/base');
const timeSystemKey = timeSystemOptions.timeSystemKey;
const start = timeSystemOptions.bounds.start;

View File

@ -23,6 +23,7 @@ const webpackConfig = {
entry: {
openmct: './openmct.js',
couchDBChangesFeed: './src/plugins/persistence/couch/CouchChangesFeed.js',
inMemorySearchWorker: './src/api/objects/InMemorySearchWorker.js',
espressoTheme: './src/plugins/themes/espresso-theme.scss',
snowTheme: './src/plugins/themes/snow-theme.scss',
maelstromTheme: './src/plugins/themes/maelstrom-theme.scss'