import { test, expect } from '../support/cross-app-fixtures'; import { mockAllAPIs } from '../support/cross-app-fixtures'; import { S, tid } from '../support/selectors'; /** * User Stories 17-30: Advanced Search & Filter Scenarios * * 17: User clears search inputs * 18: User swaps departure and arrival cities * 19: User selects date from calendar * 20: User searches with date range * 21: User views search history * 22: User searches from search history * 23: User searches with invalid city * 24: User searches with empty fields * 25: User searches with date before today * 26: User searches with same departure and arrival * 27: User searches with special characters * 28: User searches with Unicode characters * 29: User searches with very long city name * 30: User searches with rapid attempts */ test.describe('User Stories 17-30: Advanced Search & Filter Scenarios', () => { test.beforeEach(async ({ page, localePath }) => { await mockAllAPIs(page); await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); }); // ───────────────────────────────────────────────────────────────────────── // Story 17: User clears search inputs // ───────────────────────────────────────────────────────────────────────── test('17.1: User clears departure city input', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); await departureInput.fill('Moscow'); await page.waitForTimeout(500); const clearButton = departureInput .locator( 'button[aria-label*="clear"], button[aria-label*="Clear"], .p-input-icon-right button, [data-testid*="clear"]', ) .first(); if ((await clearButton.count()) > 0) { await clearButton.click(); await page.waitForTimeout(300); const value = await departureInput.inputValue(); expect(value).toBe(''); } }); test('17.2: User clears arrival city input', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_ARRIVAL_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const arrivalInput = page.locator(tid(S.FILTER_ROUTE_ARRIVAL_INPUT, app)); await arrivalInput.fill('Sochi'); await page.waitForTimeout(500); const clearButton = arrivalInput .locator( 'button[aria-label*="clear"], button[aria-label*="Clear"], .p-input-icon-right button, [data-testid*="clear"]', ) .first(); if ((await clearButton.count()) > 0) { await clearButton.click(); await page.waitForTimeout(300); const value = await arrivalInput.inputValue(); expect(value).toBe(''); } }); test('17.3: User clears flight number input', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const flightTab = page.locator(tid(S.FILTER_FLIGHT_TAB, app)); const fallback = page.locator('[data-testid="flight-filter"]'); const tabEl = (await flightTab.count()) > 0 ? flightTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_FLIGHT_NUMBER_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const flightInput = page.locator(tid(S.FILTER_FLIGHT_NUMBER_INPUT, app)); await flightInput.fill('SU1234'); await page.waitForTimeout(500); const clearButton = flightInput .locator( 'button[aria-label*="clear"], button[aria-label*="Clear"], .p-input-icon-right button, [data-testid*="clear"]', ) .first(); if ((await clearButton.count()) > 0) { await clearButton.click(); await page.waitForTimeout(300); const value = await flightInput.inputValue(); expect(value).toBe(''); } }); test('17.4: User clears all search inputs at once', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); const arrivalInput = page.locator(tid(S.FILTER_ROUTE_ARRIVAL_INPUT, app)); await departureInput.fill('Moscow'); await arrivalInput.fill('Sochi'); await page.waitForTimeout(500); const clearButtons = page.locator( 'button[aria-label*="clear"], button[aria-label*="Clear"], .p-input-icon-right button, [data-testid*="clear"]', ); const count = await clearButtons.count(); if (count > 0) { for (let i = 0; i < Math.min(count, 5); i++) { await clearButtons.nth(i).click(); await page.waitForTimeout(200); } } const depValue = await departureInput.inputValue(); const arrValue = await arrivalInput.inputValue(); expect(depValue).toBe(''); expect(arrValue).toBe(''); }); // ───────────────────────────────────────────────────────────────────────── // Story 18: User swaps departure and arrival cities // ───────────────────────────────────────────────────────────────────────── test('18.1: User swaps departure and arrival cities', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); const arrivalInput = page.locator(tid(S.FILTER_ROUTE_ARRIVAL_INPUT, app)); const swapButton = page.locator(tid(S.FILTER_ROUTE_SWAP_BUTTON, app)); await departureInput.fill('Moscow'); await arrivalInput.fill('Sochi'); await page.waitForTimeout(500); const depBefore = await departureInput.inputValue(); const arrBefore = await arrivalInput.inputValue(); if ((await swapButton.count()) > 0) { await swapButton.click(); await page.waitForTimeout(500); const depAfter = await departureInput.inputValue(); const arrAfter = await arrivalInput.inputValue(); expect(depAfter).toBe(arrBefore); expect(arrAfter).toBe(depBefore); } }); test('18.2: Swap button is visible and enabled', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const swapButton = page.locator(tid(S.FILTER_ROUTE_SWAP_BUTTON, app)); await expect(swapButton).toBeVisible(); await expect(swapButton).toBeEnabled(); }); // ───────────────────────────────────────────────────────────────────────── // Story 19: User selects date from calendar // ───────────────────────────────────────────────────────────────────────── test('19.1: Calendar input is visible and clickable', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_CALENDAR, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const calendarInput = page.locator(tid(S.FILTER_ROUTE_CALENDAR, app)); await expect(calendarInput).toBeVisible(); await expect(calendarInput).toBeEnabled(); }); test('19.2: Calendar overlay opens on click', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_CALENDAR, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const calendarInput = page.locator(tid(S.FILTER_ROUTE_CALENDAR, app)); await calendarInput.click(); await page.waitForTimeout(500); const calendarOverlay = page.locator( '.p-calendar-panel, .p-datepicker, .ng-tns-c-date-picker, [role="dialog"]', ); await expect(calendarOverlay.first()).toBeVisible({ timeout: 5000 }); }); test('19.3: User selects future date from calendar', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_CALENDAR, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const calendarInput = page.locator(tid(S.FILTER_ROUTE_CALENDAR, app)); await calendarInput.click(); await page.waitForTimeout(500); const calendarOverlay = page.locator( '.p-calendar-panel, .p-datepicker, .ng-tns-c-date-picker, [role="dialog"]', ); await expect(calendarOverlay.first()).toBeVisible({ timeout: 5000 }); const futureDate = calendarOverlay.locator( 'td.p-datepicker-day:not(.p-disabled):not(.p-datepicker-today):nth-child(>7)', ); const count = await futureDate.count(); if (count > 0) { await futureDate.first().click(); await page.waitForTimeout(300); } }); test('19.4: Selected date displays in correct format', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_CALENDAR, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const calendarInput = page.locator(tid(S.FILTER_ROUTE_CALENDAR, app)); await calendarInput.click(); await page.waitForTimeout(500); const calendarOverlay = page.locator( '.p-calendar-panel, .p-datepicker, .ng-tns-c-date-picker, [role="dialog"]', ); await expect(calendarOverlay.first()).toBeVisible({ timeout: 5000 }); const todayCell = calendarOverlay.locator('td.p-datepicker-today'); if ((await todayCell.count()) > 0) { await todayCell.first().click(); await page.waitForTimeout(300); const inputValue = await calendarInput.inputValue(); expect(inputValue.length).toBeGreaterThan(0); const dateRegex = /\d{1,2}[.\\/-]\d{1,2}[.\\/-]\d{2,4}/; expect(inputValue).toMatch(dateRegex); } }); // ───────────────────────────────────────────────────────────────────────── // Story 20: User searches with date range // ───────────────────────────────────────────────────────────────────────── test('20.1: Schedule page has date range inputs', async ({ page, app, localePath }) => { await page.goto(localePath('schedule')); await page.waitForLoadState('networkidle'); const outboundCalendar = page.locator(tid(S.SCHEDULE_CALENDAR, app)); const returnCalendar = page.locator(tid(S.SCHEDULE_RETURN_CALENDAR, app)); const outboundVisible = await outboundCalendar.count(); const returnVisible = await returnCalendar.count(); expect(outboundVisible).toBeGreaterThan(0); expect(returnVisible).toBeGreaterThan(0); }); test('20.2: User selects outbound date', async ({ page, app, localePath }) => { await page.goto(localePath('schedule')); await page.waitForLoadState('networkidle'); const outboundCalendar = page.locator(tid(S.SCHEDULE_CALENDAR, app)); await outboundCalendar.click(); await page.waitForTimeout(500); const calendarOverlay = page.locator( '.p-calendar-panel, .p-datepicker, .ng-tns-c-date-picker, [role="dialog"]', ); await expect(calendarOverlay.first()).toBeVisible({ timeout: 5000 }); const todayCell = calendarOverlay.locator('td.p-datepicker-today'); if ((await todayCell.count()) > 0) { await todayCell.first().click(); await page.waitForTimeout(300); } }); test('20.3: User selects return date', async ({ page, app, localePath }) => { await page.goto(localePath('schedule')); await page.waitForLoadState('networkidle'); const returnCalendar = page.locator(tid(S.SCHEDULE_RETURN_CALENDAR, app)); await returnCalendar.click(); await page.waitForTimeout(500); const calendarOverlay = page.locator( '.p-calendar-panel, .p-datepicker, .ng-tns-c-date-picker, [role="dialog"]', ); await expect(calendarOverlay.first()).toBeVisible({ timeout: 5000 }); const todayCell = calendarOverlay.locator('td.p-datepicker-today'); if ((await todayCell.count()) > 0) { await todayCell.first().click(); await page.waitForTimeout(300); } }); test('20.4: Date range search executes successfully', async ({ page, app, localePath }) => { await page.goto(localePath('schedule')); await page.waitForLoadState('networkidle'); const departureInput = page.locator(tid(S.SCHEDULE_DEPARTURE_INPUT, app)); const arrivalInput = page.locator(tid(S.SCHEDULE_ARRIVAL_INPUT, app)); const outboundCalendar = page.locator(tid(S.SCHEDULE_CALENDAR, app)); const returnCalendar = page.locator(tid(S.SCHEDULE_RETURN_CALENDAR, app)); const searchButton = page.locator(tid(S.SCHEDULE_SEARCH_BUTTON, app)); await departureInput.fill('Moscow'); await arrivalInput.fill('Sochi'); await outboundCalendar.click(); await page.waitForTimeout(500); const outboundOverlay = page.locator( '.p-calendar-panel, .p-datepicker, .ng-tns-c-date-picker, [role="dialog"]', ); const todayCell = outboundOverlay.locator('td.p-datepicker-today'); if ((await todayCell.count()) > 0) { await todayCell.first().click(); await page.waitForTimeout(300); } await returnCalendar.click(); await page.waitForTimeout(500); const returnOverlay = page.locator( '.p-calendar-panel, .p-datepicker, .ng-tns-c-date-picker, [role="dialog"]', ); const futureCell = returnOverlay.locator('td.p-datepicker-day:not(.p-disabled):nth-child(>7)'); const count = await futureCell.count(); if (count > 0) { await futureCell.first().click(); await page.waitForTimeout(300); } await searchButton.click(); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); const results = page.locator( 'schedule-result, .schedule-result, [data-testid*="schedule-flight"], .schedule__item', ); const countResults = await results.count(); expect(countResults).toBeGreaterThanOrEqual(0); }); // ───────────────────────────────────────────────────────────────────────── // Story 21: User views search history // ───────────────────────────────────────────────────────────────────────── test('21.1: Search history section exists on landing', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const historySection = page.locator( 'search-history, [data-testid="landing-search-history"], .search-history, [class*="search-history"]', ); const count = await historySection.count(); expect(count).toBeGreaterThan(0); }); test('21.2: Search history is empty by default', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const historySection = page.locator( 'search-history, [data-testid="landing-search-history"], .search-history, [class*="search-history"]', ); const historyItems = historySection.locator( '.history-item, [data-testid="landing-search-history-item"], .search-history__item', ); const count = await historyItems.count(); expect(count).toBeGreaterThanOrEqual(0); }); test('21.3: Search history appears after performing search', async ({ page, app, localePath, }) => { const today = formatToday(); await page.goto(`/${localePath('onlineboard')}/departure/MOW-${today}`); await page.waitForLoadState('networkidle'); await page.waitForTimeout(2000); await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); const historyItems = page.locator( 'search-history .history-item, [data-testid="landing-search-history-item"], .search-history__item', ); const count = await historyItems.count(); if (count > 0) { expect(count).toBeGreaterThanOrEqual(1); } }); // ───────────────────────────────────────────────────────────────────────── // Story 22: User searches from search history // ───────────────────────────────────────────────────────────────────────── test('22.1: Clicking history item re-executes search', async ({ page, app, localePath }) => { const today = formatToday(); await page.goto(`/${localePath('onlineboard')}/departure/MOW-${today}`); await page.waitForLoadState('networkidle'); await page.waitForTimeout(2000); await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); const historyItems = page.locator( 'search-history .history-item, [data-testid="landing-search-history-item"], .search-history__item', ); const count = await historyItems.count(); if (count > 0) { const urlBefore = page.url(); await historyItems.first().click(); await page.waitForTimeout(1000); const urlAfter = page.url(); expect(urlAfter).not.toBe(urlBefore); } }); test('22.2: History item URL matches search parameters', async ({ page, app, localePath }) => { const today = formatToday(); await page.goto(`/${localePath('onlineboard')}/departure/MOW-${today}`); await page.waitForLoadState('networkidle'); await page.waitForTimeout(2000); await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); const historyItems = page.locator( 'search-history .history-item, [data-testid="landing-search-history-item"], .search-history__item', ); const count = await historyItems.count(); if (count > 0) { await historyItems.first().click(); await page.waitForTimeout(1000); const url = page.url(); expect(url).toContain('departure'); expect(url).toContain('MOW'); } }); // ───────────────────────────────────────────────────────────────────────── // Story 23: User searches with invalid city // ───────────────────────────────────────────────────────────────────────── test('23.1: Invalid city shows error message', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); await departureInput.fill('INVALIDCITYXYZ'); await page.waitForTimeout(500); const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); await searchButton.click(); await page.waitForTimeout(1000); const errorMessages = page.locator( '.p-error, .error-message, [role="alert"], .ng-invalid.ng-dirty', ); const count = await errorMessages.count(); if (count > 0) { expect(count).toBeGreaterThanOrEqual(1); } }); test('23.2: Invalid city search does not return flights', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); await departureInput.fill('INVALIDCITYXYZ'); await page.waitForTimeout(500); const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); await searchButton.click(); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); const flightResults = page.locator( 'flight-result, .flight-result, [data-testid*="flight-result"], .flight__item', ); const count = await flightResults.count(); expect(count).toBeGreaterThanOrEqual(0); }); // ───────────────────────────────────────────────────────────────────────── // Story 24: User searches with empty fields // ───────────────────────────────────────────────────────────────────────── test('24.1: Empty search shows validation error', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); await searchButton.click(); await page.waitForTimeout(500); const errorMessages = page.locator( '.p-error, .error-message, [role="alert"], .ng-invalid.ng-dirty', ); const count = await errorMessages.count(); if (count > 0) { expect(count).toBeGreaterThanOrEqual(1); } }); test('24.2: Empty search does not navigate away', async ({ page, app, localePath }) => { const urlBefore = page.url(); await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); await searchButton.click(); await page.waitForTimeout(500); const urlAfter = page.url(); expect(urlAfter).toBe(urlBefore); }); // ───────────────────────────────────────────────────────────────────────── // Story 25: User searches with date before today // ───────────────────────────────────────────────────────────────────────── test('25.1: Past date shows validation error', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_CALENDAR, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const calendarInput = page.locator(tid(S.FILTER_ROUTE_CALENDAR, app)); await calendarInput.click(); await page.waitForTimeout(500); const calendarOverlay = page.locator( '.p-calendar-panel, .p-datepicker, .ng-tns-c-date-picker, [role="dialog"]', ); await expect(calendarOverlay.first()).toBeVisible({ timeout: 5000 }); const pastDate = calendarOverlay.locator('td.p-datepicker-day.p-disabled'); const count = await pastDate.count(); if (count > 0) { await pastDate.first().click(); await page.waitForTimeout(300); const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); await searchButton.click(); await page.waitForTimeout(500); const errorMessages = page.locator( '.p-error, .error-message, [role="alert"], .ng-invalid.ng-dirty', ); const errorCount = await errorMessages.count(); if (errorCount > 0) { expect(errorCount).toBeGreaterThanOrEqual(1); } } }); // ───────────────────────────────────────────────────────────────────────── // Story 26: User searches with same departure and arrival // ───────────────────────────────────────────────────────────────────────── test('26.1: Same departure and arrival shows validation error', async ({ page, app, localePath, }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); const arrivalInput = page.locator(tid(S.FILTER_ROUTE_ARRIVAL_INPUT, app)); await departureInput.fill('Moscow'); await arrivalInput.fill('Moscow'); await page.waitForTimeout(500); const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); await searchButton.click(); await page.waitForTimeout(500); const errorMessages = page.locator( '.p-error, .error-message, [role="alert"], .ng-invalid.ng-dirty', ); const count = await errorMessages.count(); if (count > 0) { expect(count).toBeGreaterThanOrEqual(1); } }); // ───────────────────────────────────────────────────────────────────────── // Story 27: User searches with special characters // ───────────────────────────────────────────────────────────────────────── test('27.1: Special characters in flight number', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const flightTab = page.locator(tid(S.FILTER_FLIGHT_TAB, app)); const fallback = page.locator('[data-testid="flight-filter"]'); const tabEl = (await flightTab.count()) > 0 ? flightTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_FLIGHT_NUMBER_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const flightInput = page.locator(tid(S.FILTER_FLIGHT_NUMBER_INPUT, app)); await flightInput.fill('SU@#$%123'); await page.waitForTimeout(500); const searchButton = page.locator(tid(S.FILTER_FLIGHT_NUMBER_SEARCH, app)); await searchButton.click(); await page.waitForTimeout(1000); const errorMessages = page.locator( '.p-error, .error-message, [role="alert"], .ng-invalid.ng-dirty', ); const count = await errorMessages.count(); if (count > 0) { expect(count).toBeGreaterThanOrEqual(1); } }); test('27.2: Special characters in city name', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); await departureInput.fill('Moscow@#$%'); await page.waitForTimeout(500); const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); await searchButton.click(); await page.waitForTimeout(1000); const errorMessages = page.locator( '.p-error, .error-message, [role="alert"], .ng-invalid.ng-dirty', ); const count = await errorMessages.count(); if (count > 0) { expect(count).toBeGreaterThanOrEqual(1); } }); // ───────────────────────────────────────────────────────────────────────── // Story 28: User searches with Unicode characters // ───────────────────────────────────────────────────────────────────────── test('28.1: Unicode characters in city name', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); await departureInput.fill('Москва'); await page.waitForTimeout(500); const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); await searchButton.click(); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); const results = page.locator( 'flight-result, .flight-result, [data-testid*="flight-result"], .flight__item', ); const count = await results.count(); expect(count).toBeGreaterThanOrEqual(0); }); test('28.2: Unicode characters in flight number', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const flightTab = page.locator(tid(S.FILTER_FLIGHT_TAB, app)); const fallback = page.locator('[data-testid="flight-filter"]'); const tabEl = (await flightTab.count()) > 0 ? flightTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_FLIGHT_NUMBER_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const flightInput = page.locator(tid(S.FILTER_FLIGHT_NUMBER_INPUT, app)); await flightInput.fill('СУ1234'); await page.waitForTimeout(500); const searchButton = page.locator(tid(S.FILTER_FLIGHT_NUMBER_SEARCH, app)); await searchButton.click(); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); const results = page.locator( 'flight-result, .flight-result, [data-testid*="flight-result"], .flight__item', ); const count = await results.count(); expect(count).toBeGreaterThanOrEqual(0); }); // ───────────────────────────────────────────────────────────────────────── // Story 29: User searches with very long city name // ───────────────────────────────────────────────────────────────────────── test('29.1: Very long city name is accepted', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); const longName = 'Москва' + ' '.repeat(100) + 'Moscow'; await departureInput.fill(longName); await page.waitForTimeout(500); const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); await searchButton.click(); await page.waitForTimeout(1000); const errorMessages = page.locator( '.p-error, .error-message, [role="alert"], .ng-invalid.ng-dirty', ); const count = await errorMessages.count(); if (count > 0) { expect(count).toBeGreaterThanOrEqual(1); } }); // ───────────────────────────────────────────────────────────────────────── // Story 30: User searches with rapid attempts // ───────────────────────────────────────────────────────────────────────── test('30.1: Rapid searches do not crash the app', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); for (let i = 0; i < 5; i++) { await departureInput.fill(`City${i}`); await page.waitForTimeout(100); await searchButton.click(); await page.waitForTimeout(200); } const consoleErrors: string[] = []; page.on('console', (msg) => { if (msg.type() === 'error') { consoleErrors.push(msg.text()); } }); await page.waitForTimeout(1000); expect(consoleErrors.length).toBeLessThanOrEqual(0); }); test('30.2: Rapid searches show loading state', async ({ page, app, localePath }) => { await page.goto(localePath('onlineboard')); await page.waitForLoadState('networkidle'); const routeTab = page.locator(tid(S.FILTER_ROUTE_TAB, app)); const fallback = page.locator('[data-testid="route-filter"]'); const tabEl = (await routeTab.count()) > 0 ? routeTab : fallback; const isExpanded = await page .locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)) .isVisible() .catch(() => false); if (!isExpanded) { const headerLink = tabEl.locator('.p-accordion-header-link, .p-accordion-header a').first(); if ((await headerLink.count()) > 0) { await headerLink.click(); } else { await tabEl.click(); } await page.waitForTimeout(500); } const departureInput = page.locator(tid(S.FILTER_ROUTE_DEPARTURE_INPUT, app)); const searchButton = page.locator(tid(S.FILTER_ROUTE_SEARCH, app)); const loader = page.locator(tid(S.BOARD_LOADER, app)); for (let i = 0; i < 3; i++) { await departureInput.fill(`City${i}`); await page.waitForTimeout(100); await searchButton.click(); await page.waitForTimeout(200); const loaderVisible = await loader.count(); if (loaderVisible > 0) { await loader.first().waitFor({ state: 'hidden', timeout: 10000 }); } } }); }); function formatToday(): string { const d = new Date(); return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; }