diff --git a/e2e/constants.js b/e2e/constants.js index d5da651bca..cd513b1217 100644 --- a/e2e/constants.js +++ b/e2e/constants.js @@ -11,8 +11,9 @@ export const MISSION_TIME = 1732413600000; // Saturday, November 23, 2024 6:00:0 /** * URL Constants - * - This is the URL that the browser will be directed to when running visual tests. This URL + * - This is the URL that the browser will be directed to when running visual tests. This URL * - hides the tree and inspector to prevent visual noise * - sets the time bounds to a fixed range */ -export const VISUAL_URL = './#/browse/mine?tc.mode=fixed&tc.startBound=1693592063607&tc.endBound=1693593893607&tc.timeSystem=utc&view=grid&hideInspector=true&hideTree=true'; +export const VISUAL_URL = + './#/browse/mine?tc.mode=fixed&tc.startBound=1693592063607&tc.endBound=1693593893607&tc.timeSystem=utc&view=grid&hideInspector=true&hideTree=true'; diff --git a/e2e/helper/planningUtils.js b/e2e/helper/planningUtils.js index e7ad8799a4..219ec2c7e2 100644 --- a/e2e/helper/planningUtils.js +++ b/e2e/helper/planningUtils.js @@ -113,17 +113,35 @@ export async function assertPlanOrderedSwimLanes(page, plan, objectUrl) { * @param {string} planObjectUrl */ export async function setBoundsToSpanAllActivities(page, planJson, planObjectUrl) { - const activities = Object.values(planJson).flat(); // Get the earliest start value - const start = Math.min(...activities.map((activity) => activity.start)); + const start = getEarliestStartTime(planJson); // Get the latest end value - const end = Math.max(...activities.map((activity) => activity.end)); + const end = getLatestEndTime(planJson); // Set the start and end bounds to the earliest start and latest end await page.goto( `${planObjectUrl}?tc.mode=fixed&tc.startBound=${start}&tc.endBound=${end}&tc.timeSystem=utc&view=plan.view` ); } +/** + * @param {object} planJson + * @returns {number} + */ +export function getEarliestStartTime(planJson) { + const activities = Object.values(planJson).flat(); + return Math.min(...activities.map((activity) => activity.start)); +} + +/** + * + * @param {object} planJson + * @returns {number} + */ +export function getLatestEndTime(planJson) { + const activities = Object.values(planJson).flat(); + return Math.max(...activities.map((activity) => activity.end)); +} + /** * Uses the Open MCT API to set the status of a plan to 'draft'. * @param {import('@playwright/test').Page} page diff --git a/e2e/test-data/examplePlans/ExamplePlan_Small3.json b/e2e/test-data/examplePlans/ExamplePlan_Small3.json new file mode 100644 index 0000000000..2304cf708b --- /dev/null +++ b/e2e/test-data/examplePlans/ExamplePlan_Small3.json @@ -0,0 +1,38 @@ +{ + "Group 1": [ + { + "name": "Time until birthday", + "start": 1650320402000, + "end": 1660343797000, + "type": "Group 1", + "color": "orange", + "textColor": "white" + }, + { + "name": "Time until supper", + "start": 1650320402000, + "end": 1650420410000, + "type": "Group 2", + "color": "blue", + "textColor": "white" + } + ], + "Group 2": [ + { + "name": "Time since the last time I ate", + "start": 1650320102001, + "end": 1650320102001, + "type": "Group 2", + "color": "green", + "textColor": "white" + }, + { + "name": "Time since last accident", + "start": 1650320102002, + "end": 1650320102002, + "type": "Group 1", + "color": "yellow", + "textColor": "white" + } + ] +} diff --git a/e2e/tests/functional/planning/timelist.e2e.spec.js b/e2e/tests/functional/planning/timelist.e2e.spec.js index 005b0809d4..72f2e8c5d8 100644 --- a/e2e/tests/functional/planning/timelist.e2e.spec.js +++ b/e2e/tests/functional/planning/timelist.e2e.spec.js @@ -19,10 +19,27 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ +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) + ) +); +// 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 = 4; + const testPlan = { TEST_GROUP: [ { @@ -84,35 +101,29 @@ test.describe('Time List', () => { }); await test.step('Create a Plan and add it to the timelist', async () => { - const createdPlan = await createPlanFromJSON(page, { + await createPlanFromJSON(page, { name: 'Test Plan', - json: testPlan + json: testPlan, + parent: timelist.uuid }); - await page.goto(timelist.url); - // Expand the tree to show the plan - await page.click("button[title='Show selected item in tree']"); - await page.dragAndDrop(`role=treeitem[name=/${createdPlan.name}/]`, '.c-object-view'); - await page.click("button[title='Save']"); - await page.click("li[title='Save and Finish Editing']"); const startBound = testPlan.TEST_GROUP[0].start; const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end; - await page.goto(timelist.url); - // 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` ); // Verify all events are displayed - const eventCount = await page.locator('.js-list-item').count(); - expect(eventCount).toEqual(testPlan.TEST_GROUP.length); + const eventCount = await page.getByRole('row').count(); + // subtracting one for the header + await expect(eventCount - 1).toEqual(testPlan.TEST_GROUP.length); }); await test.step('Does not show milliseconds in times', async () => { - // Get the first activity - const row = page.locator('.js-list-item').first(); + // Get an activity + const row = page.getByRole('row').nth(2); // Verify that none fo the times have milliseconds displayed. // Example: 2024-11-17T16:00:00Z is correct and 2024-11-17T16:00:00.000Z is wrong @@ -122,3 +133,162 @@ test.describe('Time List', () => { }); }); }); + +/** + * 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} CountdownObject + * @property {string} sign - The sign of the countdown ('-' if the countdown is negative, otherwise undefined). + * @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 getAndAssertCountdownObject(page, i + 3); + // Wait until it changes + await expect(countdownCell).not.toHaveText(beforeCountdown.toString()); + // Get the new countdown timestamp object + const afterCountdown = await getAndAssertCountdownObject(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 countdownCell = countUpCells[i]; + // Get the initial count-up timestamp object + const beforeCountdown = await getAndAssertCountdownObject(page, i + 1); + // Wait until it changes + await expect(countdownCell).not.toHaveText(beforeCountdown.toString()); + // Get the new count-up timestamp object + const afterCountdown = await getAndAssertCountdownObject(page, i + 1); + // Verify that the new count-up timestamp object is greater than the old one + expect(Number(afterCountdown.seconds)).toBeGreaterThan(Number(beforeCountdown.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} text + */ +async function getCellTextByIndex(page, rowIndex, columnIndex) { + const text = await getCellByIndex(page, rowIndex, columnIndex).innerText(); + return text; +} + +/** + * Get the text from the countdown cell in the given row, assert that it matches the countdown + * regex, and return an object representing the countdown. + * @param {import('@playwright/test').Page} page + * @param {number} rowIndex the row index + * @returns {Promise} countdownObject + */ +async function getAndAssertCountdownObject(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/src/plugins/timelist/TimelistComponent.vue b/src/plugins/timelist/TimelistComponent.vue index 22cede5837..37dec3603d 100644 --- a/src/plugins/timelist/TimelistComponent.vue +++ b/src/plugins/timelist/TimelistComponent.vue @@ -77,9 +77,12 @@ const headerItems = [ format: function (value) { let result; if (value < 0) { - result = `+${getPreciseDuration(Math.abs(value), true)}`; + result = `+${getPreciseDuration(Math.abs(value), { + excludeMilliSeconds: true, + useDayFormat: true + })}`; } else if (value > 0) { - result = `-${getPreciseDuration(value, true)}`; + result = `-${getPreciseDuration(value, { excludeMilliSeconds: true, useDayFormat: true })}`; } else { result = 'Now'; } @@ -351,45 +354,55 @@ export default { return regex.test(name.toLowerCase()); }); }, + // Add activity classes, increase activity counts by type, + // set indices of the first occurrences of current and future activities - used for scrolling + styleActivity(activity, index) { + if (this.timestamp >= activity.start && this.timestamp <= activity.end) { + activity.cssClass = CURRENT_CSS_SUFFIX; + if (this.firstCurrentActivityIndex < 0) { + this.firstCurrentActivityIndex = index; + } + this.currentActivitiesCount = this.currentActivitiesCount + 1; + } else if (this.timestamp < activity.start) { + activity.cssClass = FUTURE_CSS_SUFFIX; + //the index of the first activity that's greater than the current timestamp + if (this.firstFutureActivityIndex < 0) { + this.firstFutureActivityIndex = index; + } + this.futureActivitiesCount = this.futureActivitiesCount + 1; + } else { + activity.cssClass = PAST_CSS_SUFFIX; + this.pastActivitiesCount = this.pastActivitiesCount + 1; + } + + if (!activity.key) { + activity.key = uuid(); + } + + if (activity.start < this.timestamp) { + //if the activity start time has passed, display the time to the end of the activity + activity.duration = activity.end - this.timestamp; + } else { + activity.duration = activity.start - this.timestamp; + } + + return activity; + }, applyStyles(activities) { - let firstCurrentActivityIndex = -1; - let activityClosestToNowIndex = -1; - let currentActivitiesCount = 0; - const styledActivities = activities.map((activity, index) => { - if (this.timestamp >= activity.start && this.timestamp <= activity.end) { - activity.cssClass = CURRENT_CSS_SUFFIX; - if (firstCurrentActivityIndex < 0) { - firstCurrentActivityIndex = index; - } + this.firstCurrentOrFutureActivityIndex = -1; + this.firstCurrentActivityIndex = -1; + this.firstFutureActivityIndex = -1; + this.currentActivitiesCount = 0; + this.pastActivitiesCount = 0; + this.futureActivitiesCount = 0; - currentActivitiesCount = currentActivitiesCount + 1; - } else if (this.timestamp < activity.start) { - activity.cssClass = FUTURE_CSS_SUFFIX; - //the index of the first activity that's greater than the current timestamp - if (activityClosestToNowIndex < 0) { - activityClosestToNowIndex = index; - } - } else { - activity.cssClass = PAST_CSS_SUFFIX; - } + const styledActivities = activities.map(this.styleActivity); - if (!activity.key) { - activity.key = uuid(); - } - - if (activity.start < this.timestamp) { - //if the activity start time has passed, display the time to the end of the activity - activity.duration = activity.end - this.timestamp; - } else { - activity.duration = activity.start - this.timestamp; - } - - return activity; - }); - - this.activityClosestToNowIndex = activityClosestToNowIndex; - this.firstCurrentActivityIndex = firstCurrentActivityIndex; - this.currentActivitiesCount = currentActivitiesCount; + if (this.firstCurrentActivityIndex > -1) { + this.firstCurrentOrFutureActivityIndex = this.firstCurrentActivityIndex; + } else if (this.firstFutureActivityIndex > -1) { + this.firstCurrentOrFutureActivityIndex = this.firstFutureActivityIndex; + } return styledActivities; }, @@ -404,9 +417,10 @@ export default { return; } - this.firstCurrentActivityIndex = -1; - this.activityClosestToNowIndex = -1; + this.firstCurrentOrFutureActivityIndex = -1; + this.pastActivitiesCount = 0; this.currentActivitiesCount = 0; + this.futureActivitiesCount = 0; this.$el.parentElement?.scrollTo({ top: 0 }); this.autoScrolled = false; }, @@ -416,40 +430,51 @@ export default { return; } - const row = this.$el.querySelector('.js-list-item'); - if (row && this.firstCurrentActivityIndex > -1) { - // scroll to somewhere mid-way of the current activities - const ROW_HEIGHT = row.getBoundingClientRect().height; - - if (this.canAutoScroll() === false) { - return; - } - - const scrollOffset = - this.currentActivitiesCount > 0 ? Math.floor(this.currentActivitiesCount / 2) : 0; - this.$el.parentElement?.scrollTo({ - top: ROW_HEIGHT * (this.firstCurrentActivityIndex + scrollOffset), - behavior: 'smooth' - }); - this.autoScrolled = false; - } else if (row && this.activityClosestToNowIndex > -1) { - // scroll to somewhere close to 'now' - - const ROW_HEIGHT = row.getBoundingClientRect().height; - - if (this.canAutoScroll() === false) { - return; - } - - this.$el.parentElement.scrollTo({ - top: ROW_HEIGHT * (this.activityClosestToNowIndex - 1), - behavior: 'smooth' - }); - this.autoScrolled = false; - } else { - // scroll to the top - this.resetScroll(); + if (this.canAutoScroll() === false) { + return; } + + // See #7167 for scrolling algorithm + const scrollTop = this.calculateScrollOffset(); + + if (scrollTop === undefined) { + this.resetScroll(); + } else { + this.$el.parentElement?.scrollTo({ + top: scrollTop, + behavior: 'smooth' + }); + this.autoScrolled = false; + } + }, + calculateScrollOffset() { + let scrollTop; + + //No scrolling necessary if no past events are present + if (this.pastActivitiesCount > 0) { + const row = this.$el.querySelector('.js-list-item'); + const ROW_HEIGHT = row.getBoundingClientRect().height; + + const maxViewableActivities = + Math.floor(this.$el.parentElement.getBoundingClientRect().height / ROW_HEIGHT) - 1; + + const currentAndFutureActivities = this.currentActivitiesCount + this.futureActivitiesCount; + + //If there is more viewable area than all current and future activities combined, then show some past events + const numberOfPastEventsToShow = maxViewableActivities - currentAndFutureActivities; + if (numberOfPastEventsToShow > 0) { + //some past events can be shown - get that scroll index + if (this.pastActivitiesCount > numberOfPastEventsToShow) { + scrollTop = + ROW_HEIGHT * (this.firstCurrentOrFutureActivityIndex + numberOfPastEventsToShow); + } + } else { + // only show current and future events + scrollTop = ROW_HEIGHT * this.firstCurrentOrFutureActivityIndex; + } + } + + return scrollTop; }, deferAutoScroll() { //if this is not a user-triggered event, don't defer auto scrolling diff --git a/src/utils/duration.js b/src/utils/duration.js index 5aeafc0ff2..5baba42602 100644 --- a/src/utils/duration.js +++ b/src/utils/duration.js @@ -63,10 +63,12 @@ export function millisecondsToDHMS(numericDuration) { return `${dhms ? '+' : ''} ${dhms}`; } -export function getPreciseDuration(value, excludeMilliSeconds) { +export function getPreciseDuration(value, { excludeMilliSeconds, useDayFormat } = {}) { + let preciseDuration; const ms = value || 0; + const duration = [ - toDoubleDigits(Math.floor(normalizeAge(ms / ONE_DAY))), + Math.floor(normalizeAge(ms / ONE_DAY)), toDoubleDigits(Math.floor(normalizeAge((ms % ONE_DAY) / ONE_HOUR))), toDoubleDigits(Math.floor(normalizeAge((ms % ONE_HOUR) / ONE_MINUTE))), toDoubleDigits(Math.floor(normalizeAge((ms % ONE_MINUTE) / ONE_SECOND))) @@ -74,5 +76,20 @@ export function getPreciseDuration(value, excludeMilliSeconds) { if (!excludeMilliSeconds) { duration.push(toTripleDigits(Math.floor(normalizeAge(ms % ONE_SECOND)))); } - return duration.join(':'); + + if (useDayFormat) { + // Format days as XD + const days = duration.shift(); + if (days > 0) { + preciseDuration = `${days}D ${duration.join(':')}`; + } else { + preciseDuration = duration.join(':'); + } + } else { + const days = toDoubleDigits(duration.shift()); + duration.unshift(days); + preciseDuration = duration.join(':'); + } + + return preciseDuration; }