diff --git a/front/src/modules/timesStops/TimesStopsInput.tsx b/front/src/modules/timesStops/TimesStopsInput.tsx index 2620f5278c9..ec39867ca85 100644 --- a/front/src/modules/timesStops/TimesStopsInput.tsx +++ b/front/src/modules/timesStops/TimesStopsInput.tsx @@ -47,7 +47,7 @@ const createClearViaButton = ({ rowData.onStopSignal === true); if (isClearBtnShown) { return ( - ); diff --git a/front/tests/011-op-times-and-stops-tab.spec.ts b/front/tests/011-op-times-and-stops-tab.spec.ts new file mode 100644 index 00000000000..24e798f3c3b --- /dev/null +++ b/front/tests/011-op-times-and-stops-tab.spec.ts @@ -0,0 +1,234 @@ +import { test, expect } from '@playwright/test'; + +import type { Project, Scenario, Study } from 'common/api/osrdEditoastApi'; + +import HomePage from './pages/home-page-model'; +import OperationalStudiesInputTablePage from './pages/op-input-table-page-model'; +import OperationalStudiesOutputTablePage from './pages/op-output-table-page-model'; +import OperationalStudiesTimetablePage from './pages/op-timetable-page-model'; +import OperationalStudiesPage from './pages/operational-studies-page-model'; +import ScenarioPage from './pages/scenario-page-model'; +import { readJsonFile, safeClick } from './utils'; +import { cleanWhitespace, cleanWhitespaces, type StationData } from './utils/dataNormalizer'; +import setupScenario from './utils/scenario'; +import scrollContainer from './utils/scrollHelper'; +import enTranslations from '../public/locales/en/timesStops.json'; +import frTranslations from '../public/locales/fr/timesStops.json'; + +let project: Project; +let study: Study; +let scenario: Scenario; +let selectedLanguage: string; + +const dualRollingStockName = 'dual-mode_rollingstock_test_e2e'; + +const initialInputsData: CellData[] = readJsonFile( + './tests/assets/operationStudies/timesAndStops/initialInputs.json' +); +const updatedInputsData: CellData[] = readJsonFile( + './tests/assets/operationStudies/timesAndStops/updatedInputs.json' +); +const outputExpectedCellData: StationData[] = readJsonFile( + './tests/assets/operationStudies/timesAndStops/expectedOutputsCellsData.json' +); +const inputExpectedData = readJsonFile( + './tests/assets/operationStudies/timesAndStops/expectedInputsCellsData.json' +); +const updatedCellData = readJsonFile( + './tests/assets/operationStudies/timesAndStops/updatedInputsCellsData.json' +); + +const expectedViaValues = [ + { name: 'Mid_West_station', ch: 'BV', uic: '3', km: 'KM 11.850' }, + { name: 'Mid_East_station', ch: 'BV', uic: '4', km: 'KM 26.300' }, +]; + +type TranslationKeys = keyof typeof enTranslations; + +// Define CellData interface for table cell data +interface CellData { + stationName: string; + header: TranslationKeys; + value: string; + marginForm?: string; +} + +test.beforeEach(async ({ page }) => { + // Create a new scenario + ({ project, study, scenario } = await setupScenario()); + + // Navigate to home page and retrieve the language setting + const homePage = new HomePage(page); + await homePage.goToHomePage(); + selectedLanguage = await homePage.getOSRDLanguage(); + + // Go to the specific operational study scenario page + await page.goto( + `/operational-studies/projects/${project.id}/studies/${study.id}/scenarios/${scenario.id}` + ); +}); + +test.describe('Times and Stops Tab Verification', () => { + // Set viewport to avoid scrolling issues and ensure elements are attached to the DOM + test.use({ viewport: { width: 1920, height: 1080 } }); + + test('should correctly set and display times and stops tables', async ({ page }) => { + // Page models + const [ + opInputTablePage, + opTimetablePage, + opOutputTablePage, + operationalStudiesPage, + scenarioPage, + ] = [ + new OperationalStudiesInputTablePage(page), + new OperationalStudiesTimetablePage(page), + new OperationalStudiesOutputTablePage(page), + new OperationalStudiesPage(page), + new ScenarioPage(page), + ]; + + // Setup the initial train configuration and schedule + await scenarioPage.checkInfraLoaded(); + await operationalStudiesPage.clickOnAddTrainBtn(); + await scenarioPage.setTrainScheduleName('Train-name-e2e-test'); + await page.waitForTimeout(500); + await operationalStudiesPage.setTrainStartTime('11:22:40'); + await operationalStudiesPage.selectRollingStock(dualRollingStockName); + + // Perform pathfinding + await scenarioPage.openTabByDataId('tab-pathfinding'); + await operationalStudiesPage.performPathfindingByTrigram('WS', 'NES'); + + // Navigate to the Times and Stops tab and scroll into view + await scenarioPage.openTabByDataId('tab-timesStops'); + await scrollContainer(page, '.time-stops-datasheet .dsg-container'); + + // Set column names based on the selected language + const translations = selectedLanguage === 'English' ? enTranslations : frTranslations; + const expectedColumnNames = cleanWhitespaces([ + translations.name, + 'Ch', + translations.arrivalTime, + translations.departureTime, + translations.stopTime, + translations.receptionOnClosedSignal, + translations.theoreticalMargin, + ]); + + // Verify that the actual column headers match the expected headers + const actualColumnHeaders = cleanWhitespaces( + await opInputTablePage.columnHeaders.allInnerTexts() + ); + expect(actualColumnHeaders).toEqual(expectedColumnNames); + + // Validate the initial active row count + await opInputTablePage.verifyActiveRowsCount(2); + + // Fill in table cells based on the predefined cell data + for (const cell of initialInputsData) { + const translatedHeader = cleanWhitespace(translations[cell.header]); + await opInputTablePage.fillTableCellByStationAndHeader( + cell.stationName, + translatedHeader, + cell.value, + selectedLanguage, + cell.marginForm + ); + } + + // Verify the table after modification + await opInputTablePage.verifyActiveRowsCount(4); + await opInputTablePage.verifyDeleteButtons(2); + await opInputTablePage.verifyInputTableData(inputExpectedData); + + // Switch to Pathfinding tab and validate waypoints + await scenarioPage.openTabByDataId('tab-pathfinding'); + for (const [viaIndex, expectedValue] of expectedViaValues.entries()) { + const droppedWaypoint = operationalStudiesPage.droppedWaypoints.nth(viaIndex); + await OperationalStudiesPage.validateAddedWaypoint( + droppedWaypoint, + expectedValue.name, + expectedValue.ch, + expectedValue.uic + ); + } + + // Add the train schedule and verify simulation results + await scenarioPage.addTrainSchedule(); + await scenarioPage.returnSimulationResult(); + opTimetablePage.verifyTimeStopsDatasheetVisibility(); + // Scroll and extract data from output table + await scrollContainer(page, '.osrd-simulation-container .time-stops-datasheet .dsg-container'); + await opOutputTablePage.getOutputTableData(outputExpectedCellData, selectedLanguage); + }); + + test('should correctly update and clear input table row', async ({ page }) => { + // Page models + const [opInputTablePage, operationalStudiesPage, scenarioPage] = [ + new OperationalStudiesInputTablePage(page), + new OperationalStudiesPage(page), + new ScenarioPage(page), + ]; + + // Setup initial train configuration + await scenarioPage.checkInfraLoaded(); + await operationalStudiesPage.clickOnAddTrainBtn(); + await scenarioPage.setTrainScheduleName('Train-name-e2e-test'); + await page.waitForTimeout(500); + await operationalStudiesPage.setTrainStartTime('11:22:40'); + await operationalStudiesPage.selectRollingStock(dualRollingStockName); + + // Perform pathfinding and navigate to Times and Stops tab + await scenarioPage.openTabByDataId('tab-pathfinding'); + await operationalStudiesPage.performPathfindingByTrigram('WS', 'NES'); + await scenarioPage.openTabByDataId('tab-timesStops'); + await scrollContainer(page, '.time-stops-datasheet .dsg-container'); + + const translations = selectedLanguage === 'English' ? enTranslations : frTranslations; + // Fill in table cells based on the predefined cell data + for (const cell of initialInputsData) { + const translatedHeader = cleanWhitespace(translations[cell.header]); + await opInputTablePage.fillTableCellByStationAndHeader( + cell.stationName, + translatedHeader, + cell.value, + selectedLanguage, + cell.marginForm + ); + } + await opInputTablePage.verifyInputTableData(inputExpectedData); + + // Update table inputs + await opInputTablePage.verifyActiveRowsCount(4); + for (const cell of updatedInputsData) { + const translatedHeader = cleanWhitespace(translations[cell.header]); + await opInputTablePage.fillTableCellByStationAndHeader( + cell.stationName, + translatedHeader, + cell.value, + selectedLanguage, + cell.marginForm + ); + } + + // Delete a row and validate row count + await opInputTablePage.verifyDeleteButtons(2); + await safeClick(opInputTablePage.deleteButtons.nth(0)); + await opInputTablePage.verifyActiveRowsCount(4); + await opInputTablePage.verifyDeleteButtons(1); + await opInputTablePage.verifyInputTableData(updatedCellData); + + // Switch to Pathfinding tab and validate waypoints + await scenarioPage.openTabByDataId('tab-pathfinding'); + for (const [viaIndex, expectedValue] of expectedViaValues.entries()) { + const droppedWaypoint = operationalStudiesPage.droppedWaypoints.nth(viaIndex); + await OperationalStudiesPage.validateAddedWaypoint( + droppedWaypoint, + expectedValue.name, + expectedValue.ch, + expectedValue.uic + ); + } + }); +}); diff --git a/front/tests/assets/operationStudies/timesAndStops/expectedInputsCellsData.json b/front/tests/assets/operationStudies/timesAndStops/expectedInputsCellsData.json new file mode 100644 index 00000000000..3107957630d --- /dev/null +++ b/front/tests/assets/operationStudies/timesAndStops/expectedInputsCellsData.json @@ -0,0 +1,18 @@ +[ + { + "row": 1, + "values": ["West_station", "BV", "11:22:40", "", "", "5%"] + }, + { + "row": 2, + "values": ["Mid_West_station", "BV", "11:30:40", "11:35:40", "300", "1min/100km"] + }, + { + "row": 3, + "values": ["Mid_East_station", "BV", "11:45:21", "11:47:25", "124", ""] + }, + { + "row": 4, + "values": ["North_East_station", "BV", "", "", "0", ""] + } +] diff --git a/front/tests/assets/operationStudies/timesAndStops/expectedOutputsCellsData.json b/front/tests/assets/operationStudies/timesAndStops/expectedOutputsCellsData.json new file mode 100644 index 00000000000..75d1906555d --- /dev/null +++ b/front/tests/assets/operationStudies/timesAndStops/expectedOutputsCellsData.json @@ -0,0 +1,66 @@ +[ + { + "stationName": "West_station", + "stationCh": "BV", + "requestedArrival": "11:22:40", + "requestedDeparture": "", + "stopTime": "", + "signalReceptionClosed": false, + "margin": { + "theoretical": "5 %", + "theoreticalS": "23 s", + "actual": "23 s", + "difference": "0 s" + }, + "calculatedArrival": "11:22:40", + "calculatedDeparture": "" + }, + { + "stationName": "Mid_West_station", + "stationCh": "BV", + "requestedArrival": "11:30:40", + "requestedDeparture": "11:35:40", + "stopTime": "300", + "signalReceptionClosed": true, + "margin": { + "theoretical": "1 min/100km", + "theoreticalS": "9 s", + "actual": "167 s", + "difference": "159 s" + }, + "calculatedArrival": "11:30:39", + "calculatedDeparture": "11:35:39" + }, + { + "stationName": "Mid_East_station", + "stationCh": "BV", + "requestedArrival": "11:45:21", + "requestedDeparture": "11:47:25", + "stopTime": "124", + "signalReceptionClosed": false, + "margin": { + "theoretical": "", + "theoreticalS": "12 s", + "actual": "12 s", + "difference": "0 s" + }, + "calculatedArrival": "11:45:20", + "calculatedDeparture": "11:47:24" + }, + { + "stationName": "North_East_station", + "stationCh": "BV", + "requestedArrival": "", + "requestedDeparture": "", + "stopTime": "0", + "signalReceptionClosed": false, + "margin": { + "theoretical": "", + "theoreticalS": "", + "actual": "", + "difference": "" + }, + "calculatedArrival": "11:56:23", + "calculatedDeparture": "" + } +] diff --git a/front/tests/assets/operationStudies/timesAndStops/initialInputs.json b/front/tests/assets/operationStudies/timesAndStops/initialInputs.json new file mode 100644 index 00000000000..5f6fc6b904b --- /dev/null +++ b/front/tests/assets/operationStudies/timesAndStops/initialInputs.json @@ -0,0 +1,34 @@ +[ + { + "stationName": "West_station", + "header": "theoreticalMargin", + "value": "5%", + "marginForm": "% ou min/100km" + }, + { + "stationName": "Mid_West_station", + "header": "theoreticalMargin", + "value": "1min/100km", + "marginForm": "% ou min/100km" + }, + { + "stationName": "Mid_West_station", + "header": "arrivalTime", + "value": "11:30:40" + }, + { + "stationName": "Mid_West_station", + "header": "stopTime", + "value": "300" + }, + { + "stationName": "Mid_East_station", + "header": "arrivalTime", + "value": "11:45:21" + }, + { + "stationName": "Mid_East_station", + "header": "stopTime", + "value": "124" + } +] diff --git a/front/tests/assets/operationStudies/timesAndStops/updatedInputs.json b/front/tests/assets/operationStudies/timesAndStops/updatedInputs.json new file mode 100644 index 00000000000..4fc930786cf --- /dev/null +++ b/front/tests/assets/operationStudies/timesAndStops/updatedInputs.json @@ -0,0 +1,18 @@ +[ + { + "stationName": "West_station", + "header": "theoreticalMargin", + "value": "3%", + "marginForm": "% ou min/100km" + }, + { + "stationName": "Mid_East_station", + "header": "arrivalTime", + "value": "12:58:19" + }, + { + "stationName": "Mid_East_station", + "header": "stopTime", + "value": "21" + } +] diff --git a/front/tests/assets/operationStudies/timesAndStops/updatedInputsCellsData.json b/front/tests/assets/operationStudies/timesAndStops/updatedInputsCellsData.json new file mode 100644 index 00000000000..8233e5f52d1 --- /dev/null +++ b/front/tests/assets/operationStudies/timesAndStops/updatedInputsCellsData.json @@ -0,0 +1,18 @@ +[ + { + "row": 1, + "values": ["West_station", "BV", "11:22:40", "", "", "3%"] + }, + { + "row": 2, + "values": ["Mid_West_station", "BV", "", "", "", ""] + }, + { + "row": 3, + "values": ["Mid_East_station", "BV", "12:58:19", "12:58:40", "21", ""] + }, + { + "row": 4, + "values": ["North_East_station", "BV", "", "", "0", ""] + } +] diff --git a/front/tests/pages/op-input-table-page-model.ts b/front/tests/pages/op-input-table-page-model.ts new file mode 100644 index 00000000000..8a922b257be --- /dev/null +++ b/front/tests/pages/op-input-table-page-model.ts @@ -0,0 +1,113 @@ +import { type Locator, type Page, expect } from '@playwright/test'; + +import enTranslations from '../../public/locales/en/timesStops.json'; +import frTranslations from '../../public/locales/fr/timesStops.json'; +import { cleanWhitespace } from '../utils/dataNormalizer'; + +class OperationalStudiesInputTablePage { + readonly page: Page; + + readonly columnHeaders: Locator; + + readonly activeRows: Locator; + + readonly tableRows: Locator; + + readonly deleteButtons: Locator; + + constructor(page: Page) { + this.page = page; + this.activeRows = page.locator('.dsg-container .dsg-row.activeRow'); + this.columnHeaders = page.locator( + '[class^="dsg-cell dsg-cell-header"] .dsg-cell-header-container' + ); + this.tableRows = page.locator('.dsg-row'); + this.deleteButtons = page.getByTestId('remove-via-button'); + } + + // Verify the count of rows with 'activeRow' class + async verifyActiveRowsCount(expectedCount: number) { + const activeRowCount = await this.activeRows.count(); + expect(activeRowCount).toBe(expectedCount); + } + + async fillTableCellByStationAndHeader( + stationName: string, + header: string, + fillValue: string, + selectedLanguage: string, + inputPlaceholder?: string + ) { + const translations = selectedLanguage === 'English' ? enTranslations : frTranslations; + + const normalizedHeaderName = cleanWhitespace(header); + + const headersCount = await this.columnHeaders.count(); + let columnIndex = -1; + + for (let headerIndex = 0; headerIndex < headersCount; headerIndex += 1) { + const headerText = await this.columnHeaders.nth(headerIndex).innerText(); + const normalizedHeaderText = cleanWhitespace(headerText); + if (normalizedHeaderText === normalizedHeaderName) { + columnIndex = headerIndex + 1; + break; + } + } + + const rowLocator = this.tableRows + .filter({ + has: this.page.locator(`input.dsg-input[value="${stationName}"]`), + }) + .first(); + await rowLocator.waitFor({ state: 'attached' }); + const cell = rowLocator.locator('.dsg-cell').nth(columnIndex); + await cell.waitFor({ state: 'visible', timeout: 5000 }); + await cell.dblclick(); + + // Fill the input field based on the presence of a placeholder + if (inputPlaceholder) { + await cell.getByPlaceholder(inputPlaceholder).fill(fillValue); + } else { + await cell.locator('.dsg-input').fill(fillValue); + + if (cleanWhitespace(header) === cleanWhitespace(translations.stopTime)) { + await cell.locator('.dsg-input').press('Enter'); + + if (stationName === 'Mid_West_station') { + const signalReceptionCheckbox = rowLocator.locator('.dsg-cell .dsg-checkbox'); + await signalReceptionCheckbox.click(); + await expect(signalReceptionCheckbox).toBeChecked(); + } + } + } + } + + // Verify delete buttons visibility and count + async verifyDeleteButtons(expectedCount: number) { + await expect(this.deleteButtons).toHaveCount(expectedCount); + const deleteButtonsArray = this.deleteButtons; + for (let buttonIndex = 0; buttonIndex < expectedCount; buttonIndex += 1) { + await expect(deleteButtonsArray.nth(buttonIndex)).toBeVisible(); + } + } + + // Retrieve and verify input table data + async verifyInputTableData(expectedTableData: JSON) { + const actualTableData = []; + const rowCount = await this.tableRows.count(); + + for (let rowIndex = 1; rowIndex < rowCount; rowIndex += 1) { + const rowCells = this.tableRows.nth(rowIndex).locator('.dsg-cell .dsg-input'); + await rowCells.first().waitFor({ state: 'visible', timeout: 5000 }); + const rowValues = await rowCells.evaluateAll((cells) => + cells.map((cell) => cell.getAttribute('value')) + ); + actualTableData.push({ row: rowIndex, values: rowValues }); + } + + // Compare actual output to expected data + expect(actualTableData).toEqual(expectedTableData); + } +} + +export default OperationalStudiesInputTablePage; diff --git a/front/tests/pages/op-output-table-page-model.ts b/front/tests/pages/op-output-table-page-model.ts new file mode 100644 index 00000000000..66f3d004f86 --- /dev/null +++ b/front/tests/pages/op-output-table-page-model.ts @@ -0,0 +1,134 @@ +import { type Locator, type Page, expect } from '@playwright/test'; + +import enTranslations from '../../public/locales/en/timesStops.json'; +import frTranslations from '../../public/locales/fr/timesStops.json'; +import { normalizeData, type StationData } from '../utils/dataNormalizer'; + +class OperationalStudiesOutputTablePage { + readonly page: Page; + + readonly columnHeaders: Locator; + + readonly tableRows: Locator; + + constructor(page: Page) { + this.page = page; + this.columnHeaders = page.locator( + '[class="dsg-cell dsg-cell-header"] .dsg-cell-header-container' + ); + this.tableRows = page.locator('.osrd-simulation-container .time-stops-datasheet .dsg-row'); + } + + // Retrieve the cell value based on the locator type + static async getCellValue(cell: Locator, isInput: boolean = true): Promise { + return isInput + ? (await cell.locator('input').getAttribute('value'))?.trim() || '' + : (await cell.textContent())?.trim() || ''; + } + + // Extract the column index for each header name + async getHeaderIndexMap(): Promise> { + await this.columnHeaders.first().waitFor({ state: 'visible' }); + const headers = await this.columnHeaders.allTextContents(); + const headerMap: Record = {}; + headers.forEach((header, index) => { + const cleanedHeader = header.trim(); + headerMap[cleanedHeader] = index; + }); + return headerMap; + } + + async getOutputTableData(expectedTableData: StationData[], selectedLanguage: string) { + const actualTableData: StationData[] = []; + const translations = selectedLanguage === 'English' ? enTranslations : frTranslations; + const headerIndexMap = await this.getHeaderIndexMap(); + const rowCount = await this.tableRows.count(); + + // Iterate through each active row and extract data based on header mappings + for (let rowIndex = 1; rowIndex < rowCount; rowIndex += 1) { + const row = this.tableRows.nth(rowIndex); + await row.waitFor({ state: 'visible' }); + + // Extract cells from the current row + const cells = row.locator('.dsg-cell.dsg-cell-disabled'); + + const [ + stationName, + stationCh, + requestedArrival, + requestedDeparture, + stopTime, + signalReceptionClosed, + theoreticalMargin, + theoreticalMarginS, + actualMargin, + marginDifference, + calculatedArrival, + calculatedDeparture, + ] = await Promise.all([ + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.name]) + ), + await OperationalStudiesOutputTablePage.getCellValue(cells.nth(headerIndexMap.Ch)), + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.arrivalTime]), + false + ), + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.departureTime]), + false + ), + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.stopTime]) + ), + await cells + .nth(headerIndexMap[translations.receptionOnClosedSignal]) + .locator('input.dsg-checkbox') + .isChecked(), + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.theoreticalMargin]) + ), + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.theoreticalMarginSeconds]) + ), + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.realMargin]) + ), + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.diffMargins]) + ), + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.calculatedArrivalTime]) + ), + await OperationalStudiesOutputTablePage.getCellValue( + cells.nth(headerIndexMap[translations.calculatedDepartureTime]) + ), + ]); + + // Push the row data into the actual table data array + actualTableData.push({ + stationName, + stationCh, + requestedArrival, + requestedDeparture, + stopTime, + signalReceptionClosed, + margin: { + theoretical: theoreticalMargin, + theoreticalS: theoreticalMarginS, + actual: actualMargin, + difference: marginDifference, + }, + calculatedArrival, + calculatedDeparture, + }); + } + + // // Normalize and compare data + const normalizedActualData = normalizeData(actualTableData); + const normalizedExpectedData = normalizeData(expectedTableData); + expect(normalizedActualData).toEqual(normalizedExpectedData); + } +} + +export default OperationalStudiesOutputTablePage; diff --git a/front/tests/pages/op-timetable-page-model.ts b/front/tests/pages/op-timetable-page-model.ts index f02ca90d7c3..6138f8547c6 100644 --- a/front/tests/pages/op-timetable-page-model.ts +++ b/front/tests/pages/op-timetable-page-model.ts @@ -18,6 +18,8 @@ class OperationalStudiesTimetablePage { readonly spaceTimeChart: Locator; + readonly speedSapceChart: Locator; + readonly timeStopsDatasheet: Locator; readonly spaceCurvesSlopesChart: Locator; @@ -37,7 +39,8 @@ class OperationalStudiesTimetablePage { this.selectedTimetableTrain = page.locator('[data-testid="scenario-timetable-train"].selected'); this.simulationBar = page.locator('.osrd-simulation-sticky-bar'); this.manchetteSpaceTimeChart = page.locator('.manchette-space-time-chart-wrapper'); - this.spaceTimeChart = page.locator('#container-SpeedSpaceChart'); + this.speedSapceChart = page.locator('#container-SpeedSpaceChart'); + this.spaceTimeChart = page.locator('.space-time-chart-container'); this.timeStopsDatasheet = page.locator('.time-stops-datasheet'); this.spaceCurvesSlopesChart = page.locator('#container-SpaceCurvesSlopes'); this.simulationMap = page.locator('.osrd-simulation-map'); @@ -82,6 +85,7 @@ class OperationalStudiesTimetablePage { const simulationResultsLocators = [ this.simulationBar, this.manchetteSpaceTimeChart, + this.speedSapceChart, this.spaceTimeChart, this.timeStopsDatasheet, this.spaceCurvesSlopesChart, @@ -193,6 +197,12 @@ class OperationalStudiesTimetablePage { await this.verifySimulationResultsVisibility(); } } + + async verifyTimeStopsDatasheetVisibility(timeout = 30 * 1000): Promise { + await this.timeStopsDatasheet.scrollIntoViewIfNeeded(); + // Wait for the Times and Stops simulation datasheet to be fully loaded with a specified timeout (default: 30 seconds) + await expect(this.timeStopsDatasheet).toBeVisible({ timeout }); + } } export default OperationalStudiesTimetablePage; diff --git a/front/tests/pages/operational-studies-page-model.ts b/front/tests/pages/operational-studies-page-model.ts index cf92187f770..952d7557785 100644 --- a/front/tests/pages/operational-studies-page-model.ts +++ b/front/tests/pages/operational-studies-page-model.ts @@ -3,6 +3,7 @@ import { expect, type Locator, type Page } from '@playwright/test'; import RollingStockSelectorPage from './rollingstock-selector-page'; import enTranslations from '../../public/locales/en/operationalStudies/manageTrainSchedule.json'; import frTranslations from '../../public/locales/fr/operationalStudies/manageTrainSchedule.json'; +import { safeClick } from '../utils'; class OperationalStudiesPage { readonly page: Page; @@ -61,6 +62,8 @@ class OperationalStudiesPage { readonly missingParamMessage: Locator; + readonly startTimeField: Locator; + constructor(page: Page) { this.page = page; this.addScenarioTrainBtn = page.getByTestId('scenarios-add-train-schedule-button'); @@ -90,6 +93,7 @@ class OperationalStudiesPage { this.viaModal = page.locator('.manage-vias-modal'); this.closeViaModalButton = page.getByLabel('Close'); this.missingParamMessage = page.getByTestId('missing-params-info'); + this.startTimeField = page.locator('#trainSchedule-startTime'); } // Gets the name locator of a waypoint suggestion. @@ -184,6 +188,15 @@ class OperationalStudiesPage { await this.getRollingStockSelector.click(); } + // Set the train start time + async setTrainStartTime(departureTime: string) { + const currentDate = new Date().toISOString().split('T')[0]; + const startTime = `${currentDate}T${departureTime}`; + await this.startTimeField.waitFor({ state: 'visible' }); + await this.startTimeField.fill(startTime); + await expect(this.startTimeField).toHaveValue(startTime); + } + // Clicks the button to submit the search by trigram. async clickSearchByTrigramSubmitButton() { await this.searchByTrigramSubmit.click(); @@ -248,16 +261,16 @@ class OperationalStudiesPage { // Clicks the buttons to delete origin, destination, and via waypoints and verifies missing parameters message. async clickOnDeleteOPButtons(selectedLanguage: string) { - await this.viaDeleteButton.click(); - await this.originDeleteButton.click(); - await this.destinationDeleteButton.click(); + await safeClick(this.viaDeleteButton); + await safeClick(this.originDeleteButton); + await safeClick(this.destinationDeleteButton); const translations = selectedLanguage === 'English' ? enTranslations : frTranslations; const expectedMessage = translations.pathfindingMissingParams.replace( ': {{missingElements}}.', '' ); - await this.missingParamMessage.waitFor(); + await this.missingParamMessage.waitFor({ state: 'visible' }); const actualMessage = await this.missingParamMessage.innerText(); expect(actualMessage).toContain(expectedMessage); } diff --git a/front/tests/pages/scenario-page-model.ts b/front/tests/pages/scenario-page-model.ts index 75fa0d954d0..a53075ca89f 100644 --- a/front/tests/pages/scenario-page-model.ts +++ b/front/tests/pages/scenario-page-model.ts @@ -200,7 +200,7 @@ class ScenarioPage extends BasePage { } async checkInfraLoaded() { - await this.page.waitForSelector('.cached'); + await this.page.waitForSelector('.cached', { timeout: 60 * 1000 }); // Wait for the infrastructure to be fully loaded with a timeout of 60 seconds await expect(this.getInfraLoadState).toHaveClass(/cached/); } diff --git a/front/tests/utils/dataNormalizer.ts b/front/tests/utils/dataNormalizer.ts new file mode 100644 index 00000000000..8022aedc826 --- /dev/null +++ b/front/tests/utils/dataNormalizer.ts @@ -0,0 +1,48 @@ +interface Margin { + theoretical: string; + theoreticalS: string; + actual: string; + difference: string; +} + +export interface StationData { + stationName: string; + stationCh: string; + requestedArrival: string; + requestedDeparture: string; + stopTime: string; + signalReceptionClosed: boolean; + margin: Margin; + calculatedArrival: string; + calculatedDeparture: string; +} + +// Apply whitespace cleaning to a string +export function cleanWhitespace(text: string = ''): string { + return text.trim().replace(/\s+/g, ' '); +} + +// Apply whitespace cleaning to an array of strings. +export function cleanWhitespaces(headers: string[]): string[] { + return headers.map(cleanWhitespace); +} + +// Normalize data by applying whitespace cleaning to all relevant fields. +export function normalizeData(data: StationData[]): StationData[] { + return data.map((item) => ({ + stationName: cleanWhitespace(item.stationName), + stationCh: cleanWhitespace(item.stationCh), + requestedArrival: cleanWhitespace(item.requestedArrival), + requestedDeparture: cleanWhitespace(item.requestedDeparture), + stopTime: cleanWhitespace(item.stopTime), + signalReceptionClosed: item.signalReceptionClosed, + margin: { + theoretical: cleanWhitespace(item.margin.theoretical), + theoreticalS: cleanWhitespace(item.margin.theoreticalS), + actual: cleanWhitespace(item.margin.actual), + difference: cleanWhitespace(item.margin.difference), + }, + calculatedArrival: cleanWhitespace(item.calculatedArrival), + calculatedDeparture: cleanWhitespace(item.calculatedDeparture), + })); +} diff --git a/front/tests/utils/index.ts b/front/tests/utils/index.ts index ebf135b7502..be50f0c91f7 100644 --- a/front/tests/utils/index.ts +++ b/front/tests/utils/index.ts @@ -1,6 +1,6 @@ import fs from 'fs'; -import { type APIResponse, type Page, expect, request } from '@playwright/test'; +import { type APIResponse, type Locator, type Page, expect, request } from '@playwright/test'; import { v4 as uuidv4 } from 'uuid'; import type { Project, Scenario, Study, RollingStock, Infra } from 'common/api/osrdEditoastApi'; @@ -146,3 +146,40 @@ export function handleApiResponse(response: APIResponse, errorMessage: string) { throw new Error(`${errorMessage}: ${response.status()} ${response.statusText()}`); } } +type ClickOptions = { + scroll?: boolean; // Optional flag to scroll into view + waitForVisible?: boolean; // Optional flag to wait for the element to be visible + visibleTimeout?: number; // Optional custom timeout for visible wait + waitForHidden?: boolean; // Optional flag to wait for the element to disappear after clicking + hiddenTimeout?: number; // Optional custom timeout for hidden wait +}; + +/** + * Generic safe click function for any element or button. + * + * @param element - The locator of the element to click. + * @param options - Optional settings to customize the click behavior. + */ +export async function safeClick(element: Locator, options: ClickOptions = {}) { + const { + scroll = true, + waitForVisible = true, + visibleTimeout = 5000, + waitForHidden = true, + hiddenTimeout = 5000, + } = options; + + if (scroll) { + await element.scrollIntoViewIfNeeded(); + } + + if (waitForVisible) { + await element.waitFor({ state: 'visible', timeout: visibleTimeout }); + } + + await element.click(); + + if (waitForHidden) { + await element.waitFor({ state: 'hidden', timeout: hiddenTimeout }); + } +} diff --git a/front/tests/utils/scrollHelper.ts b/front/tests/utils/scrollHelper.ts new file mode 100644 index 00000000000..2c81cd6b17c --- /dev/null +++ b/front/tests/utils/scrollHelper.ts @@ -0,0 +1,50 @@ +import { type Page } from 'playwright'; + +interface ScrollOptions { + stepSize?: number; + timeout?: number; +} + +const scrollContainer = async ( + page: Page, + containerSelector: string, + { stepSize = 300, timeout = 20 }: ScrollOptions = {} +): Promise => { + // Locate the scrollable container once + const container = await page.evaluateHandle( + (selector: string) => document.querySelector(selector), + containerSelector + ); + + // Ensure the container exists and has scrollable content + const scrollWidth = await page.evaluate( + (containerElement) => (containerElement ? containerElement.scrollWidth : 0), + container + ); + + // Exit early if scrollWidth is 0 + if (scrollWidth === 0) { + await container.dispose(); + return; + } + + // Scroll in steps + let currentScrollPosition = 0; + while (currentScrollPosition < scrollWidth) { + await page.evaluate( + ({ containerElement, step }) => { + if (containerElement) { + containerElement.scrollLeft += step; + } + }, + { containerElement: container, step: stepSize } + ); + + await page.waitForTimeout(timeout); + currentScrollPosition += stepSize; + } + + await container.dispose(); +}; + +export default scrollContainer;