diff --git a/Procfile b/Procfile
index aa6094edfe..1e13b4ae05 100644
--- a/Procfile
+++ b/Procfile
@@ -1 +1 @@
-web: node app.js --port $PORT --include example/localstorage
\ No newline at end of file
+web: node app.js --port $PORT
diff --git a/bundles.json b/bundles.json
index a0f7edd7a8..2751dbd3d8 100644
--- a/bundles.json
+++ b/bundles.json
@@ -22,7 +22,7 @@
"platform/forms",
"platform/identity",
"platform/persistence/local",
- "platform/persistence/queue",
+ "platform/persistence/elastic",
"platform/policy",
"platform/entanglement",
"platform/search",
diff --git a/docs/gendocs.js b/docs/gendocs.js
index 329a9b607f..cd61b9a9bf 100644
--- a/docs/gendocs.js
+++ b/docs/gendocs.js
@@ -30,7 +30,8 @@
var CONSTANTS = {
DIAGRAM_WIDTH: 800,
DIAGRAM_HEIGHT: 500
- };
+ },
+ TOC_HEAD = "# Table of Contents";
GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be defined
(function () {
@@ -44,6 +45,7 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
split = require("split"),
stream = require("stream"),
nomnoml = require('nomnoml'),
+ toc = require("markdown-toc"),
Canvas = require('canvas'),
options = require("minimist")(process.argv.slice(2));
@@ -110,6 +112,9 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
done();
};
transform._flush = function (done) {
+ // Prepend table of contents
+ markdown =
+ [ TOC_HEAD, toc(markdown).content, "", markdown ].join("\n");
this.push("
\n");
this.push(marked(markdown));
this.push("\n\n");
@@ -133,8 +138,8 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define
customRenderer.link = function (href, title, text) {
// ...but only if they look like relative paths
return (href || "").indexOf(":") === -1 && href[0] !== "/" ?
- renderer.link(href.replace(/\.md/, ".html"), title, text) :
- renderer.link.apply(renderer, arguments);
+ renderer.link(href.replace(/\.md/, ".html"), title, text) :
+ renderer.link.apply(renderer, arguments);
};
return customRenderer;
}
diff --git a/example/generator/src/SinewaveLimitCapability.js b/example/generator/src/SinewaveLimitCapability.js
index 30d222b0c7..ac4f4718a2 100644
--- a/example/generator/src/SinewaveLimitCapability.js
+++ b/example/generator/src/SinewaveLimitCapability.js
@@ -30,25 +30,25 @@ define(
YELLOW = 0.5,
LIMITS = {
rh: {
- cssClass: "s-limit-upr-red",
+ cssClass: "s-limit-upr s-limit-red",
low: RED,
high: Number.POSITIVE_INFINITY,
name: "Red High"
},
rl: {
- cssClass: "s-limit-lwr-red",
+ cssClass: "s-limit-lwr s-limit-red",
high: -RED,
low: Number.NEGATIVE_INFINITY,
name: "Red Low"
},
yh: {
- cssClass: "s-limit-upr-yellow",
+ cssClass: "s-limit-upr s-limit-yellow",
low: YELLOW,
high: RED,
name: "Yellow High"
},
yl: {
- cssClass: "s-limit-lwr-yellow",
+ cssClass: "s-limit-lwr s-limit-yellow",
low: -RED,
high: -YELLOW,
name: "Yellow Low"
diff --git a/package.json b/package.json
index a1ac01f437..c96642129c 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,8 @@
"split": "^1.0.0",
"mkdirp": "^0.5.1",
"nomnoml": "^0.0.3",
- "canvas": "^1.2.7"
+ "canvas": "^1.2.7",
+ "markdown-toc": "^0.11.7"
},
"scripts": {
"start": "node app.js",
diff --git a/platform/commonUI/browse/bundle.json b/platform/commonUI/browse/bundle.json
index 34448dd0d7..9e7772c66d 100644
--- a/platform/commonUI/browse/bundle.json
+++ b/platform/commonUI/browse/bundle.json
@@ -1,4 +1,9 @@
{
+ "configuration": {
+ "paths": {
+ "uuid": "uuid"
+ }
+ },
"extensions": {
"routes": [
{
diff --git a/platform/commonUI/browse/src/creation/CreationService.js b/platform/commonUI/browse/src/creation/CreationService.js
index 667863ef20..2b059724b3 100644
--- a/platform/commonUI/browse/src/creation/CreationService.js
+++ b/platform/commonUI/browse/src/creation/CreationService.js
@@ -25,7 +25,7 @@
* Module defining CreateService. Created by vwoeltje on 11/10/14.
*/
define(
- ["../../lib/uuid"],
+ ["uuid"],
function (uuid) {
"use strict";
diff --git a/platform/commonUI/general/res/sass/_constants.scss b/platform/commonUI/general/res/sass/_constants.scss
index 9ba1c962c1..e5a7c90eb0 100644
--- a/platform/commonUI/general/res/sass/_constants.scss
+++ b/platform/commonUI/general/res/sass/_constants.scss
@@ -110,3 +110,8 @@ $dirImgs: $dirCommonRes + 'images/';
/************************** TIMINGS */
$controlFadeMs: 100ms;
+
+/************************** LIMITS */
+$glyphLimit: '\e603';
+$glyphLimitUpr: '\0000eb';
+$glyphLimitLwr: '\0000ee';
diff --git a/platform/commonUI/general/res/sass/_limits.scss b/platform/commonUI/general/res/sass/_limits.scss
index 12411554cd..51f9a1c863 100644
--- a/platform/commonUI/general/res/sass/_limits.scss
+++ b/platform/commonUI/general/res/sass/_limits.scss
@@ -1,26 +1,39 @@
-@mixin limit($bg, $ic, $glyph) {
- background: $bg !important;
- //color: $fg !important;
- &:before {
- //@include pulse(1000ms);
- color: $ic;
- content: $glyph;
- }
+@mixin limitGlyph($iconColor, $glyph: $glyphLimit) {
+ &:before {
+ color: $iconColor;
+ content: $glyph;
+ font-family: symbolsfont;
+ font-size: 0.8em;
+ display: inline;
+ margin-right: $interiorMarginSm;
+ }
+
}
-[class*="s-limit"] {
- //white-space: nowrap;
- &:before {
- display: inline-block;
- font-family: symbolsfont;
- font-size: 0.75em;
- font-style: normal !important;
- margin-right: $interiorMarginSm;
- vertical-align: middle;
- }
+.s-limit-red { background: $colorLimitRedBg !important; }
+.s-limit-yellow { background: $colorLimitYellowBg !important; }
+
+// Handle limit when applied to a tr
+tr[class*="s-limit"] {
+ &.s-limit-red td:first-child {
+ @include limitGlyph($colorLimitRedIc);
+ }
+ &.s-limit-yellow td:first-child {
+ @include limitGlyph($colorLimitYellowIc);
+ }
+ &.s-limit-upr td:first-child:before { content:$glyphLimitUpr; }
+ &.s-limit-lwr td:first-child:before { content:$glyphLimitLwr; }
}
-.s-limit-upr-red { @include limit($colorLimitRedBg, $colorLimitRedIc, "\0000eb"); };
-.s-limit-upr-yellow { @include limit($colorLimitYellowBg, $colorLimitYellowIc, "\0000ed"); };
-.s-limit-lwr-yellow { @include limit($colorLimitYellowBg, $colorLimitYellowIc, "\0000ec"); };
-.s-limit-lwr-red { @include limit($colorLimitRedBg, $colorLimitRedIc, "\0000ee"); };
\ No newline at end of file
+// Handle limit when applied directly to a non-tr element
+// Assume this is applied to the element that displays the limit value
+:not(tr)[class*="s-limit"] {
+ &.s-limit-red {
+ @include limitGlyph($colorLimitRedIc);
+ }
+ &.s-limit-yellow {
+ @include limitGlyph($colorLimitYellowIc);
+ }
+ &.s-limit-upr:before { content:$glyphLimitUpr; }
+ &.s-limit-lwr:before { content:$glyphLimitLwr; }
+}
\ No newline at end of file
diff --git a/platform/commonUI/general/res/templates/controls/time-controller.html b/platform/commonUI/general/res/templates/controls/time-controller.html
index ff142ea189..300e56c381 100644
--- a/platform/commonUI/general/res/templates/controls/time-controller.html
+++ b/platform/commonUI/general/res/templates/controls/time-controller.html
@@ -19,84 +19,90 @@
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
-
\ No newline at end of file
+
+
diff --git a/platform/commonUI/general/src/controllers/TimeRangeController.js b/platform/commonUI/general/src/controllers/TimeRangeController.js
index 55bddd712f..d4fb21be08 100644
--- a/platform/commonUI/general/src/controllers/TimeRangeController.js
+++ b/platform/commonUI/general/src/controllers/TimeRangeController.js
@@ -26,9 +26,8 @@ define(
function (moment) {
"use strict";
- var
- DATE_FORMAT = "YYYY-MM-DD HH:mm:ss",
- TICK_SPACING_PX = 150;
+ var DATE_FORMAT = "YYYY-MM-DD HH:mm:ss",
+ TICK_SPACING_PX = 150;
/**
* @memberof platform/commonUI/general
@@ -44,6 +43,15 @@ define(
return moment.utc(ts).format(DATE_FORMAT);
}
+ function parseTimestamp(text) {
+ var m = moment.utc(text, DATE_FORMAT);
+ if (m.isValid()) {
+ return m.valueOf();
+ } else {
+ throw new Error("Could not parse " + text);
+ }
+ }
+
// From 0.0-1.0 to "0%"-"1%"
function toPercent(p) {
return (100 * p) + "%";
@@ -93,6 +101,25 @@ define(
return { start: bounds.start, end: bounds.end };
}
+ function updateBoundsTextForProperty(ngModel, property) {
+ try {
+ if (!$scope.boundsModel[property] ||
+ parseTimestamp($scope.boundsModel[property]) !==
+ ngModel.outer[property]) {
+ $scope.boundsModel[property] =
+ formatTimestamp(ngModel.outer[property]);
+ }
+ } catch (e) {
+ // User-entered text is invalid, so leave it be
+ // until they fix it.
+ }
+ }
+
+ function updateBoundsText(ngModel) {
+ updateBoundsTextForProperty(ngModel, 'start');
+ updateBoundsTextForProperty(ngModel, 'end');
+ }
+
function updateViewFromModel(ngModel) {
var t = now();
@@ -101,8 +128,7 @@ define(
ngModel.inner = ngModel.inner || copyBounds(ngModel.outer);
// First, dates for the date pickers for outer bounds
- $scope.startOuterDate = new Date(ngModel.outer.start);
- $scope.endOuterDate = new Date(ngModel.outer.end);
+ updateBoundsText(ngModel);
// Then various updates for the inner span
updateViewForInnerSpanFromModel(ngModel);
@@ -178,6 +204,8 @@ define(
function updateOuterStart(t) {
var ngModel = $scope.ngModel;
+ ngModel.outer.start = t;
+
ngModel.outer.end = Math.max(
ngModel.outer.start + outerMinimumSpan,
ngModel.outer.end
@@ -190,14 +218,15 @@ define(
ngModel.inner.end
);
- $scope.startOuterText = formatTimestamp(t);
-
updateViewForInnerSpanFromModel(ngModel);
+ updateTicks();
}
function updateOuterEnd(t) {
var ngModel = $scope.ngModel;
+ ngModel.outer.end = t;
+
ngModel.outer.start = Math.min(
ngModel.outer.end - outerMinimumSpan,
ngModel.outer.start
@@ -210,9 +239,40 @@ define(
ngModel.inner.start
);
- $scope.endOuterText = formatTimestamp(t);
-
updateViewForInnerSpanFromModel(ngModel);
+ updateTicks();
+ }
+
+ function updateStartFromText(value) {
+ try {
+ updateOuterStart(parseTimestamp(value));
+ updateBoundsTextForProperty($scope.ngModel, 'end');
+ $scope.boundsModel.startValid = true;
+ } catch (e) {
+ $scope.boundsModel.startValid = false;
+ return;
+ }
+ }
+
+ function updateEndFromText(value) {
+ try {
+ updateOuterEnd(parseTimestamp(value));
+ updateBoundsTextForProperty($scope.ngModel, 'start');
+ $scope.boundsModel.endValid = true;
+ } catch (e) {
+ $scope.boundsModel.endValid = false;
+ return;
+ }
+ }
+
+ function updateStartFromPicker(value) {
+ updateOuterStart(value);
+ updateBoundsText($scope.ngModel);
+ }
+
+ function updateEndFromPicker(value) {
+ updateOuterEnd(value);
+ updateBoundsText($scope.ngModel);
}
$scope.startLeftDrag = startLeftDrag;
@@ -224,14 +284,17 @@ define(
$scope.state = false;
$scope.ticks = [];
+ $scope.boundsModel = {};
// Initialize scope to defaults
updateViewFromModel($scope.ngModel);
$scope.$watchCollection("ngModel", updateViewFromModel);
$scope.$watch("spanWidth", updateSpanWidth);
- $scope.$watch("ngModel.outer.start", updateOuterStart);
- $scope.$watch("ngModel.outer.end", updateOuterEnd);
+ $scope.$watch("ngModel.outer.start", updateStartFromPicker);
+ $scope.$watch("ngModel.outer.end", updateEndFromPicker);
+ $scope.$watch("boundsModel.start", updateStartFromText);
+ $scope.$watch("boundsModel.end", updateEndFromText);
}
return TimeConductorController;
diff --git a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js
index 9d7a6a9f52..91d3ecb9db 100644
--- a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js
+++ b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js
@@ -22,8 +22,8 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
- ["../../src/controllers/TimeRangeController"],
- function (TimeRangeController) {
+ ["../../src/controllers/TimeRangeController", "moment"],
+ function (TimeRangeController, moment) {
"use strict";
var SEC = 1000,
@@ -166,8 +166,72 @@ define(
expect(mockScope.ngModel.inner.end)
.toBeGreaterThan(mockScope.ngModel.inner.start);
});
+
+ describe("by typing", function () {
+ it("updates models", function () {
+ var newStart = "1977-05-25 17:30:00",
+ newEnd = "2015-12-18 03:30:00";
+
+ mockScope.boundsModel.start = newStart;
+ fireWatch("boundsModel.start", newStart);
+ expect(mockScope.ngModel.outer.start)
+ .toEqual(moment.utc(newStart).valueOf());
+ expect(mockScope.boundsModel.startValid)
+ .toBeTruthy();
+
+ mockScope.boundsModel.end = newEnd;
+ fireWatch("boundsModel.end", newEnd);
+ expect(mockScope.ngModel.outer.end)
+ .toEqual(moment.utc(newEnd).valueOf());
+ expect(mockScope.boundsModel.endValid)
+ .toBeTruthy();
+ });
+
+ it("displays error state", function () {
+ var newStart = "Not a date",
+ newEnd = "Definitely not a date",
+ oldStart = mockScope.ngModel.outer.start,
+ oldEnd = mockScope.ngModel.outer.end;
+
+ mockScope.boundsModel.start = newStart;
+ fireWatch("boundsModel.start", newStart);
+ expect(mockScope.ngModel.outer.start)
+ .toEqual(oldStart);
+ expect(mockScope.boundsModel.startValid)
+ .toBeFalsy();
+
+ mockScope.boundsModel.end = newEnd;
+ fireWatch("boundsModel.end", newEnd);
+ expect(mockScope.ngModel.outer.end)
+ .toEqual(oldEnd);
+ expect(mockScope.boundsModel.endValid)
+ .toBeFalsy();
+ });
+
+ it("does not modify user input", function () {
+ // Don't want the controller "fixing" bad or
+ // irregularly-formatted input out from under
+ // the user's fingertips.
+ var newStart = "Not a date",
+ newEnd = "2015-3-3 01:02:04",
+ oldStart = mockScope.ngModel.outer.start,
+ oldEnd = mockScope.ngModel.outer.end;
+
+ mockScope.boundsModel.start = newStart;
+ fireWatch("boundsModel.start", newStart);
+ expect(mockScope.boundsModel.start)
+ .toEqual(newStart);
+
+ mockScope.boundsModel.end = newEnd;
+ fireWatch("boundsModel.end", newEnd);
+ expect(mockScope.boundsModel.end)
+ .toEqual(newEnd);
+ });
+ });
});
+
+
});
}
);
diff --git a/platform/commonUI/themes/espresso/res/css/theme-espresso.css b/platform/commonUI/themes/espresso/res/css/theme-espresso.css
index 988b759608..4679297105 100644
--- a/platform/commonUI/themes/espresso/res/css/theme-espresso.css
+++ b/platform/commonUI/themes/espresso/res/css/theme-espresso.css
@@ -147,6 +147,7 @@ article, aside, details, figcaption, figure, footer, header, hgroup, main, menu,
/************************** CONTROLS */
/************************** PATHS */
/************************** TIMINGS */
+/************************** LIMITS */
/*****************************************************************************
* Open MCT Web, Copyright (c) 2014-2015, United States Government
* as represented by the Administrator of the National Aeronautics and Space
@@ -667,45 +668,58 @@ mct-container {
content: "!"; }
/* line 13, ../../../../general/res/sass/_limits.scss */
-[class*="s-limit"]:before {
- display: inline-block;
+.s-limit-red {
+ background: rgba(255, 0, 0, 0.3) !important; }
+
+/* line 14, ../../../../general/res/sass/_limits.scss */
+.s-limit-yellow {
+ background: rgba(255, 170, 0, 0.3) !important; }
+
+/* line 2, ../../../../general/res/sass/_limits.scss */
+tr[class*="s-limit"].s-limit-red td:first-child:before {
+ color: red;
+ content: "";
font-family: symbolsfont;
- font-size: 0.75em;
- font-style: normal !important;
- margin-right: 3px;
- vertical-align: middle; }
-
-/* line 23, ../../../../general/res/sass/_limits.scss */
-.s-limit-upr-red {
- background: rgba(255, 0, 0, 0.3) !important; }
- /* line 4, ../../../../general/res/sass/_limits.scss */
- .s-limit-upr-red:before {
- color: red;
- content: "ë"; }
-
+ font-size: 0.8em;
+ display: inline;
+ margin-right: 3px; }
+/* line 2, ../../../../general/res/sass/_limits.scss */
+tr[class*="s-limit"].s-limit-yellow td:first-child:before {
+ color: #ffaa00;
+ content: "";
+ font-family: symbolsfont;
+ font-size: 0.8em;
+ display: inline;
+ margin-right: 3px; }
/* line 24, ../../../../general/res/sass/_limits.scss */
-.s-limit-upr-yellow {
- background: rgba(255, 170, 0, 0.3) !important; }
- /* line 4, ../../../../general/res/sass/_limits.scss */
- .s-limit-upr-yellow:before {
- color: #ffaa00;
- content: "í"; }
-
+tr[class*="s-limit"].s-limit-upr td:first-child:before {
+ content: "ë"; }
/* line 25, ../../../../general/res/sass/_limits.scss */
-.s-limit-lwr-yellow {
- background: rgba(255, 170, 0, 0.3) !important; }
- /* line 4, ../../../../general/res/sass/_limits.scss */
- .s-limit-lwr-yellow:before {
- color: #ffaa00;
- content: "ì"; }
+tr[class*="s-limit"].s-limit-lwr td:first-child:before {
+ content: "î"; }
-/* line 26, ../../../../general/res/sass/_limits.scss */
-.s-limit-lwr-red {
- background: rgba(255, 0, 0, 0.3) !important; }
- /* line 4, ../../../../general/res/sass/_limits.scss */
- .s-limit-lwr-red:before {
- color: red;
- content: "î"; }
+/* line 2, ../../../../general/res/sass/_limits.scss */
+:not(tr)[class*="s-limit"].s-limit-red:before {
+ color: red;
+ content: "";
+ font-family: symbolsfont;
+ font-size: 0.8em;
+ display: inline;
+ margin-right: 3px; }
+/* line 2, ../../../../general/res/sass/_limits.scss */
+:not(tr)[class*="s-limit"].s-limit-yellow:before {
+ color: #ffaa00;
+ content: "";
+ font-family: symbolsfont;
+ font-size: 0.8em;
+ display: inline;
+ margin-right: 3px; }
+/* line 37, ../../../../general/res/sass/_limits.scss */
+:not(tr)[class*="s-limit"].s-limit-upr:before {
+ content: "ë"; }
+/* line 38, ../../../../general/res/sass/_limits.scss */
+:not(tr)[class*="s-limit"].s-limit-lwr:before {
+ content: "î"; }
/* line 1, ../../../../general/res/sass/_data-status.scss */
.s-stale {
@@ -4275,13 +4289,45 @@ span.req {
/* line 65, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-hidden .pane.left.treeview {
- right: 100% !important;
- width: auto !important;
- overflow-y: hidden;
- overflow-x: hidden; }
+ -moz-transition-property: opacity;
+ -o-transition-property: opacity;
+ -webkit-transition-property: opacity;
+ transition-property: opacity;
+ -moz-transition-duration: 150ms;
+ -o-transition-duration: 150ms;
+ -webkit-transition-duration: 150ms;
+ transition-duration: 150ms;
+ -moz-transition-timing-function: ease-in-out;
+ -o-transition-timing-function: ease-in-out;
+ -webkit-transition-timing-function: ease-in-out;
+ transition-timing-function: ease-in-out;
+ -moz-transition-delay: 0;
+ -o-transition-delay: 0;
+ -webkit-transition-delay: 0;
+ transition-delay: 0;
+ opacity: 0 !important; }
+ /* line 73, ../../../../general/res/sass/mobile/_layout.scss */
+ .pane-tree-hidden .pane.right.items {
+ left: 0 !important; }
- /* line 82, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 87, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.left.treeview {
+ -moz-transition-property: opacity;
+ -o-transition-property: opacity;
+ -webkit-transition-property: opacity;
+ transition-property: opacity;
+ -moz-transition-duration: 250ms;
+ -o-transition-duration: 250ms;
+ -webkit-transition-duration: 250ms;
+ transition-duration: 250ms;
+ -moz-transition-timing-function: ease-in-out;
+ -o-transition-timing-function: ease-in-out;
+ -webkit-transition-timing-function: ease-in-out;
+ transition-timing-function: ease-in-out;
+ -moz-transition-delay: 250ms;
+ -o-transition-delay: 250ms;
+ -webkit-transition-delay: 250ms;
+ transition-delay: 250ms;
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuMCIgeTE9IjAuNSIgeDI9IjEuMCIgeTI9IjAuNSI+PHN0b3Agb2Zmc2V0PSI5OCUiIHN0b3AtY29sb3I9IiMwMDAwMDAiIHN0b3Atb3BhY2l0eT0iMC4wIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMDAwMDAwIiBzdG9wLW9wYWNpdHk9IjAuMyIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA==');
background-size: 100%;
background-image: -moz-linear-gradient(0deg, rgba(0, 0, 0, 0) 98%, rgba(0, 0, 0, 0.3) 100%);
@@ -4289,52 +4335,52 @@ span.req {
background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 98%, rgba(0, 0, 0, 0.3) 100%);
right: auto !important;
width: 40% !important; }
- /* line 88, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 94, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.right.items {
left: 40% !important; }
- /* line 93, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 99, ../../../../general/res/sass/mobile/_layout.scss */
.toggle-tree {
color: #0099cc !important;
font-size: 110%;
position: absolute;
top: 12px;
left: 10px; }
- /* line 99, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 105, ../../../../general/res/sass/mobile/_layout.scss */
.toggle-tree:after {
content: 'm' !important;
font-family: symbolsfont; }
- /* line 105, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 111, ../../../../general/res/sass/mobile/_layout.scss */
.object-browse-bar {
left: 30px !important; }
- /* line 108, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 114, ../../../../general/res/sass/mobile/_layout.scss */
.object-browse-bar .context-available {
opacity: 1 !important; }
- /* line 111, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 117, ../../../../general/res/sass/mobile/_layout.scss */
.object-browse-bar .view-switcher {
margin-right: 0 !important; }
- /* line 113, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 119, ../../../../general/res/sass/mobile/_layout.scss */
.object-browse-bar .view-switcher .title-label {
display: none; }
- /* line 120, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 126, ../../../../general/res/sass/mobile/_layout.scss */
.tree-holder {
overflow-x: hidden !important; }
- /* line 124, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 130, ../../../../general/res/sass/mobile/_layout.scss */
.mobile-disable-select {
-moz-user-select: -moz-none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none; }
- /* line 129, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 135, ../../../../general/res/sass/mobile/_layout.scss */
.mobile-hide,
.mobile-hide-important {
display: none !important; }
- /* line 134, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 140, ../../../../general/res/sass/mobile/_layout.scss */
.mobile-back-hide {
pointer-events: none;
-moz-transition-property: opacity;
@@ -4355,7 +4401,7 @@ span.req {
transition-delay: 0;
opacity: 0; }
- /* line 139, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 145, ../../../../general/res/sass/mobile/_layout.scss */
.mobile-back-unhide {
pointer-events: all;
-moz-transition-property: opacity;
@@ -4376,21 +4422,21 @@ span.req {
transition-delay: 0;
opacity: 1; } }
@media screen and (orientation: portrait) and (max-width: 514px) and (max-height: 740px) and (max-device-width: 799px) and (max-device-height: 1024px) {
- /* line 148, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 154, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.left.treeview {
width: 90% !important; }
- /* line 151, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 157, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.right.items {
left: 0 !important;
-moz-transform: translateX(90%);
-ms-transform: translateX(90%);
-webkit-transform: translateX(90%);
transform: translateX(90%); }
- /* line 154, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 160, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.right.items #content-area {
opacity: 0; } }
@media screen and (min-device-width: 800px) and (min-device-height: 1025px), screen and (min-device-width: 1025px) and (min-device-height: 800px) {
- /* line 162, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 168, ../../../../general/res/sass/mobile/_layout.scss */
.desktop-hide {
display: none; } }
/*****************************************************************************
diff --git a/platform/commonUI/themes/snow/res/css/theme-snow.css b/platform/commonUI/themes/snow/res/css/theme-snow.css
index 4956c9fcde..ed04d1e5b2 100644
--- a/platform/commonUI/themes/snow/res/css/theme-snow.css
+++ b/platform/commonUI/themes/snow/res/css/theme-snow.css
@@ -147,6 +147,7 @@ article, aside, details, figcaption, figure, footer, header, hgroup, main, menu,
/************************** CONTROLS */
/************************** PATHS */
/************************** TIMINGS */
+/************************** LIMITS */
/*****************************************************************************
* Open MCT Web, Copyright (c) 2014-2015, United States Government
* as represented by the Administrator of the National Aeronautics and Space
@@ -667,45 +668,58 @@ mct-container {
content: "!"; }
/* line 13, ../../../../general/res/sass/_limits.scss */
-[class*="s-limit"]:before {
- display: inline-block;
+.s-limit-red {
+ background: rgba(255, 0, 0, 0.3) !important; }
+
+/* line 14, ../../../../general/res/sass/_limits.scss */
+.s-limit-yellow {
+ background: rgba(255, 170, 0, 0.3) !important; }
+
+/* line 2, ../../../../general/res/sass/_limits.scss */
+tr[class*="s-limit"].s-limit-red td:first-child:before {
+ color: red;
+ content: "";
font-family: symbolsfont;
- font-size: 0.75em;
- font-style: normal !important;
- margin-right: 3px;
- vertical-align: middle; }
-
-/* line 23, ../../../../general/res/sass/_limits.scss */
-.s-limit-upr-red {
- background: rgba(255, 0, 0, 0.3) !important; }
- /* line 4, ../../../../general/res/sass/_limits.scss */
- .s-limit-upr-red:before {
- color: red;
- content: "ë"; }
-
+ font-size: 0.8em;
+ display: inline;
+ margin-right: 3px; }
+/* line 2, ../../../../general/res/sass/_limits.scss */
+tr[class*="s-limit"].s-limit-yellow td:first-child:before {
+ color: #ffaa00;
+ content: "";
+ font-family: symbolsfont;
+ font-size: 0.8em;
+ display: inline;
+ margin-right: 3px; }
/* line 24, ../../../../general/res/sass/_limits.scss */
-.s-limit-upr-yellow {
- background: rgba(255, 170, 0, 0.3) !important; }
- /* line 4, ../../../../general/res/sass/_limits.scss */
- .s-limit-upr-yellow:before {
- color: #ffaa00;
- content: "í"; }
-
+tr[class*="s-limit"].s-limit-upr td:first-child:before {
+ content: "ë"; }
/* line 25, ../../../../general/res/sass/_limits.scss */
-.s-limit-lwr-yellow {
- background: rgba(255, 170, 0, 0.3) !important; }
- /* line 4, ../../../../general/res/sass/_limits.scss */
- .s-limit-lwr-yellow:before {
- color: #ffaa00;
- content: "ì"; }
+tr[class*="s-limit"].s-limit-lwr td:first-child:before {
+ content: "î"; }
-/* line 26, ../../../../general/res/sass/_limits.scss */
-.s-limit-lwr-red {
- background: rgba(255, 0, 0, 0.3) !important; }
- /* line 4, ../../../../general/res/sass/_limits.scss */
- .s-limit-lwr-red:before {
- color: red;
- content: "î"; }
+/* line 2, ../../../../general/res/sass/_limits.scss */
+:not(tr)[class*="s-limit"].s-limit-red:before {
+ color: red;
+ content: "";
+ font-family: symbolsfont;
+ font-size: 0.8em;
+ display: inline;
+ margin-right: 3px; }
+/* line 2, ../../../../general/res/sass/_limits.scss */
+:not(tr)[class*="s-limit"].s-limit-yellow:before {
+ color: #ffaa00;
+ content: "";
+ font-family: symbolsfont;
+ font-size: 0.8em;
+ display: inline;
+ margin-right: 3px; }
+/* line 37, ../../../../general/res/sass/_limits.scss */
+:not(tr)[class*="s-limit"].s-limit-upr:before {
+ content: "ë"; }
+/* line 38, ../../../../general/res/sass/_limits.scss */
+:not(tr)[class*="s-limit"].s-limit-lwr:before {
+ content: "î"; }
/* line 1, ../../../../general/res/sass/_data-status.scss */
.s-stale {
@@ -4216,13 +4230,45 @@ span.req {
/* line 65, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-hidden .pane.left.treeview {
- right: 100% !important;
- width: auto !important;
- overflow-y: hidden;
- overflow-x: hidden; }
+ -moz-transition-property: opacity;
+ -o-transition-property: opacity;
+ -webkit-transition-property: opacity;
+ transition-property: opacity;
+ -moz-transition-duration: 150ms;
+ -o-transition-duration: 150ms;
+ -webkit-transition-duration: 150ms;
+ transition-duration: 150ms;
+ -moz-transition-timing-function: ease-in-out;
+ -o-transition-timing-function: ease-in-out;
+ -webkit-transition-timing-function: ease-in-out;
+ transition-timing-function: ease-in-out;
+ -moz-transition-delay: 0;
+ -o-transition-delay: 0;
+ -webkit-transition-delay: 0;
+ transition-delay: 0;
+ opacity: 0 !important; }
+ /* line 73, ../../../../general/res/sass/mobile/_layout.scss */
+ .pane-tree-hidden .pane.right.items {
+ left: 0 !important; }
- /* line 82, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 87, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.left.treeview {
+ -moz-transition-property: opacity;
+ -o-transition-property: opacity;
+ -webkit-transition-property: opacity;
+ transition-property: opacity;
+ -moz-transition-duration: 250ms;
+ -o-transition-duration: 250ms;
+ -webkit-transition-duration: 250ms;
+ transition-duration: 250ms;
+ -moz-transition-timing-function: ease-in-out;
+ -o-transition-timing-function: ease-in-out;
+ -webkit-transition-timing-function: ease-in-out;
+ transition-timing-function: ease-in-out;
+ -moz-transition-delay: 250ms;
+ -o-transition-delay: 250ms;
+ -webkit-transition-delay: 250ms;
+ transition-delay: 250ms;
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuMCIgeTE9IjAuNSIgeDI9IjEuMCIgeTI9IjAuNSI+PHN0b3Agb2Zmc2V0PSI5OCUiIHN0b3AtY29sb3I9IiMwMDAwMDAiIHN0b3Atb3BhY2l0eT0iMC4wIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMDAwMDAwIiBzdG9wLW9wYWNpdHk9IjAuMyIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA==');
background-size: 100%;
background-image: -moz-linear-gradient(0deg, rgba(0, 0, 0, 0) 98%, rgba(0, 0, 0, 0.3) 100%);
@@ -4230,52 +4276,52 @@ span.req {
background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 98%, rgba(0, 0, 0, 0.3) 100%);
right: auto !important;
width: 40% !important; }
- /* line 88, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 94, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.right.items {
left: 40% !important; }
- /* line 93, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 99, ../../../../general/res/sass/mobile/_layout.scss */
.toggle-tree {
color: #0099cc !important;
font-size: 110%;
position: absolute;
top: 12px;
left: 10px; }
- /* line 99, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 105, ../../../../general/res/sass/mobile/_layout.scss */
.toggle-tree:after {
content: 'm' !important;
font-family: symbolsfont; }
- /* line 105, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 111, ../../../../general/res/sass/mobile/_layout.scss */
.object-browse-bar {
left: 30px !important; }
- /* line 108, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 114, ../../../../general/res/sass/mobile/_layout.scss */
.object-browse-bar .context-available {
opacity: 1 !important; }
- /* line 111, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 117, ../../../../general/res/sass/mobile/_layout.scss */
.object-browse-bar .view-switcher {
margin-right: 0 !important; }
- /* line 113, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 119, ../../../../general/res/sass/mobile/_layout.scss */
.object-browse-bar .view-switcher .title-label {
display: none; }
- /* line 120, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 126, ../../../../general/res/sass/mobile/_layout.scss */
.tree-holder {
overflow-x: hidden !important; }
- /* line 124, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 130, ../../../../general/res/sass/mobile/_layout.scss */
.mobile-disable-select {
-moz-user-select: -moz-none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none; }
- /* line 129, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 135, ../../../../general/res/sass/mobile/_layout.scss */
.mobile-hide,
.mobile-hide-important {
display: none !important; }
- /* line 134, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 140, ../../../../general/res/sass/mobile/_layout.scss */
.mobile-back-hide {
pointer-events: none;
-moz-transition-property: opacity;
@@ -4296,7 +4342,7 @@ span.req {
transition-delay: 0;
opacity: 0; }
- /* line 139, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 145, ../../../../general/res/sass/mobile/_layout.scss */
.mobile-back-unhide {
pointer-events: all;
-moz-transition-property: opacity;
@@ -4317,21 +4363,21 @@ span.req {
transition-delay: 0;
opacity: 1; } }
@media screen and (orientation: portrait) and (max-width: 514px) and (max-height: 740px) and (max-device-width: 799px) and (max-device-height: 1024px) {
- /* line 148, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 154, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.left.treeview {
width: 90% !important; }
- /* line 151, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 157, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.right.items {
left: 0 !important;
-moz-transform: translateX(90%);
-ms-transform: translateX(90%);
-webkit-transform: translateX(90%);
transform: translateX(90%); }
- /* line 154, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 160, ../../../../general/res/sass/mobile/_layout.scss */
.pane-tree-showing .pane.right.items #content-area {
opacity: 0; } }
@media screen and (min-device-width: 800px) and (min-device-height: 1025px), screen and (min-device-width: 1025px) and (min-device-height: 800px) {
- /* line 162, ../../../../general/res/sass/mobile/_layout.scss */
+ /* line 168, ../../../../general/res/sass/mobile/_layout.scss */
.desktop-hide {
display: none; } }
/*****************************************************************************
diff --git a/platform/entanglement/bundle.json b/platform/entanglement/bundle.json
index 61c3d90539..d8cde0ada6 100644
--- a/platform/entanglement/bundle.json
+++ b/platform/entanglement/bundle.json
@@ -30,6 +30,14 @@
"category": "contextual",
"implementation": "actions/LinkAction.js",
"depends": ["locationService", "linkService"]
+ },
+ {
+ "key": "follow",
+ "name": "Go To Original",
+ "description": "Go to the original, un-linked instance of this object.",
+ "glyph": "\u00F4",
+ "category": "contextual",
+ "implementation": "actions/GoToOriginalAction.js"
}
],
"components": [
@@ -52,7 +60,8 @@
"key": "location",
"name": "Location Capability",
"description": "Provides a capability for retrieving the location of an object based upon it's context.",
- "implementation": "capabilities/LocationCapability"
+ "implementation": "capabilities/LocationCapability",
+ "depends": [ "$q", "$injector" ]
}
],
"services": [
diff --git a/platform/entanglement/src/actions/GoToOriginalAction.js b/platform/entanglement/src/actions/GoToOriginalAction.js
new file mode 100644
index 0000000000..9722915ad6
--- /dev/null
+++ b/platform/entanglement/src/actions/GoToOriginalAction.js
@@ -0,0 +1,62 @@
+/*****************************************************************************
+ * Open MCT Web, Copyright (c) 2014-2015, United States Government
+ * as represented by the Administrator of the National Aeronautics and Space
+ * Administration. All rights reserved.
+ *
+ * Open MCT Web is licensed under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ *
+ * Open MCT Web includes source code licensed under additional open source
+ * licenses. See the Open Source Licenses file (LICENSES.md) included with
+ * this source code distribution or the Licensing information page available
+ * at runtime from the About dialog for additional information.
+ *****************************************************************************/
+
+/*global define */
+define(
+ function () {
+ "use strict";
+
+ /**
+ * Implements the "Go To Original" action, which follows a link back
+ * to an original instance of an object.
+ *
+ * @implements {Action}
+ * @constructor
+ * @private
+ * @memberof platform/entanglement
+ * @param {ActionContext} context the context in which the action
+ * will be performed
+ */
+ function GoToOriginalAction(context) {
+ this.domainObject = context.domainObject;
+ }
+
+ GoToOriginalAction.prototype.perform = function () {
+ return this.domainObject.getCapability("location").getOriginal()
+ .then(function (originalObject) {
+ var actionCapability =
+ originalObject.getCapability("action");
+ return actionCapability &&
+ actionCapability.perform("navigate");
+ });
+ };
+
+ GoToOriginalAction.appliesTo = function (context) {
+ var domainObject = context.domainObject;
+ return domainObject && domainObject.hasCapability("location")
+ && domainObject.getCapability("location").isLink();
+ };
+
+ return GoToOriginalAction;
+ }
+);
+
diff --git a/platform/entanglement/src/capabilities/LocationCapability.js b/platform/entanglement/src/capabilities/LocationCapability.js
index 17d678f57e..27e1f74c74 100644
--- a/platform/entanglement/src/capabilities/LocationCapability.js
+++ b/platform/entanglement/src/capabilities/LocationCapability.js
@@ -1,3 +1,25 @@
+/*****************************************************************************
+ * Open MCT Web, Copyright (c) 2014-2015, United States Government
+ * as represented by the Administrator of the National Aeronautics and Space
+ * Administration. All rights reserved.
+ *
+ * Open MCT Web is licensed under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ *
+ * Open MCT Web includes source code licensed under additional open source
+ * licenses. See the Open Source Licenses file (LICENSES.md) included with
+ * this source code distribution or the Licensing information page available
+ * at runtime from the About dialog for additional information.
+ *****************************************************************************/
+
/*global define */
define(
@@ -12,11 +34,41 @@ define(
*
* @constructor
*/
- function LocationCapability(domainObject) {
+ function LocationCapability($q, $injector, domainObject) {
this.domainObject = domainObject;
+ this.$q = $q;
+ this.$injector = $injector;
return this;
}
+ /**
+ * Get an instance of this domain object in its original location.
+ *
+ * @returns {Promise.} a promise for the original
+ * instance of this domain object
+ */
+ LocationCapability.prototype.getOriginal = function () {
+ var id;
+
+ if (this.isOriginal()) {
+ return this.$q.when(this.domainObject);
+ }
+
+ id = this.domainObject.getId();
+
+ this.objectService =
+ this.objectService || this.$injector.get("objectService");
+
+ // Assume that an object will be correctly contextualized when
+ // loaded directly from the object service; this is true
+ // so long as LocatingObjectDecorator is present, and that
+ // decorator is also contained in this bundle.
+ return this.objectService.getObjects([id])
+ .then(function (objects) {
+ return objects[id];
+ });
+ };
+
/**
* Set the primary location (the parent id) of the current domain
* object.
@@ -78,10 +130,6 @@ define(
return !this.isLink();
};
- function createLocationCapability(domainObject) {
- return new LocationCapability(domainObject);
- }
-
- return createLocationCapability;
+ return LocationCapability;
}
);
diff --git a/platform/entanglement/test/actions/GoToOriginalActionSpec.js b/platform/entanglement/test/actions/GoToOriginalActionSpec.js
new file mode 100644
index 0000000000..40c2f213ce
--- /dev/null
+++ b/platform/entanglement/test/actions/GoToOriginalActionSpec.js
@@ -0,0 +1,95 @@
+/*****************************************************************************
+ * Open MCT Web, Copyright (c) 2014-2015, United States Government
+ * as represented by the Administrator of the National Aeronautics and Space
+ * Administration. All rights reserved.
+ *
+ * Open MCT Web is licensed under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ *
+ * Open MCT Web includes source code licensed under additional open source
+ * licenses. See the Open Source Licenses file (LICENSES.md) included with
+ * this source code distribution or the Licensing information page available
+ * at runtime from the About dialog for additional information.
+ *****************************************************************************/
+
+/*global define,describe,beforeEach,it,jasmine,expect */
+
+define(
+ [
+ '../../src/actions/GoToOriginalAction',
+ '../DomainObjectFactory',
+ '../ControlledPromise'
+ ],
+ function (GoToOriginalAction, domainObjectFactory, ControlledPromise) {
+ 'use strict';
+
+ describe("The 'go to original' action", function () {
+ var testContext,
+ originalDomainObject,
+ mockLocationCapability,
+ mockOriginalActionCapability,
+ originalPromise,
+ action;
+
+ beforeEach(function () {
+ mockLocationCapability = jasmine.createSpyObj(
+ 'location',
+ [ 'isLink', 'isOriginal', 'getOriginal' ]
+ );
+ mockOriginalActionCapability = jasmine.createSpyObj(
+ 'action',
+ [ 'perform', 'getActions' ]
+ );
+ originalPromise = new ControlledPromise();
+ mockLocationCapability.getOriginal.andReturn(originalPromise);
+ mockLocationCapability.isLink.andReturn(true);
+ mockLocationCapability.isOriginal.andCallFake(function () {
+ return !mockLocationCapability.isLink();
+ });
+ testContext = {
+ domainObject: domainObjectFactory({
+ capabilities: {
+ location: mockLocationCapability
+ }
+ })
+ };
+ originalDomainObject = domainObjectFactory({
+ capabilities: {
+ action: mockOriginalActionCapability
+ }
+ });
+
+ action = new GoToOriginalAction(testContext);
+ });
+
+ it("is applicable to links", function () {
+ expect(GoToOriginalAction.appliesTo(testContext))
+ .toBeTruthy();
+ });
+
+ it("is not applicable to originals", function () {
+ mockLocationCapability.isLink.andReturn(false);
+ expect(GoToOriginalAction.appliesTo(testContext))
+ .toBeFalsy();
+ });
+
+ it("navigates to original objects when performed", function () {
+ expect(mockOriginalActionCapability.perform)
+ .not.toHaveBeenCalled();
+ action.perform();
+ originalPromise.resolve(originalDomainObject);
+ expect(mockOriginalActionCapability.perform)
+ .toHaveBeenCalledWith('navigate');
+ });
+
+ });
+ }
+);
diff --git a/platform/entanglement/test/capabilities/LocationCapabilitySpec.js b/platform/entanglement/test/capabilities/LocationCapabilitySpec.js
index 9cbfcc1bea..442bfe20aa 100644
--- a/platform/entanglement/test/capabilities/LocationCapabilitySpec.js
+++ b/platform/entanglement/test/capabilities/LocationCapabilitySpec.js
@@ -1,3 +1,25 @@
+/*****************************************************************************
+ * Open MCT Web, Copyright (c) 2014-2015, United States Government
+ * as represented by the Administrator of the National Aeronautics and Space
+ * Administration. All rights reserved.
+ *
+ * Open MCT Web is licensed under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ *
+ * Open MCT Web includes source code licensed under additional open source
+ * licenses. See the Open Source Licenses file (LICENSES.md) included with
+ * this source code distribution or the Licensing information page available
+ * at runtime from the About dialog for additional information.
+ *****************************************************************************/
+
/*global define,describe,it,expect,beforeEach,jasmine */
define(
@@ -7,6 +29,7 @@ define(
'../ControlledPromise'
],
function (LocationCapability, domainObjectFactory, ControlledPromise) {
+ 'use strict';
describe("LocationCapability", function () {
@@ -14,13 +37,17 @@ define(
var locationCapability,
persistencePromise,
mutationPromise,
+ mockQ,
+ mockInjector,
+ mockObjectService,
domainObject;
beforeEach(function () {
domainObject = domainObjectFactory({
+ id: "testObject",
capabilities: {
context: {
- getParent: function() {
+ getParent: function () {
return domainObjectFactory({id: 'root'});
}
},
@@ -35,6 +62,11 @@ define(
}
});
+ mockQ = jasmine.createSpyObj("$q", ["when"]);
+ mockInjector = jasmine.createSpyObj("$injector", ["get"]);
+ mockObjectService =
+ jasmine.createSpyObj("objectService", ["getObjects"]);
+
persistencePromise = new ControlledPromise();
domainObject.capabilities.persistence.persist.andReturn(
persistencePromise
@@ -49,7 +81,11 @@ define(
}
);
- locationCapability = new LocationCapability(domainObject);
+ locationCapability = new LocationCapability(
+ mockQ,
+ mockInjector,
+ domainObject
+ );
});
it("returns contextual location", function () {
@@ -88,6 +124,57 @@ define(
expect(whenComplete).toHaveBeenCalled();
});
+ describe("when used to load an original instance", function () {
+ var objectPromise,
+ qPromise,
+ originalObjects,
+ mockCallback;
+
+ function resolvePromises() {
+ if (mockQ.when.calls.length > 0) {
+ qPromise.resolve(mockQ.when.mostRecentCall.args[0]);
+ }
+ if (mockObjectService.getObjects.calls.length > 0) {
+ objectPromise.resolve(originalObjects);
+ }
+ }
+
+ beforeEach(function () {
+ objectPromise = new ControlledPromise();
+ qPromise = new ControlledPromise();
+ originalObjects = {
+ testObject: domainObjectFactory()
+ };
+
+ mockInjector.get.andCallFake(function (key) {
+ return key === 'objectService' && mockObjectService;
+ });
+ mockObjectService.getObjects.andReturn(objectPromise);
+ mockQ.when.andReturn(qPromise);
+
+ mockCallback = jasmine.createSpy('callback');
+ });
+
+ it("provides originals directly", function () {
+ domainObject.model.location = 'root';
+ locationCapability.getOriginal().then(mockCallback);
+ expect(mockCallback).not.toHaveBeenCalled();
+ resolvePromises();
+ expect(mockCallback)
+ .toHaveBeenCalledWith(domainObject);
+ });
+
+ it("loads from the object service for links", function () {
+ domainObject.model.location = 'some-other-root';
+ locationCapability.getOriginal().then(mockCallback);
+ expect(mockCallback).not.toHaveBeenCalled();
+ resolvePromises();
+ expect(mockCallback)
+ .toHaveBeenCalledWith(originalObjects.testObject);
+ });
+ });
+
+
});
});
}
diff --git a/platform/entanglement/test/suite.json b/platform/entanglement/test/suite.json
index 12831b407a..89c082f9c8 100644
--- a/platform/entanglement/test/suite.json
+++ b/platform/entanglement/test/suite.json
@@ -1,5 +1,9 @@
[
"actions/AbstractComposeAction",
+ "actions/CopyAction",
+ "actions/GoToOriginalAction",
+ "actions/LinkAction",
+ "actions/MoveAction",
"services/CopyService",
"services/LinkService",
"services/MoveService",
diff --git a/platform/features/plot/src/elements/PlotUpdater.js b/platform/features/plot/src/elements/PlotUpdater.js
index d4b4ad3eec..53cfd1c4c4 100644
--- a/platform/features/plot/src/elements/PlotUpdater.js
+++ b/platform/features/plot/src/elements/PlotUpdater.js
@@ -159,7 +159,9 @@ define(
// Update dimensions and origin based on extrema of plots
PlotUpdater.prototype.updateBounds = function () {
- var bufferArray = this.bufferArray,
+ var bufferArray = this.bufferArray.filter(function (lineBuffer) {
+ return lineBuffer.getLength() > 0; // Ignore empty lines
+ }),
priorDomainOrigin = this.origin[0],
priorDomainDimensions = this.dimensions[0];
diff --git a/platform/features/plot/test/elements/PlotUpdaterSpec.js b/platform/features/plot/test/elements/PlotUpdaterSpec.js
index 9d7ab563de..c287dbfdf8 100644
--- a/platform/features/plot/test/elements/PlotUpdaterSpec.js
+++ b/platform/features/plot/test/elements/PlotUpdaterSpec.js
@@ -202,6 +202,38 @@ define(
expect(updater.getDimensions()[1]).toBeGreaterThan(20);
});
+ describe("when no data is initially available", function () {
+ beforeEach(function () {
+ testDomainValues = {};
+ testRangeValues = {};
+ updater = new PlotUpdater(
+ mockSubscription,
+ testDomain,
+ testRange,
+ 1350 // Smaller max size for easier testing
+ );
+ });
+
+ it("has no line data", function () {
+ // Either no lines, or empty lines are fine
+ expect(updater.getLineBuffers().map(function (lineBuffer) {
+ return lineBuffer.getLength();
+ }).reduce(function (a, b) {
+ return a + b;
+ }, 0)).toEqual(0);
+ });
+
+ it("determines initial domain bounds from first available data", function () {
+ testDomainValues.a = 123;
+ testRangeValues.a = 456;
+ updater.update();
+ expect(updater.getOrigin()[0]).toEqual(jasmine.any(Number));
+ expect(updater.getOrigin()[1]).toEqual(jasmine.any(Number));
+ expect(isNaN(updater.getOrigin()[0])).toBeFalsy();
+ expect(isNaN(updater.getOrigin()[1])).toBeFalsy();
+ });
+ });
+
});
}
);
diff --git a/platform/persistence/elastic/bundle.json b/platform/persistence/elastic/bundle.json
index f78d8504f2..3e1383351e 100644
--- a/platform/persistence/elastic/bundle.json
+++ b/platform/persistence/elastic/bundle.json
@@ -13,7 +13,7 @@
"provides": "searchService",
"type": "provider",
"implementation": "ElasticSearchProvider.js",
- "depends": [ "$http", "objectService", "ELASTIC_ROOT" ]
+ "depends": [ "$http", "ELASTIC_ROOT" ]
}
],
"constants": [
diff --git a/platform/persistence/elastic/src/ElasticSearchProvider.js b/platform/persistence/elastic/src/ElasticSearchProvider.js
index 604e3d0ed3..84290d999a 100644
--- a/platform/persistence/elastic/src/ElasticSearchProvider.js
+++ b/platform/persistence/elastic/src/ElasticSearchProvider.js
@@ -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;
}
-);
\ No newline at end of file
+
+ /**
+ * 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;
+});
diff --git a/platform/persistence/elastic/test/ElasticSearchProviderSpec.js b/platform/persistence/elastic/test/ElasticSearchProviderSpec.js
index decb1a5c98..f8337e0862 100644
--- a/platform/persistence/elastic/test/ElasticSearchProviderSpec.js
+++ b/platform/persistence/elastic/test/ElasticSearchProviderSpec.js
@@ -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;
+ });
});
});
- }
-);
\ No newline at end of file
+
+ 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
+ });
+ });
+ });
+
+});
diff --git a/platform/search/bundle.json b/platform/search/bundle.json
index 67dc058ed9..9e39e28d03 100644
--- a/platform/search/bundle.json
+++ b/platform/search/bundle.json
@@ -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": [
diff --git a/platform/search/res/templates/search.html b/platform/search/res/templates/search.html
index e65ab072af..225df353b0 100644
--- a/platform/search/res/templates/search.html
+++ b/platform/search/res/templates/search.html
@@ -21,21 +21,16 @@
-->
-
+
-
+
-
@@ -43,20 +38,20 @@
ng-class="{content: !(ngModel.input === '' || ngModel.input === undefined)}">
M
-
+
-
+
-
+
-
+
-
+
Filtered by: {{ ngModel.filtersString }}
-
-
+
-
+
-
-
\ No newline at end of file
+
+
diff --git a/platform/search/src/controllers/SearchController.js b/platform/search/src/controllers/SearchController.js
index 10cf056b4f..629e495331 100644
--- a/platform/search/src/controllers/SearchController.js
+++ b/platform/search/src/controllers/SearchController.js
@@ -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;
});
diff --git a/platform/search/src/services/GenericSearchProvider.js b/platform/search/src/services/GenericSearchProvider.js
index 9574d156fb..71dfe8c0ed 100644
--- a/platform/search/src/services/GenericSearchProvider.js
+++ b/platform/search/src/services/GenericSearchProvider.js
@@ -19,234 +19,262 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
-/*global define*/
+/*global define,setTimeout*/
/**
* Module defining GenericSearchProvider. Created by shale on 07/16/2015.
*/
-define(
- [],
- function () {
- "use strict";
+define([
- var DEFAULT_MAX_RESULTS = 100,
- DEFAULT_TIMEOUT = 1000,
- MAX_CONCURRENT_REQUESTS = 100,
- FLUSH_INTERVAL = 0,
- stopTime;
+], function (
- /**
- * A search service which searches through domain objects in
- * the filetree without using external search implementations.
- *
- * @constructor
- * @param $q Angular's $q, for promise consolidation.
- * @param $log Anglar's $log, for logging.
- * @param {Function} throttle a function to throttle function invocations
- * @param {ObjectService} objectService The service from which
- * domain objects can be gotten.
- * @param {WorkerService} workerService The service which allows
- * more easy creation of web workers.
- * @param {GENERIC_SEARCH_ROOTS} ROOTS An array of the root
- * domain objects' IDs.
- */
- function GenericSearchProvider($q, $log, throttle, objectService, workerService, topic, ROOTS) {
- var indexed = {},
- pendingIndex = {},
- pendingQueries = {},
- toRequest = [],
- worker = workerService.run('genericSearchWorker'),
- mutationTopic = topic("mutation"),
- indexingStarted = Date.now(),
- pendingRequests = 0,
- scheduleFlush;
+) {
+ "use strict";
- this.worker = worker;
- this.pendingQueries = pendingQueries;
- this.$q = $q;
- // pendingQueries is a dictionary with the key value pairs st
- // the key is the timestamp and the value is the promise
+ /**
+ * A search service which searches through domain objects in
+ * the filetree without using external search implementations.
+ *
+ * @constructor
+ * @param $q Angular's $q, for promise consolidation.
+ * @param $log Anglar's $log, for logging.
+ * @param {ModelService} modelService the model service.
+ * @param {WorkerService} workerService the workerService.
+ * @param {TopicService} topic the topic service.
+ * @param {Array} ROOTS An array of object Ids to begin indexing.
+ */
+ function GenericSearchProvider($q, $log, modelService, workerService, topic, ROOTS) {
+ var provider = this;
+ this.$q = $q;
+ this.$log = $log;
+ this.modelService = modelService;
- function scheduleIdsForIndexing(ids) {
- ids.forEach(function (id) {
- if (!indexed[id] && !pendingIndex[id]) {
- indexed[id] = true;
- pendingIndex[id] = true;
- toRequest.push(id);
- }
- });
- scheduleFlush();
- }
+ this.indexedIds = {};
+ this.idsToIndex = [];
+ this.pendingIndex = {};
+ this.pendingRequests = 0;
- // Tell the web worker to add a domain object's model to its list of items.
- function indexItem(domainObject) {
- var model = domainObject.getModel();
+ this.pendingQueries = {};
- worker.postMessage({
- request: 'index',
- model: model,
- id: domainObject.getId()
- });
+ this.worker = this.startWorker(workerService);
+ this.indexOnMutation(topic);
- if (Array.isArray(model.composition)) {
- scheduleIdsForIndexing(model.composition);
- }
- }
+ ROOTS.forEach(function indexRoot(rootId) {
+ provider.scheduleForIndexing(rootId);
+ });
- // Handles responses from the web worker. Namely, the results of
- // a search request.
- function handleResponse(event) {
- var ids = [],
- id;
- // If we have the results from a search
- if (event.data.request === 'search') {
- // Convert the ids given from the web worker into domain objects
- for (id in event.data.results) {
- ids.push(id);
- }
- objectService.getObjects(ids).then(function (objects) {
- var searchResults = [],
- id;
+ }
- // Create searchResult objects
- for (id in objects) {
- searchResults.push({
- object: objects[id],
- id: id,
- score: event.data.results[id]
- });
- }
+ /**
+ * Maximum number of concurrent index requests to allow.
+ */
+ GenericSearchProvider.prototype.MAX_CONCURRENT_REQUESTS = 100;
- // Resove the promise corresponding to this
- pendingQueries[event.data.timestamp].resolve({
- hits: searchResults,
- total: event.data.total,
- timedOut: event.data.timedOut
- });
- });
- }
- }
+ /**
+ * Query the search provider for results.
+ *
+ * @param {String} input the string to search by.
+ * @param {Number} maxResults max number of results to return.
+ * @returns {Promise} a promise for a modelResults object.
+ */
+ GenericSearchProvider.prototype.query = function (
+ input,
+ maxResults
+ ) {
- function requestAndIndex(id) {
- pendingRequests += 1;
- objectService.getObjects([id]).then(function (objects) {
- delete pendingIndex[id];
- if (objects[id]) {
- indexItem(objects[id]);
- }
- }, function () {
- $log.warn("Failed to index domain object " + id);
- }).then(function () {
- pendingRequests -= 1;
- scheduleFlush();
- });
- }
+ var queryId = this.dispatchSearch(input, maxResults),
+ pendingQuery = this.$q.defer();
- scheduleFlush = throttle(function flush() {
- var batchSize =
- Math.max(MAX_CONCURRENT_REQUESTS - pendingRequests, 0);
+ this.pendingQueries[queryId] = pendingQuery;
- if (toRequest.length + pendingRequests < 1) {
- $log.info([
- 'GenericSearch finished indexing after ',
- ((Date.now() - indexingStarted) / 1000).toFixed(2),
- ' seconds.'
- ].join(''));
- } else {
- toRequest.splice(-batchSize, batchSize)
- .forEach(requestAndIndex);
- }
- }, FLUSH_INTERVAL);
+ return pendingQuery.promise;
+ };
- worker.onmessage = handleResponse;
+ /**
+ * Creates a search worker and attaches handlers.
+ *
+ * @private
+ * @param workerService
+ * @returns worker the created search worker.
+ */
+ GenericSearchProvider.prototype.startWorker = function (workerService) {
+ var worker = workerService.run('genericSearchWorker'),
+ provider = this;
- // Index the tree's contents once at the beginning
- scheduleIdsForIndexing(ROOTS);
+ worker.addEventListener('message', function (messageEvent) {
+ provider.onWorkerMessage(messageEvent);
+ });
- // Re-index items when they are mutated
- mutationTopic.listen(function (domainObject) {
- var id = domainObject.getId();
- indexed[id] = false;
- scheduleIdsForIndexing([id]);
+ return worker;
+ };
+
+ /**
+ * Listen to the mutation topic and re-index objects when they are
+ * mutated.
+ *
+ * @private
+ * @param topic the topicService.
+ */
+ GenericSearchProvider.prototype.indexOnMutation = function (topic) {
+ var mutationTopic = topic('mutation'),
+ provider = this;
+
+ mutationTopic.listen(function (mutatedObject) {
+ var id = mutatedObject.getId();
+ provider.indexedIds[id] = false;
+ provider.scheduleForIndexing(id);
+ });
+ };
+
+ /**
+ * Schedule an id to be indexed at a later date. If there are less
+ * pending requests then allowed, will kick off an indexing request.
+ *
+ * @private
+ * @param {String} id to be indexed.
+ */
+ GenericSearchProvider.prototype.scheduleForIndexing = function (id) {
+ if (!this.indexedIds[id] && !this.pendingIndex[id]) {
+ this.indexedIds[id] = true;
+ this.pendingIndex[id] = true;
+ this.idsToIndex.push(id);
+ }
+ this.keepIndexing();
+ };
+
+ /**
+ * If there are less pending requests than concurrent requests, keep
+ * firing requests.
+ *
+ * @private
+ */
+ GenericSearchProvider.prototype.keepIndexing = function () {
+ while (this.pendingRequests < this.MAX_CONCURRENT_REQUESTS &&
+ this.idsToIndex.length
+ ) {
+ this.beginIndexRequest();
+ }
+ };
+
+ /**
+ * Pass an id and model to the worker to be indexed. If the model has
+ * composition, schedule those ids for later indexing.
+ *
+ * @private
+ * @param id a model id
+ * @param model a model
+ */
+ GenericSearchProvider.prototype.index = function (id, model) {
+ var provider = this;
+
+ this.worker.postMessage({
+ request: 'index',
+ model: model,
+ id: id
+ });
+
+ if (Array.isArray(model.composition)) {
+ model.composition.forEach(function (id) {
+ provider.scheduleForIndexing(id);
});
}
+ };
- /**
- * Searches through the filetree for domain objects which match
- * the search term. This function is to be used as a fallback
- * in the case where other search services are not avaliable.
- * Returns a promise for a result object that has the format
- * {hits: searchResult[], total: number, timedOut: boolean}
- * where a searchResult has the format
- * {id: string, object: domainObject, score: number}
- *
- * Notes:
- * * The order of the results is not guarenteed.
- * * A domain object qualifies as a match for a search input if
- * the object's name property contains any of the search terms
- * (which are generated by splitting the input at spaces).
- * * Scores are higher for matches that have more of the terms
- * as substrings.
- *
- * @param input The text input that is the query.
- * @param timestamp The time at which this function was called.
- * This timestamp is used as a unique identifier for this
- * query and the corresponding results.
- * @param maxResults (optional) The maximum number of results
- * that this function should return.
- * @param timeout (optional) The time after which the search should
- * stop calculations and return partial results.
- */
- GenericSearchProvider.prototype.query = function query(input, timestamp, maxResults, timeout) {
- var terms = [],
- searchResults = [],
- pendingQueries = this.pendingQueries,
- worker = this.worker,
- defer = this.$q.defer();
+ /**
+ * Pulls an id from the indexing queue, loads it from the model service,
+ * and indexes it. Upon completion, tells the provider to keep
+ * indexing.
+ *
+ * @private
+ */
+ GenericSearchProvider.prototype.beginIndexRequest = function () {
+ var idToIndex = this.idsToIndex.shift(),
+ provider = this;
- // Tell the worker to search for items it has that match this searchInput.
- // Takes the searchInput, as well as a max number of results (will return
- // less than that if there are fewer matches).
- function workerSearch(searchInput, maxResults, timestamp, timeout) {
- var message = {
- request: 'search',
- input: searchInput,
- maxNumber: maxResults,
- timestamp: timestamp,
- timeout: timeout
- };
- worker.postMessage(message);
- }
-
- // If the input is nonempty, do a search
- if (input !== '' && input !== undefined) {
-
- // Allow us to access this promise later to resolve it later
- pendingQueries[timestamp] = defer;
-
- // Check to see if the user provided a maximum
- // number of results to display
- if (!maxResults) {
- // Else, we provide a default value
- maxResults = DEFAULT_MAX_RESULTS;
- }
- // Similarly, check if timeout was provided
- if (!timeout) {
- timeout = DEFAULT_TIMEOUT;
+ this.pendingRequests += 1;
+ this.modelService
+ .getModels([idToIndex])
+ .then(function (models) {
+ delete provider.pendingIndex[idToIndex];
+ if (models[idToIndex]) {
+ provider.index(idToIndex, models[idToIndex]);
}
+ }, function () {
+ provider
+ .$log
+ .warn('Failed to index domain object ' + idToIndex);
+ })
+ .then(function () {
+ setTimeout(function () {
+ provider.pendingRequests -= 1;
+ provider.keepIndexing();
+ }, 0);
+ });
+ };
- // Send the query to the worker
- workerSearch(input, maxResults, timestamp, timeout);
+ /**
+ * Handle messages from the worker. Only really knows how to handle search
+ * results, which are parsed, transformed into a modelResult object, which
+ * is used to resolve the corresponding promise.
+ * @private
+ */
+ GenericSearchProvider.prototype.onWorkerMessage = function (event) {
+ if (event.data.request !== 'search') {
+ return;
+ }
- return defer.promise;
- } else {
- // Otherwise return an empty result
- return { hits: [], total: 0 };
- }
- };
+ var pendingQuery = this.pendingQueries[event.data.queryId],
+ modelResults = {
+ total: event.data.total
+ };
+
+ modelResults.hits = event.data.results.map(function (hit) {
+ return {
+ id: hit.item.id,
+ model: hit.item.model,
+ score: hit.matchCount
+ };
+ });
+
+ pendingQuery.resolve(modelResults);
+ delete this.pendingQueries[event.data.queryId];
+ };
+
+ /**
+ * @private
+ * @returns {Number} a unique, unusued query Id.
+ */
+ GenericSearchProvider.prototype.makeQueryId = function () {
+ var queryId = Math.ceil(Math.random() * 100000);
+ while (this.pendingQueries[queryId]) {
+ queryId = Math.ceil(Math.random() * 100000);
+ }
+ return queryId;
+ };
+
+ /**
+ * Dispatch a search query to the worker and return a queryId.
+ *
+ * @private
+ * @returns {Number} a unique query Id for the query.
+ */
+ GenericSearchProvider.prototype.dispatchSearch = function (
+ searchInput,
+ maxResults
+ ) {
+ var queryId = this.makeQueryId();
+
+ this.worker.postMessage({
+ request: 'search',
+ input: searchInput,
+ maxResults: maxResults,
+ queryId: queryId
+ });
+
+ return queryId;
+ };
- return GenericSearchProvider;
- }
-);
+ return GenericSearchProvider;
+});
diff --git a/platform/search/src/services/GenericSearchWorker.js b/platform/search/src/services/GenericSearchWorker.js
index 57be98b423..928f66cab8 100644
--- a/platform/search/src/services/GenericSearchWorker.js
+++ b/platform/search/src/services/GenericSearchWorker.js
@@ -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));
}
};
-}());
\ No newline at end of file
+}());
diff --git a/platform/search/src/services/SearchAggregator.js b/platform/search/src/services/SearchAggregator.js
index 2324090595..00988f81a8 100644
--- a/platform/search/src/services/SearchAggregator.js
+++ b/platform/search/src/services/SearchAggregator.js
@@ -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;
- }
-);
\ No newline at end of file
+ /**
+ * 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;
+});
diff --git a/platform/search/test/controllers/SearchControllerSpec.js b/platform/search/test/controllers/SearchControllerSpec.js
index 720d9bd64a..a755594d58 100644
--- a/platform/search/test/controllers/SearchControllerSpec.js
+++ b/platform/search/test/controllers/SearchControllerSpec.js
@@ -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);
});
});
- }
-);
\ No newline at end of file
+
+ 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));
+ });
+ });
+});
diff --git a/platform/search/test/services/GenericSearchProviderSpec.js b/platform/search/test/services/GenericSearchProviderSpec.js
index e3ee0a97ba..cc80e4210d 100644
--- a/platform/search/test/services/GenericSearchProviderSpec.js
+++ b/platform/search/test/services/GenericSearchProviderSpec.js
@@ -19,275 +19,321 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
-/*global define,describe,it,expect,beforeEach,jasmine*/
+/*global define,describe,it,expect,beforeEach,jasmine,Promise,spyOn,waitsFor,
+ runs*/
/**
* SearchSpec. Created by shale on 07/31/2015.
*/
-define(
- ["../../src/services/GenericSearchProvider"],
- function (GenericSearchProvider) {
- "use strict";
+define([
+ "../../src/services/GenericSearchProvider"
+], function (
+ GenericSearchProvider
+) {
+ "use strict";
- describe("The generic search provider ", function () {
- var mockQ,
- mockLog,
- mockThrottle,
- mockDeferred,
- mockObjectService,
- mockObjectPromise,
- mockChainedPromise,
- mockDomainObjects,
- mockCapability,
- mockCapabilityPromise,
- mockWorkerService,
- mockWorker,
- mockTopic,
- mockMutationTopic,
- mockRoots = ['root1', 'root2'],
- mockThrottledFn,
- throttledCallCount,
- provider,
- mockProviderResults;
+ describe('GenericSearchProvider', function () {
+ var $q,
+ $log,
+ modelService,
+ models,
+ workerService,
+ worker,
+ topic,
+ mutationTopic,
+ ROOTS,
+ provider;
- function resolveObjectPromises() {
- var i;
- for (i = 0; i < mockObjectPromise.then.calls.length; i += 1) {
- mockChainedPromise.then.calls[i].args[0](
- mockObjectPromise.then.calls[i]
- .args[0](mockDomainObjects)
- );
- }
- }
+ beforeEach(function () {
+ $q = jasmine.createSpyObj(
+ '$q',
+ ['defer']
+ );
+ $log = jasmine.createSpyObj(
+ '$log',
+ ['warn']
+ );
+ models = {};
+ modelService = jasmine.createSpyObj(
+ 'modelService',
+ ['getModels']
+ );
+ modelService.getModels.andReturn(Promise.resolve(models));
+ workerService = jasmine.createSpyObj(
+ 'workerService',
+ ['run']
+ );
+ worker = jasmine.createSpyObj(
+ 'worker',
+ [
+ 'postMessage',
+ 'addEventListener'
+ ]
+ );
+ workerService.run.andReturn(worker);
+ topic = jasmine.createSpy('topic');
+ mutationTopic = jasmine.createSpyObj(
+ 'mutationTopic',
+ ['listen']
+ );
+ topic.andReturn(mutationTopic);
+ ROOTS = [
+ 'mine'
+ ];
- function resolveThrottledFn() {
- if (mockThrottledFn.calls.length > throttledCallCount) {
- mockThrottle.mostRecentCall.args[0]();
- throttledCallCount = mockThrottledFn.calls.length;
- }
- }
+ spyOn(GenericSearchProvider.prototype, 'scheduleForIndexing');
- function resolveAsyncTasks() {
- resolveThrottledFn();
- resolveObjectPromises();
- }
+ provider = new GenericSearchProvider(
+ $q,
+ $log,
+ modelService,
+ workerService,
+ topic,
+ ROOTS
+ );
+ });
+
+ it('listens for general mutation', function () {
+ expect(topic).toHaveBeenCalledWith('mutation');
+ expect(mutationTopic.listen)
+ .toHaveBeenCalledWith(jasmine.any(Function));
+ });
+
+ it('reschedules indexing when mutation occurs', function () {
+ var mockDomainObject =
+ jasmine.createSpyObj('domainObj', ['getId']);
+ mockDomainObject.getId.andReturn("some-id");
+ mutationTopic.listen.mostRecentCall.args[0](mockDomainObject);
+ expect(provider.scheduleForIndexing).toHaveBeenCalledWith('some-id');
+ });
+
+ it('starts indexing roots', function () {
+ expect(provider.scheduleForIndexing).toHaveBeenCalledWith('mine');
+ });
+
+ it('runs a worker', function () {
+ expect(workerService.run)
+ .toHaveBeenCalledWith('genericSearchWorker');
+ });
+
+ it('listens for messages from worker', function () {
+ expect(worker.addEventListener)
+ .toHaveBeenCalledWith('message', jasmine.any(Function));
+ spyOn(provider, 'onWorkerMessage');
+ worker.addEventListener.mostRecentCall.args[1]('mymessage');
+ expect(provider.onWorkerMessage).toHaveBeenCalledWith('mymessage');
+ });
+
+ it('has a maximum number of concurrent requests', function () {
+ expect(provider.MAX_CONCURRENT_REQUESTS).toBe(100);
+ });
+
+ describe('scheduleForIndexing', function () {
+ beforeEach(function () {
+ provider.scheduleForIndexing.andCallThrough();
+ spyOn(provider, 'keepIndexing');
+ });
+
+ it('tracks ids to index', function () {
+ expect(provider.indexedIds.a).not.toBeDefined();
+ expect(provider.pendingIndex.a).not.toBeDefined();
+ expect(provider.idsToIndex).not.toContain('a');
+ provider.scheduleForIndexing('a');
+ expect(provider.indexedIds.a).toBeDefined();
+ expect(provider.pendingIndex.a).toBeDefined();
+ expect(provider.idsToIndex).toContain('a');
+ });
+
+ it('calls keep indexing', function () {
+ provider.scheduleForIndexing('a');
+ expect(provider.keepIndexing).toHaveBeenCalled();
+ });
+ });
+
+ describe('keepIndexing', function () {
+ it('calls beginIndexRequest until at maximum', function () {
+ spyOn(provider, 'beginIndexRequest').andCallThrough();
+ provider.pendingRequests = 9;
+ provider.idsToIndex = ['a', 'b', 'c'];
+ provider.MAX_CONCURRENT_REQUESTS = 10;
+ provider.keepIndexing();
+ expect(provider.beginIndexRequest).toHaveBeenCalled();
+ expect(provider.beginIndexRequest.calls.length).toBe(1);
+ });
+
+ it('calls beginIndexRequest for all ids to index', function () {
+ spyOn(provider, 'beginIndexRequest').andCallThrough();
+ provider.pendingRequests = 0;
+ provider.idsToIndex = ['a', 'b', 'c'];
+ provider.MAX_CONCURRENT_REQUESTS = 10;
+ provider.keepIndexing();
+ expect(provider.beginIndexRequest).toHaveBeenCalled();
+ expect(provider.beginIndexRequest.calls.length).toBe(3);
+ });
+
+ it('does not index when at capacity', function () {
+ spyOn(provider, 'beginIndexRequest');
+ provider.pendingRequests = 10;
+ provider.idsToIndex.push('a');
+ provider.MAX_CONCURRENT_REQUESTS = 10;
+ provider.keepIndexing();
+ expect(provider.beginIndexRequest).not.toHaveBeenCalled();
+ });
+
+ it('does not index when no ids to index', function () {
+ spyOn(provider, 'beginIndexRequest');
+ provider.pendingRequests = 0;
+ provider.MAX_CONCURRENT_REQUESTS = 10;
+ provider.keepIndexing();
+ expect(provider.beginIndexRequest).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('index', function () {
+ it('sends index message to worker', function () {
+ var id = 'anId',
+ model = {};
+
+ provider.index(id, model);
+ expect(worker.postMessage).toHaveBeenCalledWith({
+ request: 'index',
+ id: id,
+ model: model
+ });
+ });
+
+ it('schedules composed ids for indexing', function () {
+ var id = 'anId',
+ model = {composition: ['abc', 'def']};
+
+ provider.index(id, model);
+ expect(provider.scheduleForIndexing)
+ .toHaveBeenCalledWith('abc');
+ expect(provider.scheduleForIndexing)
+ .toHaveBeenCalledWith('def');
+ });
+ });
+
+ describe('beginIndexRequest', function () {
beforeEach(function () {
- mockQ = jasmine.createSpyObj(
- "$q",
- [ "defer" ]
- );
- mockLog = jasmine.createSpyObj(
- "$log",
- [ "error", "warn", "info", "debug" ]
- );
- mockDeferred = jasmine.createSpyObj(
- "deferred",
- [ "resolve", "reject"]
- );
- mockDeferred.promise = "mock promise";
- mockQ.defer.andReturn(mockDeferred);
-
- mockThrottle = jasmine.createSpy("throttle");
- mockThrottledFn = jasmine.createSpy("throttledFn");
- throttledCallCount = 0;
-
- mockObjectService = jasmine.createSpyObj(
- "objectService",
- [ "getObjects" ]
- );
- mockObjectPromise = jasmine.createSpyObj(
- "promise",
- [ "then", "catch" ]
- );
- mockChainedPromise = jasmine.createSpyObj(
- "chainedPromise",
- [ "then" ]
- );
- mockObjectService.getObjects.andReturn(mockObjectPromise);
-
- mockTopic = jasmine.createSpy('topic');
-
- mockWorkerService = jasmine.createSpyObj(
- "workerService",
- [ "run" ]
- );
- mockWorker = jasmine.createSpyObj(
- "worker",
- [ "postMessage" ]
- );
- mockWorkerService.run.andReturn(mockWorker);
-
- mockCapabilityPromise = jasmine.createSpyObj(
- "promise",
- [ "then", "catch" ]
- );
-
- mockDomainObjects = {};
- ['a', 'root1', 'root2'].forEach(function (id) {
- mockDomainObjects[id] = (
- jasmine.createSpyObj(
- "domainObject",
- [
- "getId",
- "getModel",
- "hasCapability",
- "getCapability",
- "useCapability"
- ]
- )
- );
- mockDomainObjects[id].getId.andReturn(id);
- mockDomainObjects[id].getCapability.andReturn(mockCapability);
- mockDomainObjects[id].useCapability.andReturn(mockCapabilityPromise);
- mockDomainObjects[id].getModel.andReturn({});
- });
-
- mockCapability = jasmine.createSpyObj(
- "capability",
- [ "invoke", "listen" ]
- );
- mockCapability.invoke.andReturn(mockCapabilityPromise);
- mockDomainObjects.a.getCapability.andReturn(mockCapability);
- mockMutationTopic = jasmine.createSpyObj(
- 'mutationTopic',
- [ 'listen' ]
- );
- mockTopic.andCallFake(function (key) {
- return key === 'mutation' && mockMutationTopic;
- });
- mockThrottle.andReturn(mockThrottledFn);
- mockObjectPromise.then.andReturn(mockChainedPromise);
-
- provider = new GenericSearchProvider(
- mockQ,
- mockLog,
- mockThrottle,
- mockObjectService,
- mockWorkerService,
- mockTopic,
- mockRoots
- );
+ provider.pendingRequests = 0;
+ provider.pendingIds = {'abc': true};
+ provider.idsToIndex = ['abc'];
+ models.abc = {};
+ spyOn(provider, 'index');
});
- it("indexes tree on initialization", function () {
- var i;
+ it('removes items from queue', function () {
+ provider.beginIndexRequest();
+ expect(provider.idsToIndex.length).toBe(0);
+ });
- resolveThrottledFn();
-
- expect(mockObjectService.getObjects).toHaveBeenCalled();
- expect(mockObjectPromise.then).toHaveBeenCalled();
-
- // Call through the root-getting part
- resolveObjectPromises();
-
- mockRoots.forEach(function (id) {
- expect(mockWorker.postMessage).toHaveBeenCalledWith({
- request: 'index',
- model: mockDomainObjects[id].getModel(),
- id: id
- });
+ it('tracks number of pending requests', function () {
+ provider.beginIndexRequest();
+ expect(provider.pendingRequests).toBe(1);
+ waitsFor(function () {
+ return provider.pendingRequests === 0;
+ });
+ runs(function () {
+ expect(provider.pendingRequests).toBe(0);
});
});
- it("indexes members of composition", function () {
- mockDomainObjects.root1.getModel.andReturn({
- composition: ['a']
+ it('indexes objects', function () {
+ provider.beginIndexRequest();
+ waitsFor(function () {
+ return provider.pendingRequests === 0;
});
-
- resolveAsyncTasks();
- resolveAsyncTasks();
-
- expect(mockWorker.postMessage).toHaveBeenCalledWith({
- request: 'index',
- model: mockDomainObjects.a.getModel(),
- id: 'a'
+ runs(function () {
+ expect(provider.index)
+ .toHaveBeenCalledWith('abc', models.abc);
});
});
- it("listens for changes to mutation", function () {
- expect(mockMutationTopic.listen)
- .toHaveBeenCalledWith(jasmine.any(Function));
- mockMutationTopic.listen.mostRecentCall
- .args[0](mockDomainObjects.a);
-
- resolveAsyncTasks();
-
- expect(mockWorker.postMessage).toHaveBeenCalledWith({
- request: 'index',
- model: mockDomainObjects.a.getModel(),
- id: mockDomainObjects.a.getId()
- });
- });
-
- it("sends search queries to the worker", function () {
- var timestamp = Date.now();
- provider.query(' test "query" ', timestamp, 1, 2);
- expect(mockWorker.postMessage).toHaveBeenCalledWith({
- request: "search",
- input: ' test "query" ',
- timestamp: timestamp,
- maxNumber: 1,
- timeout: 2
- });
- });
-
- it("gives an empty result for an empty query", function () {
- var timestamp = Date.now(),
- queryOutput;
-
- queryOutput = provider.query('', timestamp, 1, 2);
- expect(queryOutput.hits).toEqual([]);
- expect(queryOutput.total).toEqual(0);
-
- queryOutput = provider.query();
- expect(queryOutput.hits).toEqual([]);
- expect(queryOutput.total).toEqual(0);
- });
-
- it("handles responses from the worker", function () {
- var timestamp = Date.now(),
- event = {
- data: {
- request: "search",
- results: {
- 1: 1,
- 2: 2
- },
- total: 2,
- timedOut: false,
- timestamp: timestamp
- }
- };
-
- provider.query(' test "query" ', timestamp);
- mockWorker.onmessage(event);
- mockObjectPromise.then.mostRecentCall.args[0](mockDomainObjects);
- expect(mockDeferred.resolve).toHaveBeenCalled();
- });
-
- it("warns when objects are unavailable", function () {
- resolveAsyncTasks();
- expect(mockLog.warn).not.toHaveBeenCalled();
- mockChainedPromise.then.mostRecentCall.args[0](
- mockObjectPromise.then.mostRecentCall.args[1]()
- );
- expect(mockLog.warn).toHaveBeenCalled();
- });
-
- it("throttles the loading of objects to index", function () {
- expect(mockObjectService.getObjects).not.toHaveBeenCalled();
- resolveThrottledFn();
- expect(mockObjectService.getObjects).toHaveBeenCalled();
- });
-
- it("logs when all objects have been processed", function () {
- expect(mockLog.info).not.toHaveBeenCalled();
- resolveAsyncTasks();
- resolveThrottledFn();
- expect(mockLog.info).toHaveBeenCalled();
- });
-
});
- }
-);
+
+
+ it('can dispatch searches to worker', function () {
+ spyOn(provider, 'makeQueryId').andReturn(428);
+ expect(provider.dispatchSearch('searchTerm', 100))
+ .toBe(428);
+
+ expect(worker.postMessage).toHaveBeenCalledWith({
+ request: 'search',
+ input: 'searchTerm',
+ maxResults: 100,
+ queryId: 428
+ });
+ });
+
+ it('can generate queryIds', function () {
+ expect(provider.makeQueryId()).toEqual(jasmine.any(Number));
+ });
+
+ it('can query for terms', function () {
+ var deferred = {promise: {}};
+ spyOn(provider, 'dispatchSearch').andReturn(303);
+ $q.defer.andReturn(deferred);
+
+ expect(provider.query('someTerm', 100)).toBe(deferred.promise);
+ expect(provider.pendingQueries[303]).toBe(deferred);
+ });
+
+ describe('onWorkerMessage', function () {
+ var pendingQuery;
+ beforeEach(function () {
+ pendingQuery = jasmine.createSpyObj(
+ 'pendingQuery',
+ ['resolve']
+ );
+ provider.pendingQueries[143] = pendingQuery;
+ });
+
+ it('resolves pending searches', function () {
+ provider.onWorkerMessage({
+ data: {
+ request: 'search',
+ total: 2,
+ results: [
+ {
+ item: {
+ id: 'abc',
+ model: {id: 'abc'}
+ },
+ matchCount: 4
+ },
+ {
+ item: {
+ id: 'def',
+ model: {id: 'def'}
+ },
+ matchCount: 2
+ }
+ ],
+ queryId: 143
+ }
+ });
+
+ expect(pendingQuery.resolve)
+ .toHaveBeenCalledWith({
+ total: 2,
+ hits: [{
+ id: 'abc',
+ model: {id: 'abc'},
+ score: 4
+ }, {
+ id: 'def',
+ model: {id: 'def'},
+ score: 2
+ }]
+ });
+
+ expect(provider.pendingQueries[143]).not.toBeDefined();
+
+ });
+
+ });
+
+ });
+});
diff --git a/platform/search/test/services/GenericSearchWorkerSpec.js b/platform/search/test/services/GenericSearchWorkerSpec.js
index b95ec5a1bb..20afb4c781 100644
--- a/platform/search/test/services/GenericSearchWorkerSpec.js
+++ b/platform/search/test/services/GenericSearchWorkerSpec.js
@@ -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);
});
});
- }
-);
\ No newline at end of file
+
+ 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);
+ });
+ });
+ });
+});
diff --git a/platform/search/test/services/SearchAggregatorSpec.js b/platform/search/test/services/SearchAggregatorSpec.js
index 3205f0f9ec..f8bee0dcc0 100644
--- a/platform/search/test/services/SearchAggregatorSpec.js
+++ b/platform/search/test/services/SearchAggregatorSpec.js
@@ -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);
});
- }
-);
\ No newline at end of file
+
+ 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;
+ });
+ });
+
+ });
+});
diff --git a/test-main.js b/test-main.js
index 46740a93b2..18b5f2a0d4 100644
--- a/test-main.js
+++ b/test-main.js
@@ -44,7 +44,8 @@ require.config({
paths: {
'es6-promise': 'platform/framework/lib/es6-promise-2.0.0.min',
- 'moment-duration-format': 'warp/clock/lib/moment-duration-format'
+ 'moment-duration-format': 'warp/clock/lib/moment-duration-format',
+ 'uuid': 'platform/commonUI/browse/lib/uuid'
},
// dynamically load all test files