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 <charles.f.hacskaylo@nasa.gov>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: Jesse Mazzella <jesse.d.mazzella@nasa.gov>
This commit is contained in:
Shefali Joshi 2024-03-05 14:45:28 -08:00 committed by GitHub
parent ef62633df1
commit df969722d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 550 additions and 237 deletions

View File

@ -238,6 +238,7 @@ Current list of test tags:
|`@unstable` | A new test or test which is known to be flaky.| |`@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.| |`@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.| |`@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 ### 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). - 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 - 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. - 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: 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')` - `await page.goto('./#/browse/mine?hideTree=true&hideInspector=true')`

View File

@ -20,6 +20,7 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../appActions.js';
import { expect } from '../pluginFixtures.js'; import { expect } from '../pluginFixtures.js';
/** /**
@ -142,6 +143,18 @@ export function getLatestEndTime(planJson) {
return Math.max(...activities.map((activity) => activity.end)); 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'. * Uses the Open MCT API to set the status of a plan to 'draft'.
* @param {import('@playwright/test').Page} page * @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' });
}

View File

@ -39,7 +39,7 @@ import { expect, test } from '../../pluginFixtures.js';
const overlayPlotName = 'Overlay Plot with Telemetry Object'; 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({ test.use({
clockOptions: { clockOptions: {
now: MISSION_TIME, now: MISSION_TIME,

View File

@ -22,29 +22,13 @@
import fs from 'fs'; import fs from 'fs';
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../../appActions.js'; import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../../appActions.js';
import { getEarliestStartTime } from '../../../helper/planningUtils';
import { expect, test } from '../../../pluginFixtures.js'; 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( const examplePlanSmall1 = JSON.parse(
fs.readFileSync( fs.readFileSync(
new URL('../../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url) 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.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 ({ 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 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 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(); await page.getByRole('row').nth(2).click();
// Find the activity state section in the inspector // 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' 'Not started'
); );
}); });
});
/** await test.step("Verify absence of progress indication for an activity that's not in progress", async () => {
* The regular expression used to parse the countdown string. // When an activity is not in progress, the progress pie is not visible
* Some examples of valid Countdown strings: const hidden = await page.getByRole('row').locator('path').nth(1).isHidden();
* ``` await expect(hidden).toBe(true);
* '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));
});
}
}); });
}); });
/**
* 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<string>} 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<CountdownOrUpObject>} 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
};
}

View File

@ -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<string>} 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<CountdownOrUpObject>} 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
};
}

View File

@ -66,7 +66,7 @@ test.describe('Timer', () => {
}); });
}); });
test.describe('Timer with target date', () => { test.describe('Timer with target date @clock', () => {
let timer; let timer;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {

View File

@ -25,11 +25,12 @@ Tests the branding associated with the default deployment. At least the about mo
*/ */
import percySnapshot from '@percy/playwright'; import percySnapshot from '@percy/playwright';
import { fileURLToPath } from 'url';
import { expect, test } from '../../../avpFixtures.js'; import { expect, test } from '../../../avpFixtures.js';
import { VISUAL_URL } from '../../../constants.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'; const header = '.l-shell__head';
test.describe('Visual - Header @a11y', () => { test.describe('Visual - Header @a11y', () => {
@ -78,6 +79,26 @@ test.describe('Visual - Header @a11y', () => {
await expect(await page.getByLabel('Show Snapshots')).toBeVisible(); 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 // Skipping for https://github.com/nasa/openmct/issues/7421
// test.afterEach(async ({ page }, testInfo) => { // test.afterEach(async ({ page }, testInfo) => {
// await scanForA11yViolations(page, testInfo.title); // await scanForA11yViolations(page, testInfo.title);

View File

@ -28,7 +28,7 @@ import { MISSION_TIME, VISUAL_URL } from '../../../constants.js';
//Declare the scope of the visual test //Declare the scope of the visual test
const inspectorPane = '.l-shell__pane-inspector'; const inspectorPane = '.l-shell__pane-inspector';
test.describe('Visual - Inspector @ally', () => { test.describe('Visual - Inspector @ally @clock', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' }); await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
}); });

View File

@ -30,7 +30,7 @@ import percySnapshot from '@percy/playwright';
import { MISSION_TIME, VISUAL_URL } from '../../constants.js'; import { MISSION_TIME, VISUAL_URL } from '../../constants.js';
import { expect, test } from '../../pluginFixtures.js'; import { expect, test } from '../../pluginFixtures.js';
test.describe('Visual - Controlled Clock', () => { test.describe('Visual - Controlled Clock @clock', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' }); await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
}); });

View File

@ -20,18 +20,18 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import percySnapshot from '@percy/playwright'; import percySnapshot from '@percy/playwright';
import { fileURLToPath } from 'url';
import * as utils from '../../helper/faultUtils.js'; import * as utils from '../../helper/faultUtils.js';
import { expect, test } from '../../pluginFixtures.js'; import { expect, test } from '../../pluginFixtures.js';
test.describe('Fault Management Visual Tests', () => { test.describe('Fault Management Visual Tests - without example', () => {
test('icon test', async ({ page, theme }) => { test.beforeEach(async ({ page }) => {
await page.addInitScript({ await utils.navigateToFaultManagementWithoutExample(page);
path: fileURLToPath(new URL('../../helper/addInitFaultManagementPlugin.js', import.meta.url)) await page.getByLabel('Collapse Inspect Pane').click();
await page.getByLabel('Click to collapse items').click();
}); });
await page.goto('./', { waitUntil: 'domcontentloaded' });
test('fault management icon appears in tree', async ({ page, theme }) => {
// Wait for status bar to load // Wait for status bar to load
await expect( await expect(
page.getByRole('status', { page.getByRole('status', {
@ -51,10 +51,16 @@ test.describe('Fault Management Visual Tests', () => {
await percySnapshot(page, `Fault Management icon appears in tree (theme: '${theme}')`); 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 }) => { 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 percySnapshot(page, `Shows a list of faults in the standard view (theme: '${theme}')`);
await utils.acknowledgeFault(page, 1); await utils.acknowledgeFault(page, 1);
@ -67,8 +73,6 @@ test.describe('Fault Management Visual Tests', () => {
}); });
test('shelved faults', async ({ page, theme }) => { test('shelved faults', async ({ page, theme }) => {
await utils.navigateToFaultManagementWithStaticExample(page);
await utils.shelveFault(page, 1); await utils.shelveFault(page, 1);
await utils.changeViewTo(page, 'shelved'); await utils.changeViewTo(page, 'shelved');
@ -83,8 +87,6 @@ test.describe('Fault Management Visual Tests', () => {
}); });
test('3-dot menu for fault', async ({ page, theme }) => { test('3-dot menu for fault', async ({ page, theme }) => {
await utils.navigateToFaultManagementWithStaticExample(page);
await utils.openFaultRowMenu(page, 1); await utils.openFaultRowMenu(page, 1);
await percySnapshot( await percySnapshot(
@ -94,8 +96,6 @@ test.describe('Fault Management Visual Tests', () => {
}); });
test('ability to acknowledge or shelve', async ({ page, theme }) => { test('ability to acknowledge or shelve', async ({ page, theme }) => {
await utils.navigateToFaultManagementWithStaticExample(page);
await utils.selectFaultItem(page, 1); await utils.selectFaultItem(page, 1);
await percySnapshot( await percySnapshot(

View File

@ -32,12 +32,12 @@ test.describe('Mission Status Visual Tests @a11y', () => {
}); });
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
await expect(page.getByText('Select Role')).toBeVisible(); 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 // set role
await page.getByRole('button', { name: 'Select', exact: true }).click(); await page.getByRole('button', { name: 'Select', exact: true }).click();
// dismiss role confirmation popup // dismiss role confirmation popup
await page.getByRole('button', { name: 'Dismiss' }).click(); 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 }) => { test('Mission status panel', async ({ page, theme }) => {
await page.getByLabel('Toggle Mission Status Panel').click(); await page.getByLabel('Toggle Mission Status Panel').click();

View File

@ -26,13 +26,41 @@ import fs from 'fs';
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js'; import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js';
import { test } from '../../avpFixtures.js'; import { test } from '../../avpFixtures.js';
import { VISUAL_URL } from '../../constants.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)) 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.describe('Visual - Planning', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@ -42,42 +70,41 @@ test.describe('Visual - Planning', () => {
test('Plan View', async ({ page, theme }) => { test('Plan View', async ({ page, theme }) => {
const plan = await createPlanFromJSON(page, { const plan = await createPlanFromJSON(page, {
name: 'Plan Visual Test', name: 'Plan Visual Test',
json: examplePlanSmall json: examplePlanSmall2
}); });
await setBoundsToSpanAllActivities(page, examplePlanSmall, plan.url); await setBoundsToSpanAllActivities(page, examplePlanSmall2, plan.url);
await percySnapshot(page, `Plan View (theme: ${theme})`, { await percySnapshot(page, `Plan View (theme: ${theme})`);
scope: snapshotScope
});
}); });
test('Plan View w/ draft status', async ({ page, theme }) => { test('Plan View w/ draft status', async ({ page, theme }) => {
const plan = await createPlanFromJSON(page, { const plan = await createPlanFromJSON(page, {
name: 'Plan Visual Test (Draft)', name: 'Plan Visual Test (Draft)',
json: examplePlanSmall json: examplePlanSmall2
}); });
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' }); await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
await setDraftStatusForPlan(page, plan); await setDraftStatusForPlan(page, plan);
await setBoundsToSpanAllActivities(page, examplePlanSmall, plan.url); await setBoundsToSpanAllActivities(page, examplePlanSmall2, plan.url);
await percySnapshot(page, `Plan View w/ draft status (theme: ${theme})`, { await percySnapshot(page, `Plan View w/ draft status (theme: ${theme})`);
scope: snapshotScope
}); });
}); });
test.describe('Visual - Gantt Chart', () => {
test.beforeEach(async ({ page }) => {
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
});
test('Gantt Chart View', async ({ page, theme }) => { test('Gantt Chart View', async ({ page, theme }) => {
const ganttChart = await createDomainObjectWithDefaults(page, { const ganttChart = await createDomainObjectWithDefaults(page, {
type: 'Gantt Chart', type: 'Gantt Chart',
name: 'Gantt Chart Visual Test' name: 'Gantt Chart Visual Test'
}); });
await createPlanFromJSON(page, { await createPlanFromJSON(page, {
json: examplePlanSmall, json: examplePlanSmall2,
parent: ganttChart.uuid parent: ganttChart.uuid
}); });
await setBoundsToSpanAllActivities(page, examplePlanSmall, ganttChart.url); await setBoundsToSpanAllActivities(page, examplePlanSmall2, ganttChart.url);
await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Clipped Activity Names`, { await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Clipped Activity Names`);
scope: snapshotScope
});
// Expand the inspect pane and uncheck the 'Clip Activity Names' option // Expand the inspect pane and uncheck the 'Clip Activity Names' option
await page.getByRole('button', { name: 'Expand Inspect Pane' }).click(); await page.getByRole('button', { name: 'Expand Inspect Pane' }).click();
@ -93,9 +120,7 @@ test.describe('Visual - Planning', () => {
// Dismiss the notification // Dismiss the notification
await page.getByLabel('Dismiss').click(); await page.getByLabel('Dismiss').click();
await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Unclipped Activity Names`, { await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Unclipped Activity Names`);
scope: snapshotScope
});
}); });
test('Gantt Chart View w/ draft status', async ({ page, theme }) => { test('Gantt Chart View w/ draft status', async ({ page, theme }) => {
@ -104,7 +129,7 @@ test.describe('Visual - Planning', () => {
name: 'Gantt Chart Visual Test (Draft)' name: 'Gantt Chart Visual Test (Draft)'
}); });
const plan = await createPlanFromJSON(page, { const plan = await createPlanFromJSON(page, {
json: examplePlanSmall, json: examplePlanSmall2,
parent: ganttChart.uuid parent: ganttChart.uuid
}); });
@ -112,10 +137,8 @@ test.describe('Visual - Planning', () => {
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' }); await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
await setBoundsToSpanAllActivities(page, examplePlanSmall, ganttChart.url); await setBoundsToSpanAllActivities(page, examplePlanSmall2, ganttChart.url);
await percySnapshot(page, `Gantt Chart View w/ draft status (theme: ${theme})`, { await percySnapshot(page, `Gantt Chart View w/ draft status (theme: ${theme})`);
scope: snapshotScope
});
// Expand the inspect pane and uncheck the 'Clip Activity Names' option // Expand the inspect pane and uncheck the 'Clip Activity Names' option
await page.getByRole('button', { name: 'Expand Inspect Pane' }).click(); await page.getByRole('button', { name: 'Expand Inspect Pane' }).click();
@ -133,14 +156,12 @@ test.describe('Visual - Planning', () => {
await percySnapshot( await percySnapshot(
page, page,
`Gantt Chart View w/ draft status (theme: ${theme}) - Unclipped Activity Names`, `Gantt Chart View w/ draft status (theme: ${theme}) - Unclipped Activity Names`
{
scope: snapshotScope
}
); );
}); });
});
// Skipping for https://github.com/nasa/openmct/issues/7421 // Skipping for https://github.com/nasa/openmct/issues/7421
// test.afterEach(async ({ page }, testInfo) => { // test.afterEach(async ({ page }, testInfo) => {
// await scanForA11yViolations(page, testInfo.title); // await scanForA11yViolations(page, testInfo.title);
// }); // });
});

View File

@ -12,6 +12,7 @@
"@percy/playwright": "1.0.4", "@percy/playwright": "1.0.4",
"@playwright/test": "1.39.0", "@playwright/test": "1.39.0",
"@types/d3-axis": "3.0.6", "@types/d3-axis": "3.0.6",
"@types/d3-shape": "3.0.0",
"@types/d3-scale": "4.0.8", "@types/d3-scale": "4.0.8",
"@types/d3-selection": "3.0.10", "@types/d3-selection": "3.0.10",
"@types/eventemitter3": "1.2.0", "@types/eventemitter3": "1.2.0",
@ -26,6 +27,7 @@
"cspell": "7.3.8", "cspell": "7.3.8",
"css-loader": "6.10.0", "css-loader": "6.10.0",
"d3-axis": "3.0.0", "d3-axis": "3.0.0",
"d3-shape": "3.0.0",
"d3-scale": "4.0.2", "d3-scale": "4.0.2",
"d3-selection": "3.0.0", "d3-selection": "3.0.0",
"eslint": "8.56.0", "eslint": "8.56.0",

View File

@ -37,6 +37,18 @@
</div> </div>
<div class="c-tli__graphic"> <div class="c-tli__graphic">
<svg viewBox="0 0 100 100"> <svg viewBox="0 0 100 100">
<g aria-label="Activity in progress" class="c-tli__graphic__pie">
<circle class="c-svg-progress__bg" r="50" cx="50" cy="50"></circle>
<path ref="progressElement" class="c-svg-progress__progress"></path>
<circle
class="c-svg-progress__ticks"
r="40"
cx="50"
cy="50"
stroke-dasharray="3 7.472"
></circle>
<rect class="c-svg-progress__sweep-hand" x="48" y="18" width="4" height="27"></rect>
</g>
<path <path
aria-label="Activity complete" aria-label="Activity complete"
class="c-tli__graphic__check" class="c-tli__graphic__check"
@ -80,6 +92,7 @@ import _ from 'lodash';
import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js'; import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js';
import { CURRENT_CSS_SUFFIX, FUTURE_CSS_SUFFIX, PAST_CSS_SUFFIX } from './constants.js'; import { CURRENT_CSS_SUFFIX, FUTURE_CSS_SUFFIX, PAST_CSS_SUFFIX } from './constants.js';
import { updateProgress } from './svg-progress.js';
const ITEM_COLORS = { const ITEM_COLORS = {
[CURRENT_CSS_SUFFIX]: '#ffcc00', [CURRENT_CSS_SUFFIX]: '#ffcc00',
@ -212,6 +225,7 @@ export default {
}, },
followTimeContext() { followTimeContext() {
this.timeContext.on(TIME_CONTEXT_EVENTS.tick, this.updateTimestamp); this.timeContext.on(TIME_CONTEXT_EVENTS.tick, this.updateTimestamp);
this.updateTimestamp(this.timeContext.now());
}, },
stopFollowingTimeContext() { stopFollowingTimeContext() {
if (this.timeContext) { if (this.timeContext) {
@ -220,6 +234,10 @@ export default {
}, },
updateTimestamp(time) { updateTimestamp(time) {
this.timestamp = time; this.timestamp = time;
const progressElement = this.$refs.progressElement;
if (this.isInProgress && progressElement) {
updateProgress(this.start, this.end, this.timestamp, progressElement);
}
this.formatItemLabel(); this.formatItemLabel();
}, },
formatItemLabel() { formatItemLabel() {

View File

@ -63,6 +63,7 @@
<list-item <list-item
v-for="item in sortedItems" v-for="item in sortedItems"
:key="item.key" :key="item.key"
:class="{ '--is-in-progress': persistedActivityStates[item.id] === 'in-progress' }"
:item="item" :item="item"
:item-properties="itemProperties" :item-properties="itemProperties"
@click.stop="setSelectionForActivity(item, $event.currentTarget)" @click.stop="setSelectionForActivity(item, $event.currentTarget)"

View File

@ -0,0 +1,49 @@
const PI = Math.PI; // Use the built-in constant directly
const DEGREES_TO_RADIANS = PI / 180; // Calculate the conversion factor
import { arc } from 'd3-shape';
const SVG_VB_SIZE = 100;
const UPDATE_RATE_MS = 1000; // 1 Hz
function progToDegrees(progVal) {
return (progVal / 100) * 360;
}
function renderProgress(progressPercent, element) {
let startAngleInDegrees = 0;
let endAngleInDegrees = progToDegrees(progressPercent);
// Convert angles to radians for calculations
const startAngleInRadians = startAngleInDegrees * DEGREES_TO_RADIANS;
const endAngleInRadians = endAngleInDegrees * DEGREES_TO_RADIANS;
// d3's arc API does the work for us
const progressArc = arc();
progressArc.innerRadius(0);
progressArc.outerRadius(SVG_VB_SIZE / 2);
progressArc.startAngle(startAngleInRadians);
progressArc.endAngle(endAngleInRadians);
element.setAttribute('d', progressArc());
}
export function updateProgress(start, end, timestamp, element) {
const duration = end - start;
const update_per_cycle = 100 / (duration / UPDATE_RATE_MS);
let progressPercent = 0;
if (timestamp > 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);
}

View File

@ -29,7 +29,15 @@
} }
.c-list-item { .c-list-item {
/* Time Lists */ /* Compact Time Lists; is a <tr> element */
@mixin sSelected($bgColor, $fgColor) {
&[s-selected] {
background: $bgColor !important;
border: 1px solid $colorSelectedFg !important;
color: $fgColor !important;
}
}
td { td {
$p: $interiorMarginSm; $p: $interiorMarginSm;
@ -37,19 +45,26 @@
padding-bottom: $p; padding-bottom: $p;
} }
&.--is-past {
@include sSelected(transparent, $colorPastFgEm);
}
&.--is-current { &.--is-current {
@include sSelected($colorCurrentBg, $colorCurrentFgEm);
background-color: $colorCurrentBg; background-color: $colorCurrentBg;
border-top: 1px solid $colorCurrentBorder !important; border-top: 1px solid $colorCurrentBorder !important;
color: $colorCurrentFgEm; color: $colorCurrentFgEm;
} }
&.--is-future { &.--is-future {
@include sSelected($colorFutureBg, $colorFutureFgEm);
background-color: $colorFutureBg; background-color: $colorFutureBg;
border-top-color: $colorFutureBorder !important; border-top-color: $colorFutureBorder !important;
color: $colorFutureFgEm; color: $colorFutureFgEm;
} }
&.--is-in-progress { &.--is-in-progress {
@include sSelected($colorInProgressBg, $colorInProgressFgEm);
background-color: $colorInProgressBg; background-color: $colorInProgressBg;
} }
@ -105,9 +120,10 @@
grid-column-gap: $interiorMargin; grid-column-gap: $interiorMargin;
&[s-selected] { &[s-selected] {
background: $colorSelectedBg !important; box-shadow: inset rgba($colorSelectedFg, 0.8) 0 0 0 1px;
box-shadow: inset rgba($colorSelectedFg, 0.1) 0 0 0 1px;
color: $colorSelectedFg !important; color: $colorSelectedFg !important;
@include styleTliEm($colorSelectedFg);
} }
@include styleTliEm($baseFgEm); @include styleTliEm($baseFgEm);
@ -308,6 +324,7 @@
&__progress { &__progress {
fill: $colorInProgressFgEm; fill: $colorInProgressFgEm;
transform: translateX(50%) translateY(50%);
} }
&__sweep-hand { &__sweep-hand {