diff --git a/.circleci/config.yml b/.circleci/config.yml index 5da66eafc6..3998a1c918 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,8 +8,8 @@ executors: - image: mcr.microsoft.com/playwright:v1.47.2-focal environment: NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed - PERCY_POSTINSTALL_BROWSER: "true" # Needed to store the percy browser in cache deps - PERCY_LOGLEVEL: "debug" # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742) + PERCY_POSTINSTALL_BROWSER: 'true' # Needed to store the percy browser in cache deps + PERCY_LOGLEVEL: 'debug' # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742) PERCY_PARALLEL_TOTAL: 2 ubuntu: machine: @@ -17,7 +17,7 @@ executors: docker_layer_caching: true commands: build_and_install: - description: "All steps used to build and install." + description: 'All steps used to build and install.' parameters: node-version: type: string @@ -27,7 +27,7 @@ commands: node-version: << parameters.node-version >> - node/install-packages generate_and_store_version_and_filesystem_artifacts: - description: "Track important packages and files" + description: 'Track important packages and files' steps: - run: | [[ $EUID -ne 0 ]] && (sudo mkdir -p /tmp/artifacts && sudo chmod 777 /tmp/artifacts) || (mkdir -p /tmp/artifacts && chmod 777 /tmp/artifacts) @@ -61,7 +61,7 @@ commands: [[ $EUID -ne 0 ]] && sudo chmod +x codecov || chmod +x codecov ./codecov --help generate_e2e_code_cov_report: - description: "Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test" + description: 'Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test' parameters: suite: type: string @@ -135,13 +135,13 @@ jobs: suite: #ci or full type: string executor: pw-focal-development - parallelism: 7 + parallelism: 8 steps: - build_and_install: node-version: lts/hydrogen - when: #Only install chrome-beta when running the 'full' suite to save $$$ condition: - equal: ["full", <>] + equal: ['full', <>] steps: - run: npx playwright install chrome-beta - run: @@ -323,7 +323,7 @@ workflows: - e2e-couchdb triggers: - schedule: - cron: "0 0 * * *" + cron: '0 0 * * *' filters: branches: only: diff --git a/e2e/appActions.js b/e2e/appActions.js index 1458635300..549de3c4dc 100644 --- a/e2e/appActions.js +++ b/e2e/appActions.js @@ -243,6 +243,37 @@ async function createExampleTelemetryObject(page, parent = 'mine') { }; } +/** + * Create a Stable State Telemetry Object (State Generator) for use in visual tests + * and tests against plotting telemetry (e.g. logPlot tests). This will change state every 2 seconds. + * @param {import('@playwright/test').Page} page + * @param {string | import('../src/api/objects/ObjectAPI').Identifier} [parent] the uuid or identifier of the parent object. Defaults to 'mine' + * @returns {Promise} An object containing information about the telemetry object. + */ +async function createStableStateTelemetry(page, parent = 'mine') { + const parentUrl = await getHashUrlToDomainObject(page, parent); + + await page.goto(`${parentUrl}`); + const createdObject = await createDomainObjectWithDefaults(page, { + type: 'State Generator', + name: 'Stable State Generator' + }); + // edit the state generator to have a 1 second update rate + await page.getByLabel('More actions').click(); + await page.getByRole('menuitem', { name: 'Edit Properties...' }).click(); + await page.getByLabel('State Duration (seconds)', { exact: true }).fill('2'); + await page.getByLabel('Save').click(); + // Wait until the URL is updated + const uuid = await getFocusedObjectUuid(page); + const url = await getHashUrlToDomainObject(page, uuid); + + return { + name: createdObject.name, + uuid, + url + }; +} + /** * Navigates directly to a given object url, in fixed time mode, with the given start and end bounds. Note: does not set * default view type. @@ -645,14 +676,34 @@ async function getCanvasPixels(page, canvasSelector) { ); } +/** + * 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 linkParameterToObject(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(); +} + export { createDomainObjectWithDefaults, createExampleTelemetryObject, createNotification, createPlanFromJSON, + createStableStateTelemetry, expandEntireTree, getCanvasPixels, getDomainObject, + linkParameterToObject, navigateToObjectWithFixedTimeBounds, navigateToObjectWithRealTime, setEndOffset, diff --git a/e2e/tests/framework/appActions.e2e.spec.js b/e2e/tests/framework/appActions.e2e.spec.js index 280ef33f17..9e044d3d07 100644 --- a/e2e/tests/framework/appActions.e2e.spec.js +++ b/e2e/tests/framework/appActions.e2e.spec.js @@ -26,8 +26,10 @@ import { createExampleTelemetryObject, createNotification, createPlanFromJSON, + createStableStateTelemetry, expandEntireTree, getCanvasPixels, + linkParameterToObject, navigateToObjectWithFixedTimeBounds, navigateToObjectWithRealTime, setEndOffset, @@ -339,4 +341,23 @@ test.describe('AppActions @framework', () => { // Expect this step to fail await waitForPlotsToRender(page, { timeout: 1000 }); }); + test('createStableStateTelemetry', async ({ page }) => { + const stableStateTelemetry = await createStableStateTelemetry(page); + expect(stableStateTelemetry.name).toBe('Stable State Generator'); + expect(stableStateTelemetry.url).toBe(`./#/browse/mine/${stableStateTelemetry.uuid}`); + expect(stableStateTelemetry.uuid).toBeDefined(); + }); + test('linkParameterToObject', async ({ page }) => { + const displayLayout = await createDomainObjectWithDefaults(page, { + type: 'Display Layout', + name: 'Test Display Layout' + }); + const exampleTelemetry = await createExampleTelemetryObject(page); + + await linkParameterToObject(page, exampleTelemetry.name, displayLayout.name); + await page.goto(displayLayout.url); + await expect(page.getByRole('main').getByText('Test Display Layout')).toBeVisible(); + await expandEntireTree(page); + await expect(page.getByLabel('Navigate to VIPER Rover').first()).toBeVisible(); + }); }); diff --git a/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js b/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js index ef1709fb99..102dbd81de 100644 --- a/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js +++ b/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js @@ -1,5 +1,5 @@ /***************************************************************************** - * Open MCT, Copyright (c) 2014-2023, United States Government + * Open MCT, Copyright (c) 2014-2024, United States Government * as represented by the Administrator of the National Aeronautics and Space * Administration. All rights reserved. * diff --git a/e2e/tests/functional/plugins/reloadAction/reloadAction.e2e.spec.js b/e2e/tests/functional/plugins/reloadAction/reloadAction.e2e.spec.js index 499454bb2a..345283f4d6 100644 --- a/e2e/tests/functional/plugins/reloadAction/reloadAction.e2e.spec.js +++ b/e2e/tests/functional/plugins/reloadAction/reloadAction.e2e.spec.js @@ -1,5 +1,5 @@ /***************************************************************************** - * Open MCT, Copyright (c) 2014-2023, United States Government + * Open MCT, Copyright (c) 2014-2024, United States Government * as represented by the Administrator of the National Aeronautics and Space * Administration. All rights reserved. * 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..bfb6f4ba63 --- /dev/null +++ b/e2e/tests/functional/plugins/styling/conditionSetStyling.e2e.spec.js @@ -0,0 +1,163 @@ +/***************************************************************************** + * 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, + linkParameterToObject, + 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 linkParameterToObject(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 +} diff --git a/e2e/tests/functional/staleness.e2e.spec.js b/e2e/tests/functional/staleness.e2e.spec.js index d247674cae..d1a9bfd201 100644 --- a/e2e/tests/functional/staleness.e2e.spec.js +++ b/e2e/tests/functional/staleness.e2e.spec.js @@ -1,5 +1,5 @@ /***************************************************************************** - * Open MCT, Copyright (c) 2014-2023, United States Government + * Open MCT, Copyright (c) 2014-2024, United States Government * as represented by the Administrator of the National Aeronautics and Space * Administration. All rights reserved. * diff --git a/e2e/tests/functional/userRoles.e2e.spec.js b/e2e/tests/functional/userRoles.e2e.spec.js index 455b096aab..40fdca548b 100644 --- a/e2e/tests/functional/userRoles.e2e.spec.js +++ b/e2e/tests/functional/userRoles.e2e.spec.js @@ -1,5 +1,5 @@ /***************************************************************************** - * Open MCT, Copyright (c) 2014-2023, United States Government + * Open MCT, Copyright (c) 2014-2024, United States Government * as represented by the Administrator of the National Aeronautics and Space * Administration. All rights reserved. * diff --git a/e2e/tests/visual-a11y/displayLayout.visual.spec.js b/e2e/tests/visual-a11y/displayLayout.visual.spec.js index 401c5b243c..462b778fad 100644 --- a/e2e/tests/visual-a11y/displayLayout.visual.spec.js +++ b/e2e/tests/visual-a11y/displayLayout.visual.spec.js @@ -22,7 +22,11 @@ import percySnapshot from '@percy/playwright'; -import { createDomainObjectWithDefaults } from '../../appActions.js'; +import { + createDomainObjectWithDefaults, + createStableStateTelemetry, + linkParameterToObject +} from '../../appActions.js'; import { MISSION_TIME, VISUAL_FIXED_URL } from '../../constants.js'; import { test } from '../../pluginFixtures.js'; @@ -47,16 +51,13 @@ test.describe('Visual - Display Layout @clock', () => { name: 'Child Right Layout', parent: parentLayout.uuid }); - await createDomainObjectWithDefaults(page, { - type: 'Sine Wave Generator', - name: 'SWG 1', - parent: child1Layout.uuid - }); - await createDomainObjectWithDefaults(page, { - type: 'Sine Wave Generator', - name: 'SWG 2', - parent: child2Layout.uuid - }); + + const stableStateTelemetry = await createStableStateTelemetry(page); + await linkParameterToObject(page, stableStateTelemetry.name, child1Layout.name); + await linkParameterToObject(page, stableStateTelemetry.name, child2Layout.name); + + // Pause the clock at a time where the telemetry is stable 20 minutes in the future + await page.clock.pauseAt(new Date(MISSION_TIME + 1200000)); await page.goto(parentLayout.url, { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: 'Edit Object' }).click(); diff --git a/karma.conf.cjs b/karma.conf.cjs index b3e4d33632..4304662402 100644 --- a/karma.conf.cjs +++ b/karma.conf.cjs @@ -1,5 +1,5 @@ /***************************************************************************** - * Open MCT, Copyright (c) 2014-2023, United States Government + * Open MCT, Copyright (c) 2014-2024, United States Government * as represented by the Administrator of the National Aeronautics and Space * Administration. All rights reserved. * diff --git a/package.json b/package.json index 32740dc960..e5280188c4 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "test:perf:contract": "npm test --workspace e2e -- --config=playwright-performance-dev.config.js", "test:perf:localhost": "npm test --workspace e2e -- --config=playwright-performance-prod.config.js --project=chrome", "test:perf:memory": "npm test --workspace e2e -- --config=playwright-performance-prod.config.js --project=chrome-memory", - "update-about-dialog-copyright": "perl -pi -e 's/20\\d\\d\\-202\\d/2014\\-2023/gm' ./src/ui/layout/AboutDialog.vue", + "update-about-dialog-copyright": "perl -pi -e 's/20\\d\\d\\-202\\d/2014\\-2024/gm' ./src/ui/layout/AboutDialog.vue", "update-copyright-date": "npm run update-about-dialog-copyright && grep -lr --null --include=*.{js,scss,vue,ts,sh,html,md,frag} 'Copyright (c) 20' . | xargs -r0 perl -pi -e 's/Copyright\\s\\(c\\)\\s20\\d\\d\\-20\\d\\d/Copyright \\(c\\)\\ 2014\\-2024/gm'", "cov:e2e:report": "nyc report --reporter=lcovonly --report-dir=./coverage/e2e", "prepare": "npm run build:prod && npx tsc" diff --git a/src/api/telemetry/TelemetryAPI.js b/src/api/telemetry/TelemetryAPI.js index 5e963e0f58..086337d048 100644 --- a/src/api/telemetry/TelemetryAPI.js +++ b/src/api/telemetry/TelemetryAPI.js @@ -231,26 +231,20 @@ export default class TelemetryAPI { * @returns {TelemetryRequestOptions} the options, with defaults filled in */ standardizeRequestOptions(options = {}) { - if (!Object.hasOwn(options, 'start')) { - const bounds = options.timeContext?.getBounds(); - if (bounds?.start) { - options.start = options.timeContext.getBounds().start; - } else { - options.start = this.openmct.time.getBounds().start; - } - } - - if (!Object.hasOwn(options, 'end')) { - const bounds = options.timeContext?.getBounds(); - if (bounds?.end) { - options.end = options.timeContext.getBounds().end; - } else { - options.end = this.openmct.time.getBounds().end; - } + if (!Object.hasOwn(options, 'timeContext')) { + options.timeContext = this.openmct.time; } if (!Object.hasOwn(options, 'domain')) { - options.domain = this.openmct.time.getTimeSystem().key; + options.domain = options.timeContext.getTimeSystem().key; + } + + if (!Object.hasOwn(options, 'start')) { + options.start = options.timeContext.getBounds().start; + } + + if (!Object.hasOwn(options, 'end')) { + options.end = options.timeContext.getBounds().end; } return options; diff --git a/src/api/telemetry/TelemetryAPISpec.js b/src/api/telemetry/TelemetryAPISpec.js index 03ea417501..648b1172ca 100644 --- a/src/api/telemetry/TelemetryAPISpec.js +++ b/src/api/telemetry/TelemetryAPISpec.js @@ -269,36 +269,40 @@ describe('Telemetry API', () => { await telemetryAPI.request(domainObject); const { signal } = new AbortController(); - expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(jasmine.any(Object), { + expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(domainObject, { signal, start: 0, end: 1, - domain: 'system' + domain: 'system', + timeContext: openmct.time }); - expect(telemetryProvider.request).toHaveBeenCalledWith(jasmine.any(Object), { + expect(telemetryProvider.request).toHaveBeenCalledWith(domainObject, { signal, start: 0, end: 1, - domain: 'system' + domain: 'system', + timeContext: openmct.time }); telemetryProvider.supportsRequest.calls.reset(); telemetryProvider.request.calls.reset(); await telemetryAPI.request(domainObject, {}); - expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(jasmine.any(Object), { + expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(domainObject, { signal, start: 0, end: 1, - domain: 'system' + domain: 'system', + timeContext: openmct.time }); - expect(telemetryProvider.request).toHaveBeenCalledWith(jasmine.any(Object), { + expect(telemetryProvider.request).toHaveBeenCalledWith(domainObject, { signal, start: 0, end: 1, - domain: 'system' + domain: 'system', + timeContext: openmct.time }); }); @@ -313,18 +317,20 @@ describe('Telemetry API', () => { domain: 'someDomain' }); const { signal } = new AbortController(); - expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(jasmine.any(Object), { + expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(domainObject, { start: 20, end: 30, domain: 'someDomain', - signal + signal, + timeContext: openmct.time }); - expect(telemetryProvider.request).toHaveBeenCalledWith(jasmine.any(Object), { + expect(telemetryProvider.request).toHaveBeenCalledWith(domainObject, { start: 20, end: 30, domain: 'someDomain', - signal + signal, + timeContext: openmct.time }); }); describe('telemetry batching support', () => { diff --git a/src/api/telemetry/TelemetryCollection.js b/src/api/telemetry/TelemetryCollection.js index addfa008f1..ed1463933e 100644 --- a/src/api/telemetry/TelemetryCollection.js +++ b/src/api/telemetry/TelemetryCollection.js @@ -62,9 +62,6 @@ export default class TelemetryCollection extends EventEmitter { this.futureBuffer = []; this.parseTime = undefined; this.metadata = this.openmct.telemetry.getMetadata(domainObject); - if (!Object.hasOwn(options, 'timeContext')) { - options.timeContext = this.openmct.time; - } this.options = options; this.unsubscribe = undefined; this.pageState = undefined; @@ -84,6 +81,9 @@ export default class TelemetryCollection extends EventEmitter { this._error(LOADED_ERROR); } + if (!Object.hasOwn(this.options, 'timeContext')) { + this.options.timeContext = this.openmct.time; + } this._setTimeSystem(this.options.timeContext.getTimeSystem()); this.lastBounds = this.options.timeContext.getBounds(); // prioritize passed options over time bounds @@ -93,9 +93,6 @@ export default class TelemetryCollection extends EventEmitter { if (this.options.end) { this.lastBounds.end = this.options.end; } - console.debug( - `🫙 Bounds for collection are start ${new Date(this.lastBounds.start).toISOString()} and end ${new Date(this.lastBounds.end).toISOString()}` - ); this._watchBounds(); this._watchTimeSystem(); this._watchTimeModeChange(); @@ -140,10 +137,7 @@ export default class TelemetryCollection extends EventEmitter { * @private */ async _requestHistoricalTelemetry() { - console.debug( - `🫙 Requesting historical telemetry with start ${new Date(this.lastBounds.start).toISOString()} and end ${new Date(this.lastBounds.end).toISOString()}}` - ); - let options = this.openmct.telemetry.standardizeRequestOptions({ ...this.options }); + const options = this.openmct.telemetry.standardizeRequestOptions({ ...this.options }); const historicalProvider = this.openmct.telemetry.findRequestProvider( this.domainObject, options @@ -243,19 +237,6 @@ export default class TelemetryCollection extends EventEmitter { beforeStartOfBounds = parsedValue < boundsToUse.start; afterEndOfBounds = parsedValue > boundsToUse.end; - if (beforeStartOfBounds) { - console.debug( - `🫙 Datum is BEFORE start of bounds: ${new Date(parsedValue).toISOString()} < ${new Date(this.lastBounds.start).toISOString()}`, - this.options - ); - } - if (afterEndOfBounds) { - console.debug( - `🫙 Datum is AFTER start of bounds: ${new Date(parsedValue).toISOString()} < ${new Date(this.lastBounds.start).toISOString()}`, - this.options - ); - } - if ( !afterEndOfBounds && (!beforeStartOfBounds || (this.isStrategyLatest && this.openmct.telemetry.greedyLAD())) diff --git a/src/plugins/comps/CompsManager.js b/src/plugins/comps/CompsManager.js index 6d912ef397..68aafca4b0 100644 --- a/src/plugins/comps/CompsManager.js +++ b/src/plugins/comps/CompsManager.js @@ -1,5 +1,4 @@ import { EventEmitter } from 'eventemitter3'; -import _ from 'lodash'; export default class CompsManager extends EventEmitter { #openmct; @@ -12,6 +11,8 @@ export default class CompsManager extends EventEmitter { #loaded = false; #compositionLoaded = false; #telemetryProcessors = {}; + #loadVersion = 0; + #currentLoadPromise = null; constructor(openmct, domainObject) { super(); @@ -59,7 +60,9 @@ export default class CompsManager extends EventEmitter { name: `${this.#getNextAlphabeticalParameterName()}`, valueToUse, testValue: 0, - timeMetaData + timeMetaData, + accumulateValues: false, + sampleSize: null }); this.emit('parameterAdded', this.#domainObject); } @@ -108,23 +111,56 @@ export default class CompsManager extends EventEmitter { } async load(telemetryOptions) { - if (!_.isEqual(telemetryOptions, this.#telemetryOptions)) { + // Increment the load version to mark a new load operation + const loadVersion = ++this.#loadVersion; + + if (!_.isEqual(this.#telemetryOptions, telemetryOptions)) { console.debug( `😩 Reloading comps manager ${this.#domainObject.name} due to telemetry options change.`, telemetryOptions ); this.#destroy(); } + this.#telemetryOptions = telemetryOptions; - if (!this.#compositionLoaded) { - await this.#loadComposition(); - this.#compositionLoaded = true; - } - if (!this.#loaded) { - await this.#startListeningToUnderlyingTelemetry(); - this.#telemetryLoadedPromises = []; - this.#loaded = true; - } + + // Start the load process and store the promise + this.#currentLoadPromise = (async () => { + // Load composition if not already loaded + if (!this.#compositionLoaded) { + await this.#loadComposition(); + // Check if a newer load has been initiated + if (loadVersion !== this.#loadVersion) { + console.debug( + `🔄 Reloading comps manager in composition wait ${this.#domainObject.name} due to newer load.` + ); + await this.#currentLoadPromise; + return; + } + this.#compositionLoaded = true; + } + + // Start listening to telemetry if not already done + if (!this.#loaded) { + await this.#startListeningToUnderlyingTelemetry(); + // Check again for newer load + if (loadVersion !== this.#loadVersion) { + console.debug( + `🔄 Reloading comps manager in telemetry wait ${this.#domainObject.name} due to newer load.` + ); + await this.#currentLoadPromise; + return; + } + console.debug( + `✅ Comps manager ${this.#domainObject.name} is ready.`, + this.#telemetryCollections + ); + this.#loaded = true; + } + })(); + + // Await the load process + await this.#currentLoadPromise; } async #startListeningToUnderlyingTelemetry() { @@ -173,27 +209,45 @@ export default class CompsManager extends EventEmitter { } } - getFullDataFrame(newTelemetry) { - const dataFrame = {}; - // can assume on data item - const newTelemetryKey = Object.keys(newTelemetry)[0]; - const newTelemetryData = newTelemetry[newTelemetryKey]; - const otherTelemetryKeys = Object.keys(this.#telemetryCollections).filter( - (keyString) => keyString !== newTelemetryKey + #getParameterForKeyString(keyString) { + return this.#domainObject.configuration.comps.parameters.find( + (parameter) => parameter.keyString === keyString ); - // initialize the data frame with the new telemetry data - dataFrame[newTelemetryKey] = newTelemetryData; - // initialize the other telemetry data + } + + getTelemetryForComps(newTelemetry) { + const telemetryForComps = {}; + const newTelemetryKey = Object.keys(newTelemetry)[0]; + const newTelemetryParameter = this.#getParameterForKeyString(newTelemetryKey); + const newTelemetryData = newTelemetry[newTelemetryKey]; + const otherTelemetryKeys = Object.keys(this.#telemetryCollections).slice(0); + if (newTelemetryParameter.accumulateValues) { + telemetryForComps[newTelemetryKey] = this.#telemetryCollections[newTelemetryKey].getAll(); + } else { + telemetryForComps[newTelemetryKey] = newTelemetryData; + } otherTelemetryKeys.forEach((keyString) => { - dataFrame[keyString] = []; + telemetryForComps[keyString] = []; }); - // march through the new telemetry data and add data to the frame from the other telemetry objects - // using LOCF + const otherTelemetryKeysNotAccumulating = otherTelemetryKeys.filter( + (keyString) => !this.#getParameterForKeyString(keyString).accumulateValues + ); + const otherTelemetryKeysAccumulating = otherTelemetryKeys.filter( + (keyString) => this.#getParameterForKeyString(keyString).accumulateValues + ); + // if we're accumulating, just add all the data + otherTelemetryKeysAccumulating.forEach((keyString) => { + telemetryForComps[keyString] = this.#telemetryCollections[keyString].getAll(); + }); + + // for the others, march through the new telemetry data and add data to the frame from the other telemetry objects + // using LOCF newTelemetryData.forEach((newDatum) => { - otherTelemetryKeys.forEach((otherKeyString) => { + otherTelemetryKeysNotAccumulating.forEach((otherKeyString) => { const otherCollection = this.#telemetryCollections[otherKeyString]; + // otherwise we need to find the closest datum to the new datum let insertionPointForNewData = otherCollection._sortedIndex(newDatum); const otherCollectionData = otherCollection.getAll(); if (insertionPointForNewData && insertionPointForNewData >= otherCollectionData.length) { @@ -202,11 +256,11 @@ export default class CompsManager extends EventEmitter { // get the closest datum to the new datum const closestDatum = otherCollectionData[insertionPointForNewData]; if (closestDatum) { - dataFrame[otherKeyString].push(closestDatum); + telemetryForComps[otherKeyString].push(closestDatum); } }); }); - return dataFrame; + return telemetryForComps; } #removeTelemetryObject = (telemetryObjectIdentifier) => { diff --git a/src/plugins/comps/CompsMathWorker.js b/src/plugins/comps/CompsMathWorker.js index adec37f357..606c3b5a3e 100644 --- a/src/plugins/comps/CompsMathWorker.js +++ b/src/plugins/comps/CompsMathWorker.js @@ -5,17 +5,19 @@ onconnect = function (e) { const port = e.ports[0]; port.onmessage = function (event) { - const { type, callbackID, telemetryForComps, expression, parameters } = event.data; + const { type, callbackID, telemetryForComps, expression, parameters, newTelemetry } = + event.data; let responseType = 'unknown'; let error = null; let result = []; try { if (type === 'calculateRequest') { responseType = 'calculationRequestResult'; + console.debug(`📫 Received new calculation request with callback ID ${callbackID}`); result = calculateRequest(telemetryForComps, parameters, expression); } else if (type === 'calculateSubscription') { responseType = 'calculationSubscriptionResult'; - result = calculateSubscription(telemetryForComps, parameters, expression); + result = calculateSubscription(telemetryForComps, newTelemetry, parameters, expression); } else if (type === 'init') { port.postMessage({ type: 'ready' }); return; @@ -25,6 +27,7 @@ onconnect = function (e) { } catch (errorInCalculation) { error = errorInCalculation; } + console.debug(`📭 Sending response for callback ID ${callbackID}`, result); port.postMessage({ type: responseType, callbackID, result, error }); }; }; @@ -40,9 +43,16 @@ function getFullDataFrame(telemetryForComps, parameters) { return dataFrame; } -function calculateSubscription(telemetryForComps, parameters, expression) { +function calculateSubscription(telemetryForComps, newTelemetry, parameters, expression) { const dataFrame = getFullDataFrame(telemetryForComps, parameters); - return calculate(dataFrame, parameters, expression); + const calculation = calculate(dataFrame, parameters, expression); + const newTelemetryKey = Object.keys(newTelemetry)[0]; + const newTelemetrySize = newTelemetry[newTelemetryKey].length; + let trimmedCalculation = calculation; + if (calculation.length > newTelemetrySize) { + trimmedCalculation = calculation.slice(calculation.length - newTelemetrySize); + } + return trimmedCalculation; } function calculateRequest(telemetryForComps, parameters, expression) { @@ -56,14 +66,40 @@ function calculate(dataFrame, parameters, expression) { if (!expression) { return sumResults; } + // set up accumulated data structure + const accumulatedData = {}; + parameters.forEach((parameter) => { + if (parameter.accumulateValues) { + accumulatedData[parameter.name] = []; + } + }); + // take the first parameter keyString as the reference const referenceParameter = parameters[0]; const otherParameters = parameters.slice(1); // iterate over the reference telemetry data const referenceTelemetry = dataFrame[referenceParameter.keyString]; referenceTelemetry?.forEach((referenceTelemetryItem) => { + let referenceValue = referenceTelemetryItem[referenceParameter.valueToUse]; + if (referenceParameter.accumulateValues) { + accumulatedData[referenceParameter.name].push(referenceValue); + referenceValue = accumulatedData[referenceParameter.name]; + } + if ( + referenceParameter.accumulateValues && + referenceParameter.sampleSize && + referenceParameter.sampleSize > 0 + ) { + // enforce sample size by ensuring referenceValue has the latest n elements + // if we don't have at least the sample size, skip this iteration + if (!referenceValue.length || referenceValue.length < referenceParameter.sampleSize) { + return; + } + referenceValue = referenceValue.slice(-referenceParameter.sampleSize); + } + const scope = { - [referenceParameter.name]: referenceTelemetryItem[referenceParameter.valueToUse] + [referenceParameter.name]: referenceValue }; const referenceTime = referenceTelemetryItem[referenceParameter.timeKey]; // iterate over the other parameters to set the scope @@ -75,7 +111,12 @@ function calculate(dataFrame, parameters, expression) { missingData = true; return; } - scope[parameter.name] = otherTelemetry[parameter.valueToUse]; + let otherValue = otherTelemetry[parameter.valueToUse]; + if (parameter.accumulateValues) { + accumulatedData[parameter.name].push(referenceValue); + otherValue = accumulatedData[referenceParameter.name]; + } + scope[parameter.name] = otherValue; }); if (missingData) { return; diff --git a/src/plugins/comps/CompsMetadataProvider.js b/src/plugins/comps/CompsMetadataProvider.js index b12fc34ef2..690b2c999b 100644 --- a/src/plugins/comps/CompsMetadataProvider.js +++ b/src/plugins/comps/CompsMetadataProvider.js @@ -61,6 +61,7 @@ export default class CompsMetadataProvider { key: 'compsOutput', source: 'compsOutput', name: 'Output', + derived: true, formatString: specificCompsManager.getOutputFormat(), hints: { range: 1 diff --git a/src/plugins/comps/CompsTelemetryProvider.js b/src/plugins/comps/CompsTelemetryProvider.js index bf069fd36d..cb75afe847 100644 --- a/src/plugins/comps/CompsTelemetryProvider.js +++ b/src/plugins/comps/CompsTelemetryProvider.js @@ -95,7 +95,7 @@ export default class CompsTelemetryProvider { return; } const expression = specificCompsManager.getExpression(); - const telemetryForComps = specificCompsManager.getFullDataFrame(newTelemetry); + const telemetryForComps = specificCompsManager.getTelemetryForComps(newTelemetry); const parameters = JSON.parse(JSON.stringify(specificCompsManager.getParameters())); if (!expression || !parameters) { return; @@ -103,6 +103,7 @@ export default class CompsTelemetryProvider { const payload = { type: 'calculateSubscription', telemetryForComps, + newTelemetry, expression, parameters, callbackID @@ -134,10 +135,6 @@ export default class CompsTelemetryProvider { ); return () => { delete this.#subscriptionCallbacks[callbackID]; - console.debug( - `🛑 Stopping subscription for ${domainObject.name} with callback ID ${callbackID}. We now have ${Object.keys(this.#subscriptionCallbacks).length} subscribers`, - this.#subscriptionCallbacks - ); specificCompsManager.stopListeningToUnderlyingTelemetry(); specificCompsManager.off('underlyingTelemetryUpdated', boundComputeOnNewTelemetry); }; diff --git a/src/plugins/comps/components/CompsView.vue b/src/plugins/comps/components/CompsView.vue index ec8f1abeab..109733e4c6 100644 --- a/src/plugins/comps/components/CompsView.vue +++ b/src/plugins/comps/components/CompsView.vue @@ -95,6 +95,37 @@
{{ parameter.valueToUse }}
+
+ + + Sample Size + +
Test value @@ -104,7 +135,7 @@ :aria-label="`Reference Test Value for ${parameter.name}`" type="text" class="c-input--md" - @change="updateParameters" + @change="updateTestValue(parameter)" /> @@ -235,6 +266,22 @@ function updateParameters() { applyTestData(); } +function updateAccumulateValues(parameter) { + if (parameter.accumulateValues) { + parameter.testValue = ['']; + } else { + parameter.testValue = ''; + } + updateParameters(); +} + +function updateTestValue(parameter) { + if (parameter.accumulateValues && parameter.testValue === '') { + parameter.testValue = []; + } + updateParameters(); +} + function toggleTestData() { testDataApplied.value = !testDataApplied.value; if (testDataApplied.value) { @@ -270,6 +317,20 @@ function applyTestData() { } return acc; }, {}); + + // see which parameters are misconfigured as non-arrays + const misconfiguredParameterNames = parameters.value + .filter((parameter) => { + return parameter.accumulateValues && !Array.isArray(scope[parameter.name]); + }) + .map((parameter) => parameter.name); + if (misconfiguredParameterNames.length) { + const misconfiguredParameterNamesString = misconfiguredParameterNames.join(', '); + currentTestOutput.value = null; + expressionOutput.value = `Reference "${misconfiguredParameterNamesString}" set to accumulating, but test values aren't arrays.`; + return; + } + try { const testOutput = evaluate(expression.value, scope); const formattedData = getValueFormatter().format(testOutput); diff --git a/src/plugins/plan/components/PlanView.vue b/src/plugins/plan/components/PlanView.vue index e3d6728268..86a9f3cdf1 100644 --- a/src/plugins/plan/components/PlanView.vue +++ b/src/plugins/plan/components/PlanView.vue @@ -69,7 +69,6 @@ const INNER_TEXT_PADDING = 15; const TEXT_LEFT_PADDING = 5; const ROW_PADDING = 5; const SWIMLANE_PADDING = 3; -const RESIZE_POLL_INTERVAL = 200; const ROW_HEIGHT = 22; const MAX_TEXT_WIDTH = 300; const MIN_ACTIVITY_WIDTH = 2; @@ -143,13 +142,15 @@ export default { this.canvasContext = canvas.getContext('2d'); this.setDimensions(); this.setTimeContext(); - this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL); this.handleConfigurationChange(this.configuration); this.planViewConfiguration.on('change', this.handleConfigurationChange); this.loadComposition(); + + this.resizeObserver = new ResizeObserver(this.resize); + this.resizeObserver.observe(this.$refs.plan); }, beforeUnmount() { - clearInterval(this.resizeTimer); + this.resizeObserver.disconnect(); this.stopFollowingTimeContext(); if (this.unlisten) { this.unlisten(); diff --git a/src/plugins/plan/inspector/components/PlanActivityPropertiesView.vue b/src/plugins/plan/inspector/components/PlanActivityPropertiesView.vue index 5d4b771589..b6b7a20ded 100644 --- a/src/plugins/plan/inspector/components/PlanActivityPropertiesView.vue +++ b/src/plugins/plan/inspector/components/PlanActivityPropertiesView.vue @@ -1,5 +1,5 @@