diff --git a/e2e/tests/functional/plugins/styling/conditionSetStyling.e2e.spec.js b/e2e/tests/functional/plugins/styling/conditionSetStyling.e2e.spec.js new file mode 100644 index 0000000000..f059fe9ee7 --- /dev/null +++ b/e2e/tests/functional/plugins/styling/conditionSetStyling.e2e.spec.js @@ -0,0 +1,177 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ +/* +This test suite is dedicated to tests which verify the basic operations surrounding conditionSets and styling +*/ + +import { createDomainObjectWithDefaults, setRealTimeMode } from '../../../../appActions.js'; +import { MISSION_TIME } from '../../../../constants.js'; +import { expect, test } from '../../../../pluginFixtures.js'; + +test.describe('Conditionally Styling, using a Condition Set', () => { + let stateGenerator; + let conditionSet; + let displayLayout; + const STATE_CHANGE_INTERVAL = '1'; + + test.beforeEach(async ({ page }) => { + // Install the clock and set the time to the mission time such that the state generator will be controllable + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + await page.goto('./', { waitUntil: 'domcontentloaded' }); + // Create Condition Set, State Generator, and Display Layout + conditionSet = await createDomainObjectWithDefaults(page, { + type: 'Condition Set', + name: 'Test Condition Set' + }); + stateGenerator = await createDomainObjectWithDefaults(page, { + type: 'State Generator', + name: 'One Second State Generator' + }); + // edit the state generator to have a 1 second update rate + await page.getByTitle('More actions').click(); + await page.getByRole('menuitem', { name: 'Edit Properties...' }).click(); + await page.getByLabel('State Duration (seconds)', { exact: true }).fill(STATE_CHANGE_INTERVAL); + await page.getByLabel('Save').click(); + + displayLayout = await createDomainObjectWithDefaults(page, { + type: 'Display Layout', + name: 'Test Display Layout' + }); + }); + + test('Conditional styling, using a Condition Set, will style correctly based on the output @clock', async ({ + page + }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7840' + }); + + // set up the condition set to use the state generator + await page.goto(conditionSet.url, { waitUntil: 'domcontentloaded' }); + + // Add the State Generator to the Condition Set by dragging from the main tree + await page.getByLabel('Show selected item in tree').click(); + await page + .getByRole('tree', { + name: 'Main Tree' + }) + .getByRole('treeitem', { + name: stateGenerator.name + }) + .dragTo(page.locator('#conditionCollection')); + + // Add the state generator to the first criterion such that there is a condition named 'OFF' when the state generator is off + await page.getByLabel('Add Condition').click(); + await page + .getByLabel('Criterion Telemetry Selection') + .selectOption({ label: stateGenerator.name }); + await page.getByLabel('Criterion Metadata Selection').selectOption({ label: 'State' }); + await page.getByLabel('Criterion Comparison Selection').selectOption({ label: 'is' }); + await page.getByLabel('Condition Name Input').first().fill('OFF'); + await page.getByLabel('Save').click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + + await searchAndLinkParameterToObject(page, stateGenerator.name, displayLayout.name); + + //Add a box to the display layout + await page.goto(displayLayout.url, { waitUntil: 'domcontentloaded' }); + await page.getByLabel('Edit Object').click(); + + //Add a box to the display layout and move it to the right + //TEMP: Click the layout such that the state generator is deselected + await page.getByLabel('Test Display Layout Layout Grid').locator('div').nth(1).click(); + await page.getByLabel('Add Drawing Object').click(); + await page.getByText('Box').click(); + await page.getByLabel('X:').click(); + await page.getByLabel('X:').fill('10'); + await page.getByLabel('X:').press('Enter'); + + // set up conditional styling such that the box is red when the state generator condition is 'OFF' + await page.getByRole('tab', { name: 'Styles' }).click(); + await page.getByRole('button', { name: 'Use Conditional Styling...' }).click(); + await page.getByLabel('Modal Overlay').getByLabel('Expand My Items folder').click(); + await page.getByLabel('Modal Overlay').getByLabel(`Preview ${conditionSet.name}`).click(); + await page.getByText('Ok').click(); + await page.getByLabel('Set background color').first().click(); + await page.getByLabel('#ff0000').click(); + await page.getByLabel('Save', { exact: true }).click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + + await setRealTimeMode(page); + + //Pause at a time when the state generator is 'OFF' which is 20 minutes in the future + await page.clock.pauseAt(new Date(MISSION_TIME + 1200000)); + + const redBG = 'background-color: rgb(255, 0, 0);'; + const defaultBG = 'background-color: rgb(102, 102, 102);'; + const textElement = page.getByLabel('Alpha-numeric telemetry value').locator('div:first-child'); + const styledElement = page.getByLabel('Box', { exact: true }); + + await page.clock.resume(); + + // Check if the style is red when text is 'OFF' + await expect(textElement).toHaveText('OFF'); + await waitForStyleChange(styledElement, redBG); + + // Fast forward to the next state change + await page.clock.fastForward(STATE_CHANGE_INTERVAL * 1000); + + // Check if the style is not red when text is 'ON' + await expect(textElement).toHaveText('ON'); + await waitForStyleChange(styledElement, defaultBG); + }); +}); + +/** + * Wait for the style of an element to change to the expected style. + * @param {import('@playwright/test').Locator} element - The element to check. + * @param {string} expectedStyle - The expected style to wait for. + * @param {number} timeout - The timeout in milliseconds. + */ +async function waitForStyleChange(element, expectedStyle, timeout = 0) { + await expect(async () => { + const style = await element.getAttribute('style'); + + // eslint-disable-next-line playwright/prefer-web-first-assertions + expect(style).toBe(expectedStyle); + }).toPass({ timeout: 1000 }); // timeout allows for the style to be applied +} + +/** + * Search for telemetry and link it to an object. objectName should come from the domainObject.name function. + * @param {import('@playwright/test').Page} page + * @param {string} parameterName + * @param {string} objectName + */ +async function searchAndLinkParameterToObject(page, parameterName, objectName) { + await page.getByRole('searchbox', { name: 'Search Input' }).click(); + await page.getByRole('searchbox', { name: 'Search Input' }).fill(parameterName); + await page.getByLabel('Object Results').getByText(parameterName).click(); + await page.getByLabel('More actions').click(); + await page.getByLabel('Create Link').click(); + await page.getByLabel('Modal Overlay').getByLabel('Search Input').click(); + await page.getByLabel('Modal Overlay').getByLabel('Search Input').fill(objectName); + await page.getByLabel('Modal Overlay').getByLabel(`Navigate to ${objectName}`).click(); + await page.getByLabel('Save').click(); +} diff --git a/src/plugins/condition/ConditionManager.js b/src/plugins/condition/ConditionManager.js index 838730883d..4a18140cf7 100644 --- a/src/plugins/condition/ConditionManager.js +++ b/src/plugins/condition/ConditionManager.js @@ -39,7 +39,7 @@ export default class ConditionManager extends EventEmitter { this.shouldEvaluateNewTelemetry = this.shouldEvaluateNewTelemetry.bind(this); this.compositionLoad = this.composition.load(); - this.subscriptions = {}; + this.telemetryCollections = {}; this.telemetryObjects = {}; this.testData = { conditionTestInputs: this.conditionSetDomainObject.configuration.conditionTestData, @@ -48,55 +48,46 @@ export default class ConditionManager extends EventEmitter { this.initialize(); } - async requestLatestValue(endpoint) { - const options = { + subscribeToTelemetry(telemetryObject) { + const keyString = this.openmct.objects.makeKeyString(telemetryObject.identifier); + + if (this.telemetryCollections[keyString]) { + return; + } + + const requestOptions = { size: 1, strategy: 'latest' }; - const latestData = await this.openmct.telemetry.request(endpoint, options); - if (!latestData) { - throw new Error('Telemetry request failed by returning a falsy response'); - } - if (latestData.length === 0) { - return; - } - this.telemetryReceived(endpoint, latestData[0]); - } - - subscribeToTelemetry(endpoint) { - const telemetryKeyString = this.openmct.objects.makeKeyString(endpoint.identifier); - if (this.subscriptions[telemetryKeyString]) { - return; - } - - const metadata = this.openmct.telemetry.getMetadata(endpoint); - - this.telemetryObjects[telemetryKeyString] = Object.assign({}, endpoint, { - telemetryMetaData: metadata ? metadata.valueMetadatas : [] - }); - - // get latest telemetry value (in case subscription is cached and no new data is coming in) - this.requestLatestValue(endpoint); - - this.subscriptions[telemetryKeyString] = this.openmct.telemetry.subscribe( - endpoint, - this.telemetryReceived.bind(this, endpoint) + this.telemetryCollections[keyString] = this.openmct.telemetry.requestCollection( + telemetryObject, + requestOptions ); + + const metadata = this.openmct.telemetry.getMetadata(telemetryObject); + const telemetryMetaData = metadata ? metadata.valueMetadatas : []; + + this.telemetryObjects[keyString] = { ...telemetryObject, telemetryMetaData }; + + this.telemetryCollections[keyString].on( + 'add', + this.telemetryReceived.bind(this, telemetryObject) + ); + this.telemetryCollections[keyString].load(); + this.updateConditionTelemetryObjects(); } unsubscribeFromTelemetry(endpointIdentifier) { - const id = this.openmct.objects.makeKeyString(endpointIdentifier); - if (!this.subscriptions[id]) { - console.log('no subscription to remove'); - + const keyString = this.openmct.objects.makeKeyString(endpointIdentifier); + if (!this.telemetryCollections[keyString]) { return; } - this.subscriptions[id](); - delete this.subscriptions[id]; - delete this.telemetryObjects[id]; + this.telemetryCollections[keyString].destroy(); + this.telemetryCollections[keyString] = null; + this.telemetryObjects[keyString] = null; this.removeConditionTelemetryObjects(); //force re-computation of condition set result as we might be in a state where @@ -107,7 +98,7 @@ export default class ConditionManager extends EventEmitter { this.timeSystems, this.openmct.time.getTimeSystem() ); - this.updateConditionResults({ id: id }); + this.updateConditionResults({ id: keyString }); this.updateCurrentCondition(latestTimestamp); if (Object.keys(this.telemetryObjects).length === 0) { @@ -410,11 +401,13 @@ export default class ConditionManager extends EventEmitter { return this.openmct.time.getBounds().end >= currentTimestamp; } - telemetryReceived(endpoint, datum) { + telemetryReceived(endpoint, data) { if (!this.isTelemetryUsed(endpoint)) { return; } + const datum = data[0]; + const normalizedDatum = this.createNormalizedDatum(datum, endpoint); const timeSystemKey = this.openmct.time.getTimeSystem().key; let timestamp = {}; @@ -507,8 +500,9 @@ export default class ConditionManager extends EventEmitter { destroy() { this.composition.off('add', this.subscribeToTelemetry, this); this.composition.off('remove', this.unsubscribeFromTelemetry, this); - Object.values(this.subscriptions).forEach((unsubscribe) => unsubscribe()); - delete this.subscriptions; + Object.values(this.telemetryCollections).forEach((telemetryCollection) => + telemetryCollection.destroy() + ); this.conditions.forEach((condition) => { condition.destroy(); diff --git a/src/plugins/condition/pluginSpec.js b/src/plugins/condition/pluginSpec.js index d995d235da..233ee9ad06 100644 --- a/src/plugins/condition/pluginSpec.js +++ b/src/plugins/condition/pluginSpec.js @@ -720,50 +720,69 @@ describe('the plugin', function () { }; }); - it('should evaluate as old when telemetry is not received in the allotted time', (done) => { + it('should evaluate as old when telemetry is not received in the allotted time', async () => { + let onAddResolve; + const onAddCalledPromise = new Promise((resolve) => { + onAddResolve = resolve; + }); + const mockTelemetryCollection = { + load: jasmine.createSpy('load'), + on: jasmine.createSpy('on').and.callFake((event, callback) => { + if (event === 'add') { + onAddResolve(); + } + }) + }; + openmct.telemetry = jasmine.createSpyObj('telemetry', [ - 'subscribe', 'getMetadata', 'request', 'getValueFormatter', - 'abortAllRequests' + 'abortAllRequests', + 'requestCollection' ]); + openmct.telemetry.request.and.returnValue(Promise.resolve([])); openmct.telemetry.getMetadata.and.returnValue({ ...testTelemetryObject.telemetry, - valueMetadatas: [] + valueMetadatas: testTelemetryObject.telemetry.values, + valuesForHints: jasmine + .createSpy('valuesForHints') + .and.returnValue(testTelemetryObject.telemetry.values), + value: jasmine.createSpy('value').and.callFake((key) => { + return testTelemetryObject.telemetry.values.find((value) => value.key === key); + }) }); - openmct.telemetry.request.and.returnValue(Promise.resolve([])); + openmct.telemetry.requestCollection.and.returnValue(mockTelemetryCollection); openmct.telemetry.getValueFormatter.and.returnValue({ parse: function (value) { return value; } }); + let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct); conditionMgr.on('conditionSetResultUpdated', mockListener); conditionMgr.telemetryObjects = { 'test-object': testTelemetryObject }; conditionMgr.updateConditionTelemetryObjects(); - setTimeout(() => { - expect(mockListener).toHaveBeenCalledWith({ - output: 'Any old telemetry', - id: { - namespace: '', - key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9' - }, - conditionId: '39584410-cbf9-499e-96dc-76f27e69885d', - utc: undefined - }); - done(); - }, 400); + // Wait for the 'on' callback to be called + await onAddCalledPromise; + + // Simulate the passage of time and no data received + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect(mockListener).toHaveBeenCalledWith({ + output: 'Any old telemetry', + id: { + namespace: '', + key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9' + }, + conditionId: '39584410-cbf9-499e-96dc-76f27e69885d', + utc: undefined + }); }); - it('should not evaluate as old when telemetry is received in the allotted time', (done) => { - openmct.telemetry.getMetadata = jasmine.createSpy('getMetadata'); - openmct.telemetry.getMetadata.and.returnValue({ - ...testTelemetryObject.telemetry, - valueMetadatas: testTelemetryObject.telemetry.values - }); + it('should not evaluate as old when telemetry is received in the allotted time', async () => { const testDatum = { 'some-key2': '', utc: 1, @@ -771,8 +790,49 @@ describe('the plugin', function () { 'some-key': null, id: 'test-object' }; - openmct.telemetry.request = jasmine.createSpy('request'); + + let onAddResolve; + let onAddCallback; + const onAddCalledPromise = new Promise((resolve) => { + onAddResolve = resolve; + }); + + const mockTelemetryCollection = { + load: jasmine.createSpy('load'), + on: jasmine.createSpy('on').and.callFake((event, callback) => { + if (event === 'add') { + onAddCallback = callback; + onAddResolve(); + } + }) + }; + + openmct.telemetry = jasmine.createSpyObj('telemetry', [ + 'getMetadata', + 'getValueFormatter', + 'request', + 'subscribe', + 'requestCollection' + ]); + openmct.telemetry.subscribe.and.returnValue(function () {}); openmct.telemetry.request.and.returnValue(Promise.resolve([testDatum])); + openmct.telemetry.getMetadata.and.returnValue({ + ...testTelemetryObject.telemetry, + valueMetadatas: testTelemetryObject.telemetry.values, + valuesForHints: jasmine + .createSpy('valuesForHints') + .and.returnValue(testTelemetryObject.telemetry.values), + value: jasmine.createSpy('value').and.callFake((key) => { + return testTelemetryObject.telemetry.values.find((value) => value.key === key); + }) + }); + openmct.telemetry.requestCollection.and.returnValue(mockTelemetryCollection); + openmct.telemetry.getValueFormatter.and.returnValue({ + parse: function (value) { + return value; + } + }); + const date = 1; conditionSetDomainObject.configuration.conditionCollection[0].configuration.criteria[0].input = ['0.4']; @@ -782,19 +842,25 @@ describe('the plugin', function () { 'test-object': testTelemetryObject }; conditionMgr.updateConditionTelemetryObjects(); - conditionMgr.telemetryReceived(testTelemetryObject, testDatum); - setTimeout(() => { - expect(mockListener).toHaveBeenCalledWith({ - output: 'Default', - id: { - namespace: '', - key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9' - }, - conditionId: '2532d90a-e0d6-4935-b546-3123522da2de', - utc: date - }); - done(); - }, 300); + + // Wait for the 'on' callback to be called + await onAddCalledPromise; + + // Simulate receiving telemetry data + onAddCallback([testDatum]); + + // Wait a bit for the condition manager to process the data + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockListener).toHaveBeenCalledWith({ + output: 'Default', + id: { + namespace: '', + key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9' + }, + conditionId: '2532d90a-e0d6-4935-b546-3123522da2de', + utc: date + }); }); }); @@ -902,17 +968,25 @@ describe('the plugin', function () { openmct.telemetry.getMetadata = jasmine.createSpy('getMetadata'); openmct.telemetry.getMetadata.and.returnValue({ ...testTelemetryObject.telemetry, - valueMetadatas: [] + valueMetadatas: testTelemetryObject.telemetry.values, + valuesForHints: jasmine + .createSpy('valuesForHints') + .and.returnValue(testTelemetryObject.telemetry.values), + value: jasmine.createSpy('value').and.callFake((key) => { + return testTelemetryObject.telemetry.values.find((value) => value.key === key); + }) }); conditionMgr.on('conditionSetResultUpdated', mockListener); conditionMgr.telemetryObjects = { 'test-object': testTelemetryObject }; conditionMgr.updateConditionTelemetryObjects(); - conditionMgr.telemetryReceived(testTelemetryObject, { - 'some-key': 2, - utc: date - }); + conditionMgr.telemetryReceived(testTelemetryObject, [ + { + 'some-key': 2, + utc: date + } + ]); let result = conditionMgr.conditions.map((condition) => condition.result); expect(result[2]).toBeUndefined(); }); @@ -1002,26 +1076,37 @@ describe('the plugin', function () { } }; openmct.$injector = jasmine.createSpyObj('$injector', ['get']); - // const mockTransactionService = jasmine.createSpyObj( - // 'transactionService', - // ['commit'] - // ); openmct.telemetry = jasmine.createSpyObj('telemetry', [ 'isTelemetryObject', + 'request', 'subscribe', 'getMetadata', 'getValueFormatter', - 'request' + 'requestCollection' ]); - openmct.telemetry.isTelemetryObject.and.returnValue(true); openmct.telemetry.subscribe.and.returnValue(function () {}); + openmct.telemetry.request.and.returnValue(Promise.resolve([])); + openmct.telemetry.isTelemetryObject.and.returnValue(true); + openmct.telemetry.getMetadata.and.returnValue(testTelemetryObject.telemetry); + openmct.telemetry.getMetadata.and.returnValue({ + ...testTelemetryObject.telemetry, + valueMetadatas: testTelemetryObject.telemetry.values, + valuesForHints: jasmine + .createSpy('valuesForHints') + .and.returnValue(testTelemetryObject.telemetry.values), + value: jasmine.createSpy('value').and.callFake((key) => { + return testTelemetryObject.telemetry.values.find((value) => value.key === key); + }) + }); openmct.telemetry.getValueFormatter.and.returnValue({ parse: function (value) { return value; } }); - openmct.telemetry.getMetadata.and.returnValue(testTelemetryObject.telemetry); - openmct.telemetry.request.and.returnValue(Promise.resolve([])); + openmct.telemetry.requestCollection.and.returnValue({ + load: jasmine.createSpy('load'), + on: jasmine.createSpy('on') + }); const styleRuleManger = new StyleRuleManager(stylesObject, openmct, null, true); spyOn(styleRuleManger, 'subscribeToConditionSet'); diff --git a/src/plugins/plot/stackedPlot/mixins/objectStyles-mixin.js b/src/plugins/plot/stackedPlot/mixins/objectStyles-mixin.js index f559ea3ced..0caa57c68b 100644 --- a/src/plugins/plot/stackedPlot/mixins/objectStyles-mixin.js +++ b/src/plugins/plot/stackedPlot/mixins/objectStyles-mixin.js @@ -128,35 +128,22 @@ export default { } }, updateStyle(styleObj) { - let elemToStyle = this.getStyleReceiver(); + const elemToStyle = this.getStyleReceiver(); - if (!styleObj || elemToStyle === undefined) { + if (!styleObj || !elemToStyle) { return; } + // handle visibility separately + if (styleObj.isStyleInvisible !== undefined) { + elemToStyle.classList.toggle(STYLE_CONSTANTS.isStyleInvisible, styleObj.isStyleInvisible); + styleObj.isStyleInvisible = null; + } - let keys = Object.keys(styleObj); - - keys.forEach((key) => { - if (elemToStyle) { - if (typeof styleObj[key] === 'string' && styleObj[key].indexOf('__no_value') > -1) { - if (elemToStyle.style[key]) { - elemToStyle.style[key] = ''; - } - } else { - if ( - !styleObj.isStyleInvisible && - elemToStyle.classList.contains(STYLE_CONSTANTS.isStyleInvisible) - ) { - elemToStyle.classList.remove(STYLE_CONSTANTS.isStyleInvisible); - } else if ( - styleObj.isStyleInvisible && - !elemToStyle.classList.contains(styleObj.isStyleInvisible) - ) { - elemToStyle.classList.add(styleObj.isStyleInvisible); - } - - elemToStyle.style[key] = styleObj[key]; - } + Object.entries(styleObj).forEach(([key, value]) => { + if (typeof value !== 'string' || !value.includes('__no_value')) { + elemToStyle.style[key] = value; + } else { + elemToStyle.style[key] = ''; // remove the property } }); }