openmct/src/plugins/plot/pluginSpec.js
Jesse Mazzella 4ee68cccd6
docs: better docs and types for the API (#7796)
* docs: fix type imports in openmct.js

* docs: fix type imports

* docs: fix types for eventHelpers

* docs: types for TypeRegistry

* docs: types for StatusAPI

* docs: fix ObjectAPI types and docs

* docs: more types

* docs: improved types for main entry

* docs: improved types

* fix: unbreak the linting

* chore: remove EventEmitter webpack alias as it hide types

* fix: return type

* fix: parameter type

* fix: types for composables

* chore: add webpack consts to eslintrc

* fix: remove usage of deprecated timeAPI methods and add a ton of docs and types

* docs: update README.md

* lint: clean up API.md

* chore: upgrade eventemitter to v5.0.2

* refactor: update imports for EventEmitter to remove alias

* format: lint

* docs: update types for Views and ViewProviders

* docs: expose common types at the base import level

* docs(types): remove unnecessary tsconfig options

* docs: ActionAPI

* docs: AnnotationAPI

* docs: import common types from the same origin

* docs: FormsAPI & TelemetryAPI types

* docs: FormController, IndicatorAPI

* docs: MenuAPI, ActionsAPI

* docs: `@memberof` is not supported by `tsc` and JSDoc generation so remove it

* docs: RootRegistry and RootObjectProvider

* docs: Transaction + Overlay

* lint: words for the word god

* fix: review comments
2024-07-31 10:46:16 -07:00

953 lines
28 KiB
JavaScript

/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { EventEmitter } from 'eventemitter3';
import mount from 'utils/mount';
import {
createMouseEvent,
createOpenMct,
renderWhenVisible,
resetApplicationState,
spyOnBuiltins
} from 'utils/testing';
import { nextTick } from 'vue';
import configStore from './configuration/ConfigStore.js';
import PlotConfigurationModel from './configuration/PlotConfigurationModel.js';
import PlotOptions from './inspector/PlotOptions.vue';
import PlotVuePlugin from './plugin.js';
const TEST_KEY_ID = 'some-other-key';
describe('the plugin', function () {
let element;
let child;
let openmct;
let telemetryPromise;
let telemetryPromiseResolve;
let mockObjectPath;
let telemetrylimitProvider;
beforeEach((done) => {
mockObjectPath = [
{
name: 'mock folder',
type: 'fake-folder',
identifier: {
key: 'mock-folder',
namespace: ''
}
},
{
name: 'mock parent folder',
type: 'time-strip',
identifier: {
key: 'mock-parent-folder',
namespace: ''
}
}
];
const testTelemetry = [
{
utc: 1,
'some-key': 'some-value 1',
'some-other-key': 'some-other-value 1'
},
{
utc: 2,
'some-key': 'some-value 2',
'some-other-key': 'some-other-value 2'
},
{
utc: 3,
'some-key': 'some-value 3',
'some-other-key': 'some-other-value 3'
}
];
const timeSystem = {
timeSystemKey: 'utc',
bounds: {
start: 0,
end: 4
}
};
openmct = createOpenMct(timeSystem);
telemetryPromise = new Promise((resolve) => {
telemetryPromiseResolve = resolve;
});
spyOn(openmct.telemetry, 'request').and.callFake(() => {
telemetryPromiseResolve(testTelemetry);
return telemetryPromise;
});
telemetrylimitProvider = jasmine.createSpyObj('telemetrylimitProvider', [
'supportsLimits',
'getLimits',
'getLimitEvaluator'
]);
telemetrylimitProvider.supportsLimits.and.returnValue(true);
telemetrylimitProvider.getLimits.and.returnValue({
limits: function () {
return Promise.resolve({
WARNING: {
low: {
cssClass: 'is-limit--lwr is-limit--yellow',
'some-key': -0.5
},
high: {
cssClass: 'is-limit--upr is-limit--yellow',
'some-key': 0.5
}
},
DISTRESS: {
low: {
cssClass: 'is-limit--lwr is-limit--red',
'some-key': -0.9
},
high: {
cssClass: 'is-limit--upr is-limit--red',
'some-key': 0.9
}
}
});
}
});
telemetrylimitProvider.getLimitEvaluator.and.returnValue({
evaluate: function () {
return {};
}
});
openmct.telemetry.addProvider(telemetrylimitProvider);
openmct.install(new PlotVuePlugin());
element = document.createElement('div');
element.style.width = '640px';
element.style.height = '480px';
child = document.createElement('div');
child.style.width = '640px';
child.style.height = '480px';
element.appendChild(child);
document.body.appendChild(element);
openmct.types.addType('test-object', {
creatable: true
});
spyOnBuiltins(['requestAnimationFrame']);
window.requestAnimationFrame.and.callFake((callBack) => {
callBack();
});
openmct.on('start', done);
openmct.startHeadless();
});
afterEach(async () => {
openmct.time.setTimeSystem('utc', {
start: 0,
end: 2
});
await nextTick();
configStore.deleteAll();
return resetApplicationState(openmct);
});
describe('the plot views', () => {
it('provides a plot view for objects with telemetry', () => {
const testTelemetryObject = {
id: 'test-object',
type: 'test-object',
telemetry: {
values: [
{
key: 'some-key',
hints: {
domain: 1
}
},
{
key: 'other-key',
hints: {
range: 1
}
},
{
key: 'yet-another-key',
format: 'string',
hints: {
range: 2
}
}
]
}
};
const applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath);
const plotView = applicableViews.find((viewProvider) => viewProvider.key === 'plot-single');
expect(plotView).toBeDefined();
});
it('does not provide a plot view if the telemetry is entirely non numeric', () => {
const testTelemetryObject = {
id: 'test-object',
type: 'test-object',
telemetry: {
values: [
{
key: 'some-key',
hints: {
domain: 1
}
},
{
key: 'other-key',
format: 'string',
hints: {
range: 1
}
},
{
key: 'yet-another-key',
format: 'string',
hints: {
range: 1
}
}
]
}
};
const applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath);
const plotView = applicableViews.find((viewProvider) => viewProvider.key === 'plot-single');
expect(plotView).toBeUndefined();
});
it('provides an overlay plot view for objects with telemetry', () => {
const testTelemetryObject = {
id: 'test-object',
type: 'telemetry.plot.overlay',
telemetry: {
values: [
{
key: 'some-key'
}
]
}
};
const applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath);
let plotView = applicableViews.find((viewProvider) => viewProvider.key === 'plot-overlay');
expect(plotView).toBeDefined();
});
it('provides an inspector view for overlay plots', () => {
let selection = [
[
{
context: {
item: {
id: 'test-object',
type: 'telemetry.plot.overlay',
telemetry: {
values: [
{
key: 'some-key'
}
]
}
}
}
},
{
context: {
item: {
type: 'time-strip'
}
}
}
]
];
const applicableInspectorViews = openmct.inspectorViews.get(selection);
const plotInspectorView = applicableInspectorViews.find(
(view) => view.key === 'plots-inspector'
);
expect(plotInspectorView).toBeDefined();
});
it('provides a stacked plot view for objects with telemetry', () => {
const testTelemetryObject = {
id: 'test-object',
type: 'telemetry.plot.stacked',
telemetry: {
values: [
{
key: 'some-key'
}
]
}
};
const applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath);
let plotView = applicableViews.find((viewProvider) => viewProvider.key === 'plot-stacked');
expect(plotView).toBeDefined();
});
});
describe('The single plot view', () => {
let testTelemetryObject;
let applicableViews;
let plotViewProvider;
let plotView;
beforeEach(() => {
openmct.time.timeSystem('utc', {
start: 0,
end: 4
});
testTelemetryObject = {
identifier: {
namespace: '',
key: 'test-object'
},
type: 'test-object',
name: 'Test Object',
telemetry: {
values: [
{
key: 'utc',
format: 'utc',
name: 'Time',
hints: {
domain: 1
}
},
{
key: 'some-key',
name: 'Some attribute',
hints: {
range: 1
}
},
{
key: 'some-other-key',
name: 'Another attribute',
hints: {
range: 2
}
}
]
}
};
openmct.router.path = [testTelemetryObject];
applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath);
plotViewProvider = applicableViews.find((viewProvider) => viewProvider.key === 'plot-single');
plotView = plotViewProvider.view(testTelemetryObject, []);
plotView.show(child, true, { renderWhenVisible });
return nextTick();
});
afterEach(() => {
openmct.router.path = null;
});
it('Makes only one request for telemetry on load', () => {
expect(openmct.telemetry.request).toHaveBeenCalledTimes(1);
});
it('Renders a collapsed legend for every telemetry', async () => {
await nextTick();
let legend = element.querySelectorAll('.plot-wrapper-collapsed-legend .plot-series-name');
expect(legend.length).toBe(1);
expect(legend[0].innerHTML).toEqual('Test Object');
});
it('Renders an expanded legend for every telemetry', async () => {
await nextTick();
let legendControl = element.querySelector(
'.c-plot-legend__view-control.gl-plot-legend__view-control.c-disclosure-triangle'
);
const clickEvent = createMouseEvent('click');
legendControl.dispatchEvent(clickEvent);
await nextTick();
let legend = element.querySelectorAll('.plot-wrapper-expanded-legend .plot-legend-item td');
expect(legend.length).toBe(6);
});
it('Renders X-axis ticks for the telemetry object', (done) => {
const configId = openmct.objects.makeKeyString(testTelemetryObject.identifier);
const config = configStore.get(configId);
config.xAxis.set('displayRange', {
min: 0,
max: 4
});
nextTick(() => {
let xAxisElement = element.querySelectorAll(
'.gl-plot-axis-area.gl-plot-x .gl-plot-tick-wrapper'
);
expect(xAxisElement.length).toBe(1);
let ticks = xAxisElement[0].querySelectorAll('.gl-plot-tick');
expect(ticks.length).toBe(9);
done();
});
});
it('Renders Y-axis options for the telemetry object', async () => {
await nextTick();
let yAxisElement = element.querySelectorAll(
'.gl-plot-axis-area.gl-plot-y .gl-plot-y-label__select'
);
expect(yAxisElement.length).toBe(1);
//Object{name: "Some attribute", key: "some-key"}, Object{name: "Another attribute", key: "some-other-key"}
let options = yAxisElement[0].querySelectorAll('option');
expect(options.length).toBe(2);
expect(options[0].value).toBe('Some attribute');
expect(options[1].value).toBe('Another attribute');
});
xit('Updates the Y-axis label when changed', () => {
const configId = openmct.objects.makeKeyString(testTelemetryObject.identifier);
const config = configStore.get(configId);
const yAxisElement = element.querySelectorAll('.gl-plot-axis-area.gl-plot-y')[0].__vue__;
config.yAxis.seriesCollection.models.forEach((plotSeries) => {
expect(plotSeries.model.yKey).toBe('some-key');
});
yAxisElement.$emit('y-key-changed', TEST_KEY_ID, 1);
config.yAxis.seriesCollection.models.forEach((plotSeries) => {
expect(plotSeries.model.yKey).toBe(TEST_KEY_ID);
});
});
it('hides the pause and play controls', () => {
let pauseEl = element.querySelectorAll('.c-button-set .icon-pause');
let playEl = element.querySelectorAll('.c-button-set .icon-arrow-right');
expect(pauseEl.length).toBe(0);
expect(playEl.length).toBe(0);
});
describe('pause and play controls', () => {
beforeEach(() => {
openmct.time.setClock('local');
openmct.time.setClockOffsets({
start: -1000,
end: 100
});
return nextTick();
});
it('shows the pause controls', (done) => {
nextTick(() => {
let pauseEl = element.querySelectorAll('.c-button-set .icon-pause');
expect(pauseEl.length).toBe(1);
done();
});
});
it('shows the play control if plot is paused', (done) => {
let pauseEl = element.querySelector('.c-button-set .icon-pause');
const clickEvent = createMouseEvent('click');
pauseEl.dispatchEvent(clickEvent);
nextTick(() => {
let playEl = element.querySelectorAll('.c-button-set .is-paused');
expect(playEl.length).toBe(1);
done();
});
});
});
describe('resume actions on errant click', () => {
beforeEach(() => {
openmct.time.setClock('local');
openmct.time.setClockOffsets({
start: -1000,
end: 100
});
return nextTick();
});
it('clicking the plot view without movement resumes the plot while active', async () => {
const pauseEl = element.querySelectorAll('.c-button-set .icon-pause');
// if the pause button is present, the chart is running
expect(pauseEl.length).toBe(1);
// simulate an errant mouse click
// the second item is the canvas we need to use
const canvas = element.querySelectorAll('canvas')[1];
const mouseDownEvent = new MouseEvent('mousedown');
const mouseUpEvent = new MouseEvent('mouseup');
canvas.dispatchEvent(mouseDownEvent);
// mouseup event is bound to the window
window.dispatchEvent(mouseUpEvent);
await nextTick();
const pauseElAfterClick = element.querySelectorAll('.c-button-set .icon-pause');
console.log('pauseElAfterClick', pauseElAfterClick);
expect(pauseElAfterClick.length).toBe(1);
});
it('clicking the plot view without movement leaves the plot paused', async () => {
const pauseEl = element.querySelector('.c-button-set .icon-pause');
// pause the plot
pauseEl.dispatchEvent(createMouseEvent('click'));
await nextTick();
const playEl = element.querySelectorAll('.c-button-set .is-paused');
expect(playEl.length).toBe(1);
// simulate an errant mouse click
// the second item is the canvas we need to use
const canvas = element.querySelectorAll('canvas')[1];
const mouseDownEvent = new MouseEvent('mousedown');
const mouseUpEvent = new MouseEvent('mouseup');
canvas.dispatchEvent(mouseDownEvent);
// mouseup event is bound to the window
window.dispatchEvent(mouseUpEvent);
await nextTick();
const playElAfterChartClick = element.querySelectorAll('.c-button-set .is-paused');
expect(playElAfterChartClick.length).toBe(1);
});
it('clicking the plot does not request historical data', async () => {
expect(openmct.telemetry.request).toHaveBeenCalledTimes(2);
// simulate an errant mouse click
// the second item is the canvas we need to use
const canvas = element.querySelectorAll('canvas')[1];
const mouseDownEvent = new MouseEvent('mousedown');
const mouseUpEvent = new MouseEvent('mouseup');
canvas.dispatchEvent(mouseDownEvent);
// mouseup event is bound to the window
window.dispatchEvent(mouseUpEvent);
await nextTick();
expect(openmct.telemetry.request).toHaveBeenCalledTimes(2);
});
describe('limits', () => {
it('lines are not displayed by default', () => {
let limitEl = element.querySelectorAll('.js-limit-area .js-limit-line');
expect(limitEl.length).toBe(0);
});
it('lines are displayed when configuration is set to true', async () => {
await nextTick();
const configId = openmct.objects.makeKeyString(testTelemetryObject.identifier);
const config = configStore.get(configId);
config.yAxis.set('displayRange', {
min: 0,
max: 4
});
config.series.models[0].set('limitLines', true);
await nextTick();
let limitEl = element.querySelectorAll('.js-limit-area .js-limit-line');
expect(limitEl.length).toBe(4);
});
});
});
describe('controls in time strip view', () => {
it('zoom controls are hidden', () => {
let pauseEl = element.querySelectorAll('.c-button-set .js-zoom');
expect(pauseEl.length).toBe(0);
});
it('pan controls are hidden', () => {
let pauseEl = element.querySelectorAll('.c-button-set .js-pan');
expect(pauseEl.length).toBe(0);
});
it('pause/play controls are hidden', () => {
let pauseEl = element.querySelectorAll('.c-button-set .js-pause');
expect(pauseEl.length).toBe(0);
});
});
});
describe('resizing the plot', () => {
let plotContainerResizeObserver;
let resizePromiseResolve;
let testTelemetryObject;
let applicableViews;
let plotViewProvider;
let plotView;
let resizePromise;
beforeEach(() => {
testTelemetryObject = {
identifier: {
namespace: '',
key: 'test-object'
},
type: 'test-object',
name: 'Test Object',
telemetry: {
values: [
{
key: 'utc',
format: 'utc',
name: 'Time',
hints: {
domain: 1
}
},
{
key: 'some-key',
name: 'Some attribute',
hints: {
range: 1
}
},
{
key: 'some-other-key',
name: 'Another attribute',
hints: {
range: 2
}
}
]
}
};
openmct.router.path = [testTelemetryObject];
applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath);
plotViewProvider = applicableViews.find((viewProvider) => viewProvider.key === 'plot-single');
plotView = plotViewProvider.view(testTelemetryObject, []);
plotView.show(child, true, { renderWhenVisible });
resizePromise = new Promise((resolve) => {
resizePromiseResolve = resolve;
});
const handlePlotResize = _.debounce(() => {
resizePromiseResolve(true);
}, 600);
plotContainerResizeObserver = new ResizeObserver(handlePlotResize);
plotContainerResizeObserver.observe(
plotView.getComponent().$refs.plotComponent.$refs.plotWrapper
);
return nextTick(() => {
plotView.getComponent().$refs.plotComponent.$refs.mctPlot.stopFollowingTimeContext();
spyOn(
plotView.getComponent().$refs.plotComponent.$refs.mctPlot,
'loadSeriesData'
).and.callThrough();
});
});
afterEach(() => {
plotContainerResizeObserver.disconnect();
openmct.router.path = null;
});
xit('requests historical data when over the threshold', async () => {
await nextTick();
element.style.width = '680px';
await resizePromise;
expect(
plotView.getComponent().$refs.plotComponent.$refs.mctPlot.loadSeriesData
).toHaveBeenCalledTimes(1);
});
it('does not request historical data when under the threshold', async () => {
element.style.width = '644px';
await resizePromise;
expect(
plotView.getComponent().$refs.plotComponent.$refs.mctPlot.loadSeriesData
).not.toHaveBeenCalled();
});
});
describe('the inspector view', () => {
let component;
let viewComponentObject;
let mockComposition;
let testTelemetryObject;
let selection;
let config;
beforeEach((done) => {
testTelemetryObject = {
identifier: {
namespace: '',
key: 'test-object'
},
type: 'test-object',
name: 'Test Object',
telemetry: {
values: [
{
key: 'utc',
format: 'utc',
name: 'Time',
hints: {
domain: 1
}
},
{
key: 'some-key',
name: 'Some attribute',
hints: {
range: 1
}
},
{
key: 'some-other-key',
name: 'Another attribute',
hints: {
range: 2
}
}
]
}
};
selection = [
[
{
context: {
item: {
id: 'test-object',
identifier: {
key: 'test-object',
namespace: ''
},
type: 'telemetry.plot.overlay',
configuration: {
series: [
{
identifier: {
key: 'test-object',
namespace: ''
}
}
]
},
composition: []
}
}
},
{
context: {
item: {
type: 'time-strip',
identifier: {
key: 'some-other-key',
namespace: ''
}
}
}
}
]
];
openmct.router.path = [testTelemetryObject];
mockComposition = new EventEmitter();
mockComposition.load = () => {
mockComposition.emit('add', testTelemetryObject);
return [testTelemetryObject];
};
spyOn(openmct.composition, 'get').and.returnValue(mockComposition);
const configId = openmct.objects.makeKeyString(selection[0][0].context.item.identifier);
config = new PlotConfigurationModel({
id: configId,
domainObject: selection[0][0].context.item,
openmct: openmct
});
configStore.add(configId, config);
let viewContainer = document.createElement('div');
child.append(viewContainer);
const { vNode } = mount(
{
components: {
PlotOptions
},
provide: {
openmct: openmct,
domainObject: selection[0][0].context.item,
path: [selection[0][0].context.item, selection[0][1].context.item],
renderWhenVisible
},
template: '<plot-options ref="root"/>'
},
{
element: viewContainer
}
);
component = vNode.componentInstance;
nextTick(() => {
viewComponentObject = component.$refs.root;
done();
});
});
afterEach(() => {
openmct.router.path = null;
});
describe('in view only mode', () => {
let browseOptionsEl;
let editOptionsEl;
beforeEach(() => {
browseOptionsEl = viewComponentObject.$el.querySelector('.js-plot-options-browse');
editOptionsEl = viewComponentObject.$el.querySelector('.js-plot-options-edit');
});
it('does not show the edit options', () => {
expect(editOptionsEl).toBeNull();
});
it('shows the name', () => {
const seriesEl = browseOptionsEl.querySelector('.c-object-label__name');
expect(seriesEl.innerHTML).toEqual(testTelemetryObject.name);
});
it('shows in collapsed mode', () => {
const seriesEl = browseOptionsEl.querySelectorAll('.c-disclosure-triangle--expanded');
expect(seriesEl.length).toEqual(0);
});
it('shows in expanded mode', () => {
let expandControl = browseOptionsEl.querySelector('.c-disclosure-triangle');
const clickEvent = createMouseEvent('click');
expandControl.dispatchEvent(clickEvent);
const plotOptionsProperties = browseOptionsEl.querySelectorAll(
'.js-plot-options-browse-properties .grid-row'
);
expect(plotOptionsProperties.length).toEqual(6);
});
});
describe('in edit mode', () => {
let editOptionsEl;
let browseOptionsEl;
beforeEach((done) => {
viewComponentObject.setEditState(true);
nextTick(() => {
editOptionsEl = viewComponentObject.$el.querySelector('.js-plot-options-edit');
browseOptionsEl = viewComponentObject.$el.querySelector('.js-plot-options-browse');
done();
});
});
it('does not show the browse options', () => {
expect(browseOptionsEl).toBeNull();
});
it('shows the name', () => {
const seriesEl = editOptionsEl.querySelector('.c-object-label__name');
expect(seriesEl.innerHTML).toEqual(testTelemetryObject.name);
});
it('shows in collapsed mode', () => {
const seriesEl = editOptionsEl.querySelectorAll('.c-disclosure-triangle--expanded');
expect(seriesEl.length).toEqual(0);
});
it('shows in collapsed mode', () => {
const seriesEl = editOptionsEl.querySelectorAll('.c-disclosure-triangle--expanded');
expect(seriesEl.length).toEqual(0);
});
it('renders expanded', () => {
const expandControl = editOptionsEl.querySelector('.c-disclosure-triangle');
const clickEvent = createMouseEvent('click');
expandControl.dispatchEvent(clickEvent);
const plotOptionsProperties = editOptionsEl.querySelectorAll(
'.js-plot-options-edit-properties .grid-row'
);
expect(plotOptionsProperties.length).toEqual(8);
});
it('shows yKeyOptions', () => {
const expandControl = editOptionsEl.querySelector('.c-disclosure-triangle');
const clickEvent = createMouseEvent('click');
expandControl.dispatchEvent(clickEvent);
const plotOptionsProperties = editOptionsEl.querySelectorAll(
'.js-plot-options-edit-properties .grid-row'
);
const yKeySelection = plotOptionsProperties[0].querySelector('select');
const options = Array.from(yKeySelection.options).map((option) => {
return option.value;
});
expect(options).toEqual([
testTelemetryObject.telemetry.values[1].key,
testTelemetryObject.telemetry.values[2].key
]);
});
it('shows yAxis options', () => {
const expandControl = editOptionsEl.querySelector('.c-disclosure-triangle');
const clickEvent = createMouseEvent('click');
expandControl.dispatchEvent(clickEvent);
const yAxisProperties = editOptionsEl.querySelectorAll(
'div.grid-properties:first-of-type .l-inspector-part'
);
// TODO better test
expect(yAxisProperties.length).toEqual(2);
});
it('renders color palette options', () => {
const colorSwatch = editOptionsEl.querySelector('.c-click-swatch');
expect(colorSwatch).toBeDefined();
});
});
});
});