From df969722d17583bd9c93c1549e985b17f8945a55 Mon Sep 17 00:00:00 2001 From: Shefali Joshi Date: Tue, 5 Mar 2024 14:45:28 -0800 Subject: [PATCH] d3 implementation of progress pie chart (#7485) * d3 implementation of progress pie chart * Handle 0% and 100% cases * PR #7485 - Minor tweaks to `s-selected` styling. * add in-progress class for compact view * Fix issue where updating progress for pie chart wasn't working till at least one clock tick. Write tests for progress pie * update documentation for clock annotation * Update clock annotation in tests * split long testfile * driveby missing test * driveby fix flake * temp: fix flake and prep for visual test * Fix linting errors * this should be resolved * these keep popping up * moving some of this around * moving this around * the test * Fix imports for tests * no longer need constant * move to front * Stabalize name * test(missionStatus): fix visual test --------- Co-authored-by: Charles Hacskaylo Co-authored-by: Jesse Mazzella Co-authored-by: John Hill Co-authored-by: Andrew Henry Co-authored-by: Jesse Mazzella --- e2e/README.md | 2 + e2e/helper/planningUtils.js | 65 ++++ .../generateLocalStorageData.e2e.spec.js | 2 +- .../functional/planning/timelist.e2e.spec.js | 183 +---------- .../timelistControlledClock.e2e.spec.js | 290 ++++++++++++++++++ .../plugins/timer/timer.e2e.spec.js | 2 +- .../components/header.visual.spec.js | 23 +- .../components/inspector.visual.spec.js | 2 +- .../controlledClock.visual.spec.js | 2 +- .../faultManagement.visual.spec.js | 30 +- .../visual-a11y/missionStatus.visual.spec.js | 4 +- e2e/tests/visual-a11y/planning.visual.spec.js | 89 ++++-- package.json | 2 + src/plugins/timelist/ExpandedViewItem.vue | 18 ++ src/plugins/timelist/TimelistComponent.vue | 1 + src/plugins/timelist/svg-progress.js | 49 +++ src/plugins/timelist/timelist.scss | 23 +- 17 files changed, 550 insertions(+), 237 deletions(-) create mode 100644 e2e/tests/functional/planning/timelistControlledClock.e2e.spec.js create mode 100644 src/plugins/timelist/svg-progress.js diff --git a/e2e/README.md b/e2e/README.md index ee0debe9dd..22c41d86d5 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -238,6 +238,7 @@ Current list of test tags: |`@unstable` | A new test or test which is known to be flaky.| |`@2p` | Indicates that multiple users are involved, or multiple tabs/pages are used. Useful for testing multi-user interactivity.| |`@generatedata` | Indicates that a test is used to generate testdata or test the generated test data. Usually to be associated with localstorage, but this may grow over time.| +|`@clock` | A test which modifies the clock. These have expanded out of the visual tests and into the functional tests. ### Continuous Integration @@ -447,6 +448,7 @@ By adhering to this principle, we can create tests that are both robust and refl - Utilize `percyCSS` to ignore time-based elements. For more details, consult our [percyCSS file](./.percy.ci.yml). - Use Open MCT's fixed-time mode unless explicitly testing realtime clock - Employ the `createExampleTelemetryObject` appAction to source telemetry and specify a `name` to avoid autogenerated names. + - Avoid creating objects with a time component like timers and clocks. 5. **Hide the Tree and Inspector**: Generally, your test will not require comparisons involving the tree and inspector. These aspects are covered in component-specific tests (explained below). To exclude them from the comparison by default, navigate to the root of the main view with the tree and inspector hidden: - `await page.goto('./#/browse/mine?hideTree=true&hideInspector=true')` diff --git a/e2e/helper/planningUtils.js b/e2e/helper/planningUtils.js index 3b68f0efad..2a4cb157a9 100644 --- a/e2e/helper/planningUtils.js +++ b/e2e/helper/planningUtils.js @@ -20,6 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ +import { createDomainObjectWithDefaults, createPlanFromJSON } from '../appActions.js'; import { expect } from '../pluginFixtures.js'; /** @@ -142,6 +143,18 @@ export function getLatestEndTime(planJson) { return Math.max(...activities.map((activity) => activity.end)); } +/** + * + * @param {object} planJson + * @returns {object} + */ +export function getFirstActivity(planJson) { + const groups = Object.keys(planJson); + const firstGroupKey = groups[0]; + const firstGroupItems = planJson[firstGroupKey]; + return firstGroupItems[0]; +} + /** * Uses the Open MCT API to set the status of a plan to 'draft'. * @param {import('@playwright/test').Page} page @@ -172,3 +185,55 @@ export async function addPlanGetInterceptor(page) { }); }); } + +/** + * Create a Plan from JSON and add it to a Timelist and Navigate to the Plan view + * @param {import('@playwright/test').Page} page + */ +export async function createTimelistWithPlanAndSetActivityInProgress(page, planJson) { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const timelist = await createDomainObjectWithDefaults(page, { + name: 'Time List', + type: 'Time List' + }); + + await createPlanFromJSON(page, { + name: 'Test Plan', + json: planJson, + parent: timelist.uuid + }); + + // Ensure that all activities are shown in the expanded view + const groups = Object.keys(planJson); + const firstGroupKey = groups[0]; + const firstGroupItems = planJson[firstGroupKey]; + const firstActivityForPlan = firstGroupItems[0]; + const lastActivity = firstGroupItems[firstGroupItems.length - 1]; + const startBound = firstActivityForPlan.start; + const endBound = lastActivity.end; + + // Switch to fixed time mode with all plan events within the bounds + await page.goto( + `${timelist.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=timelist.view` + ); + + // Change the object to edit mode + await page.getByRole('button', { name: 'Edit Object' }).click(); + + // Find the display properties section in the inspector + await page.getByRole('tab', { name: 'View Properties' }).click(); + // Switch to expanded view and save the setting + await page.getByLabel('Display Style').selectOption({ label: 'Expanded' }); + + // Click on the "Save" button + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + + const anActivity = page.getByRole('row').nth(0); + + // Set the activity to in progress + await anActivity.click(); + await page.getByRole('tab', { name: 'Activity' }).click(); + await page.getByLabel('Activity Status', { exact: true }).selectOption({ label: 'In progress' }); +} diff --git a/e2e/tests/framework/generateLocalStorageData.e2e.spec.js b/e2e/tests/framework/generateLocalStorageData.e2e.spec.js index 4b28e6009a..b01aba37c8 100644 --- a/e2e/tests/framework/generateLocalStorageData.e2e.spec.js +++ b/e2e/tests/framework/generateLocalStorageData.e2e.spec.js @@ -39,7 +39,7 @@ import { expect, test } from '../../pluginFixtures.js'; const overlayPlotName = 'Overlay Plot with Telemetry Object'; -test.describe('Generate Visual Test Data @localStorage @generatedata', () => { +test.describe('Generate Visual Test Data @localStorage @generatedata @clock', () => { test.use({ clockOptions: { now: MISSION_TIME, diff --git a/e2e/tests/functional/planning/timelist.e2e.spec.js b/e2e/tests/functional/planning/timelist.e2e.spec.js index 4cd5cc12b1..a0e600d63f 100644 --- a/e2e/tests/functional/planning/timelist.e2e.spec.js +++ b/e2e/tests/functional/planning/timelist.e2e.spec.js @@ -22,29 +22,13 @@ import fs from 'fs'; import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../../appActions.js'; -import { getEarliestStartTime } from '../../../helper/planningUtils'; import { expect, test } from '../../../pluginFixtures.js'; -const examplePlanSmall3 = JSON.parse( - fs.readFileSync( - new URL('../../../test-data/examplePlans/ExamplePlan_Small3.json', import.meta.url) - ) -); const examplePlanSmall1 = JSON.parse( fs.readFileSync( new URL('../../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url) ) ); -// eslint-disable-next-line no-unused-vars -const START_TIME_COLUMN = 0; -// eslint-disable-next-line no-unused-vars -const END_TIME_COLUMN = 1; -const TIME_TO_FROM_COLUMN = 2; -// eslint-disable-next-line no-unused-vars -const ACTIVITY_COLUMN = 3; -const HEADER_ROW = 0; -const NUM_COLUMNS = 5; - test.describe('Time List', () => { test("Create a Time List, add a single Plan to it, verify all the activities are displayed with no milliseconds and selecting an activity shows it's properties", async ({ page @@ -161,7 +145,7 @@ test("View a timelist in expanded view, verify all the activities are displayed await expect(eventCount).toEqual(firstGroupItems.length); }); - await test.step('Shows activity properties when a row is selected', async () => { + await test.step('Shows activity properties when a row is selected in the expanded view', async () => { await page.getByRole('row').nth(2).click(); // Find the activity state section in the inspector @@ -171,167 +155,10 @@ test("View a timelist in expanded view, verify all the activities are displayed 'Not started' ); }); -}); -/** - * The regular expression used to parse the countdown string. - * Some examples of valid Countdown strings: - * ``` - * '35D 02:03:04' - * '-1D 01:02:03' - * '01:02:03' - * '-05:06:07' - * ``` - */ -const COUNTDOWN_REGEXP = /(-)?(\d+D\s)?(\d{2}):(\d{2}):(\d{2})/; - -/** - * @typedef {Object} CountdownOrUpObject - * @property {string} sign - The sign of the countdown ('-' if the countdown is negative, '+' otherwise). - * @property {string} days - The number of days in the countdown (undefined if there are no days). - * @property {string} hours - The number of hours in the countdown. - * @property {string} minutes - The number of minutes in the countdown. - * @property {string} seconds - The number of seconds in the countdown. - * @property {string} toString - The countdown string. - */ - -/** - * Object representing the indices of the capture groups in a countdown regex match. - * - * @typedef {{ SIGN: number, DAYS: number, HOURS: number, MINUTES: number, SECONDS: number, REGEXP: RegExp }} - * @property {number} SIGN - The index for the sign capture group (1 if a '-' sign is present, otherwise undefined). - * @property {number} DAYS - The index for the days capture group (2 for the number of days, otherwise undefined). - * @property {number} HOURS - The index for the hours capture group (3 for the hour part of the time). - * @property {number} MINUTES - The index for the minutes capture group (4 for the minute part of the time). - * @property {number} SECONDS - The index for the seconds capture group (5 for the second part of the time). - */ -const COUNTDOWN = Object.freeze({ - SIGN: 1, - DAYS: 2, - HOURS: 3, - MINUTES: 4, - SECONDS: 5 -}); - -test.describe('Time List with controlled clock', () => { - test.use({ - clockOptions: { - now: getEarliestStartTime(examplePlanSmall3), - shouldAdvanceTime: true - } - }); - test.beforeEach(async ({ page }) => { - await page.goto('./', { waitUntil: 'domcontentloaded' }); - }); - test('Time List shows current events and counts down correctly in real-time mode', async ({ - page - }) => { - await test.step('Create a Time List, add a Plan to it, and switch to real-time mode', async () => { - // Create Time List - const timelist = await createDomainObjectWithDefaults(page, { - type: 'Time List' - }); - - // Create a Plan with events that count down and up. - // Add it as a child to the Time List. - await createPlanFromJSON(page, { - json: examplePlanSmall3, - parent: timelist.uuid - }); - - // Navigate to the Time List in real-time mode - await page.goto( - `${timelist.url}?tc.mode=local&tc.startDelta=900000&tc.endDelta=1800000&tc.timeSystem=utc&view=grid` - ); - }); - - const countUpCells = [ - getCellByIndex(page, 1, TIME_TO_FROM_COLUMN), - getCellByIndex(page, 2, TIME_TO_FROM_COLUMN) - ]; - const countdownCells = [ - getCellByIndex(page, 3, TIME_TO_FROM_COLUMN), - getCellByIndex(page, 4, TIME_TO_FROM_COLUMN) - ]; - - // Verify that the countdown cells are counting down - for (let i = 0; i < countdownCells.length; i++) { - await test.step(`Countdown cell ${i + 1} counts down`, async () => { - const countdownCell = countdownCells[i]; - // Get the initial countdown timestamp object - const beforeCountdown = await getAndAssertCountdownOrUpObject(page, i + 3); - // should not have a '-' sign - await expect(countdownCell).not.toHaveText('-'); - // Wait until it changes - await expect(countdownCell).not.toHaveText(beforeCountdown.toString()); - // Get the new countdown timestamp object - const afterCountdown = await getAndAssertCountdownOrUpObject(page, i + 3); - // Verify that the new countdown timestamp object is less than the old one - expect(Number(afterCountdown.seconds)).toBeLessThan(Number(beforeCountdown.seconds)); - }); - } - - // Verify that the count-up cells are counting up - for (let i = 0; i < countUpCells.length; i++) { - await test.step(`Count-up cell ${i + 1} counts up`, async () => { - const countUpCell = countUpCells[i]; - // Get the initial count-up timestamp object - const beforeCountUp = await getAndAssertCountdownOrUpObject(page, i + 1); - // should not have a '+' sign - await expect(countUpCell).not.toHaveText('+'); - // Wait until it changes - await expect(countUpCell).not.toHaveText(beforeCountUp.toString()); - // Get the new count-up timestamp object - const afterCountUp = await getAndAssertCountdownOrUpObject(page, i + 1); - // Verify that the new count-up timestamp object is greater than the old one - expect(Number(afterCountUp.seconds)).toBeGreaterThan(Number(beforeCountUp.seconds)); - }); - } + await test.step("Verify absence of progress indication for an activity that's not in progress", async () => { + // When an activity is not in progress, the progress pie is not visible + const hidden = await page.getByRole('row').locator('path').nth(1).isHidden(); + await expect(hidden).toBe(true); }); }); - -/** - * Get the cell at the given row and column indices. - * @param {import('@playwright/test').Page} page - * @param {number} rowIndex - * @param {number} columnIndex - * @returns {import('@playwright/test').Locator} cell - */ -function getCellByIndex(page, rowIndex, columnIndex) { - return page.getByRole('cell').nth(rowIndex * NUM_COLUMNS + columnIndex); -} - -/** - * Return the innerText of the cell at the given row and column indices. - * @param {import('@playwright/test').Page} page - * @param {number} rowIndex - * @param {number} columnIndex - * @returns {Promise} text - */ -async function getCellTextByIndex(page, rowIndex, columnIndex) { - const text = await getCellByIndex(page, rowIndex, columnIndex).innerText(); - return text; -} - -/** - * Get the text from the countdown (or countup) cell in the given row, assert that it matches the countdown/countup - * regex, and return an object representing the countdown. - * @param {import('@playwright/test').Page} page - * @param {number} rowIndex the row index - * @returns {Promise} The countdown (or countup) object - */ -async function getAndAssertCountdownOrUpObject(page, rowIndex) { - const timeToFrom = await getCellTextByIndex(page, HEADER_ROW + rowIndex, TIME_TO_FROM_COLUMN); - - expect(timeToFrom).toMatch(COUNTDOWN_REGEXP); - const match = timeToFrom.match(COUNTDOWN_REGEXP); - - return { - sign: match[COUNTDOWN.SIGN], - days: match[COUNTDOWN.DAYS], - hours: match[COUNTDOWN.HOURS], - minutes: match[COUNTDOWN.MINUTES], - seconds: match[COUNTDOWN.SECONDS], - toString: () => timeToFrom - }; -} diff --git a/e2e/tests/functional/planning/timelistControlledClock.e2e.spec.js b/e2e/tests/functional/planning/timelistControlledClock.e2e.spec.js new file mode 100644 index 0000000000..4baec66718 --- /dev/null +++ b/e2e/tests/functional/planning/timelistControlledClock.e2e.spec.js @@ -0,0 +1,290 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +/* +Collection of Time List tests set to run with browser clock manipulate made possible with the +clockOptions plugin fixture. +*/ + +import fs from 'fs'; + +import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../../appActions.js'; +import { + createTimelistWithPlanAndSetActivityInProgress, + getEarliestStartTime, + getFirstActivity +} from '../../../helper/planningUtils'; +import { expect, test } from '../../../pluginFixtures.js'; + +const examplePlanSmall3 = JSON.parse( + fs.readFileSync( + new URL('../../../test-data/examplePlans/ExamplePlan_Small3.json', import.meta.url) + ) +); + +const examplePlanSmall1 = JSON.parse( + fs.readFileSync( + new URL('../../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url) + ) +); + +const TIME_TO_FROM_COLUMN = 2; +const HEADER_ROW = 0; +const NUM_COLUMNS = 5; +const FULL_CIRCLE_PATH = + 'M3.061616997868383e-15,-50A50,50,0,1,1,-3.061616997868383e-15,50A50,50,0,1,1,3.061616997868383e-15,-50Z'; + +/** + * The regular expression used to parse the countdown string. + * Some examples of valid Countdown strings: + * ``` + * '35D 02:03:04' + * '-1D 01:02:03' + * '01:02:03' + * '-05:06:07' + * ``` + */ +const COUNTDOWN_REGEXP = /(-)?(\d+D\s)?(\d{2}):(\d{2}):(\d{2})/; + +/** + * @typedef {Object} CountdownOrUpObject + * @property {string} sign - The sign of the countdown ('-' if the countdown is negative, '+' otherwise). + * @property {string} days - The number of days in the countdown (undefined if there are no days). + * @property {string} hours - The number of hours in the countdown. + * @property {string} minutes - The number of minutes in the countdown. + * @property {string} seconds - The number of seconds in the countdown. + * @property {string} toString - The countdown string. + */ + +/** + * Object representing the indices of the capture groups in a countdown regex match. + * + * @typedef {{ SIGN: number, DAYS: number, HOURS: number, MINUTES: number, SECONDS: number, REGEXP: RegExp }} + * @property {number} SIGN - The index for the sign capture group (1 if a '-' sign is present, otherwise undefined). + * @property {number} DAYS - The index for the days capture group (2 for the number of days, otherwise undefined). + * @property {number} HOURS - The index for the hours capture group (3 for the hour part of the time). + * @property {number} MINUTES - The index for the minutes capture group (4 for the minute part of the time). + * @property {number} SECONDS - The index for the seconds capture group (5 for the second part of the time). + */ +const COUNTDOWN = Object.freeze({ + SIGN: 1, + DAYS: 2, + HOURS: 3, + MINUTES: 4, + SECONDS: 5 +}); + +test.describe('Time List with controlled clock @clock', () => { + test.use({ + clockOptions: { + now: getEarliestStartTime(examplePlanSmall3), + shouldAdvanceTime: true + } + }); + test.beforeEach(async ({ page }) => { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + // Create Time List + const timelist = await createDomainObjectWithDefaults(page, { + type: 'Time List' + }); + + // Create a Plan with events that count down and up. + // Add it as a child to the Time List. + await createPlanFromJSON(page, { + json: examplePlanSmall3, + parent: timelist.uuid + }); + + // Navigate to the Time List in real-time mode + await page.goto( + `${timelist.url}?tc.mode=local&tc.startDelta=900000&tc.endDelta=1800000&tc.timeSystem=utc&view=grid` + ); + + //Expand the viewport to show the entire time list + await page.getByLabel('Collapse Inspect Pane').click(); + await page.getByLabel('Collapse Browse Pane').click(); + }); + test('Time List shows current events and counts down correctly in real-time mode', async ({ + page + }) => { + const countUpCells = [ + getTimeListCellByIndex(page, 1, TIME_TO_FROM_COLUMN), + getTimeListCellByIndex(page, 2, TIME_TO_FROM_COLUMN) + ]; + const countdownCells = [ + getTimeListCellByIndex(page, 3, TIME_TO_FROM_COLUMN), + getTimeListCellByIndex(page, 4, TIME_TO_FROM_COLUMN) + ]; + + // Verify that the countdown cells are counting down + for (let i = 0; i < countdownCells.length; i++) { + await test.step(`Countdown cell ${i + 1} counts down`, async () => { + const countdownCell = countdownCells[i]; + // Get the initial countdown timestamp object + const beforeCountdown = await getAndAssertCountdownOrUpObject(page, i + 3); + // should not have a '-' sign + await expect(countdownCell).not.toHaveText('-'); + // Wait until it changes + await expect(countdownCell).not.toHaveText(beforeCountdown.toString()); + // Get the new countdown timestamp object + const afterCountdown = await getAndAssertCountdownOrUpObject(page, i + 3); + // Verify that the new countdown timestamp object is less than the old one + expect(Number(afterCountdown.seconds)).toBeLessThan(Number(beforeCountdown.seconds)); + }); + } + + // Verify that the count-up cells are counting up + for (let i = 0; i < countUpCells.length; i++) { + await test.step(`Count-up cell ${i + 1} counts up`, async () => { + const countUpCell = countUpCells[i]; + // Get the initial count-up timestamp object + const beforeCountUp = await getAndAssertCountdownOrUpObject(page, i + 1); + // should not have a '+' sign + await expect(countUpCell).not.toHaveText('+'); + // Wait until it changes + await expect(countUpCell).not.toHaveText(beforeCountUp.toString()); + // Get the new count-up timestamp object + const afterCountUp = await getAndAssertCountdownOrUpObject(page, i + 1); + // Verify that the new count-up timestamp object is greater than the old one + expect(Number(afterCountUp.seconds)).toBeGreaterThan(Number(beforeCountUp.seconds)); + }); + } + }); +}); + +test.describe('Activity progress when activity is in the future @clock', () => { + const firstActivity = getFirstActivity(examplePlanSmall1); + + test.use({ + clockOptions: { + now: firstActivity.start - 1, + shouldAdvanceTime: true + } + }); + + test.beforeEach(async ({ page }) => { + await createTimelistWithPlanAndSetActivityInProgress(page, examplePlanSmall1); + }); + + test('progress pie is empty', async ({ page }) => { + const anActivity = page.getByRole('row').nth(0); + // Progress pie shows no progress when now is less than the start time + await expect(anActivity.getByLabel('Activity in progress').locator('path')).not.toHaveAttribute( + 'd' + ); + }); +}); + +test.describe('Activity progress when now is between start and end of the activity @clock', () => { + const firstActivity = getFirstActivity(examplePlanSmall1); + test.beforeEach(async ({ page }) => { + await createTimelistWithPlanAndSetActivityInProgress(page, examplePlanSmall1); + }); + + test.use({ + clockOptions: { + now: firstActivity.start + 50000, + shouldAdvanceTime: true + } + }); + + test('progress pie is partially filled', async ({ page }) => { + const anActivity = page.getByRole('row').nth(0); + const pathElement = anActivity.getByLabel('Activity in progress').locator('path'); + // Progress pie shows progress when now is greater than the start time + await expect(pathElement).toHaveAttribute('d'); + }); +}); + +test.describe('Activity progress when now is after end of the activity @clock', () => { + const firstActivity = getFirstActivity(examplePlanSmall1); + + test.use({ + clockOptions: { + now: firstActivity.end + 10000, + shouldAdvanceTime: true + } + }); + + test.beforeEach(async ({ page }) => { + await createTimelistWithPlanAndSetActivityInProgress(page, examplePlanSmall1); + }); + + test('progress pie is full', async ({ page }) => { + const anActivity = page.getByRole('row').nth(0); + // Progress pie is completely full and doesn't update if now is greater than the end time + await expect(anActivity.getByLabel('Activity in progress').locator('path')).toHaveAttribute( + 'd', + FULL_CIRCLE_PATH + ); + }); +}); + +/** + * Get the cell at the given row and column indices. + * @param {import('@playwright/test').Page} page + * @param {number} rowIndex + * @param {number} columnIndex + * @returns {import('@playwright/test').Locator} cell + */ +function getTimeListCellByIndex(page, rowIndex, columnIndex) { + return page.getByRole('cell').nth(rowIndex * NUM_COLUMNS + columnIndex); +} + +/** + * Return the innerText of the cell at the given row and column indices. + * @param {import('@playwright/test').Page} page + * @param {number} rowIndex + * @param {number} columnIndex + * @returns {Promise} text + */ +async function getTimeListCellTextByIndex(page, rowIndex, columnIndex) { + const text = await getTimeListCellByIndex(page, rowIndex, columnIndex).innerText(); + return text; +} + +/** + * Get the text from the countdown (or countup) cell in the given row, assert that it matches the countdown/countup + * regex, and return an object representing the countdown. + * @param {import('@playwright/test').Page} page + * @param {number} rowIndex the row index + * @returns {Promise} The countdown (or countup) object + */ +async function getAndAssertCountdownOrUpObject(page, rowIndex) { + const timeToFrom = await getTimeListCellTextByIndex( + page, + HEADER_ROW + rowIndex, + TIME_TO_FROM_COLUMN + ); + + expect(timeToFrom).toMatch(COUNTDOWN_REGEXP); + const match = timeToFrom.match(COUNTDOWN_REGEXP); + + return { + sign: match[COUNTDOWN.SIGN], + days: match[COUNTDOWN.DAYS], + hours: match[COUNTDOWN.HOURS], + minutes: match[COUNTDOWN.MINUTES], + seconds: match[COUNTDOWN.SECONDS], + toString: () => timeToFrom + }; +} diff --git a/e2e/tests/functional/plugins/timer/timer.e2e.spec.js b/e2e/tests/functional/plugins/timer/timer.e2e.spec.js index 1502909c8c..1e0393545f 100644 --- a/e2e/tests/functional/plugins/timer/timer.e2e.spec.js +++ b/e2e/tests/functional/plugins/timer/timer.e2e.spec.js @@ -66,7 +66,7 @@ test.describe('Timer', () => { }); }); -test.describe('Timer with target date', () => { +test.describe('Timer with target date @clock', () => { let timer; test.beforeEach(async ({ page }) => { diff --git a/e2e/tests/visual-a11y/components/header.visual.spec.js b/e2e/tests/visual-a11y/components/header.visual.spec.js index ba9320a83b..a42e7cea8d 100644 --- a/e2e/tests/visual-a11y/components/header.visual.spec.js +++ b/e2e/tests/visual-a11y/components/header.visual.spec.js @@ -25,11 +25,12 @@ Tests the branding associated with the default deployment. At least the about mo */ import percySnapshot from '@percy/playwright'; +import { fileURLToPath } from 'url'; import { expect, test } from '../../../avpFixtures.js'; import { VISUAL_URL } from '../../../constants.js'; -//Declare the scope of the visual test +//Declare the component scope of the visual test for Percy const header = '.l-shell__head'; test.describe('Visual - Header @a11y', () => { @@ -78,6 +79,26 @@ test.describe('Visual - Header @a11y', () => { await expect(await page.getByLabel('Show Snapshots')).toBeVisible(); }); }); + +//Header test with all mission status options. Right now, this is just Mission Status, but should grow over time +test.describe('Mission Header @a11y', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript({ + path: fileURLToPath(new URL('../../../helper/addInitExampleUser.js', import.meta.url)) + }); + await page.goto('./', { waitUntil: 'domcontentloaded' }); + await expect(page.getByText('Select Role')).toBeVisible(); + // set role + await page.getByRole('button', { name: 'Select', exact: true }).click(); + // dismiss role confirmation popup + await page.getByRole('button', { name: 'Dismiss' }).click(); + }); + test('Mission status panel', async ({ page, theme }) => { + await percySnapshot(page, `Header default with Mission Header (theme: '${theme}')`, { + scope: header + }); + }); +}); // Skipping for https://github.com/nasa/openmct/issues/7421 // test.afterEach(async ({ page }, testInfo) => { // await scanForA11yViolations(page, testInfo.title); diff --git a/e2e/tests/visual-a11y/components/inspector.visual.spec.js b/e2e/tests/visual-a11y/components/inspector.visual.spec.js index 799978ea47..edd64aec74 100644 --- a/e2e/tests/visual-a11y/components/inspector.visual.spec.js +++ b/e2e/tests/visual-a11y/components/inspector.visual.spec.js @@ -28,7 +28,7 @@ import { MISSION_TIME, VISUAL_URL } from '../../../constants.js'; //Declare the scope of the visual test const inspectorPane = '.l-shell__pane-inspector'; -test.describe('Visual - Inspector @ally', () => { +test.describe('Visual - Inspector @ally @clock', () => { test.beforeEach(async ({ page }) => { await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' }); }); diff --git a/e2e/tests/visual-a11y/controlledClock.visual.spec.js b/e2e/tests/visual-a11y/controlledClock.visual.spec.js index 72e4979f96..6e25b2e980 100644 --- a/e2e/tests/visual-a11y/controlledClock.visual.spec.js +++ b/e2e/tests/visual-a11y/controlledClock.visual.spec.js @@ -30,7 +30,7 @@ import percySnapshot from '@percy/playwright'; import { MISSION_TIME, VISUAL_URL } from '../../constants.js'; import { expect, test } from '../../pluginFixtures.js'; -test.describe('Visual - Controlled Clock', () => { +test.describe('Visual - Controlled Clock @clock', () => { test.beforeEach(async ({ page }) => { await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' }); }); diff --git a/e2e/tests/visual-a11y/faultManagement.visual.spec.js b/e2e/tests/visual-a11y/faultManagement.visual.spec.js index f038e6d37d..73c717ae9a 100644 --- a/e2e/tests/visual-a11y/faultManagement.visual.spec.js +++ b/e2e/tests/visual-a11y/faultManagement.visual.spec.js @@ -20,18 +20,18 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ import percySnapshot from '@percy/playwright'; -import { fileURLToPath } from 'url'; import * as utils from '../../helper/faultUtils.js'; import { expect, test } from '../../pluginFixtures.js'; -test.describe('Fault Management Visual Tests', () => { - test('icon test', async ({ page, theme }) => { - await page.addInitScript({ - path: fileURLToPath(new URL('../../helper/addInitFaultManagementPlugin.js', import.meta.url)) - }); - await page.goto('./', { waitUntil: 'domcontentloaded' }); +test.describe('Fault Management Visual Tests - without example', () => { + test.beforeEach(async ({ page }) => { + await utils.navigateToFaultManagementWithoutExample(page); + await page.getByLabel('Collapse Inspect Pane').click(); + await page.getByLabel('Click to collapse items').click(); + }); + test('fault management icon appears in tree', async ({ page, theme }) => { // Wait for status bar to load await expect( page.getByRole('status', { @@ -51,10 +51,16 @@ test.describe('Fault Management Visual Tests', () => { await percySnapshot(page, `Fault Management icon appears in tree (theme: '${theme}')`); }); +}); + +test.describe('Fault Management Visual Tests', () => { + test.beforeEach(async ({ page }) => { + await utils.navigateToFaultManagementWithStaticExample(page); + await page.getByLabel('Collapse Inspect Pane').click(); + await page.getByLabel('Click to collapse items').click(); + }); test('fault list and acknowledged faults', async ({ page, theme }) => { - await utils.navigateToFaultManagementWithStaticExample(page); - await percySnapshot(page, `Shows a list of faults in the standard view (theme: '${theme}')`); await utils.acknowledgeFault(page, 1); @@ -67,8 +73,6 @@ test.describe('Fault Management Visual Tests', () => { }); test('shelved faults', async ({ page, theme }) => { - await utils.navigateToFaultManagementWithStaticExample(page); - await utils.shelveFault(page, 1); await utils.changeViewTo(page, 'shelved'); @@ -83,8 +87,6 @@ test.describe('Fault Management Visual Tests', () => { }); test('3-dot menu for fault', async ({ page, theme }) => { - await utils.navigateToFaultManagementWithStaticExample(page); - await utils.openFaultRowMenu(page, 1); await percySnapshot( @@ -94,8 +96,6 @@ test.describe('Fault Management Visual Tests', () => { }); test('ability to acknowledge or shelve', async ({ page, theme }) => { - await utils.navigateToFaultManagementWithStaticExample(page); - await utils.selectFaultItem(page, 1); await percySnapshot( diff --git a/e2e/tests/visual-a11y/missionStatus.visual.spec.js b/e2e/tests/visual-a11y/missionStatus.visual.spec.js index 774b8fdef9..2d4600838a 100644 --- a/e2e/tests/visual-a11y/missionStatus.visual.spec.js +++ b/e2e/tests/visual-a11y/missionStatus.visual.spec.js @@ -32,12 +32,12 @@ test.describe('Mission Status Visual Tests @a11y', () => { }); await page.goto('./', { waitUntil: 'domcontentloaded' }); await expect(page.getByText('Select Role')).toBeVisible(); - // Description should be empty https://github.com/nasa/openmct/issues/6978 - await expect(page.locator('c-message__action-text')).toBeHidden(); // set role await page.getByRole('button', { name: 'Select', exact: true }).click(); // dismiss role confirmation popup await page.getByRole('button', { name: 'Dismiss' }).click(); + await page.getByLabel('Collapse Inspect Pane').click(); + await page.getByLabel('Collapse Browse Pane').click(); }); test('Mission status panel', async ({ page, theme }) => { await page.getByLabel('Toggle Mission Status Panel').click(); diff --git a/e2e/tests/visual-a11y/planning.visual.spec.js b/e2e/tests/visual-a11y/planning.visual.spec.js index 51783b7cf1..e2a5a17ff1 100644 --- a/e2e/tests/visual-a11y/planning.visual.spec.js +++ b/e2e/tests/visual-a11y/planning.visual.spec.js @@ -26,13 +26,41 @@ import fs from 'fs'; import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js'; import { test } from '../../avpFixtures.js'; import { VISUAL_URL } from '../../constants.js'; -import { setBoundsToSpanAllActivities, setDraftStatusForPlan } from '../../helper/planningUtils.js'; +import { + createTimelistWithPlanAndSetActivityInProgress, + getFirstActivity, + setBoundsToSpanAllActivities, + setDraftStatusForPlan +} from '../../helper/planningUtils.js'; -const examplePlanSmall = JSON.parse( +const examplePlanSmall1 = JSON.parse( + fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url)) +); + +const examplePlanSmall2 = JSON.parse( fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url)) ); -const snapshotScope = '.l-shell__pane-main .l-pane__contents'; +test.describe('Visual - Timelist progress bar @clock', () => { + const firstActivity = getFirstActivity(examplePlanSmall1); + + test.use({ + clockOptions: { + now: firstActivity.end + 10000, + shouldAdvanceTime: true + } + }); + + test.beforeEach(async ({ page }) => { + await createTimelistWithPlanAndSetActivityInProgress(page, examplePlanSmall1); + await page.getByLabel('Click to collapse items').click(); + }); + + test('progress pie is full', async ({ page, theme }) => { + // Progress pie is completely full and doesn't update if now is greater than the end time + await percySnapshot(page, `Time List with Activity in Progress (theme: ${theme})`); + }); +}); test.describe('Visual - Planning', () => { test.beforeEach(async ({ page }) => { @@ -42,42 +70,41 @@ test.describe('Visual - Planning', () => { test('Plan View', async ({ page, theme }) => { const plan = await createPlanFromJSON(page, { name: 'Plan Visual Test', - json: examplePlanSmall + json: examplePlanSmall2 }); - await setBoundsToSpanAllActivities(page, examplePlanSmall, plan.url); - await percySnapshot(page, `Plan View (theme: ${theme})`, { - scope: snapshotScope - }); + await setBoundsToSpanAllActivities(page, examplePlanSmall2, plan.url); + await percySnapshot(page, `Plan View (theme: ${theme})`); }); test('Plan View w/ draft status', async ({ page, theme }) => { const plan = await createPlanFromJSON(page, { name: 'Plan Visual Test (Draft)', - json: examplePlanSmall + json: examplePlanSmall2 }); await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' }); await setDraftStatusForPlan(page, plan); - await setBoundsToSpanAllActivities(page, examplePlanSmall, plan.url); - await percySnapshot(page, `Plan View w/ draft status (theme: ${theme})`, { - scope: snapshotScope - }); + await setBoundsToSpanAllActivities(page, examplePlanSmall2, plan.url); + await percySnapshot(page, `Plan View w/ draft status (theme: ${theme})`); }); +}); +test.describe('Visual - Gantt Chart', () => { + test.beforeEach(async ({ page }) => { + await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' }); + }); test('Gantt Chart View', async ({ page, theme }) => { const ganttChart = await createDomainObjectWithDefaults(page, { type: 'Gantt Chart', name: 'Gantt Chart Visual Test' }); await createPlanFromJSON(page, { - json: examplePlanSmall, + json: examplePlanSmall2, parent: ganttChart.uuid }); - await setBoundsToSpanAllActivities(page, examplePlanSmall, ganttChart.url); - await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Clipped Activity Names`, { - scope: snapshotScope - }); + await setBoundsToSpanAllActivities(page, examplePlanSmall2, ganttChart.url); + await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Clipped Activity Names`); // Expand the inspect pane and uncheck the 'Clip Activity Names' option await page.getByRole('button', { name: 'Expand Inspect Pane' }).click(); @@ -93,9 +120,7 @@ test.describe('Visual - Planning', () => { // Dismiss the notification await page.getByLabel('Dismiss').click(); - await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Unclipped Activity Names`, { - scope: snapshotScope - }); + await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Unclipped Activity Names`); }); test('Gantt Chart View w/ draft status', async ({ page, theme }) => { @@ -104,7 +129,7 @@ test.describe('Visual - Planning', () => { name: 'Gantt Chart Visual Test (Draft)' }); const plan = await createPlanFromJSON(page, { - json: examplePlanSmall, + json: examplePlanSmall2, parent: ganttChart.uuid }); @@ -112,10 +137,8 @@ test.describe('Visual - Planning', () => { await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' }); - await setBoundsToSpanAllActivities(page, examplePlanSmall, ganttChart.url); - await percySnapshot(page, `Gantt Chart View w/ draft status (theme: ${theme})`, { - scope: snapshotScope - }); + await setBoundsToSpanAllActivities(page, examplePlanSmall2, ganttChart.url); + await percySnapshot(page, `Gantt Chart View w/ draft status (theme: ${theme})`); // Expand the inspect pane and uncheck the 'Clip Activity Names' option await page.getByRole('button', { name: 'Expand Inspect Pane' }).click(); @@ -133,14 +156,12 @@ test.describe('Visual - Planning', () => { await percySnapshot( page, - `Gantt Chart View w/ draft status (theme: ${theme}) - Unclipped Activity Names`, - { - scope: snapshotScope - } + `Gantt Chart View w/ draft status (theme: ${theme}) - Unclipped Activity Names` ); }); - // Skipping for https://github.com/nasa/openmct/issues/7421 - // test.afterEach(async ({ page }, testInfo) => { - // await scanForA11yViolations(page, testInfo.title); - // }); }); + +// Skipping for https://github.com/nasa/openmct/issues/7421 +// test.afterEach(async ({ page }, testInfo) => { +// await scanForA11yViolations(page, testInfo.title); +// }); diff --git a/package.json b/package.json index c90b9b56f0..1377810faa 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@percy/playwright": "1.0.4", "@playwright/test": "1.39.0", "@types/d3-axis": "3.0.6", + "@types/d3-shape": "3.0.0", "@types/d3-scale": "4.0.8", "@types/d3-selection": "3.0.10", "@types/eventemitter3": "1.2.0", @@ -26,6 +27,7 @@ "cspell": "7.3.8", "css-loader": "6.10.0", "d3-axis": "3.0.0", + "d3-shape": "3.0.0", "d3-scale": "4.0.2", "d3-selection": "3.0.0", "eslint": "8.56.0", diff --git a/src/plugins/timelist/ExpandedViewItem.vue b/src/plugins/timelist/ExpandedViewItem.vue index 22eeaed04f..6d0dc30963 100644 --- a/src/plugins/timelist/ExpandedViewItem.vue +++ b/src/plugins/timelist/ExpandedViewItem.vue @@ -37,6 +37,18 @@
+ + + + + + start) { + // Now is after activity start datetime + if (timestamp > end) { + progressPercent = 100; + } else { + progressPercent = (1 - (end - timestamp) / duration) * 100; + } + } + if (progressPercent < 100 && progressPercent > 0) { + // If the remaining percent is less than update_per_cycle, round up to 100%. + // Otherwise, increment by update_per_cycle. + progressPercent = + 100 - progressPercent < update_per_cycle ? 100 : (progressPercent += update_per_cycle); + } + renderProgress(progressPercent, element); +} diff --git a/src/plugins/timelist/timelist.scss b/src/plugins/timelist/timelist.scss index c0a4c80881..ed54e4ea50 100644 --- a/src/plugins/timelist/timelist.scss +++ b/src/plugins/timelist/timelist.scss @@ -29,7 +29,15 @@ } .c-list-item { - /* Time Lists */ + /* Compact Time Lists; is a
element */ + + @mixin sSelected($bgColor, $fgColor) { + &[s-selected] { + background: $bgColor !important; + border: 1px solid $colorSelectedFg !important; + color: $fgColor !important; + } + } td { $p: $interiorMarginSm; @@ -37,19 +45,26 @@ padding-bottom: $p; } + &.--is-past { + @include sSelected(transparent, $colorPastFgEm); + } + &.--is-current { + @include sSelected($colorCurrentBg, $colorCurrentFgEm); background-color: $colorCurrentBg; border-top: 1px solid $colorCurrentBorder !important; color: $colorCurrentFgEm; } &.--is-future { + @include sSelected($colorFutureBg, $colorFutureFgEm); background-color: $colorFutureBg; border-top-color: $colorFutureBorder !important; color: $colorFutureFgEm; } &.--is-in-progress { + @include sSelected($colorInProgressBg, $colorInProgressFgEm); background-color: $colorInProgressBg; } @@ -105,9 +120,10 @@ grid-column-gap: $interiorMargin; &[s-selected] { - background: $colorSelectedBg !important; - box-shadow: inset rgba($colorSelectedFg, 0.1) 0 0 0 1px; + box-shadow: inset rgba($colorSelectedFg, 0.8) 0 0 0 1px; color: $colorSelectedFg !important; + + @include styleTliEm($colorSelectedFg); } @include styleTliEm($baseFgEm); @@ -308,6 +324,7 @@ &__progress { fill: $colorInProgressFgEm; + transform: translateX(50%) translateY(50%); } &__sweep-hand {