14 Commits

Author SHA1 Message Date
gnezim ac499a3fb5 feat: add data-testid attributes to RouteFilter inputs for test interactions
- Add data-testid to city autocomplete dropdown inputs via pt.input property
- Add data-testid to departure/arrival date calendar inputs via pt.input property
- Add data-testid to swap cities button for test detection
- Both wrapper and input elements now have testids for test selectors
- Ensures Cypress tests can find and interact with form elements
2026-04-05 18:07:19 +03:00
gnezim d6c6634563 fix: configure Angular 12 compatibility with Node 16
- Remove NODE_OPTIONS openssl-legacy-provider flags (Node 16 incompatible)
- Add skipLibCheck to tsconfig for Leaflet type compatibility
- Upgrade @types/leaflet to 1.7.11 for Node 16 compatibility
- Project now builds and runs successfully with Node 16.20.2
2026-04-04 20:05:20 +03:00
gnezim 77c93fa061 feat: add critical missing data-testids for e2e test compatibility
Add data-testids across priority categories:
- Error handling: error-code, error-message, error-description
- Empty states: empty-results, empty-state-message, empty-results-message
- Validation errors: validation-error (city, calendar, flight number inputs)
- Schedule filters: time-range-slider, return-time-range-slider, direct/return checkboxes
- Sort controls: departure/time/arrival ascending/descending buttons
- Loader controls: loader-cancel-button

Updated 10 component templates with strategic testid placement to enable
487+ e2e tests across error-states, i18n, and schedule test suites.
2026-04-04 19:07:11 +03:00
gnezim 2842bbd522 feat: add data-testid attributes to Angular templates for e2e test compatibility 2026-04-04 18:15:09 +03:00
gnezim 2caa5c81fe feat: add flights map e2e tests (74 tests for map rendering, list, interactions, clustering, geolocation, responsive design, api, and state) 2026-04-04 12:20:03 +03:00
gnezim 0ca49b9bf3 feat: add popular requests widget e2e tests (30 tests for load, display, navigation, fallback) 2026-04-04 12:19:42 +03:00
gnezim 393ccfea39 feat: add responsive design e2e tests (60 tests for mobile, tablet, desktop) 2026-04-04 12:19:02 +03:00
gnezim 907ea7503b feat: add online board e2e tests (130 tests covering arrival, departure, filters, modals) 2026-04-04 12:18:50 +03:00
gnezim 91b4cd7db7 feat: add error states and recovery e2e tests (30 tests for network, validation, empty states, retry) 2026-04-04 12:17:25 +03:00
gnezim 0e973d1317 feat: add cypress e2e test infrastructure and support files 2026-04-04 12:14:20 +03:00
gnezim a9b2f4ac5c docs: add e2e test implementation plan with detailed task breakdown 2026-04-04 12:05:32 +03:00
gnezim 5ef60539ce docs: add comprehensive e2e test suite design specification 2026-04-04 12:02:18 +03:00
gnezim dfb9fed99a docs: add Phase 2 Online Board implementation plan 2026-04-03 23:57:03 +03:00
gnezim 729603d27c fix: resolve build issues with ModernJS v3 + Module Federation
- Switch from @module-federation/modern-js to @module-federation/modern-js-v3 (v3 compatible)
- Rename App.tsx to AppProviders.tsx to avoid hasApp detection that blocks nested route discovery
- Move runtime.router config from modern.config.ts to modern.runtime.ts (v3 API)
- Fix PostCSS config type annotation
- Enable streaming SSR mode successfully
2026-04-03 23:34:20 +03:00
48 changed files with 41905 additions and 21574 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"c5ca7b97-b8fb-464c-9bb2-52aa0dc335be","pid":44074,"acquiredAt":1775396071197}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:4200',
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 5000,
requestTimeout: 10000,
responseTimeout: 10000,
pageLoadTimeout: 30000,
chromeWebSecurity: false,
video: true,
screenshotOnRunFailure: true,
specPattern: 'cypress/integration/**/*.ts',
supportFile: 'cypress/support/index.ts',
videosFolder: 'cypress/videos',
screenshotsFolder: 'cypress/screenshots',
fixturesFolder: 'cypress/fixtures',
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
component: {
specPattern: 'cypress/component/**/*.ts',
supportFile: 'cypress/support/index.ts',
devServer: {
framework: 'angular',
bundler: 'webpack',
},
},
});
-27
View File
@@ -1,27 +0,0 @@
{
"integrationFolder": "cypress/integration",
"supportFile": "cypress/support/index.ts",
"videosFolder": "cypress/videos",
"screenshotsFolder": "cypress/screenshots",
"pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures",
"baseUrl": "http://localhost:4200",
"screenshotOnRunFailure": false,
"video": false,
"viewportHeight": 768,
"viewportWidth": 1366,
"chromeWebSecurity": false,
"env": {
"browserPermissions": {
"notifications": "allow",
"geolocation": "block",
"camera": "block",
"microphone": "block",
"images": "allow",
"javascript": "allow",
"popups": "ask",
"plugins": "ask",
"cookies": "allow"
}
}
}
@@ -0,0 +1,475 @@
import { CITIES, MOCK_FLIGHTS_ARRIVAL } from '../../support/fixtures';
describe('Error States & Recovery Tests', () => {
beforeEach(() => {
cy.forbidGeolocation();
cy.visit('/');
});
describe('Network Errors (10 tests)', () => {
it('Should handle 404 Not Found error - error message displays', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 404,
body: { error: 'Resource not found' },
}).as('notFound');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@notFound');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-message').should('contain.text', '404');
});
it('Should handle 404 Not Found error - retry button appears', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 404,
body: { error: 'Resource not found' },
}).as('notFound');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@notFound');
cy.getByTestId('retry-button').should('be.visible');
cy.getByTestId('retry-button').should('be.enabled');
});
it('Should handle 500 Server Error - error message displays', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 500,
body: { error: 'Internal server error' },
}).as('serverError');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@serverError');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-message').should('contain.text', '500');
});
it('Should handle 500 Server Error - retry button appears', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 500,
body: { error: 'Internal server error' },
}).as('serverError');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@serverError');
cy.getByTestId('retry-button').should('be.visible');
});
it('Should handle 503 Service Unavailable - error message displays', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 503,
body: { error: 'Service unavailable' },
}).as('unavailable');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@unavailable');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-message').should('contain.text', '503');
});
it('Should handle 503 Service Unavailable - retry button appears', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 503,
body: { error: 'Service unavailable' },
}).as('unavailable');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@unavailable');
cy.getByTestId('retry-button').should('be.visible');
});
it('Should handle request timeout - timeout message shows', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
req.reply((res) => {
res.delay(15000);
});
}).as('timeout');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.getByTestId('timeout-message', { timeout: 15000 }).should('be.visible');
cy.getByTestId('timeout-message').should('contain.text', 'timeout');
});
it('Should handle connection refused - error message displays gracefully', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
forceNetworkError: true,
}).as('connectionRefused');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@connectionRefused');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-message').should('contain.text', 'network');
});
it('Should handle multiple consecutive errors - error counter increments', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
}).as('consecutiveErrors');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@consecutiveErrors');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('retry-button').click();
cy.wait('@consecutiveErrors');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-count').should('contain.text', '2');
});
it('Should handle multiple consecutive errors - escalation message appears', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 500,
body: { error: 'Server error' },
}).as('errors');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@errors');
cy.getByTestId('retry-button').click();
cy.wait('@errors');
cy.getByTestId('retry-button').click();
cy.wait('@errors');
cy.getByTestId('error-escalation-message').should('be.visible');
cy.getByTestId('error-escalation-message').should('contain.text', 'contact');
});
});
describe('Validation Errors (8 tests)', () => {
it('Should show error when required city field is missing', () => {
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('be.visible');
cy.getByTestId('validation-error').should('contain.text', 'required');
});
it('Should highlight required city field when missing', () => {
cy.getByTestId('search-button').click();
cy.getByTestId('city-autocomplete-input').parent().should('have.class', 'error');
});
it('Should show error when required date field is missing', () => {
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('be.visible');
cy.getByTestId('validation-error').should('contain.text', 'date');
});
it('Should show error for invalid date format', () => {
cy.getByTestId('calendar-input').type('invalid-date');
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('be.visible');
cy.getByTestId('validation-error').should('contain.text', 'format');
});
it('Should show error when past date is selected', () => {
cy.getByTestId('calendar-input').type('01.01.2020');
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('be.visible');
cy.getByTestId('validation-error').should('contain.text', 'past');
});
it('Should handle special characters in text fields gracefully', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('searchWithSpecial');
cy.getByTestId('city-autocomplete-input').type('Москва <script>alert("xss")</script>');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@searchWithSpecial');
cy.getByTestId('board-search-result').should('be.visible');
});
it('Should prevent or show error when max length exceeded in city input', () => {
const longString = 'A'.repeat(200);
cy.getByTestId('city-autocomplete-input').type(longString);
cy.getByTestId('city-autocomplete-input').invoke('val').then((value) => {
expect((value as string).length).to.be.lessThan(200);
});
});
it('Should show validation error for invalid email format (if applicable)', () => {
cy.getByTestId('email-input', { timeout: 3000 }).then(($el) => {
if ($el.length > 0) {
cy.wrap($el).type('invalid-email');
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('contain.text', 'email');
}
});
});
});
describe('Empty State Tests (5 tests)', () => {
it('Should display empty state message when no flights found', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('noFlights');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@noFlights');
cy.getByTestId('empty-results').should('be.visible');
cy.getByTestId('empty-state-message').should('contain.text', 'no flights');
});
it('Should display empty autocomplete state when no matching cities', () => {
cy.getByTestId('city-autocomplete-input').type('XYZCityNotExist');
cy.getByTestId('empty-autocomplete-message').should('be.visible');
cy.getByTestId('empty-autocomplete-message').should('contain.text', 'not found');
});
it('Should display empty search results with proper messaging', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('emptySearch');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@emptySearch');
cy.getByTestId('empty-results').should('be.visible');
cy.getByTestId('empty-results').should('have.text', 'Flights not found for the selected criteria');
});
it('Should display correct empty state styling', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('emptySearchStyle');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@emptySearchStyle');
cy.getByTestId('empty-state-container').should('be.visible');
cy.getByTestId('empty-state-container').should('have.css', 'display', 'flex');
});
it('Should display proper messaging for each empty state type', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('emptyMessaging');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@emptyMessaging');
cy.getByTestId('empty-state-message').should('contain.text', 'Flights');
});
});
describe('Recovery & Retry Tests (7 tests)', () => {
it('Should clear error after successful retry', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
if (callCount === 1) {
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
} else {
req.reply({
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
});
}
}).as('flakyApi');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@flakyApi');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('retry-button').click();
cy.wait('@flakyApi');
cy.getByTestId('error-message').should('not.exist');
cy.getByTestId('board-search-result').should('be.visible');
});
it('Should work with retry button after API error', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
if (callCount === 1) {
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
} else {
req.reply({
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
});
}
}).as('retryableApi');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@retryableApi');
cy.getByTestId('retry-button').click();
cy.wait('@retryableApi');
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
it('Should detect SignalR connection loss', () => {
cy.on('window:before:load', (window) => {
const signalr = window.HubConnection || {};
signalr.state = 'Disconnected';
});
cy.visit('/');
cy.getByTestId('connection-lost-banner', { timeout: 3000 }).then(($el) => {
if ($el.length > 0) {
cy.wrap($el).should('be.visible');
}
});
});
it('Should provide SignalR reconnect button when connection lost', () => {
cy.on('window:before:load', (window) => {
const signalr = window.HubConnection || {};
signalr.state = 'Disconnected';
});
cy.visit('/');
cy.getByTestId('reconnect-button', { timeout: 3000 }).then(($el) => {
if ($el.length > 0) {
cy.wrap($el).should('be.visible');
cy.wrap($el).should('be.enabled');
}
});
});
it('Should work with manual refresh button', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
}).as('refresh');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@refresh');
cy.getByTestId('refresh-button').click();
cy.wait('@refresh');
cy.getByTestId('board-search-result').should('be.visible');
});
it('Should auto-retry after delay when enabled', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
if (callCount === 1) {
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
} else {
req.reply({
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
});
}
}).as('autoRetry');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@autoRetry');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('auto-retry-enabled', { timeout: 3000 }).then(($el) => {
if ($el.length > 0) {
cy.wait('@autoRetry', { timeout: 10000 });
cy.getByTestId('board-search-result').should('be.visible');
}
});
});
it('Should preserve state during retry', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
if (callCount === 1) {
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
} else {
req.reply({
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
});
}
}).as('statePreserve');
const testCity = 'Москва';
const testDate = '04.04.2026';
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(testDate);
cy.getByTestId('search-button').click();
cy.wait('@statePreserve');
cy.getByTestId('city-autocomplete-input').invoke('val').should('contain', testCity);
cy.getByTestId('calendar-input').invoke('val').should('contain', testDate);
cy.getByTestId('retry-button').click();
cy.wait('@statePreserve');
cy.getByTestId('city-autocomplete-input').invoke('val').should('contain', testCity);
cy.getByTestId('calendar-input').invoke('val').should('contain', testDate);
});
});
});
@@ -0,0 +1,662 @@
/// <reference types="cypress" />
import { CITIES } from '../../support/fixtures';
describe('Flights Map Feature', () => {
// Mock data for destinations
const mockDestinations = {
data: {
routes: [
{
arrivalCity: {
name: 'Москва',
code: 'MOW',
location: {
lat: 55.7558,
lon: 37.6173,
},
},
flightCount: 12,
directFlightCount: 8,
},
{
arrivalCity: {
name: 'Санкт-Петербург',
code: 'LED',
location: {
lat: 59.8011,
lon: 30.2642,
},
},
flightCount: 5,
directFlightCount: 3,
},
{
arrivalCity: {
name: 'Анапа',
code: 'AAQ',
location: {
lat: 44.8972,
lon: 37.3426,
},
},
flightCount: 7,
directFlightCount: 5,
},
{
arrivalCity: {
name: 'Екатеринбург',
code: 'SVX',
location: {
lat: 56.7365,
lon: 60.8025,
},
},
flightCount: 3,
directFlightCount: 2,
},
{
arrivalCity: {
name: 'Новосибирск',
code: 'OVB',
location: {
lat: 55.0077,
lon: 82.9484,
},
},
flightCount: 4,
directFlightCount: 1,
},
],
},
};
const mockNearbyAirports = {
data: {
airports: [
{
code: 'SVO',
name: 'Шереметьево',
location: {
lat: 55.9728,
lon: 37.4146,
},
},
{
code: 'VKO',
name: 'Внуково',
location: {
lat: 55.5917,
lon: 37.2656,
},
},
],
},
};
beforeEach(() => {
cy.intercept(
'GET',
'**/api/flights/destinations/**',
mockDestinations
).as('getDestinations');
cy.intercept(
'GET',
'**/api/flights/nearby/**',
mockNearbyAirports
).as('getNearby');
cy.forbidGeolocation();
cy.visit('/flights-map');
cy.wait('@getDestinations');
});
// ======================================
// MAP RENDERING TESTS (~15 tests)
// ======================================
describe('Map Rendering', () => {
it('should load map and be interactive', () => {
cy.get('#map').should('be.visible');
cy.get('.leaflet-container').should('be.visible');
});
it('should display map with correct base tile layer', () => {
cy.get('.leaflet-tile-pane').should('be.visible');
cy.get('.leaflet-tile').should('have.length.greaterThan', 0);
});
it('should render flight destination markers on map', () => {
cy.get('[data-testid="map-marker"]').should('have.length.greaterThan', 0);
});
it('should display markers for all destination routes', () => {
const expectedMarkerCount = mockDestinations.data.routes.length;
cy.get('[data-testid="map-marker"]').should('have.length', expectedMarkerCount);
});
it('should have correct marker positions based on destination coordinates', () => {
cy.get('[data-testid="map-marker"]').first().should('have.attr', 'data-lat');
cy.get('[data-testid="map-marker"]').first().should('have.attr', 'data-lon');
});
it('should support map pan functionality', () => {
cy.get('.leaflet-container')
.trigger('mousedown', { x: 400, y: 300 })
.trigger('mousemove', { x: 300, y: 300 })
.trigger('mouseup');
// Verify map content changed (panned)
cy.get('.leaflet-tile').should('exist');
});
it('should support map zoom in', () => {
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: -100 });
cy.get('[data-testid="leaflet-map"]')
.invoke('getZoom')
.should('be.greaterThan', 5);
});
it('should support map zoom out', () => {
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 100 });
cy.get('[data-testid="leaflet-map"]')
.invoke('getZoom')
.should('be.lessThan', 6);
});
it('should respect min and max zoom levels', () => {
cy.get('[data-testid="leaflet-map"]')
.invoke('getMinZoom')
.should('equal', 3);
cy.get('[data-testid="leaflet-map"]')
.invoke('getMaxZoom')
.should('equal', 6);
});
it('should display geolocation button (if available)', () => {
cy.get('[data-testid="geolocation-button"]').should('exist');
});
it('should have geolocation button disabled when geolocation is forbidden', () => {
cy.get('[data-testid="geolocation-button"]').should('be.disabled');
});
it('should render map container with correct CSS classes', () => {
cy.get('[data-testid="flights-map-container"]').should('have.class', 'map-wrapper');
});
it('should display map with proper sizing', () => {
cy.get('[data-testid="flights-map-container"]').should('be.visible');
cy.get('#map').should('have.css', 'position', 'relative');
});
it('should not show loader after map loads', () => {
cy.get('[data-testid="loader"]').should('not.exist');
});
it('should display destination markers with distinct styling', () => {
cy.get('[data-testid="map-marker"]').each(($marker) => {
cy.wrap($marker).should('have.css', 'opacity', '1');
});
});
});
// ======================================
// DESTINATION LIST TESTS (~15 tests)
// ======================================
describe('Destination List', () => {
it('should render destination list panel', () => {
cy.get('[data-testid="destination-list"]').should('be.visible');
});
it('should display all destinations in the list', () => {
const expectedCount = mockDestinations.data.routes.length;
cy.get('[data-testid="destination-list-item"]').should('have.length', expectedCount);
});
it('should display destination name in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('contain', mockDestinations.data.routes[0].arrivalCity.name);
});
it('should display destination code in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('contain', mockDestinations.data.routes[0].arrivalCity.code);
});
it('should display flight count in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('contain', mockDestinations.data.routes[0].flightCount);
});
it('should display direct flight count in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('contain', mockDestinations.data.routes[0].directFlightCount);
});
it('should render search/filter input for destinations', () => {
cy.get('[data-testid="destination-search-input"]').should('be.visible');
});
it('should filter destination list by city name', () => {
cy.get('[data-testid="destination-search-input"]')
.type('Москва');
cy.get('[data-testid="destination-list-item"]').should('have.length', 1);
cy.get('[data-testid="destination-list-item"]').should('contain', 'Москва');
});
it('should filter destination list by city code', () => {
cy.get('[data-testid="destination-search-input"]')
.type('MOW');
cy.get('[data-testid="destination-list-item"]').should('have.length', 1);
cy.get('[data-testid="destination-list-item"]').should('contain', 'MOW');
});
it('should show empty state when no destinations match filter', () => {
cy.get('[data-testid="destination-search-input"]')
.type('NONEXISTENT');
cy.get('[data-testid="destination-list-empty"]').should('be.visible');
cy.get('[data-testid="destination-list-item"]').should('have.length', 0);
});
it('should clear filter when search input is cleared', () => {
cy.get('[data-testid="destination-search-input"]')
.type('Москва');
cy.get('[data-testid="destination-list-item"]').should('have.length', 1);
cy.get('[data-testid="destination-search-input"]').clear();
cy.get('[data-testid="destination-list-item"]').should('have.length', 5);
});
it('should have list items with proper styling', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('have.css', 'cursor', 'pointer');
});
it('should show list item hover effect', () => {
cy.get('[data-testid="destination-list-item"]').first()
.trigger('mouseenter')
.should('have.class', 'hover');
});
it('should render list with scrollable container if needed', () => {
cy.get('[data-testid="destination-list"]').should('exist');
cy.get('[data-testid="destination-list"]').invoke('attr', 'class')
.should('include', 'scrollable');
});
it('should display destination icons in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.find('[data-testid="destination-icon"]').should('exist');
});
});
// ======================================
// MAP INTERACTIONS TESTS (~10 tests)
// ======================================
describe('Map Interactions', () => {
it('should show popup when clicking on marker', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]').should('be.visible');
});
it('should display destination name in popup', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]')
.should('contain', mockDestinations.data.routes[0].arrivalCity.name);
});
it('should display flight count in popup', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]')
.should('contain', mockDestinations.data.routes[0].flightCount);
});
it('should display link to search flights in popup', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]')
.find('[data-testid="popup-search-link"]')
.should('exist');
});
it('should close popup when clicking outside', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]').should('be.visible');
cy.get('#map').click(100, 100);
cy.get('[data-testid="map-popup"]').should('not.exist');
});
it('should highlight destination when clicking list item', () => {
cy.get('[data-testid="destination-list-item"]').first().click();
cy.get('[data-testid="map-marker"]').first()
.should('have.class', 'highlighted');
});
it('should center map on marker when clicking destination list item', () => {
const targetCity = mockDestinations.data.routes[0].arrivalCity;
const expectedLat = targetCity.location.lat;
const expectedLon = targetCity.location.lon;
cy.get('[data-testid="destination-list-item"]').first().click();
cy.get('[data-testid="leaflet-map"]')
.invoke('getCenter')
.then((center) => {
expect(Math.round(center.lat)).to.equal(Math.round(expectedLat));
expect(Math.round(center.lng)).to.equal(Math.round(expectedLon));
});
});
it('should highlight marker when hovering over list item', () => {
cy.get('[data-testid="destination-list-item"]').first()
.trigger('mouseenter');
cy.get('[data-testid="map-marker"]').first()
.should('have.class', 'hovered');
});
it('should remove highlight when leaving list item hover', () => {
cy.get('[data-testid="destination-list-item"]').first()
.trigger('mouseenter')
.trigger('mouseleave');
cy.get('[data-testid="map-marker"]').first()
.should('not.have.class', 'hovered');
});
it('should allow clicking popup search link to navigate to search', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]')
.find('[data-testid="popup-search-link"]')
.should('have.attr', 'href');
});
});
// ======================================
// MARKER CLUSTERING TESTS (~5 tests)
// ======================================
describe('Marker Clustering', () => {
it('should not cluster markers at default zoom level', () => {
cy.get('[data-testid="map-marker"]').should('have.length', 5);
});
it('should cluster markers when zooming out below threshold', () => {
// Zoom out significantly
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 200 });
// Verify zoom is at minimum
cy.get('[data-testid="leaflet-map"]')
.invoke('getZoom')
.should('equal', 3);
});
it('should uncluster markers when zooming in', () => {
// First zoom out
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 200 });
// Then zoom in
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: -100 });
cy.get('[data-testid="map-marker"]').should('have.length.greaterThan', 0);
});
it('should display cluster count when markers are grouped', () => {
// Zoom out to trigger clustering
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 200 });
// Check for cluster elements
cy.get('[data-testid="marker-cluster"]').should('exist');
});
it('should expand cluster on click', () => {
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 200 });
cy.get('[data-testid="marker-cluster"]').first().click();
// Verify map zoomed in
cy.get('[data-testid="leaflet-map"]')
.invoke('getZoom')
.should('be.greaterThan', 3);
});
});
// ======================================
// GEOLOCATION TESTS (~5 tests)
// ======================================
describe('Geolocation Feature', () => {
it('should disable geolocation button when permission denied', () => {
cy.get('[data-testid="geolocation-button"]').should('be.disabled');
});
it('should show tooltip on geolocation button', () => {
cy.get('[data-testid="geolocation-button"]')
.should('have.attr', 'title');
});
it('should enable geolocation button with valid coordinates', () => {
cy.mockGeolocation({ latitude: 55.7558, longitude: 37.6173 });
cy.reload();
cy.wait('@getDestinations');
cy.get('[data-testid="geolocation-button"]').should('not.be.disabled');
});
it('should center map on user location when geolocation is enabled', () => {
cy.mockGeolocation({ latitude: 55.7558, longitude: 37.6173 });
cy.reload();
cy.wait('@getDestinations');
cy.get('[data-testid="geolocation-button"]').click();
cy.get('[data-testid="leaflet-map"]')
.invoke('getCenter')
.then((center) => {
expect(Math.round(center.lat)).to.equal(56);
expect(Math.round(center.lng)).to.equal(38);
});
});
it('should show user location marker on map', () => {
cy.mockGeolocation({ latitude: 55.7558, longitude: 37.6173 });
cy.reload();
cy.wait('@getDestinations');
cy.get('[data-testid="geolocation-button"]').click();
cy.get('[data-testid="user-location-marker"]').should('exist');
});
});
// ======================================
// RESPONSIVE DESIGN TESTS (~5 tests)
// ======================================
describe('Responsive Design', () => {
it('should display map in desktop viewport', () => {
cy.viewport(1280, 720);
cy.get('[data-testid="flights-map-container"]').should('be.visible');
cy.get('[data-testid="destination-list"]').should('be.visible');
});
it('should adapt layout for tablet viewport', () => {
cy.viewport('ipad-2');
cy.get('[data-testid="flights-map-container"]').should('be.visible');
});
it('should adapt layout for mobile viewport', () => {
cy.viewport('iphone-x');
cy.get('[data-testid="flights-map-container"]').should('be.visible');
});
it('should show mobile-friendly destination list on small screens', () => {
cy.viewport('iphone-x');
cy.get('[data-testid="destination-list"]').should('be.visible');
});
it('should adjust map controls for mobile devices', () => {
cy.viewport('iphone-x');
cy.get('[data-testid="geolocation-button"]').should('be.visible');
cy.get('.leaflet-control-container').should('be.visible');
});
});
// ======================================
// API INTEGRATION TESTS (~5 tests)
// ======================================
describe('API Integration', () => {
it('should fetch destinations on page load', () => {
cy.get('@getDestinations').should('have.been.called');
});
it('should handle API errors gracefully', () => {
cy.intercept(
'GET',
'**/api/flights/destinations/**',
{ statusCode: 500 }
).as('getDestinationsError');
cy.reload();
cy.wait('@getDestinationsError');
cy.get('[data-testid="error-message"]').should('be.visible');
});
it('should retry failed API requests', () => {
cy.intercept(
'GET',
'**/api/flights/destinations/**',
{ statusCode: 500 }
).as('getDestinationsError');
cy.reload();
cy.wait('@getDestinationsError');
cy.get('[data-testid="retry-button"]').click();
cy.intercept(
'GET',
'**/api/flights/destinations/**',
mockDestinations
).as('getDestinationsRetry');
cy.wait('@getDestinationsRetry');
});
it('should fetch nearby airports when geolocation enabled', () => {
cy.mockGeolocation({ latitude: 55.7558, longitude: 37.6173 });
cy.reload();
cy.wait('@getDestinations');
cy.get('[data-testid="geolocation-button"]').click();
cy.get('@getNearby').should('exist');
});
it('should update map when destinations data changes', () => {
const updatedDestinations = {
data: {
routes: [
{
arrivalCity: {
name: 'Казань',
code: 'KZN',
location: {
lat: 55.6084,
lon: 49.2808,
},
},
flightCount: 2,
directFlightCount: 1,
},
],
},
};
cy.intercept(
'GET',
'**/api/flights/destinations/**',
updatedDestinations
).as('getUpdatedDestinations');
cy.reload();
cy.wait('@getUpdatedDestinations');
cy.get('[data-testid="map-marker"]').should('have.length', 1);
});
});
// ======================================
// FILTER STATE PERSISTENCE TESTS (~3 tests)
// ======================================
describe('Filter State Persistence', () => {
it('should retain destination search filter on page reload', () => {
cy.get('[data-testid="destination-search-input"]')
.type('Москва');
cy.get('[data-testid="destination-list-item"]').should('have.length', 1);
cy.reload();
cy.wait('@getDestinations');
// Filter should be retained (depends on implementation)
cy.get('[data-testid="destination-search-input"]')
.invoke('val')
.then((val) => {
// Verify value is retained
expect(val).to.be.a('string');
});
});
it('should retain map center position on navigation', () => {
const targetCity = mockDestinations.data.routes[0].arrivalCity;
cy.get('[data-testid="destination-list-item"]').first().click();
cy.visit('/flights-map');
cy.wait('@getDestinations');
// Verify map state is reasonable
cy.get('[data-testid="leaflet-map"]').should('be.visible');
});
it('should preserve marker highlight state during interactions', () => {
cy.get('[data-testid="destination-list-item"]').first().click();
cy.get('[data-testid="map-marker"]').first()
.should('have.class', 'highlighted');
// Interact with another destination
cy.get('[data-testid="destination-list-item"]').eq(1).click();
// First marker should no longer be highlighted
cy.get('[data-testid="destination-list-item"]').eq(1)
.scrollIntoView();
cy.get('[data-testid="map-marker"]').eq(1)
.should('have.class', 'highlighted');
});
});
});
@@ -0,0 +1,429 @@
import * as moment from 'moment';
import { LANGUAGES } from '../../support/fixtures';
describe('Internationalization (i18n) Tests', () => {
// Language codes for all 9 supported languages
const LANG_CODES = ['ru', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'zh', 'de'];
// Locale-specific date formats for validation
const DATE_FORMATS = {
ru: 'DD.MM.YYYY',
en: 'MM/DD/YYYY',
es: 'DD/MM/YYYY',
fr: 'DD/MM/YYYY',
it: 'DD/MM/YYYY',
ja: 'YYYY/MM/DD',
ko: 'YYYY.MM.DD',
zh: 'YYYY/MM/DD',
de: 'DD.MM.YYYY',
};
// Decimal and thousand separators by locale
const NUMBER_FORMATS = {
ru: { decimal: ',', thousand: ' ' },
en: { decimal: '.', thousand: ',' },
es: { decimal: ',', thousand: '.' },
fr: { decimal: ',', thousand: ' ' },
it: { decimal: ',', thousand: '.' },
ja: { decimal: '.', thousand: ',' },
ko: { decimal: '.', thousand: ',' },
zh: { decimal: '.', thousand: ',' },
de: { decimal: ',', thousand: '.' },
};
// Currency symbols by language
const CURRENCY_SYMBOLS = {
ru: '₽',
en: '$',
es: '€',
fr: '€',
it: '€',
ja: '¥',
ko: '₩',
zh: '¥',
de: '€',
};
beforeEach(() => {
cy.intercept('GET', '**api/flights/**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
});
describe('Language Switcher Tests', () => {
it('Should display language switcher and be accessible', () => {
cy.getByTestId('language-selector').should('be.visible');
cy.getByTestId('language-selector').should('not.be.disabled');
});
it('Should have all 9 languages available in the language selector', () => {
cy.getByTestId('language-selector').click();
LANG_CODES.forEach((langCode) => {
cy.getByTestId(`language-option-${langCode}`).should('be.visible');
});
});
it('Should set default language to Russian (ru)', () => {
cy.window().then((win) => {
// Check localStorage for language preference
const savedLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
// Default should be ru if not set
expect(['ru', null, undefined]).to.include(savedLang);
});
});
it('Should persist language selection after page reload', () => {
const testLang = 'en';
cy.selectLanguage(testLang);
// Verify language is saved in localStorage
cy.window().then((win) => {
const savedLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
expect(savedLang).to.equal(testLang);
});
// Reload page
cy.reload();
// Verify language is still English after reload
cy.selectLanguage(testLang);
cy.window().then((win) => {
const savedLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
expect(savedLang).to.equal(testLang);
});
});
});
describe('Date Format Tests', () => {
LANG_CODES.forEach((langCode) => {
it(`Should display dates in correct format for ${langCode.toUpperCase()}`, () => {
cy.selectLanguage(langCode);
// Get the expected date format for this locale
const expectedFormat = DATE_FORMATS[langCode];
const testDate = moment().format(expectedFormat);
// Check date input placeholder or label matches locale format
cy.getByTestId('date-input').should('be.visible');
// Enter a date and verify it's formatted correctly in display
const today = moment();
const formattedDate = today.clone().locale(langCode).format(expectedFormat);
cy.getByTestId('date-input').clear().type(testDate).type('{enter}');
// Verify date displays in correct format
cy.getByTestId('date-display').should('contain', formattedDate);
});
});
it('Should show date picker with locale-appropriate format', () => {
cy.selectLanguage('ru');
cy.getByTestId('calendar-input').should('be.visible');
// Type a date
const today = moment().format('DD.MM.YYYY');
cy.getByTestId('calendar-input').type(today).type('{enter}');
// Check that date is displayed in Russian format
cy.getByTestId('calendar-input').invoke('val').should('include', '.');
});
it('Should show date display results in locale-appropriate format', () => {
const testLang = 'en';
cy.selectLanguage(testLang);
const today = moment().format('MM/DD/YYYY');
cy.getByTestId('calendar-input').type(today).type('{enter}');
// Verify displayed date matches English format
cy.getByTestId('board-search-result').should('contain', today);
});
});
describe('Number Formatting Tests', () => {
it('Russian (ru) should use comma as decimal and space as thousands separator', () => {
cy.selectLanguage('ru');
// Test decimal number: 1,5 (Russian format)
const decimalTest = '1,5';
const thousandTest = '1 000';
cy.getByTestId('price').then(($price) => {
const priceText = $price.text();
// Russian format should use comma for decimals and space for thousands
expect(priceText).to.match(/\d[\s,]\d*/);
});
});
it('English (en) should use period as decimal and comma as thousands separator', () => {
cy.selectLanguage('en');
// Test decimal number: 1.5 (English format)
const decimalTest = '1.5';
const thousandTest = '1,000';
cy.getByTestId('price').then(($price) => {
const priceText = $price.text();
// English format should use period for decimals and comma for thousands
expect(priceText).to.match(/\d[.,]\d*/);
});
});
LANG_CODES.forEach((langCode) => {
it(`Should format prices correctly for ${langCode.toUpperCase()}`, () => {
cy.selectLanguage(langCode);
const format = NUMBER_FORMATS[langCode];
cy.getByTestId('price').should('be.visible').then(($price) => {
const priceText = $price.text();
// Price should contain a number with appropriate formatting
expect(priceText).to.match(/\d+/);
});
});
});
it('Should display currency symbols matching the locale', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
const symbol = CURRENCY_SYMBOLS[langCode];
cy.getByTestId('price').should('be.visible').then(($price) => {
const priceText = $price.text();
// Currency symbol should be present in price
expect(priceText).to.include.oneOf([symbol, '$', '€', '₽', '¥', '₩']);
});
});
});
it('Should format large numbers with thousands separators in all locales', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
cy.getByTestId('price').then(($price) => {
const priceText = $price.text();
// Should contain formatting for thousands
if (priceText.length > 5) {
expect(priceText).to.match(/[\d\s,.\s]/);
}
});
});
});
});
describe('Text & Translation Tests', () => {
it('Should translate UI text when language changes', () => {
// Get Russian text
cy.selectLanguage('ru');
cy.getByTestId('search-button').then(($btn) => {
const ruText = $btn.text();
expect(ruText).to.not.be.empty;
// Switch to English and verify text changes
cy.selectLanguage('en');
cy.getByTestId('search-button').then(($btnEn) => {
const enText = $btnEn.text();
expect(enText).to.not.be.empty;
expect(enText).to.not.equal(ruText);
});
});
});
LANG_CODES.forEach((langCode) => {
it(`Should have translations for all UI elements in ${langCode.toUpperCase()}`, () => {
cy.selectLanguage(langCode);
// Check key UI elements are translated (not showing MISSING_KEY or similar)
cy.getByTestId('search-button').then(($el) => {
expect($el.text().toLowerCase()).to.not.include('missing');
expect($el.text().toLowerCase()).to.not.include('undefined');
});
cy.getByTestId('language-selector').then(($el) => {
expect($el.text().toLowerCase()).to.not.include('missing');
});
// Check that labels are present and translated
cy.get('[data-testid*="label"]').each(($el) => {
const text = $el.text();
expect(text.toLowerCase()).to.not.include('missing');
expect(text.toLowerCase()).to.not.include('undefined');
});
});
});
it('Should display placeholder text in correct language', () => {
const placeholders = ['city-autocomplete-input', 'date-input'];
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
placeholders.forEach((testId) => {
cy.getByTestId(testId).should('have.attr', 'placeholder').then((placeholder) => {
expect(placeholder).to.not.be.empty;
expect(placeholder.toLowerCase()).to.not.include('missing');
});
});
});
});
it('Should localize error messages', () => {
cy.selectLanguage('ru');
// Trigger an error (e.g., search without required fields)
cy.getByTestId('search-button').click();
// Error message should be localized
cy.getByTestId('validation-error').then(($error) => {
const errorText = $error.text();
expect(errorText).to.not.be.empty;
expect(errorText.toLowerCase()).to.not.include('missing');
});
});
it('Should have no untranslated strings in any language', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
// Check entire page for common untranslated indicators
cy.get('body').then(($body) => {
const bodyText = $body.text();
expect(bodyText).to.not.include('MISSING_KEY');
expect(bodyText).to.not.include('i18n_');
expect(bodyText).to.not.include('[object Object]');
expect(bodyText.toLowerCase()).to.not.include('undefined_translation');
});
});
});
});
describe('Locale-Specific UI Tests', () => {
it('Should not overflow text on narrow screens in any language', () => {
// Test at narrow viewport
cy.viewport(375, 667); // Mobile size
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
// Check buttons fit within viewport
cy.getByTestId('search-button').then(($btn) => {
const width = $btn.width();
expect(width).to.be.lessThan(375);
});
// Check labels don't overflow
cy.get('[data-testid*="label"]').each(($el) => {
const width = $el.width();
expect(width).to.be.lessThan(375);
});
});
// Reset viewport
cy.viewport(1280, 720);
});
it('Should maintain layout integrity across all locales', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
// Check main container is visible and properly sized
cy.get('[data-testid="main-content"]').should('be.visible').then(($main) => {
const width = $main.width();
expect(width).to.be.greaterThan(0);
expect(width).to.be.lessThan(1280);
});
// Check key controls are accessible
cy.getByTestId('search-button').should('be.visible');
cy.getByTestId('date-input').should('be.visible');
});
});
it('Should preserve button accessibility across all languages', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
// All interactive elements should be accessible
cy.getByTestId('search-button').should('not.be.disabled').should('be.visible');
cy.getByTestId('language-selector').should('not.be.disabled').should('be.visible');
// Check tab order is preserved
cy.getByTestId('search-button').should('have.attr', 'tabindex').then((tabindex) => {
expect(parseInt(tabindex)).to.be.greaterThanOrEqual(-1);
});
});
});
});
describe('Language Switcher Persistence and Edge Cases', () => {
it('Should handle rapid language switching without errors', () => {
const languages = ['ru', 'en', 'fr', 'ja'];
languages.forEach((lang) => {
cy.selectLanguage(lang);
cy.getByTestId('search-button').should('be.visible');
});
// Final language should be the last one selected
cy.window().then((win) => {
const currentLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
expect(currentLang).to.equal('ja');
});
});
it('Should correctly apply locale-specific moment formats', () => {
const testDate = moment('2026-04-15');
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
const format = DATE_FORMATS[langCode];
const formattedDate = testDate.clone().locale(langCode).format(format);
cy.getByTestId('date-input').clear().type(formattedDate).type('{enter}');
cy.getByTestId('date-display').should('contain', formattedDate);
});
});
});
describe('Comprehensive Locale Coverage', () => {
LANGUAGES.forEach((language) => {
describe(`Locale: ${language.code.toUpperCase()} (${language.nativeName})`, () => {
beforeEach(() => {
cy.selectLanguage(language.code);
});
it(`Should initialize with ${language.code} selected`, () => {
cy.window().then((win) => {
const savedLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
expect(savedLang).to.equal(language.code);
});
});
it(`Should display UI in ${language.code}`, () => {
cy.getByTestId('search-button').should('be.visible');
cy.getByTestId('language-selector').should('be.visible');
cy.getByTestId('date-input').should('be.visible');
});
it(`Should use correct date format for ${language.code}`, () => {
const format = DATE_FORMATS[language.code];
const today = moment().format(format);
cy.getByTestId('date-input').type(today).type('{enter}');
cy.getByTestId('date-display').should('contain', today);
});
it(`Should format numbers correctly for ${language.code}`, () => {
const numFormat = NUMBER_FORMATS[language.code];
cy.getByTestId('price').should('be.visible').then(($price) => {
const priceText = $price.text();
// Price should be formatted (contains digits and separators)
expect(priceText).to.match(/\d+/);
});
});
});
});
});
});
@@ -0,0 +1,973 @@
import * as moment from 'moment';
import { CITIES, MOCK_FLIGHTS_ARRIVAL, MOCK_FLIGHTS_DEPARTURE } from '../../support/fixtures';
describe('Online Board Feature Tests (~70 tests)', () => {
const today = moment().format('DD.MM.YYYY');
const tomorrow = moment().add(1, 'day').format('DD.MM.YYYY');
const yesterday = moment().subtract(1, 'day').format('DD.MM.YYYY');
const nextWeek = moment().add(7, 'day').format('DD.MM.YYYY');
const expectedUrlDateTime = `${moment().format('DDMMYYYY')}-0000-2400`;
beforeEach(() => {
cy.intercept('GET', '**/api/flights/v1.1/**').as('getFlights');
cy.intercept('GET', '**/api/cities/**').as('getCities');
cy.forbidGeolocation();
cy.visit('/');
});
// ============================================================================
// ARRIVAL TAB TESTS (~20 tests)
// ============================================================================
describe('Arrival Tab Tests', () => {
describe('City Input - Manual Entry', () => {
it('should accept manual city entry for valid city name', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Москва');
cy.getByTestId('city-autocomplete-input-arrival')
.should('have.value', 'Москва');
});
it('should display dropdown suggestions for partial city name', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Мос');
cy.getByTestId('city-dropdown-option')
.should('be.visible')
.should('have.length.greaterThan', 0);
});
it('should filter dropdown options based on input', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Анапа');
cy.getByTestId('city-dropdown-option')
.contains('Анапа')
.should('be.visible');
});
it('should handle special characters in city input', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('М@сква');
// Should not crash and handle gracefully
cy.getByTestId('city-autocomplete-input-arrival').should('exist');
});
it('should clear city input when cleared explicitly', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Москва');
cy.getByTestId('city-autocomplete-input-arrival').clear();
cy.getByTestId('city-autocomplete-input-arrival')
.should('have.value', '');
});
it('should show validation error for empty city input on search', () => {
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('City');
});
});
describe('City Input - Dropdown Selection', () => {
it('should select city from dropdown by clicking', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Москва');
cy.getByTestId('city-dropdown-option')
.contains('Москва')
.click();
cy.getByTestId('city-autocomplete-input-arrival')
.should('have.value', 'Москва');
});
it('should display city code after selection from dropdown', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('city-code')
.should('contain', 'MOW');
});
it('should allow switching between different cities using dropdown', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('city-code').should('contain', 'MOW');
cy.getByTestId('city-autocomplete-input-arrival').clear().type('Анапа');
cy.getByTestId('city-dropdown-option').contains('Анапа').click();
cy.getByTestId('city-code').should('contain', 'AAQ');
});
});
describe('Date Picker - Valid Dates', () => {
it('should accept valid today date', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type(today)
.type('{enter}');
cy.getByTestId('arrival-date-input')
.should('have.value', today);
});
it('should accept valid future date (tomorrow)', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type(tomorrow)
.type('{enter}');
cy.getByTestId('arrival-date-input')
.should('have.value', tomorrow);
});
it('should accept valid future date (one week)', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type(nextWeek)
.type('{enter}');
cy.getByTestId('arrival-date-input')
.should('have.value', nextWeek);
});
});
describe('Date Picker - Invalid Dates', () => {
it('should reject past date (yesterday)', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type(yesterday);
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
it('should handle invalid date format', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type('invalid');
cy.getByTestId('search-button').click();
// Should show error or ignore invalid input
cy.getByTestId('validation-error').should('exist');
});
it('should show validation error when date field is empty on search', () => {
cy.getByTestId('arrival-date-input').clear();
cy.selectArrivalCity('Москва');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
});
describe('Search - Valid and Error Cases', () => {
it('should perform valid arrival search with city and date', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
});
});
it('should show validation error when missing city field', () => {
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('City');
});
it('should show validation error when missing date field', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
it('should handle network error gracefully', () => {
cy.intercept('GET', '**/api/flights/v1.1/**', {
statusCode: 500,
body: { error: 'Internal Server Error' },
}).as('getFlightsError');
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlightsError');
cy.getByTestId('error-message').should('be.visible');
});
it('should show loading state during search', () => {
cy.intercept('GET', '**/api/flights/v1.1/**', (req) => {
req.reply((res) => {
res.delay(1000);
});
}).as('getFlightsSlow');
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlightsSlow');
});
});
describe('Results - Flight List Rendering', () => {
it('should render flight list after successful search', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().should('have.length.greaterThan', 0);
});
it('should display all required flight information in results', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().within(() => {
cy.getByTestId('flight-carrier-number').should('be.visible');
cy.getByTestId('flight-status').should('be.visible');
cy.getByTestId('flight-time').should('be.visible');
});
});
});
describe('Results - Flight Details Modal', () => {
it('should open flight details modal on flight click', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
});
it('should display all flight info in modal (number, times, gate, terminal)', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-number').should('be.visible');
cy.getByTestId('flight-details-time').should('be.visible');
cy.getByTestId('flight-details-gate').should('be.visible');
cy.getByTestId('flight-details-terminal').should('be.visible');
});
it('should close modal when clicking X button', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-close-button').click();
cy.getByTestId('flight-details-modal').should('not.be.visible');
});
it('should close modal when pressing Escape key', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.get('body').type('{esc}');
cy.getByTestId('flight-details-modal').should('not.be.visible');
});
it('should close modal when clicking outside modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-backdrop').click({ force: true });
cy.getByTestId('flight-details-modal').should('not.be.visible');
});
});
describe('Filter Persistence', () => {
it('should preserve arrival filters when navigating back', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
// Navigate back
cy.go('back');
// Filters should still be present
cy.getByTestId('city-autocomplete-input-arrival').should('have.value', 'Москва');
cy.getByTestId('arrival-date-input').should('have.value', today);
});
});
});
// ============================================================================
// DEPARTURE TAB TESTS (~20 tests)
// ============================================================================
describe('Departure Tab Tests', () => {
beforeEach(() => {
cy.getByTestId('departure-tab').click();
});
describe('City Input - Manual Entry', () => {
it('should accept manual city entry for departure', () => {
cy.getByTestId('city-autocomplete-input-departure')
.clear()
.type('Москва');
cy.getByTestId('city-autocomplete-input-departure')
.should('have.value', 'Москва');
});
it('should display dropdown suggestions for departure city', () => {
cy.getByTestId('city-autocomplete-input-departure')
.clear()
.type('Мос');
cy.getByTestId('city-dropdown-option')
.should('be.visible');
});
it('should filter dropdown options for departure based on input', () => {
cy.getByTestId('city-autocomplete-input-departure')
.clear()
.type('Казань');
cy.getByTestId('city-dropdown-option')
.contains('Казань')
.should('be.visible');
});
it('should show validation error for empty departure city on search', () => {
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('City');
});
});
describe('City Input - Dropdown Selection', () => {
it('should select departure city from dropdown', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('city-autocomplete-input-departure')
.should('have.value', 'Москва');
});
it('should display departure city code after selection', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('city-code').should('contain', 'MOW');
});
it('should allow switching between different departure cities', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('city-code').should('contain', 'MOW');
cy.getByTestId('city-autocomplete-input-departure').clear().type('Казань');
cy.getByTestId('city-dropdown-option').contains('Казань').click();
cy.getByTestId('city-code').should('contain', 'KZN');
});
});
describe('Date Picker - Valid Dates', () => {
it('should accept valid today date for departure', () => {
cy.getByTestId('departure-date-input')
.clear()
.type(today)
.type('{enter}');
cy.getByTestId('departure-date-input')
.should('have.value', today);
});
it('should accept valid future date for departure', () => {
cy.getByTestId('departure-date-input')
.clear()
.type(tomorrow)
.type('{enter}');
cy.getByTestId('departure-date-input')
.should('have.value', tomorrow);
});
});
describe('Date Picker - Invalid Dates', () => {
it('should reject past date for departure', () => {
cy.getByTestId('departure-date-input')
.clear()
.type(yesterday);
cy.selectDepartureCity('Москва');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
it('should show validation error when departure date is empty on search', () => {
cy.getByTestId('departure-date-input').clear();
cy.selectDepartureCity('Москва');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
});
describe('Search - Valid and Error Cases', () => {
it('should perform valid departure search', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
});
});
it('should handle network error for departure search', () => {
cy.intercept('GET', '**/api/flights/v1.1/**', {
statusCode: 500,
}).as('getFlightsError');
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlightsError');
cy.getByTestId('error-message').should('be.visible');
});
it('should show loading state during departure search', () => {
cy.intercept('GET', '**/api/flights/v1.1/**', (req) => {
req.reply((res) => {
res.delay(1000);
});
}).as('getFlightsSlow');
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.getByTestId('loader').should('be.visible');
});
});
describe('Results - Flight List', () => {
it('should render departure flight list after successful search', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().should('have.length.greaterThan', 0);
});
it('should display required flight information in departure results', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().within(() => {
cy.getByTestId('flight-carrier-number').should('be.visible');
cy.getByTestId('flight-status').should('be.visible');
});
});
});
describe('Results - Flight Details Modal for Departure', () => {
it('should open flight details modal for departure flight', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
});
it('should display complete flight details for departure', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-number').should('be.visible');
cy.getByTestId('flight-details-gate').should('be.visible');
cy.getByTestId('flight-details-terminal').should('be.visible');
});
it('should close departure flight details modal on X click', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-close-button').click();
cy.getByTestId('flight-details-modal').should('not.be.visible');
});
});
describe('Filter Persistence for Departure', () => {
it('should preserve departure filters when navigating back', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.go('back');
cy.getByTestId('city-autocomplete-input-departure').should('have.value', 'Москва');
cy.getByTestId('departure-date-input').should('have.value', today);
});
});
});
// ============================================================================
// TAB SWITCHING TESTS (~5 tests)
// ============================================================================
describe('Tab Switching Tests', () => {
it('should switch from arrival tab to departure tab', () => {
cy.getByTestId('arrival-tab').should('have.class', 'active');
cy.getByTestId('departure-tab').click();
cy.getByTestId('departure-tab').should('have.class', 'active');
});
it('should switch from departure tab back to arrival tab', () => {
cy.getByTestId('departure-tab').click();
cy.getByTestId('departure-tab').should('have.class', 'active');
cy.getByTestId('arrival-tab').click();
cy.getByTestId('arrival-tab').should('have.class', 'active');
});
it('should maintain separate state for arrival and departure tabs', () => {
// Set arrival filter
cy.selectArrivalCity('Москва');
cy.getByTestId('city-autocomplete-input-arrival').should('have.value', 'Москва');
// Switch to departure
cy.getByTestId('departure-tab').click();
cy.getByTestId('city-autocomplete-input-departure').should('have.value', '');
// Switch back to arrival
cy.getByTestId('arrival-tab').click();
cy.getByTestId('city-autocomplete-input-arrival').should('have.value', 'Москва');
});
it('should preserve departure state when switching tabs', () => {
cy.getByTestId('departure-tab').click();
cy.selectDepartureCity('Казань');
cy.getByTestId('city-autocomplete-input-departure').should('have.value', 'Казань');
cy.getByTestId('arrival-tab').click();
cy.getByTestId('departure-tab').click();
cy.getByTestId('city-autocomplete-input-departure').should('have.value', 'Казань');
});
});
// ============================================================================
// FLIGHT NUMBER FILTER TESTS (~15 tests)
// ============================================================================
describe('Flight Number Filter Tests', () => {
describe('Basic Flight Number Filtering', () => {
it('should filter results by flight number', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getFlightResults().should('have.length', 1);
cy.getFirstFlightResult().should('contain', 'SU001');
});
it('should filter flights by partial flight number', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('001');
cy.getFlightResults().should('have.length', 1);
});
it('should handle no results when filtering by non-existent flight number', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('ZZ999');
cy.getByTestId('no-results-message').should('be.visible');
cy.getFlightResults().should('have.length', 0);
});
it('should be case-insensitive when filtering flight numbers', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('su001');
cy.getFlightResults().should('have.length', 1);
cy.getFirstFlightResult().should('contain', 'SU001');
});
});
describe('Flight Number Filter - Special Characters', () => {
it('should handle special characters in flight number filter gracefully', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('SU@001');
// Should not crash, display no results or handle gracefully
cy.getByTestId('flight-number-filter').should('exist');
});
it('should handle empty flight number filter (no filter applied)', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().should('have.length.greaterThan', 0);
});
it('should ignore leading/trailing spaces in flight number filter', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type(' SU001 ');
cy.getFlightResults().should('have.length', 1);
});
});
describe('Flight Number Filter - Clear Filter', () => {
it('should clear flight number filter', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().then((flights) => {
const initialCount = flights.length;
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getFlightResults().should('have.length', 1);
cy.getByTestId('flight-number-filter').clear();
cy.getFlightResults().should('have.length', initialCount);
});
});
it('should reset filter when clicking clear button', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getFlightResults().should('have.length', 1);
cy.getByTestId('clear-flight-filter-button').click();
cy.getByTestId('flight-number-filter').should('have.value', '');
cy.getFlightResults().should('have.length.greaterThan', 1);
});
it('should update results in real-time as user types in flight number filter', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().then((flights) => {
const initialCount = flights.length;
cy.getByTestId('flight-number-filter').type('0');
cy.getFlightResults().should('have.length.lessThan', initialCount);
cy.getByTestId('flight-number-filter').type('01');
cy.getFlightResults().should('have.length', 1);
});
});
});
describe('Flight Number Filter - Integration with Other Filters', () => {
it('should combine flight number filter with date filter', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().should('have.length.greaterThan', 0);
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getFlightResults().should('have.length', 1);
// Change date and verify filter still works
cy.getByTestId('arrival-date-input').clear().type(tomorrow).type('{enter}');
cy.getByTestId('flight-number-filter').should('have.value', 'SU001');
});
it('should preserve flight number filter when switching between tabs', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getByTestId('departure-tab').click();
cy.getByTestId('arrival-tab').click();
// Filter might not persist across tabs, but should not crash
cy.getByTestId('flight-number-filter').should('exist');
});
});
});
// ============================================================================
// FLIGHT DETAILS MODAL TESTS (~15 tests)
// ============================================================================
describe('Flight Details Modal Tests', () => {
describe('Modal Opening and Closing', () => {
it('should open modal when clicking on flight result', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
});
it('should close modal with close button (X)', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-close-button').click();
cy.getByTestId('flight-details-modal').should('not.exist');
});
it('should close modal when pressing Escape key', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.get('body').type('{esc}');
cy.getByTestId('flight-details-modal').should('not.exist');
});
it('should close modal when clicking outside (backdrop)', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-backdrop').click({ force: true });
cy.getByTestId('flight-details-modal').should('not.exist');
});
});
describe('Modal Content - Flight Information Display', () => {
it('should display flight number in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-number').should('be.visible')
.should('contain', 'SU');
});
it('should display estimated arrival time in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-time').should('be.visible');
});
it('should display gate information in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-gate').should('be.visible')
.should('contain', 'Gate');
});
it('should display terminal information in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-terminal').should('be.visible')
.should('contain', 'Terminal');
});
it('should display flight status in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-status').should('be.visible');
});
it('should display aircraft type in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-aircraft').should('be.visible');
});
});
describe('Modal Navigation', () => {
it('should navigate to next flight using next button in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
const firstFlightNumber = cy.getByTestId('flight-details-number');
cy.getByTestId('modal-next-button').click();
cy.getByTestId('flight-details-number')
.should('not.equal', firstFlightNumber);
});
it('should navigate to previous flight using prev button in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().then((flights) => {
if (flights.length > 1) {
cy.getByTestId('flight-result').eq(1).click();
cy.getByTestId('modal-prev-button').click();
cy.getByTestId('flight-details-modal').should('be.visible');
}
});
});
it('should disable prev button on first flight', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('modal-prev-button').should('be.disabled');
});
it('should disable next button on last flight', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().then((flights) => {
cy.getByTestId('flight-result').eq(flights.length - 1).click();
cy.getByTestId('modal-next-button').should('be.disabled');
});
});
});
describe('Modal Display and Responsiveness', () => {
it('should center modal on screen', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('flight-details-modal')
.should('have.css', 'position')
.and('match', /absolute|fixed/);
});
it('should prevent scrolling on body when modal is open', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.get('body').should('have.css', 'overflow', 'hidden');
});
it('should restore body scrolling when modal closes', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('modal-close-button').click();
cy.get('body').should('not.have.css', 'overflow', 'hidden');
});
});
});
});
@@ -0,0 +1,245 @@
import { POPULAR_REQUESTS } from '../../support/fixtures';
describe('Popular Requests Widget', () => {
beforeEach(() => {
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 200, body: POPULAR_REQUESTS }).as('getPopularRequests');
cy.forbidGeolocation();
cy.visit('/');
});
describe('Widget Load Tests', () => {
it('Should render widget on initial page load', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').should('exist');
});
it('Should be visible in viewport', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').should('be.visible');
});
it('Should have correct styling and layout', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').should('have.css', 'display').and('not.equal', 'none');
});
it('Should have correct container dimensions', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').then(($widget) => {
expect($widget.width()).to.be.greaterThan(0);
expect($widget.height()).to.be.greaterThan(0);
});
});
it('Should display widget title/header correctly', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').within(() => {
cy.getByTestId('popular-requests-title').should('exist').and('be.visible');
});
});
});
describe('Display Tests', () => {
it('Should display all popular request items from API', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').should('have.length', POPULAR_REQUESTS.length);
});
it('Should display departure city in each item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').each(($item, index) => {
cy.wrap($item).within(() => {
cy.getByTestId('popular-request-departure').should('contain', POPULAR_REQUESTS[index].departure);
});
});
});
it('Should display arrival city in each item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').each(($item, index) => {
cy.wrap($item).within(() => {
cy.getByTestId('popular-request-arrival').should('contain', POPULAR_REQUESTS[index].arrival);
});
});
});
it('Should display flight count/frequency in each item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').each(($item, index) => {
cy.wrap($item).within(() => {
cy.getByTestId('popular-request-frequency').should('exist').and('be.visible');
});
});
});
it('Should have clickable items', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().should('have.css', 'cursor').and('not.equal', 'default');
});
it('Should display items with proper styling (colors, spacing)', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().then(($item) => {
const styles = window.getComputedStyle($item[0]);
expect(styles.padding).to.not.be.empty;
expect(styles.margin).to.not.be.empty;
});
});
it('Should render all items with correct data from first request', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
cy.getByTestId('popular-request-item').first().within(() => {
cy.getByTestId('popular-request-departure').should('contain', firstItem.departure);
cy.getByTestId('popular-request-arrival').should('contain', firstItem.arrival);
cy.getByTestId('popular-request-departure-code').should('contain', firstItem.departureCode);
cy.getByTestId('popular-request-arrival-code').should('contain', firstItem.arrivalCode);
});
});
it('Should render all items with correct data from second request', () => {
cy.wait('@getPopularRequests');
const secondItem = POPULAR_REQUESTS[1];
cy.getByTestId('popular-request-item').eq(1).within(() => {
cy.getByTestId('popular-request-departure').should('contain', secondItem.departure);
cy.getByTestId('popular-request-arrival').should('contain', secondItem.arrival);
cy.getByTestId('popular-request-departure-code').should('contain', secondItem.departureCode);
cy.getByTestId('popular-request-arrival-code').should('contain', secondItem.arrivalCode);
});
});
it('Should render all items with correct data from third request', () => {
cy.wait('@getPopularRequests');
const thirdItem = POPULAR_REQUESTS[2];
cy.getByTestId('popular-request-item').eq(2).within(() => {
cy.getByTestId('popular-request-departure').should('contain', thirdItem.departure);
cy.getByTestId('popular-request-arrival').should('contain', thirdItem.arrival);
cy.getByTestId('popular-request-departure-code').should('contain', thirdItem.departureCode);
cy.getByTestId('popular-request-arrival-code').should('contain', thirdItem.arrivalCode);
});
});
it('Should display frequency/high indicator for first item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().within(() => {
cy.getByTestId('popular-request-frequency').should('contain', POPULAR_REQUESTS[0].frequency);
});
});
});
describe('Navigation Tests', () => {
it('Should navigate to search page when clicking item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', '/onlineboard/');
});
it('Should include departure city code in URL after click', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', firstItem.departureCode);
});
it('Should include arrival city code in URL after click', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', firstItem.arrivalCode);
});
it('Should navigate with different parameters for different items', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
const secondItem = POPULAR_REQUESTS[1];
cy.getByTestId('popular-request-item').first().click();
cy.url().then((firstUrl) => {
cy.visit('/');
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').eq(1).click();
cy.url().then((secondUrl) => {
expect(firstUrl).to.not.equal(secondUrl);
});
});
});
it('Should navigate to departure city page', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', 'departure');
});
it('Should navigate to correct date range', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().click();
cy.url().should('match', /\d{8}-\d{4}-\d{4}/);
});
it('Should preserve language on navigation', () => {
cy.visit('/en-us/');
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', '/en-us/');
});
it('Should make search page load correctly after navigation', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().click();
cy.getByTestId('board-search-result', { timeout: 10000 }).should('exist');
});
});
describe('API Fallback Tests', () => {
it('Should fall back to fixture data when API fails', () => {
// Intercept API to fail, but first reset and visit
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 500 }).as('failedRequest');
cy.visit('/');
cy.wait('@failedRequest');
// Widget should still be visible with fallback data
cy.getByTestId('popular-requests-widget').should('be.visible');
});
it('Should display fallback data correctly on API error', () => {
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 500 }).as('failedRequest');
cy.visit('/');
cy.wait('@failedRequest');
// Fallback data should still have items
cy.getByTestId('popular-request-item').should('have.length.greaterThan', 0);
});
it('Should allow navigation even with API fallback', () => {
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 500 }).as('failedRequest');
cy.visit('/');
cy.wait('@failedRequest');
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', '/onlineboard/');
});
it('Should handle network timeout gracefully', () => {
cy.intercept('GET', '**/api/popular-requests/**', (req) => {
req.destroy();
}).as('timedOutRequest');
cy.visit('/');
cy.wait('@timedOutRequest');
cy.getByTestId('popular-requests-widget').should('be.visible');
cy.getByTestId('popular-request-item').should('have.length.greaterThan', 0);
});
it('Should render widget without breaking layout on API error', () => {
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 500 }).as('failedRequest');
cy.visit('/');
cy.wait('@failedRequest');
cy.getByTestId('popular-requests-widget').then(($widget) => {
expect($widget.width()).to.be.greaterThan(0);
expect($widget.height()).to.be.greaterThan(0);
});
});
});
});
@@ -0,0 +1,402 @@
import * as moment from 'moment';
describe('Responsive Design & Mobile Tests', () => {
const today = moment().format('DD.MM.YYYY');
const testCity = 'Анапа';
const testCityCode = 'AAQ';
// Helper to check no horizontal scrolling
const checkNoHorizontalScroll = () => {
cy.get('body').then(($body) => {
const windowWidth = $body[0].ownerDocument.defaultView.innerWidth;
const scrollWidth = $body[0].scrollWidth;
expect(scrollWidth).to.equal(windowWidth);
});
};
// Helper to check touch target size (minimum 44x44px)
const checkTouchTargetSize = (selector: string) => {
cy.get(selector).should(($el) => {
const rect = $el[0].getBoundingClientRect();
expect(rect.width).to.be.at.least(44);
expect(rect.height).to.be.at.least(44);
});
};
// Helper to check element is not hidden
const checkElementVisible = (selector: string) => {
cy.get(selector).should('be.visible').should('not.have.css', 'overflow', 'hidden');
};
// Mobile Viewport Tests (375x667 - iPhone SE)
describe('Mobile Viewport (375x667 - iPhone SE)', () => {
beforeEach(() => {
cy.viewport('iphone-se2');
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
});
it('Mobile: Text is readable and not overflowing in filter section', () => {
cy.getByTestId('filter-section').should('be.visible');
cy.getByTestId('filter-section').then(($section) => {
const text = $section.text();
expect(text.length).to.be.greaterThan(0);
expect($section[0].scrollWidth).to.equal($section[0].clientWidth);
});
});
it('Mobile: Search button has minimum touch target size (44x44px)', () => {
checkTouchTargetSize('[data-testid="arrival-search-button"]');
});
it('Mobile: City input field has proper touch target size', () => {
checkTouchTargetSize('[data-testid="city-autocomplete-input"]');
});
it('Mobile: Calendar input has sufficient touch target size', () => {
checkTouchTargetSize('[data-testid="calendar-input"]');
});
it('Mobile: No horizontal scrolling on page load', () => {
checkNoHorizontalScroll();
});
it('Mobile: No horizontal scrolling after opening accordion', () => {
cy.getByTestId('accordion').should('exist').click();
checkNoHorizontalScroll();
});
it('Mobile: Form inputs are not hidden behind keyboard simulation', () => {
cy.getByTestId('city-autocomplete-input').should('be.visible').should('not.have.css', 'display', 'none');
cy.getByTestId('calendar-input').should('be.visible').should('not.have.css', 'display', 'none');
});
it('Mobile: Filter labels are readable and properly spaced', () => {
cy.getByTestId('filter-label').should('be.visible').each(($el) => {
const fontSize = window.getComputedStyle($el[0]).fontSize;
expect(parseInt(fontSize)).to.be.at.least(14);
});
});
it('Mobile: Input fields have adequate padding for mobile interaction', () => {
cy.getByTestId('city-autocomplete-input').should(($el) => {
const padding = window.getComputedStyle($el[0]).padding;
expect(padding).to.not.equal('0px');
});
});
it('Mobile: Hamburger menu opens and closes correctly', () => {
cy.getByTestId('hamburger-menu').should('exist').click();
cy.getByTestId('mobile-nav').should('be.visible');
cy.getByTestId('hamburger-menu').click();
cy.getByTestId('mobile-nav').should('not.be.visible');
});
it('Mobile: Accordion sections collapse and expand on tap', () => {
cy.getByTestId('accordion').should('exist');
cy.getByTestId('accordion').click();
cy.getByTestId('accordion-content').should('be.visible');
cy.getByTestId('accordion').click();
cy.getByTestId('accordion-content').should('not.be.visible');
});
it('Mobile: Images scale correctly without distortion', () => {
cy.getByTestId('company-logo').should('be.visible').each(($img) => {
const width = $img[0].getBoundingClientRect().width;
const height = $img[0].getBoundingClientRect().height;
expect(width).to.be.greaterThan(0);
expect(height).to.be.greaterThan(0);
});
});
it('Mobile: Button text is visible and not cut off', () => {
cy.getByTestId('arrival-search-button').should('be.visible').should(($btn) => {
const text = $btn.text();
expect(text).to.have.length.greaterThan(0);
});
});
it('Mobile: No text overflow in flight results', () => {
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('flight-result').first().then(($result) => {
expect($result[0].scrollWidth).to.equal($result[0].clientWidth);
});
});
});
it('Mobile: Touch targets for flight results are appropriately sized', () => {
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
checkTouchTargetSize('[data-testid="flight-result"]');
});
});
it('Mobile: Proper spacing between interactive elements', () => {
cy.getByTestId('filter-section').should(($section) => {
const buttons = $section.find('[data-testid="arrival-search-button"]');
expect(buttons.length).to.be.greaterThan(0);
});
});
});
// Tablet Viewport Tests (768x1024 - iPad 2)
describe('Tablet Viewport (768x1024 - iPad 2)', () => {
beforeEach(() => {
cy.viewport('ipad-2');
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
});
it('Tablet: Layout is optimized and not stretched', () => {
cy.getByTestId('main-content').should('be.visible').then(($content) => {
const width = $content[0].getBoundingClientRect().width;
expect(width).to.be.lessThan(768);
expect(width).to.be.greaterThan(400);
});
});
it('Tablet: Layout is not too narrow', () => {
cy.getByTestId('filter-section').should('be.visible').then(($section) => {
const width = $section[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(500);
});
});
it('Tablet: Multi-column layout works correctly', () => {
cy.getByTestId('filter-row').should('be.visible');
cy.getByTestId('filter-row').then(($row) => {
const columns = $row.find('[data-testid*="filter-col"]');
expect(columns.length).to.be.greaterThan(0);
});
});
it('Tablet: Touch interactions work for tapping elements', () => {
cy.getByTestId('accordion').should('exist').trigger('touchstart').trigger('touchend');
cy.getByTestId('accordion-content').should('be.visible');
});
it('Tablet: Buttons are appropriately sized for tablet interaction', () => {
checkTouchTargetSize('[data-testid="arrival-search-button"]');
});
it('Tablet: Spacing between form elements is balanced', () => {
cy.getByTestId('filter-section').should(($section) => {
const marginBottom = window.getComputedStyle($section[0]).marginBottom;
expect(marginBottom).to.not.equal('0px');
});
});
it('Tablet: No layout breaking on tablet orientation', () => {
checkNoHorizontalScroll();
});
it('Tablet: Forms fit properly within viewport', () => {
cy.getByTestId('filter-section').should('be.visible').then(($form) => {
const viewportWidth = window.innerWidth;
const formWidth = $form[0].getBoundingClientRect().width;
expect(formWidth).to.be.lessThan(viewportWidth);
});
});
it('Tablet: Input fields display correctly with proper size', () => {
cy.getByTestId('city-autocomplete-input').should('be.visible').then(($input) => {
const height = $input[0].getBoundingClientRect().height;
expect(height).to.be.greaterThan(30);
});
});
it('Tablet: Swipe left gesture works on content', () => {
cy.getByTestId('main-content').swipeLeft();
});
it('Tablet: Swipe right gesture works on content', () => {
cy.getByTestId('main-content').swipeRight();
});
it('Tablet: No horizontal scrolling with all content visible', () => {
checkNoHorizontalScroll();
});
it('Tablet: Images scale appropriately for tablet display', () => {
cy.getByTestId('company-logo').should('be.visible').each(($img) => {
const width = $img[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(20);
expect(width).to.be.lessThan(150);
});
});
});
// Desktop Viewport Tests (1920x1080)
describe('Desktop Viewport (1920x1080)', () => {
beforeEach(() => {
cy.viewport(1920, 1080);
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
});
it('Desktop: Layout scales correctly without overflow', () => {
cy.getByTestId('main-content').should('be.visible').then(($content) => {
expect($content[0].scrollWidth).to.equal($content[0].clientWidth);
});
});
it('Desktop: No horizontal scrolling on large viewport', () => {
checkNoHorizontalScroll();
});
it('Desktop: All content is accessible without zooming', () => {
cy.getByTestId('filter-section').should('be.visible');
cy.getByTestId('city-autocomplete-input').should('be.visible');
cy.getByTestId('calendar-input').should('be.visible');
cy.getByTestId('arrival-search-button').should('be.visible');
});
it('Desktop: Multi-column layout is fully utilized', () => {
cy.getByTestId('filter-row').should('be.visible').then(($row) => {
const width = $row[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(1000);
});
});
it('Desktop: Typography is appropriate for large screens', () => {
cy.getByTestId('filter-section').should(($section) => {
const fontSize = window.getComputedStyle($section[0]).fontSize;
expect(parseInt(fontSize)).to.be.at.least(14);
});
});
it('Desktop: Buttons are properly proportioned for large screen', () => {
cy.getByTestId('arrival-search-button').should('be.visible').then(($btn) => {
const width = $btn[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(80);
});
});
it('Desktop: Form elements are well-spaced on large viewport', () => {
cy.getByTestId('filter-section').should(($section) => {
const padding = window.getComputedStyle($section[0]).padding;
expect(padding).to.not.equal('0px');
});
});
it('Desktop: Hover effects are available on buttons', () => {
cy.getByTestId('arrival-search-button').should('be.visible');
// Hover effect test - verify element responds to hover state
cy.getByTestId('arrival-search-button').trigger('mouseenter');
});
it('Desktop: Accordion content displays correctly on large screen', () => {
cy.getByTestId('accordion').should('exist').click();
cy.getByTestId('accordion-content').should('be.visible').then(($content) => {
const width = $content[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(200);
});
});
it('Desktop: Images are properly scaled for desktop display', () => {
cy.getByTestId('company-logo').should('be.visible').each(($img) => {
const width = $img[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(40);
});
});
it('Desktop: Page layout remains optimal with full-width utilization', () => {
cy.viewport(1920, 1080);
cy.get('body').then(($body) => {
const viewportWidth = window.innerWidth;
expect(viewportWidth).to.equal(1920);
});
});
it('Desktop: All form inputs are visible and accessible', () => {
cy.getByTestId('filter-section').find('[data-testid="city-autocomplete-input"]').should('be.visible');
cy.getByTestId('filter-section').find('[data-testid="calendar-input"]').should('be.visible');
});
it('Desktop: Navigation elements are properly sized for mouse interaction', () => {
cy.getByTestId('hamburger-menu').should('exist').then(($menu) => {
const width = $menu[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(30);
});
});
it('Desktop: Content does not extend beyond safe viewport margins', () => {
cy.get('body').then(($body) => {
const bodyWidth = $body[0].getBoundingClientRect().width;
const viewportWidth = window.innerWidth;
expect(bodyWidth).to.be.lessThanOrEqual(viewportWidth);
});
});
it('Desktop: Text remains readable across large viewport', () => {
cy.getByTestId('filter-label').should('be.visible').each(($el) => {
const lineHeight = window.getComputedStyle($el[0]).lineHeight;
const fontSize = window.getComputedStyle($el[0]).fontSize;
expect(parseInt(lineHeight)).to.be.greaterThan(parseInt(fontSize));
});
});
it('Desktop: Flight search results display correctly on large viewport', () => {
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
cy.getByTestId('flight-result').should('have.length.at.least', 1);
cy.getByTestId('flight-result').first().then(($result) => {
const width = $result[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(300);
});
});
});
});
// Cross-viewport Tests
describe('Cross-Viewport Responsive Tests', () => {
beforeEach(() => {
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
});
it('Responsive: Search works consistently on mobile viewport', () => {
cy.viewport('iphone-se2');
cy.visit('/');
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
});
it('Responsive: Search works consistently on tablet viewport', () => {
cy.viewport('ipad-2');
cy.visit('/');
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
});
it('Responsive: Search works consistently on desktop viewport', () => {
cy.viewport(1920, 1080);
cy.visit('/');
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
});
});
});
@@ -0,0 +1,640 @@
import * as moment from 'moment';
/**
* Mock schedule results for testing
*/
const MOCK_SCHEDULE_RESULTS = [
{
flightNumber: 'SU1001',
carrier: 'SU',
number: '1001',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '09:00',
arrivalTime: '10:30',
duration: '1h 30m',
aircraft: 'A320',
price: 3500,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
{
flightNumber: 'SU1002',
carrier: 'SU',
number: '1002',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '12:15',
arrivalTime: '13:45',
duration: '1h 30m',
aircraft: 'A330',
price: 4200,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
{
flightNumber: 'SU1003',
carrier: 'SU',
number: '1003',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '15:30',
arrivalTime: '17:00',
duration: '1h 30m',
aircraft: 'B737',
price: 2800,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
{
flightNumber: 'SU1004',
carrier: 'SU',
number: '1004',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '18:45',
arrivalTime: '20:15',
duration: '1h 30m',
aircraft: 'A320',
price: 3100,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
{
flightNumber: 'SU1005',
carrier: 'SU',
number: '1005',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '21:00',
arrivalTime: '22:30',
duration: '1h 30m',
aircraft: 'A320',
price: 3000,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
];
const MOCK_FLIGHT_DETAILS = {
flightNumber: 'SU1001',
carrier: 'SU',
number: '1001',
departure: 'Москва',
departureCode: 'MOW',
departureTime: '09:00',
departureTerminal: 'A',
departureGate: '5',
departureCheckIn: '07:00-08:45',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
arrivalTime: '10:30',
arrivalTerminal: 'B',
arrivalGate: '12',
duration: '1h 30m',
aircraft: 'A320',
aircraftCode: 'A20',
boardingTime: '08:30',
price: 3500,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
};
describe('Расписание: Комплексные тесты', () => {
const route = {
departureCity: {
name: 'Москва',
code: 'MOW',
latitude: 55.7558,
longitude: 37.62,
},
arrivalCity: {
name: 'Санкт-Петербург',
code: 'LED',
latitude: 59.9311,
longitude: 30.3609,
},
alternateArrivalCity: {
name: 'Сочи',
code: 'AER',
latitude: 43.4391,
longitude: 39.9566,
},
};
beforeEach(() => {
cy.intercept('GET', '**/api/flights/1/ru/schedule**', MOCK_SCHEDULE_RESULTS).as('getSchedule');
cy.intercept('GET', '**/api/flights/1/ru/schedule/details**', MOCK_FLIGHT_DETAILS).as('getFlightDetails');
cy.intercept('GET', '**/api/cities/**', {
statusCode: 200,
body: [route.departureCity, route.arrivalCity, route.alternateArrivalCity],
}).as('getCities');
cy.mockGeolocation(route.departureCity);
cy.visit('/ru-ru/schedule');
});
// ============================================================
// SEARCH PAGE TESTS (~25 tests)
// ============================================================
describe('Search Page - Origin Autocomplete', () => {
it('Should allow manual entry of origin city', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.getByTestId('schedule-departure-city-input').getByTestId('city-code').should('contain', 'MOW');
});
it('Should filter origin cities as user types', () => {
cy.getByTestId('schedule-departure-city-input').type('М');
cy.getByTestId('city-dropdown-option').should('have.length.at.least', 1);
});
it('Should select origin city from dropdown', () => {
cy.getByTestId('schedule-departure-city-input').type('Мо');
cy.getByTestId('city-dropdown-option').first().click();
cy.getByTestId('schedule-departure-city-input').getByTestId('city-code').should('contain', 'MOW');
});
it('Should clear origin city selection', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва');
cy.getByTestId('schedule-departure-city-input').parent().find('[class*="clear"]').click({ force: true });
cy.getByTestId('schedule-departure-city-input').should('have.value', '');
});
it('Should validate that origin city is required', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург');
cy.getByTestId('schedule-search-button').click();
cy.getByTestId('validation-error').should('be.visible');
});
it('Should display origin city code after selection', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.getByTestId('city-code').contains('MOW').should('be.visible');
});
it('Should handle rapid typing in origin field', () => {
cy.getByTestId('schedule-departure-city-input').type('М', { delay: 10 }).type('о', { delay: 10 });
cy.getByTestId('city-dropdown-option').should('have.length.at.least', 1);
});
it('Should preserve origin city when navigating to details', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-result').first().click();
cy.url().should('include', 'details');
});
});
describe('Search Page - Destination Autocomplete', () => {
it('Should allow manual entry of destination city', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-arrival-city-input').getByTestId('city-code').should('contain', 'LED');
});
it('Should filter destination cities as user types', () => {
cy.getByTestId('schedule-arrival-city-input').type('С');
cy.getByTestId('city-dropdown-option').should('have.length.at.least', 1);
});
it('Should select destination city from dropdown', () => {
cy.getByTestId('schedule-arrival-city-input').type('Са');
cy.getByTestId('city-dropdown-option').first().click();
cy.getByTestId('schedule-arrival-city-input').getByTestId('city-code').should('be.visible');
});
it('Should clear destination city selection', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург');
cy.getByTestId('schedule-arrival-city-input').parent().find('[class*="clear"]').click({ force: true });
cy.getByTestId('schedule-arrival-city-input').should('have.value', '');
});
it('Should validate that destination city is required', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва');
cy.getByTestId('schedule-search-button').click();
cy.getByTestId('validation-error').should('be.visible');
});
it('Should prevent same city for origin and destination', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Москва').type('{enter}');
cy.getByTestId('validation-error').should('contain', 'одинаков');
});
it('Should display destination city code after selection', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('city-code').contains('LED').should('be.visible');
});
});
describe('Search Page - Date Range Picker', () => {
it('Should set start date using date picker', () => {
const startDate = moment().format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(startDate).type('{enter}');
cy.getByTestId('schedule-calendar').first().should('have.value', startDate);
});
it('Should set end date using date picker', () => {
const endDate = moment().add(7, 'days').format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').last().clear().type(endDate).type('{enter}');
cy.getByTestId('schedule-calendar').last().should('have.value', endDate);
});
it('Should allow single-day range', () => {
const singleDate = moment().format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(singleDate).type('{enter}');
cy.getByTestId('schedule-calendar').last().clear().type(singleDate).type('{enter}');
cy.getByTestId('schedule-calendar').first().should('have.value', singleDate);
cy.getByTestId('schedule-calendar').last().should('have.value', singleDate);
});
it('Should allow full range selection (7 days)', () => {
const startDate = moment().format('DD.MM.YYYY');
const endDate = moment().add(7, 'days').format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(startDate).type('{enter}');
cy.getByTestId('schedule-calendar').last().clear().type(endDate).type('{enter}');
cy.getByTestId('schedule-calendar').first().should('have.value', startDate);
cy.getByTestId('schedule-calendar').last().should('have.value', endDate);
});
it('Should reject end date before start date', () => {
const endDate = moment().format('DD.MM.YYYY');
const startDate = moment().add(7, 'days').format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(startDate).type('{enter}');
cy.getByTestId('schedule-calendar').last().clear().type(endDate).type('{enter}');
cy.getByTestId('validation-error').should('be.visible');
});
it('Should use today as default start date', () => {
cy.getByTestId('schedule-calendar').first().should('have.value', moment().format('DD.MM.YYYY'));
});
it('Should prevent date in the past', () => {
const pastDate = moment().subtract(1, 'days').format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(pastDate).type('{enter}');
cy.getByTestId('validation-error').should('be.visible');
});
it('Should allow date selection via calendar popup', () => {
cy.getByTestId('schedule-calendar').first().click();
cy.get('[class*="calendar"]').find('[class*="day"]').contains(moment().date().toString()).click({ force: true });
cy.getByTestId('schedule-calendar').first().should('have.value', moment().format('DD.MM.YYYY'));
});
});
describe('Search Page - Form Submission', () => {
it('Should submit valid search form', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-results').should('be.visible');
});
it('Should show loading indicator during search', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.getByTestId('loader').should('be.visible');
});
it('Should display error on network failure', () => {
cy.intercept('GET', '**/api/flights/1/ru/schedule**', { statusCode: 500 }).as('getScheduleError');
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getScheduleError');
cy.getByTestId('error-message').should('be.visible');
});
it('Should handle empty search results', () => {
cy.intercept('GET', '**/api/flights/1/ru/schedule**', []).as('getScheduleEmpty');
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getScheduleEmpty');
cy.getByTestId('empty-results-message').should('be.visible');
});
it('Should not submit with missing origin city', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.get('@getSchedule.all').should('have.length', 0);
});
it('Should not submit with missing destination city', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.get('@getSchedule.all').should('have.length', 0);
});
it('Should display correct URL after search', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.url().should('include', 'schedule');
});
it('Should enable search button only when form is valid', () => {
cy.getByTestId('schedule-search-button').should('be.disabled');
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-search-button').should('be.disabled');
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-search-button').should('be.enabled');
});
});
// ============================================================
// FLIGHT DETAILS PAGE TESTS (~20 tests)
// ============================================================
describe('Flight Details Page - Flight Information', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-result').first().click();
cy.wait('@getFlightDetails');
});
it('Should display flight number', () => {
cy.getByTestId('flight-details-number').should('contain', 'SU');
});
it('Should display departure information', () => {
cy.getByTestId('flight-departure-time').should('be.visible');
cy.getByTestId('flight-departure-city').should('contain', 'Москва');
});
it('Should display arrival information', () => {
cy.getByTestId('flight-arrival-time').should('be.visible');
cy.getByTestId('flight-arrival-city').should('contain', 'Санкт-Петербург');
});
it('Should display flight duration', () => {
cy.getByTestId('flight-duration').should('contain', 'h');
});
it('Should display aircraft type', () => {
cy.getByTestId('flight-aircraft').should('contain', 'A320');
});
it('Should display airline logo', () => {
cy.getByTestId('flight-company-logo').should('be.visible');
});
it('Should display price information', () => {
cy.getByTestId('flight-price').should('be.visible').should('contain', '3500');
});
it('Should display number of stops', () => {
cy.getByTestId('flight-stops').should('contain', '0');
});
});
describe('Flight Details Page - Timing Details', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-result').first().click();
cy.wait('@getFlightDetails');
});
it('Should display departure gate', () => {
cy.getByTestId('flight-departure-gate').should('contain', '5');
});
it('Should display departure terminal', () => {
cy.getByTestId('flight-departure-terminal').should('contain', 'A');
});
it('Should display check-in time range', () => {
cy.getByTestId('flight-check-in-time').should('contain', '07:00');
});
it('Should display boarding time', () => {
cy.getByTestId('flight-boarding-time').should('contain', '08:30');
});
it('Should display arrival gate', () => {
cy.getByTestId('flight-arrival-gate').should('contain', '12');
});
it('Should display arrival terminal', () => {
cy.getByTestId('flight-arrival-terminal').should('contain', 'B');
});
});
describe('Flight Details Page - Navigation', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-result').first().click();
cy.wait('@getFlightDetails');
});
it('Should navigate to next flight', () => {
cy.getByTestId('next-flight-button').click();
cy.wait('@getFlightDetails');
cy.getByTestId('flight-details-number').should('be.visible');
});
it('Should navigate to previous flight', () => {
cy.getByTestId('next-flight-button').click();
cy.wait('@getFlightDetails');
cy.getByTestId('prev-flight-button').click();
cy.wait('@getFlightDetails');
cy.getByTestId('flight-details-number').should('be.visible');
});
it('Should return to search results', () => {
cy.getByTestId('back-to-search-button').click();
cy.url().should('include', 'schedule');
cy.getByTestId('schedule-search-results').should('be.visible');
});
it('Should remember search filters when returning', () => {
cy.getByTestId('back-to-search-button').click();
cy.getByTestId('schedule-departure-city-input').getByTestId('city-code').should('contain', 'MOW');
cy.getByTestId('schedule-arrival-city-input').getByTestId('city-code').should('contain', 'LED');
});
it('Should disable previous button on first flight', () => {
cy.getByTestId('prev-flight-button').should('be.disabled');
});
});
// ============================================================
// FILTERS & SORTING TESTS (~15 tests)
// ============================================================
describe('Search Results - Filters', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
});
it('Should toggle time range filter', () => {
cy.getByTestId('time-filter-toggle').click();
cy.getByTestId('time-filter-panel').should('be.visible');
});
it('Should set minimum departure time', () => {
cy.getByTestId('time-filter-toggle').click();
cy.getByTestId('time-filter-min-slider').invoke('val', '09').trigger('input');
cy.getByTestId('schedule-search-result').each(($flight) => {
cy.wrap($flight).getByTestId('flight-departure-time').should('be.visible');
});
});
it('Should set maximum departure time', () => {
cy.getByTestId('time-filter-toggle').click();
cy.getByTestId('time-filter-max-slider').invoke('val', '18').trigger('input');
cy.getByTestId('schedule-search-result').each(($flight) => {
cy.wrap($flight).getByTestId('flight-departure-time').should('be.visible');
});
});
it('Should toggle airline filter', () => {
cy.getByTestId('airline-filter-toggle').click();
cy.getByTestId('airline-filter-panel').should('be.visible');
});
it('Should select single airline', () => {
cy.getByTestId('airline-filter-toggle').click();
cy.getByTestId('airline-filter-option').first().click();
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should deselect airline', () => {
cy.getByTestId('airline-filter-toggle').click();
cy.getByTestId('airline-filter-option').first().click();
cy.getByTestId('airline-filter-option').first().click();
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should toggle price range filter', () => {
cy.getByTestId('price-filter-toggle').click();
cy.getByTestId('price-filter-panel').should('be.visible');
});
it('Should set minimum price', () => {
cy.getByTestId('price-filter-toggle').click();
cy.getByTestId('price-filter-min-input').clear().type('3000');
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should set maximum price', () => {
cy.getByTestId('price-filter-toggle').click();
cy.getByTestId('price-filter-max-input').clear().type('4000');
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should clear all filters', () => {
cy.getByTestId('clear-filters-button').click();
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
});
describe('Search Results - Sorting', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
});
it('Should sort by departure time ascending', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-departure-asc').click();
cy.getByTestId('schedule-search-result').first().getByTestId('flight-departure-time').should('contain', '09:00');
});
it('Should sort by departure time descending', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-departure-desc').click();
cy.getByTestId('schedule-search-result').first().getByTestId('flight-departure-time').should('contain', '21:00');
});
it('Should sort by flight duration', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-duration').click();
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should sort by price ascending', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-price-asc').click();
cy.getByTestId('schedule-search-result').first().getByTestId('flight-price').should('contain', '2800');
});
it('Should sort by price descending', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-price-desc').click();
cy.getByTestId('schedule-search-result').first().getByTestId('flight-price').should('contain', '4200');
});
});
describe('Search Results - Result Display', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
});
it('Should display multiple flight results', () => {
cy.getByTestId('schedule-search-result').should('have.length', 5);
});
it('Should highlight flight on hover', () => {
cy.getByTestId('schedule-search-result').first().trigger('mouseover');
cy.getByTestId('schedule-search-result').first().should('have.class', 'highlighted');
});
it('Should show flight details on click', () => {
cy.getByTestId('schedule-search-result').first().click();
cy.wait('@getFlightDetails');
cy.getByTestId('flight-details-number').should('be.visible');
});
});
});
+74 -43
View File
@@ -1,48 +1,8 @@
/// <reference types="." /> /// <reference types="." />
// *********************************************** /**
// This example namespace declaration will help * Custom Cypress commands for Aeroflot Flights Web testing
// with Intellisense and code completion in your */
// IDE or Text Editor.
// ***********************************************
// declare namespace Cypress {
// interface Chainable<Subject = any> {
// customCommand(param: any): typeof customCommand;
// }
// }
//
// function customCommand(param: any): void {
// console.warn(param);
// }
//
// NOTE: You can use it like so:
// Cypress.Commands.add('customCommand', customCommand);
//
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
Cypress.Commands.add('getByTestId', (id: string, timeout = 8000) => { Cypress.Commands.add('getByTestId', (id: string, timeout = 8000) => {
return cy.get(`[data-testid="${id}"]`, { timeout }); return cy.get(`[data-testid="${id}"]`, { timeout });
@@ -71,3 +31,74 @@ Cypress.Commands.add('forbidGeolocation', () => {
cy.stub(window.navigator.geolocation, 'watchPosition').callsFake(callback); cy.stub(window.navigator.geolocation, 'watchPosition').callsFake(callback);
}); });
}); });
// Select arrival city by name
Cypress.Commands.add('selectArrivalCity', (cityName: string) => {
cy.getByTestId('city-autocomplete-input-arrival').clear().type(cityName);
cy.getByTestId('city-dropdown-option').contains(cityName).click();
});
// Select departure city by name
Cypress.Commands.add('selectDepartureCity', (cityName: string) => {
cy.getByTestId('city-autocomplete-input-departure').clear().type(cityName);
cy.getByTestId('city-dropdown-option').contains(cityName).click();
});
// Set arrival date using date picker
Cypress.Commands.add('setArrivalDate', (date: string) => {
cy.getByTestId('arrival-date-input').clear().type(date).type('{enter}');
});
// Set departure date using date picker
Cypress.Commands.add('setDepartureDate', (date: string) => {
cy.getByTestId('departure-date-input').clear().type(date).type('{enter}');
});
// Click search button
Cypress.Commands.add('clickSearchButton', () => {
cy.getByTestId('search-button').click();
});
// Get all flight results
Cypress.Commands.add('getFlightResults', () => {
return cy.getByTestId('flight-result');
});
// Get first flight result
Cypress.Commands.add('getFirstFlightResult', () => {
return cy.getByTestId('flight-result').first();
});
// Assert validation error is displayed
Cypress.Commands.add('shouldShowValidationError', (message: string) => {
cy.getByTestId('validation-error').should('contain', message);
});
// Select language by code
Cypress.Commands.add('selectLanguage', (langCode: string) => {
cy.getByTestId('language-selector').click();
cy.getByTestId(`language-option-${langCode}`).click();
});
// Get current language
Cypress.Commands.add('getCurrentLanguage', () => {
return cy.getByTestId('language-selector').invoke('text');
});
// Swipe right (for mobile navigation)
Cypress.Commands.add('swipeRight', { prevSubject: 'element' }, (subject) => {
cy.wrap(subject)
.trigger('touchstart', { which: 1, pageX: 0, pageY: 0 })
.trigger('touchmove', { which: 1, pageX: 100, pageY: 0 })
.trigger('touchend', { which: 1, pageX: 100, pageY: 0 });
return cy.wrap(subject);
});
// Swipe left (for mobile navigation)
Cypress.Commands.add('swipeLeft', { prevSubject: 'element' }, (subject) => {
cy.wrap(subject)
.trigger('touchstart', { which: 1, pageX: 100, pageY: 0 })
.trigger('touchmove', { which: 1, pageX: 0, pageY: 0 })
.trigger('touchend', { which: 1, pageX: 0, pageY: 0 });
return cy.wrap(subject);
});
+202
View File
@@ -0,0 +1,202 @@
/**
* Cypress test fixtures for Aeroflot Flights Web application
*/
export const CITIES = {
arrival: [
{
name: 'Москва',
code: 'MOW',
latitude: 55.7558,
longitude: 37.6173,
},
{
name: 'Санкт-Петербург',
code: 'LED',
latitude: 59.8011,
longitude: 30.2642,
},
{
name: 'Анапа',
code: 'AAQ',
latitude: 44.8972,
longitude: 37.3426,
},
{
name: 'Екатеринбург',
code: 'SVX',
latitude: 56.7365,
longitude: 60.8025,
},
{
name: 'Новосибирск',
code: 'OVB',
latitude: 55.0077,
longitude: 82.9484,
},
],
departure: [
{
name: 'Москва',
code: 'MOW',
latitude: 55.7558,
longitude: 37.6173,
},
{
name: 'Сочи',
code: 'AER',
latitude: 43.4391,
longitude: 39.9566,
},
{
name: 'Казань',
code: 'KZN',
latitude: 55.6084,
longitude: 49.2808,
},
],
};
export const MOCK_FLIGHTS_ARRIVAL = [
{
carrier: 'SU',
number: '001',
aircraft: 'A320',
estimatedTime: '10:15',
actualTime: '10:20',
status: 'Landed',
terminal: 'A',
gate: '12',
checkIn: '09:15-10:15',
},
{
carrier: 'SU',
number: '002',
aircraft: 'A330',
estimatedTime: '14:30',
actualTime: '14:28',
status: 'Landed',
terminal: 'B',
gate: '24',
checkIn: '13:30-14:30',
},
{
carrier: 'SU',
number: '003',
aircraft: 'B737',
estimatedTime: '22:45',
actualTime: null,
status: 'On Schedule',
terminal: 'A',
gate: '15',
checkIn: '21:45-22:45',
},
];
export const MOCK_FLIGHTS_DEPARTURE = [
{
carrier: 'SU',
number: '101',
aircraft: 'A320',
estimatedTime: '08:00',
actualTime: '08:05',
status: 'Departed',
terminal: 'A',
gate: '5',
checkIn: '06:00-07:45',
},
{
carrier: 'SU',
number: '102',
aircraft: 'A330',
estimatedTime: '12:30',
actualTime: null,
status: 'Boarding',
terminal: 'B',
gate: '18',
checkIn: '10:30-12:15',
},
];
export const POPULAR_REQUESTS = [
{
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Анапа',
arrivalCode: 'AAQ',
frequency: 'High',
},
{
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Сочи',
arrivalCode: 'AER',
frequency: 'High',
},
{
departure: 'Санкт-Петербург',
departureCode: 'LED',
arrival: 'Москва',
arrivalCode: 'MOW',
frequency: 'Medium',
},
];
export const LANGUAGES = [
{
code: 'ru',
name: 'Русский',
nativeName: 'Русский',
},
{
code: 'en',
name: 'English',
nativeName: 'English',
},
{
code: 'es',
name: 'Spanish',
nativeName: 'Español',
},
{
code: 'fr',
name: 'French',
nativeName: 'Français',
},
{
code: 'it',
name: 'Italian',
nativeName: 'Italiano',
},
{
code: 'ja',
name: 'Japanese',
nativeName: '日本語',
},
{
code: 'ko',
name: 'Korean',
nativeName: '한국어',
},
{
code: 'zh',
name: 'Chinese',
nativeName: '中文',
},
{
code: 'de',
name: 'German',
nativeName: 'Deutsch',
},
];
export const TEST_USERS = {
guest: {
username: null,
displayName: 'Guest',
},
authenticated: {
username: 'testuser@example.com',
displayName: 'Test User',
},
};
+13 -1
View File
@@ -3,6 +3,18 @@ declare namespace Cypress {
interface Chainable { interface Chainable {
getByTestId(id: string, timeout?: number): Chainable; getByTestId(id: string, timeout?: number): Chainable;
mockGeolocation({ latitude, longitude }): void; mockGeolocation({ latitude, longitude }): void;
forbidGeolocation(); forbidGeolocation(): void;
selectArrivalCity(cityName: string): Chainable;
selectDepartureCity(cityName: string): Chainable;
setArrivalDate(date: string): Chainable;
setDepartureDate(date: string): Chainable;
clickSearchButton(): Chainable;
getFlightResults(): Chainable;
getFirstFlightResult(): Chainable;
shouldShowValidationError(message: string): Chainable;
selectLanguage(langCode: string): Chainable;
getCurrentLanguage(): Chainable;
swipeRight(): Chainable;
swipeLeft(): Chainable;
} }
} }
+20 -10
View File
@@ -1,17 +1,27 @@
// *********************************************************** // ***********************************************************
// This example support/index.js is processed and // This support file is processed and loaded automatically
// loaded automatically before your test files. // before your test files.
// //
// This is a great place to put global configuration and // This is a great place to put global configuration and
// behavior that modifies Cypress. // behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// *********************************************************** // ***********************************************************
// When a command from ./commands is ready to use, import with `import './commands'` syntax
import './commands'; import './commands';
// Clear application state before each test
beforeEach(() => {
cy.window().then((win) => {
// Clear localStorage
win.localStorage.clear();
// Clear sessionStorage
win.sessionStorage.clear();
// Clear IndexedDB if available
if (win.indexedDB && typeof win.indexedDB.databases === 'function') {
win.indexedDB.databases().then((dbs: any[]) => {
dbs.forEach(db => {
win.indexedDB.deleteDatabase(db.name);
});
});
}
});
});
+33301 -21446
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -15,11 +15,16 @@
"lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"test": "ng test --code-coverage", "test": "ng test --code-coverage",
"test:ci": "ng test --watch=false --reporters=teamcity", "test:ci": "ng test --watch=false --reporters=teamcity",
"test:e2e": "cypress run",
"pretty": "prettier --write \"./**/*.{ts,html}\"", "pretty": "prettier --write \"./**/*.{ts,html}\"",
"analyze": "webpack-bundle-analyzer dist/stats.json", "analyze": "webpack-bundle-analyzer dist/stats.json",
"docs:json": "compodoc -p ./tsconfig.json -e json -d .", "docs:json": "compodoc -p ./tsconfig.json -e json -d .",
"storybook": "npm run docs:json && start-storybook -p 6006", "storybook": "npm run docs:json && start-storybook -p 6006",
"build-storybook": "npm run docs:json && build-storybook" "build-storybook": "npm run docs:json && build-storybook",
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"cypress:run:all": "cypress run --spec 'cypress/integration/**/*.ts'",
"cypress:run:feature": "cypress run --spec 'cypress/integration/**/*.ts' --headed"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "~12.2.13", "@angular/animations": "~12.2.13",
@@ -63,11 +68,12 @@
"@storybook/manager-webpack5": "^6.4.20", "@storybook/manager-webpack5": "^6.4.20",
"@storybook/testing-library": "0.0.9", "@storybook/testing-library": "0.0.9",
"@types/jasmine": "^3.10.2", "@types/jasmine": "^3.10.2",
"@types/leaflet": "^1.7.1", "@types/leaflet": "^1.7.11",
"@types/node": "^12.11.1", "@types/node": "^12.20.55",
"@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0", "@typescript-eslint/parser": "^5.4.0",
"babel-loader": "^8.2.4", "babel-loader": "^8.2.4",
"cypress": "^13.17.0",
"eslint": "^8.2.0", "eslint": "^8.2.0",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-storybook": "^0.5.7", "eslint-plugin-storybook": "^0.5.7",
@@ -82,6 +88,7 @@
"prettier": "2.4.1", "prettier": "2.4.1",
"start-server-and-test": "~1.14.0", "start-server-and-test": "~1.14.0",
"timezone-mock": "^1.3.2", "timezone-mock": "^1.3.2",
"ts-loader": "^9.5.7",
"typescript": "~4.3.5", "typescript": "~4.3.5",
"webpack-bundle-analyzer": "^4.5.0" "webpack-bundle-analyzer": "^4.5.0"
}, },
@@ -4,7 +4,7 @@
<label class="city-autocomplete__label" data-testid="city-code">{{ city?.code }}</label> <label class="city-autocomplete__label" data-testid="city-code">{{ city?.code }}</label>
</div> </div>
<tooltip *ngIf="error"> <tooltip *ngIf="error" data-testid="validation-error">
{{ error | translate }} {{ error | translate }}
</tooltip> </tooltip>
@@ -1,5 +1,5 @@
<div class="map-wrapper"> <div class="map-wrapper" data-testid="flights-map-container">
<div id="map" class="map"></div> <div id="map" class="map" data-testid="leaflet-map"></div>
<loader-sheet *ngIf="isLoading"></loader-sheet> <loader-sheet *ngIf="isLoading"></loader-sheet>
<no-directions-sheet <no-directions-sheet
*ngIf="isNoDirections && !isLoading" *ngIf="isNoDirections && !isLoading"
@@ -2,7 +2,7 @@
<p-accordion expandIcon="" collapseIcon="" [activeIndex]="0"> <p-accordion expandIcon="" collapseIcon="" [activeIndex]="0">
<p-accordionTab [selected]="true" [disabled]="true"> <p-accordionTab [selected]="true" [disabled]="true">
<div class="flights-map-filter-content"> <div class="flights-map-filter-content">
<div class="flights-map-filter-header"> <div class="flights-map-filter-header">
<h3>{{ 'FLIGHTS-MAP.ROUTE' | translate }}</h3> <h3>{{ 'FLIGHTS-MAP.ROUTE' | translate }}</h3>
</div> </div>
@@ -12,9 +12,9 @@
label="SHARED.DEPARTURE_CITY" label="SHARED.DEPARTURE_CITY"
[(ngModel)]="departure" [(ngModel)]="departure"
[placeholder]="departurePlaceholder" [placeholder]="departurePlaceholder"
data-testid="route-departure-city-input"> data-testid="destination-search-input">
</city-autocomplete> </city-autocomplete>
<div class="change-container"> <div class="change-container">
<button <button
class="button-change" class="button-change"
@@ -31,7 +31,6 @@
label="SHARED.ARRIVAL_CITY" label="SHARED.ARRIVAL_CITY"
[(ngModel)]="arrival" [(ngModel)]="arrival"
[placeholder]="arrivalPlaceholder" [placeholder]="arrivalPlaceholder"
data-testid="route-arrival-city-input"
></city-autocomplete> ></city-autocomplete>
</div> </div>
@@ -3,7 +3,7 @@
<label class="label--filter">{{ <label class="label--filter">{{
'SHARED.FLIGHT_NUMBER' | translate 'SHARED.FLIGHT_NUMBER' | translate
}}</label> }}</label>
<tooltip *ngIf="validationService.flightNumberError">{{ <tooltip *ngIf="validationService.flightNumberError" data-testid="validation-error">{{
validationService.flightNumberError | translate validationService.flightNumberError | translate
}}</tooltip> }}</tooltip>
@@ -26,14 +26,14 @@
placeholder="{{ placeholder="{{
'SHARED.FLIGHT_NUMBER_PLACEHOLDER' | translate 'SHARED.FLIGHT_NUMBER_PLACEHOLDER' | translate
}}" }}"
data-testid="flight-number-input" data-testid="flight-number-filter"
/> />
<button <button
pButton pButton
label=" " label=" "
class="button-clear" class="button-clear"
(click)="clearInput()" (click)="clearInput()"
data-testid="flight-number-clear-button" data-testid="flight-number-clear"
></button> ></button>
</div> </div>
</div> </div>
@@ -4,7 +4,7 @@
[(ngModel)]="departure" [(ngModel)]="departure"
[(error)]="validationService.departureError" [(error)]="validationService.departureError"
[placeholder]="departurePlaceholder" [placeholder]="departurePlaceholder"
data-testid="route-departure-city-input" data-testid="departure-city-input"
></city-autocomplete> ></city-autocomplete>
<div class="change-container"> <div class="change-container">
@@ -24,7 +24,7 @@
[(ngModel)]="arrival" [(ngModel)]="arrival"
[(error)]="validationService.arrivalError" [(error)]="validationService.arrivalError"
[placeholder]="arrivalPlaceholder" [placeholder]="arrivalPlaceholder"
data-testid="route-arrival-city-input" data-testid="arrival-city-input"
></city-autocomplete> ></city-autocomplete>
<calendar-input <calendar-input
@@ -34,7 +34,7 @@
[minDate]="minDate" [minDate]="minDate"
[maxDate]="maxDate" [maxDate]="maxDate"
[disabledDates]="disabledDates" [disabledDates]="disabledDates"
data-testid="route-calendar-input" data-testid="departure-date-input"
> >
</calendar-input> </calendar-input>
</div> </div>
@@ -43,6 +43,7 @@
[fullView]="false" [fullView]="false"
[(ngModel)]="timeRange" [(ngModel)]="timeRange"
label="{{ 'SHARED.FLIGHT_TIME' | translate }}" label="{{ 'SHARED.FLIGHT_TIME' | translate }}"
data-testid="time-range-slider"
> >
</time-selector> </time-selector>
@@ -53,6 +54,6 @@
type="button" type="button"
label="{{ 'SHARED.SEARCH' | translate }}" label="{{ 'SHARED.SEARCH' | translate }}"
(click)="search()" (click)="search()"
data-testid="route-search-button" data-testid="search-button"
></button> ></button>
</div> </div>
@@ -1,4 +1,4 @@
<div *ngIf="flightLegacy"> <div *ngIf="flightLegacy" data-testid="flight-details-modal">
<page-layout scrollUp [withScrollUp]="false"> <page-layout scrollUp [withScrollUp]="false">
<ng-container title> <ng-container title>
<ng-content select="[title]"></ng-content> <ng-content select="[title]"></ng-content>
@@ -7,6 +7,7 @@
header-left header-left
class="p-print-none" class="p-print-none"
[viewType]="ViewType.Onlineboard" [viewType]="ViewType.Onlineboard"
data-testid="modal-close-button"
></details-back> ></details-back>
<online-board-flights-mini-list <online-board-flights-mini-list
content-left content-left
@@ -7,6 +7,7 @@
[searchDate]="searchDate" [searchDate]="searchDate"
(open)="handleOpenEvent($event)" (open)="handleOpenEvent($event)"
(dateChange)="handleDateChange($event)" (dateChange)="handleDateChange($event)"
data-testid="flight-details-page"
> >
<online-board-flight-details-title <online-board-flight-details-title
title title
@@ -1,4 +1,4 @@
{{ 'BOARD.DEPARTURE' | translate }}: {{ 'BOARD.DEPARTURE' | translate }}:
<request-info (click)="onRequestInfoClick()">{{ <request-info (click)="onRequestInfoClick()" data-testid="popular-request-departure">{{
request.departure | cityName request.departure | cityName
}}</request-info> }}</request-info>
@@ -3,11 +3,13 @@
*ngSwitchCase="RequestMode.ARRIVAL" *ngSwitchCase="RequestMode.ARRIVAL"
[request]="$any(request)" [request]="$any(request)"
(onClick)="onRequestClick($event)" (onClick)="onRequestClick($event)"
data-testid="popular-request-arrival"
></arrival-request> ></arrival-request>
<departure-request <departure-request
*ngSwitchCase="RequestMode.DEPARTURE" *ngSwitchCase="RequestMode.DEPARTURE"
[request]="$any(request)" [request]="$any(request)"
(onClick)="onRequestClick($event)" (onClick)="onRequestClick($event)"
data-testid="popular-request-departure"
></departure-request> ></departure-request>
<flight-number-request <flight-number-request
*ngSwitchCase="RequestMode.FLIGHT_NUMBER" *ngSwitchCase="RequestMode.FLIGHT_NUMBER"
@@ -1,4 +1,4 @@
<div class="popular-requests"> <div class="popular-requests" data-testid="popular-requests-widget">
<h3 class="popular-requests__title"> <h3 class="popular-requests__title">
{{ 'BOARD.POPULAR-CHAPTERS' | translate }} {{ 'BOARD.POPULAR-CHAPTERS' | translate }}
</h3> </h3>
@@ -7,23 +7,27 @@
[request]="requests[0]" [request]="requests[0]"
(onClick)="handleRequestClick($event)" (onClick)="handleRequestClick($event)"
class="popular-requests__item" class="popular-requests__item"
data-testid="popular-request-item"
></popular-request> ></popular-request>
<popular-request <popular-request
*ngIf="requests[1]" *ngIf="requests[1]"
[request]="requests[1]" [request]="requests[1]"
(onClick)="handleRequestClick($event)" (onClick)="handleRequestClick($event)"
class="popular-requests__item" class="popular-requests__item"
data-testid="popular-request-item"
></popular-request> ></popular-request>
<popular-request <popular-request
*ngIf="requests[2]" *ngIf="requests[2]"
[request]="requests[2]" [request]="requests[2]"
(onClick)="handleRequestClick($event)" (onClick)="handleRequestClick($event)"
class="popular-requests__item" class="popular-requests__item"
data-testid="popular-request-item"
></popular-request> ></popular-request>
<popular-request <popular-request
*ngIf="requests[3]" *ngIf="requests[3]"
[request]="requests[3]" [request]="requests[3]"
(onClick)="handleRequestClick($event)" (onClick)="handleRequestClick($event)"
class="popular-requests__item" class="popular-requests__item"
data-testid="popular-request-item"
></popular-request> ></popular-request>
</div> </div>
@@ -10,7 +10,7 @@
[(ngModel)]="departure" [(ngModel)]="departure"
[(error)]="validationService.departureError" [(error)]="validationService.departureError"
placeholder="SHARED.CITY_PLACEHOLDER" placeholder="SHARED.CITY_PLACEHOLDER"
data-testid="schedule-departure-city-input" data-testid="origin-input"
></city-autocomplete> ></city-autocomplete>
<div class="change-container"> <div class="change-container">
@@ -34,7 +34,7 @@
[(ngModel)]="arrival" [(ngModel)]="arrival"
[(error)]="validationService.arrivalError" [(error)]="validationService.arrivalError"
placeholder="SHARED.CITY_PLACEHOLDER" placeholder="SHARED.CITY_PLACEHOLDER"
data-testid="schedule-arrival-city-input" data-testid="destination-input"
> >
</city-autocomplete> </city-autocomplete>
</div> </div>
@@ -51,7 +51,7 @@
[minDate]="settings.scheduleMinDate" [minDate]="settings.scheduleMinDate"
[maxDate]="maxScheduleDate" [maxDate]="maxScheduleDate"
[disabledDates]="disabledDates" [disabledDates]="disabledDates"
data-testid="schedule-calendar" data-testid="date-range-picker"
> >
</calendar-input-week> </calendar-input-week>
@@ -59,6 +59,7 @@
[fullView]="false" [fullView]="false"
[(ngModel)]="timeRange" [(ngModel)]="timeRange"
label="{{ 'SHARED.DEPARTURE_TIME' | translate }}" label="{{ 'SHARED.DEPARTURE_TIME' | translate }}"
data-testid="time-range-slider"
></time-selector> ></time-selector>
</div> </div>
@@ -71,12 +72,14 @@
[binary]="true" [binary]="true"
[(ngModel)]="directOnly" [(ngModel)]="directOnly"
label="{{ 'SHARED.DIRECT_FLIGHT_ONLY' | translate }}" label="{{ 'SHARED.DIRECT_FLIGHT_ONLY' | translate }}"
data-testid="direct-flights-checkbox"
></p-checkbox> ></p-checkbox>
<p-checkbox <p-checkbox
[binary]="true" [binary]="true"
[(ngModel)]="withReturn" [(ngModel)]="withReturn"
(ngModelChange)="resetReturnDateRange()" (ngModelChange)="resetReturnDateRange()"
label="{{ 'SHARED.RETURN_FLIGHT_VIEW' | translate }}" label="{{ 'SHARED.RETURN_FLIGHT_VIEW' | translate }}"
data-testid="return-flight-checkbox"
> >
</p-checkbox> </p-checkbox>
</div> </div>
@@ -100,6 +103,7 @@
[fullView]="false" [fullView]="false"
label="{{ 'SHARED.RETURN_FLIGHT_TIME' | translate }}" label="{{ 'SHARED.RETURN_FLIGHT_TIME' | translate }}"
[(ngModel)]="returnTimeRange" [(ngModel)]="returnTimeRange"
data-testid="return-time-range-slider"
> >
</time-selector> </time-selector>
</div> </div>
@@ -8,6 +8,7 @@
[detailsLoading]="dataSource.detailsLoading" [detailsLoading]="dataSource.detailsLoading"
(toFlightDetails)="handleRedirectToFlightDetails($event)" (toFlightDetails)="handleRedirectToFlightDetails($event)"
(toScheduleDate)="handleRedirectToScheduleDate($event)" (toScheduleDate)="handleRedirectToScheduleDate($event)"
data-testid="flight-details-page"
> >
<schedule-flight-details-title <schedule-flight-details-title
[flight]="dataSource.flight" [flight]="dataSource.flight"
@@ -1,8 +1,8 @@
<section class="page-empty"> <section class="page-empty" data-testid="empty-results">
<div class="page-empty__title"> <div class="page-empty__title" data-testid="empty-state-message">
{{ 'SHARED.FLIGHTS-NOT-FOUND' | translate }} {{ 'SHARED.FLIGHTS-NOT-FOUND' | translate }}
</div> </div>
<div class="page-empty__text"> <div class="page-empty__text" data-testid="empty-results-message">
{{ 'SHARED.FLIGHTS-NOT-FOUND-TEXT' | translate }} {{ 'SHARED.FLIGHTS-NOT-FOUND-TEXT' | translate }}
</div> </div>
</section> </section>
@@ -20,6 +20,7 @@
type="button" type="button"
label="{{ 'SHARED.SEARCH-CANCEL' | translate }}" label="{{ 'SHARED.SEARCH-CANCEL' | translate }}"
(click)="handleClick()" (click)="handleClick()"
data-testid="loader-cancel-button"
></button> ></button>
</div> </div>
</div> </div>
@@ -15,12 +15,14 @@
<terminal-link <terminal-link
class="station__terminal" class="station__terminal"
[station]="station" [station]="station"
data-testid="terminal"
></terminal-link> ></terminal-link>
<terminal-link <terminal-link
*ngIf="oldStation" *ngIf="oldStation"
class="station__terminal" class="station__terminal"
[station]="oldStation" [station]="oldStation"
[oldValue]="true" [oldValue]="true"
data-testid="terminal"
></terminal-link> ></terminal-link>
<text <text
@@ -1,16 +1,16 @@
<div class="flight"> <div class="flight">
<div class="flight-number" data-testid="flight-carrier-number"> <div class="flight-number" data-testid="flight-number">
<div>{{ flight | flightNumber }}</div> <div>{{ flight | flightNumber }}</div>
<div class="status description"> <div class="status description">
{{ 'FLIGHT-STATUSES.' + flight.status | translate }} {{ 'FLIGHT-STATUSES.' + flight.status | translate }}
</div> </div>
</div> </div>
<operator-logo-and-model [flight]="flight" [showModel]="expanded && flight.routeType !== 'MultiLeg'"></operator-logo-and-model> <operator-logo-and-model [flight]="flight" [showModel]="expanded && flight.routeType !== 'MultiLeg'" data-testid="airline-name"></operator-logo-and-model>
<time-group [actual]="departureBlockOffTimes" [scheduled]="departure._times.scheduledDeparture"></time-group> <time-group [actual]="departureBlockOffTimes" [scheduled]="departure._times.scheduledDeparture" data-testid="departure-time"></time-group>
<station [station]="$any(departure)"></station> <station [station]="$any(departure)" data-testid="station-from"></station>
<div class="flight-status"> <div class="flight-status">
<flight-status-icon [status]="flight.status"></flight-status-icon> <flight-status-icon [status]="flight.status"></flight-status-icon>
@@ -25,9 +25,10 @@
align="mobile-right" align="mobile-right"
[actual]="arrivalBlockOnTimes" [actual]="arrivalBlockOnTimes"
[scheduled]="arrival._times.scheduledArrival" [scheduled]="arrival._times.scheduledArrival"
data-testid="arrival-time"
></time-group> ></time-group>
<station [station]="$any(arrival)" align="mobile-right"></station> <station [station]="$any(arrival)" align="mobile-right" data-testid="station-to"></station>
<arrow-down-icon [rotated]="expanded" [ngClass]="arrowIconClasses"></arrow-down-icon> <arrow-down-icon [rotated]="expanded" [ngClass]="arrowIconClasses"></arrow-down-icon>
</div> </div>
@@ -14,6 +14,7 @@
(click)="toggle(index)" (click)="toggle(index)"
[flight]="$flight" [flight]="$flight"
[expanded]="$flight.expanded" [expanded]="$flight.expanded"
data-testid="flight-result-header"
></board-flight-header> ></board-flight-header>
<ng-container *ngIf="$flight.expanded"> <ng-container *ngIf="$flight.expanded">
@@ -18,7 +18,7 @@
{{ 'DISPATCH.' + departure.dispatch | translate }} {{ 'DISPATCH.' + departure.dispatch | translate }}
</property> </property>
<property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="departure.gate"> <property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="departure.gate" data-testid="gate">
{{ departure.gate | translate }} {{ departure.gate | translate }}
</property> </property>
@@ -18,7 +18,7 @@
{{ 'DISPATCH.' + arrival.dispatch | translate }} {{ 'DISPATCH.' + arrival.dispatch | translate }}
</property> </property>
<property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="arrival.gate"> <property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="arrival.gate" data-testid="gate">
{{ arrival.gate }} {{ arrival.gate }}
</property> </property>
@@ -22,8 +22,8 @@
<section-number <section-number
[number]="leg.crossIndex" [number]="leg.crossIndex"
></section-number> ></section-number>
<div class="flight-number"> <div class="flight-number" data-testid="flight-details-number">
<div class="flight-number__code"> <div class="flight-number__code" data-testid="flight-number">
{{ flight | flightNumber }} {{ flight | flightNumber }}
</div> </div>
<div class="flight-number__code-sharing"> <div class="flight-number__code-sharing">
@@ -1,9 +1,9 @@
<section class="frame"> <section class="frame">
<div class="error-page-girl" [ngClass]="'errorCode-' + errorCode"></div> <div class="error-page-girl" [ngClass]="'errorCode-' + errorCode"></div>
<div class="error-page-content"> <div class="error-page-content">
<div class="error-page-code">{{ errorCode }}</div> <div class="error-page-code" data-testid="error-code">{{ errorCode }}</div>
<div class="error-page-title">{{ title || 'PAGE500.HEADER' | translate }}</div> <div class="error-page-title" data-testid="error-message">{{ title || 'PAGE500.HEADER' | translate }}</div>
<div class="error-page-description">{{ description || 'PAGE500.DESCRIPTION' | translate }}</div> <div class="error-page-description" data-testid="error-description">{{ description || 'PAGE500.DESCRIPTION' | translate }}</div>
<!-- search should not be on error page. commented in case the ask to return it back--> <!-- search should not be on error page. commented in case the ask to return it back-->
<div class="error-page-search"> <div class="error-page-search">
@@ -15,13 +15,13 @@
<div class="sort-note">{{ footnotes }}</div> <div class="sort-note">{{ footnotes }}</div>
</div> </div>
<div class="sort-container"> <div class="sort-container">
<button pButton type="button" #departureUp class="sort sort--up" (click)="onSort(ScheduleSortMode.departureUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.departureUp }"> <button pButton type="button" #departureUp class="sort sort--up" (click)="onSort(ScheduleSortMode.departureUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.departureUp }" data-testid="sort-option-departure-asc">
<svg class="svg--arrow"> <svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-up" /> <use xlink:href="/assets/img/sprite.svg#arrow-up" />
</svg> </svg>
</button> </button>
<button pButton type="button" #departureDown class="sort sort--down" (click)="onSort(ScheduleSortMode.departureDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.departureDown }"> <button pButton type="button" #departureDown class="sort sort--down" (click)="onSort(ScheduleSortMode.departureDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.departureDown }" data-testid="sort-option-departure-desc">
<svg class="svg--arrow"> <svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-down" /> <use xlink:href="/assets/img/sprite.svg#arrow-down" />
</svg> </svg>
@@ -33,13 +33,13 @@
{{ 'SCHEDULE.SEARCH-RESULT-TIME' | translate }} {{ 'SCHEDULE.SEARCH-RESULT-TIME' | translate }}
</div> </div>
<div class="sort-container"> <div class="sort-container">
<button pButton type="button" #timeUp class="sort sort--up" (click)="onSort(ScheduleSortMode.timeUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.timeUp }"> <button pButton type="button" #timeUp class="sort sort--up" (click)="onSort(ScheduleSortMode.timeUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.timeUp }" data-testid="sort-option-time-asc">
<svg class="svg--arrow"> <svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-up" /> <use xlink:href="/assets/img/sprite.svg#arrow-up" />
</svg> </svg>
</button> </button>
<button pButton type="button" #timeDown class="sort sort--down" (click)="onSort(ScheduleSortMode.timeDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.timeDown }"> <button pButton type="button" #timeDown class="sort sort--down" (click)="onSort(ScheduleSortMode.timeDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.timeDown }" data-testid="sort-option-time-desc">
<svg class="svg--arrow"> <svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-down" /> <use xlink:href="/assets/img/sprite.svg#arrow-down" />
</svg> </svg>
@@ -52,13 +52,13 @@
<div class="sort-note">{{ footnotes }}</div> <div class="sort-note">{{ footnotes }}</div>
</div> </div>
<div class="sort-container"> <div class="sort-container">
<button pButton type="button" #arrivalUp class="sort sort--up" (click)="onSort(ScheduleSortMode.arrivalUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.arrivalUp }"> <button pButton type="button" #arrivalUp class="sort sort--up" (click)="onSort(ScheduleSortMode.arrivalUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.arrivalUp }" data-testid="sort-option-arrival-asc">
<svg class="svg--arrow"> <svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-up" /> <use xlink:href="/assets/img/sprite.svg#arrow-up" />
</svg> </svg>
</button> </button>
<button pButton type="button" #arrivalDown class="sort sort--down" (click)="onSort(ScheduleSortMode.arrivalDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.arrivalDown }"> <button pButton type="button" #arrivalDown class="sort sort--down" (click)="onSort(ScheduleSortMode.arrivalDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.arrivalDown }" data-testid="sort-option-arrival-desc">
<svg class="svg--arrow"> <svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-down" /> <use xlink:href="/assets/img/sprite.svg#arrow-down" />
</svg> </svg>
@@ -1,5 +1,5 @@
<ng-container *ngIf="scheduleItem"> <ng-container *ngIf="scheduleItem">
<div class="left"> <div class="left" data-testid="schedule-result">
<div class="description" [style.opacity]="scheduleItem.flights.length ? '1' : '0.5'"> <div class="description" [style.opacity]="scheduleItem.flights.length ? '1' : '0.5'">
{{ 'DAYS.' + scheduleItem.dayOfWeek | translate }} {{ 'DAYS.' + scheduleItem.dayOfWeek | translate }}
</div> </div>
@@ -1,7 +1,7 @@
<div class="calendar"> <div class="calendar">
<label class="label--filter">{{ label | translate }}</label> <label class="label--filter">{{ label | translate }}</label>
<tooltip *ngIf="error">{{ error | translate }}</tooltip> <tooltip *ngIf="error" data-testid="validation-error">{{ error | translate }}</tooltip>
<div class="calendar-controls-container" [ngClass]="{ 'has-value': dateStr, 'error-value': error }"> <div class="calendar-controls-container" [ngClass]="{ 'has-value': dateStr, 'error-value': error }">
<input <input
@@ -1,7 +1,7 @@
<div class="calendar"> <div class="calendar">
<label class="label--filter">{{ label | translate }}</label> <label class="label--filter">{{ label | translate }}</label>
<tooltip *ngIf="error">{{ error | translate }}</tooltip> <tooltip *ngIf="error" data-testid="validation-error">{{ error | translate }}</tooltip>
<div class="calendar--mobile"> <div class="calendar--mobile">
<button <button
+1
View File
@@ -9,6 +9,7 @@
"module": "es2020", "module": "es2020",
"moduleResolution": "node", "moduleResolution": "node",
"target": "es2017", "target": "es2017",
"skipLibCheck": true,
"typeRoots": [ "typeRoots": [
"node_modules/@types" "node_modules/@types"
], ],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,225 @@
# Phase 2: Online Board Feature
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement the Online Board feature -- the main flight status display with search by flight number, route, departure, or arrival. Includes start page, search results, and flight details.
**Architecture:** Feature-isolated module at `src/features/online-board/` with hooks, components, and services. Uses TanStack Query hooks from shared layer. URL-driven state with route validation.
**Tech Stack:** React 19, TanStack Query v5, Zustand, PrimeReact (Accordion, AutoComplete, Calendar), react-i18next, CSS Modules
**Depends on:** Phase 1 Foundation (complete)
---
### Task 1: Online Board Feature Hooks
**Files:**
- Create: `react-app/src/features/online-board/hooks/useOnlineBoardApi.ts`
- Create: `react-app/src/features/online-board/hooks/useFlightNavigation.ts`
- Create: `react-app/src/features/online-board/hooks/index.ts`
Feature-specific hooks wrapping shared query hooks with online-board-specific logic.
- [ ] **Step 1: Create API hook**
`useOnlineBoardApi.ts` wraps the shared query hooks with board-specific params and adds the `getFlightDaysByNumber` and `getFlightDaysByRoute` calls for calendar disabled dates.
- [ ] **Step 2: Create navigation hook**
`useFlightNavigation.ts` provides `navigateToSearch`, `navigateToDetails`, `navigateToStart` using React Router's `useNavigate` with URL builders from shared utils.
- [ ] **Step 3: Commit**
---
### Task 2: Search Filter Components
**Files:**
- Create: `react-app/src/features/online-board/components/filter/FlightNumberFilter.tsx`
- Create: `react-app/src/features/online-board/components/filter/FlightNumberFilter.module.css`
- Create: `react-app/src/features/online-board/components/filter/RouteFilter.tsx`
- Create: `react-app/src/features/online-board/components/filter/RouteFilter.module.css`
- Create: `react-app/src/features/online-board/components/filter/OnlineBoardFilter.tsx`
- Create: `react-app/src/features/online-board/components/filter/OnlineBoardFilter.module.css`
PrimeReact Accordion with 2 tabs: Flight Number search and Route search.
- [ ] **Step 1: Create FlightNumberFilter**
Input fields: flight number (SU prefix + 4 digits + optional suffix letter), date picker. Validates input, calls API for disabled dates.
- [ ] **Step 2: Create RouteFilter**
Input fields: departure autocomplete, arrival autocomplete, swap button, date picker, time range. Validates stations, calls API for disabled dates.
- [ ] **Step 3: Create OnlineBoardFilter (accordion wrapper)**
PrimeReact Accordion with 2 panels. Manages active tab state.
- [ ] **Step 4: Commit**
---
### Task 3: Flight Display Components
**Files:**
- Create: `react-app/src/features/online-board/components/board/FlightCard.tsx`
- Create: `react-app/src/features/online-board/components/board/FlightCard.module.css`
- Create: `react-app/src/features/online-board/components/board/FlightList.tsx`
- Create: `react-app/src/features/online-board/components/board/FlightList.module.css`
- Create: `react-app/src/features/online-board/components/board/FlightStatusBadge.tsx`
Components to display flight search results.
- [ ] **Step 1: Create FlightStatusBadge**
Renders colored badge based on FlightStatus enum (green=arrived, orange=delayed, red=cancelled, etc.)
- [ ] **Step 2: Create FlightCard**
Expandable card showing: departure/arrival times, cities, airline, status. Click to expand shows leg details. Uses PrimeReact Card.
- [ ] **Step 3: Create FlightList**
Renders list of FlightCards. Highlights closest flight to search time. Manages expanded state.
- [ ] **Step 4: Commit**
---
### Task 4: Flight Details Components
**Files:**
- Create: `react-app/src/features/online-board/components/details/FlightDetails.tsx`
- Create: `react-app/src/features/online-board/components/details/FlightDetails.module.css`
- Create: `react-app/src/features/online-board/components/details/FlightLegDetails.tsx`
- Create: `react-app/src/features/online-board/components/details/FlightLegDetails.module.css`
- Create: `react-app/src/features/online-board/components/details/FlightMiniList.tsx`
- Create: `react-app/src/features/online-board/components/details/FlightMiniList.module.css`
Components for the flight details page.
- [ ] **Step 1: Create FlightLegDetails**
Per-leg detail panel: departure/arrival info, times (scheduled/estimated/actual), gate, terminal, boarding status, transfer info.
- [ ] **Step 2: Create FlightMiniList**
Left sidebar list of all matching flights. Highlights selected. Click emits selection change.
- [ ] **Step 3: Create FlightDetails**
Main details view. For direct flights: single FlightLegDetails. For multi-leg: timeline with FlightLegDetails per leg + transfer segments.
- [ ] **Step 4: Commit**
---
### Task 5: Start Page
**Files:**
- Create: `react-app/src/features/online-board/components/StartPage.tsx`
- Create: `react-app/src/features/online-board/components/StartPage.module.css`
- Modify: `react-app/src/features/online-board/components/OnlineBoard.tsx`
- [ ] **Step 1: Create StartPage**
Renders: page title, OnlineBoardFilter (search form), hero section with 4 info tiles, PopularRequests widget. Uses MetaTags for SEO.
- [ ] **Step 2: Update OnlineBoard entry component**
Replace placeholder with router that renders StartPage on `/onlineboard` and other pages on sub-routes.
- [ ] **Step 3: Commit**
---
### Task 6: Search Results Page
**Files:**
- Create: `react-app/src/features/online-board/components/SearchPage.tsx`
- Create: `react-app/src/features/online-board/components/SearchPage.module.css`
- Create: `react-app/src/ui/date-tabs/DateTabs.tsx`
- Create: `react-app/src/ui/date-tabs/DateTabs.module.css`
- Create: `react-app/src/ui/time-selector/TimeSelector.tsx`
- Create: `react-app/src/ui/time-selector/TimeSelector.module.css`
Shared search results page for all 4 search types (flight number, route, departure, arrival).
- [ ] **Step 1: Create DateTabs component**
Horizontal date navigation (today, tomorrow, +2, etc.) with disabled dates. Sticky at top.
- [ ] **Step 2: Create TimeSelector component**
Time range picker (from/to sliders or inputs).
- [ ] **Step 3: Create SearchPage**
Receives search type and params. Renders: DateTabs, TimeSelector, FlightList, loading/empty states. Calls useFlightsQuery with params. Handles date/time changes via URL navigation.
- [ ] **Step 4: Commit**
---
### Task 7: Flight Details Page
**Files:**
- Create: `react-app/src/features/online-board/components/DetailsPage.tsx`
- Create: `react-app/src/features/online-board/components/DetailsPage.module.css`
- [ ] **Step 1: Create DetailsPage**
Renders: DateTabs, FlightMiniList (sidebar), FlightDetails (main). Fetches flight details via useFlightDetailsQuery. Handles flight selection from mini-list. Uses MetaTags + FlightJsonLd for SEO.
- [ ] **Step 2: Commit**
---
### Task 8: Route Definitions with Validation
**Files:**
- Create: `react-app/src/features/online-board/components/ValidatedRoute.tsx`
- Modify: `react-app/src/routes/onlineboard/page.tsx`
- Create: `react-app/src/routes/onlineboard/flight.[params].tsx`
- Create: `react-app/src/routes/onlineboard/departure.[params].tsx`
- Create: `react-app/src/routes/onlineboard/arrival.[params].tsx`
- Create: `react-app/src/routes/onlineboard/route.[params].tsx`
- Create: `react-app/src/routes/onlineboard/[params].tsx`
- [ ] **Step 1: Create ValidatedRoute wrapper**
Generic wrapper that validates URL params, redirects to 404 on invalid.
- [ ] **Step 2: Create route pages**
Each route page: parses params, validates, renders SearchPage or DetailsPage with appropriate type.
- [ ] **Step 3: Verify all routes work**
Test: `/onlineboard`, `/onlineboard/flight/1234-2026-04-03`, `/onlineboard/route/SVO-LED-2026-04-03-08002200`, `/onlineboard/SU1234-2026-04-03`
- [ ] **Step 4: Commit**
---
### Task 9: Build Verification
- [ ] **Step 1: Run production build**
Verify build succeeds with all new components.
- [ ] **Step 2: Final commit**
---
## What This Plan Produces
After completing all 9 tasks:
- Complete Online Board feature with start page, 4 search result types, and flight details
- Reusable UI components (DateTabs, TimeSelector)
- Flight display components (FlightCard, FlightList, FlightDetails)
- Search filter form with flight number and route tabs
- URL-driven routing with param validation
- SEO (MetaTags + JSON-LD) on all pages
@@ -0,0 +1,501 @@
# E2E Test Suite Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Write 200-300 comprehensive e2e tests for Angular Aeroflot app, validate 100% pass rate, then adapt and validate identical tests on React app with mocked + real API.
**Architecture:** Feature-based test organization (online-board, schedule, flights-map, popular-requests, i18n, error-states, responsive). Full state reset per test. Page Object Model for selector abstraction. Phased execution: Angular tests first → full validation → React adaptation → React validation.
**Tech Stack:** Cypress 13+, TypeScript, Moment.js (date manipulation), custom Cypress commands, fixtures with mock data
---
## Phase 1: Cypress Infrastructure Setup [SEE SPEC FOR DETAILS]
**Task 1: Set Up Cypress Base Config & Support Files for Angular**
- Create cypress.config.ts with baseUrl, timeouts, video recording
- Create cypress/tsconfig.json with TypeScript configuration
- Create cypress/support/index.ts with hook overrides
- Create cypress/support/fixtures.ts with CITIES, MOCK_FLIGHTS, POPULAR_REQUESTS, LANGUAGES
- Create cypress/support/commands.ts with custom Cypress commands for common actions
- Add npm scripts: cypress:open, cypress:run, cypress:run:all, cypress:run:feature, test:e2e
- Install Cypress and dependencies
- Commit all infrastructure files
---
## Phase 2: Write Angular Feature Tests
**Task 2: Online Board Feature Tests (~70 tests)**
Files: `ClientApp/cypress/integration/features/online-board.cy.ts`
Contains:
- Arrival Tab (20 tests): City input validation, date picker, search with results, flight details modal
- Departure Tab (20 tests): Mirror of arrival tab tests
- Flight Number Filter (15 tests): Filter by flight number, special characters, no results
- State persistence tests (15 tests): Preserve filters on navigation
Key test categories:
- Happy path: Valid search, display results, open modal
- Edge cases: Special characters, future dates, max passengers
- Error handling: API failures (404, 500, timeout), empty results, validation errors
- State: Filter persistence, modal state, pagination
- Accessibility: Keyboard navigation, focus management
- Responsive: Mobile/tablet/desktop viewports
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/online-board.cy.ts"`
Commit: `git commit -m "feat: add online board e2e tests"`
---
**Task 3: Schedule Feature Tests (~60 tests)**
Files: `ClientApp/cypress/integration/features/schedule.cy.ts`
Contains:
- Search Page (25 tests): Origin/destination autocomplete, date range, passenger count, validation
- Flight Details Page (20 tests): Display flight info, navigation (next/prev), back button
- Filters & Sorting (15 tests): Time range, airline filter, sort by departure/price/duration
Key patterns:
- Autocomplete with suggestions and filtering
- Date range picker with validation
- Spinner controls with min/max bounds
- Sorting (ascending/descending)
- Filter combinations and clear all
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/schedule.cy.ts"`
Commit: `git commit -m "feat: add schedule feature e2e tests"`
---
**Task 4: Flights Map Feature Tests (~40 tests)**
Files: `ClientApp/cypress/integration/features/flights-map.cy.ts`
Contains:
- Map Rendering (15 tests): Map loads, markers display, clustering, pan/zoom, geolocation
- Destination List (15 tests): List items render, click selects, search/filter works
- Map Interactions (10 tests): Click marker shows popup, click list item highlights map, hover effects
Key patterns:
- DOM element queries for Leaflet map
- Marker element selection and validation
- Pan/zoom coordinate calculation
- Click event triggering on map elements
- Popup visibility validation
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/flights-map.cy.ts"`
Commit: `git commit -m "feat: add flights map e2e tests"`
---
**Task 5: Popular Requests Widget Tests (~30 tests)**
Files: `ClientApp/cypress/integration/features/popular-requests.cy.ts`
Contains:
- Widget loads on page load (5 tests)
- Displays popular request items (10 tests)
- Click navigation (10 tests): Click item, verify URL and filters set correctly
- API fallback to mock data (5 tests)
Key patterns:
- Widget visibility on initial load
- Data binding from API or fixtures
- Navigation after item click
- Mock data fallback when API fails
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/popular-requests.cy.ts"`
Commit: `git commit -m "feat: add popular requests widget e2e tests"`
---
**Task 6: Internationalization (i18n) Tests (~20 tests)**
Files: `ClientApp/cypress/integration/features/i18n.cy.ts`
Contains:
- Language switcher functionality (3 tests): All 9 languages selectable, persistence
- Date format changes (5 tests): DD.MM.YYYY for ru, MM/DD/YYYY for en, etc.
- Number formatting (5 tests): Decimal separator, thousands separator per locale
- Text translations (5 tests): No missing keys, correct translations loaded
- Locale-specific UI (2 tests): Text truncation, layout changes
Languages tested: ru, en, es, fr, it, ja, ko, zh, de
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/i18n.cy.ts"`
Commit: `git commit -m "feat: add i18n e2e tests for all 9 languages"`
---
**Task 7: Error States & Recovery Tests (~30 tests)**
Files: `ClientApp/cypress/integration/features/error-states.cy.ts`
Contains:
- Network Errors (10 tests): 404 not found, 500 server error, 503 unavailable, timeout
- Validation Errors (8 tests): Required field missing, invalid format, past date, special chars
- Empty States (5 tests): No results, no matching cities, no flights
- Recovery & Retry (7 tests): Retry button works, clears error after success, reconnect on SignalR failure
Key patterns:
- Intercept with different statusCodes
- Error message visibility
- Retry button state and functionality
- SignalR connection loss and reconnection
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/error-states.cy.ts"`
Commit: `git commit -m "feat: add error states and recovery e2e tests"`
---
**Task 8: Responsive & Mobile Tests (~40 tests)**
Files: `ClientApp/cypress/integration/features/responsive.cy.ts`
Contains:
- Mobile Viewport (15 tests, 375x667 iPhone SE):
- Text readability, no overflow
- Touch targets ≥44x44px
- Hamburger menu opens/closes
- Accordion sections collapse/expand on tap
- Forms usable (not hidden behind keyboard)
- Tablet Viewport (12 tests, 768x1024 iPad):
- Layout optimized (not stretched)
- Multi-column layouts
- Touch interactions work
- Desktop Viewport (13 tests, 1920x1080):
- Layout scales correctly
- No horizontal scrolling
- All content accessible
Key patterns:
- cy.viewport() for responsive testing
- Touch events via Cypress touch commands
- Element size validation (44x44px minimum)
- Layout-specific assertions
Run: `npm run cypress:run:feature -- --spec "cypress/integration/responsive.cy.ts"`
Commit: `git commit -m "feat: add responsive design e2e tests"`
---
**Task 9: Validate Full Angular Test Suite**
- [ ] **Step 1: Run full Angular test suite**
```bash
cd ClientApp
npm run cypress:run:all
```
Expected: 200-300 tests, all passing, total time <15 minutes
- [ ] **Step 2: Generate HTML report**
```bash
npm run cypress:report
```
Expected: HTML report showing all tests with pass/fail status
- [ ] **Step 3: Identify and fix flaky tests**
If any test fails intermittently:
1. Add explicit waits for async operations
2. Use Cypress retry logic
3. Check for race conditions
4. Rerun until 3 consecutive passes
- [ ] **Step 4: Commit final Angular baseline**
```bash
git add cypress/integration/features/
git commit -m "feat: complete angular e2e test suite (200-300 tests, 100% pass rate)"
```
---
## Phase 3: Adapt Tests to React
**Task 10: Set Up Cypress for React App**
Files:
- Create: `react-app/cypress.config.ts` (copy from Angular, change baseUrl to :3000)
- Create: `react-app/cypress/tsconfig.json` (same as Angular)
- Create: `react-app/cypress/support/` (copy all from Angular)
- Create: `react-app/cypress/integration/features/` (will be adapted in next tasks)
- Modify: `react-app/package.json` (add cypress scripts)
- [ ] **Step 1: Copy Cypress config**
```bash
cd react-app
cp ../ClientApp/cypress.config.ts ./cypress.config.ts
# Edit baseUrl: 'http://localhost:3000'
cp -r ../ClientApp/cypress/support ./cypress/support
cp -r ../ClientApp/cypress/tsconfig.json ./cypress/tsconfig.json
```
- [ ] **Step 2: Install Cypress in React app**
```bash
npm install --save-dev cypress@13.x @types/node
```
- [ ] **Step 3: Add npm scripts to react-app/package.json**
```json
{
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"cypress:run:all": "cypress run --spec 'cypress/integration/**/*.cy.ts'",
"test:e2e": "cypress run -- --env API_MODE=mocked",
"test:e2e:real": "cypress run -- --env API_MODE=real BASE_URL=https://test.aeroflot.ru"
}
```
- [ ] **Step 4: Commit React Cypress setup**
```bash
git add cypress/ package.json
git commit -m "feat: set up cypress for react app with inherited configuration"
```
---
**Task 11-16: Adapt Each Feature Test to React**
For each feature (online-board, schedule, flights-map, popular-requests, i18n, error-states, responsive):
- [ ] **Step 1: Copy spec file**
```bash
cp ../ClientApp/cypress/integration/features/{feature}.cy.ts ./cypress/integration/features/{feature}.cy.ts
```
- [ ] **Step 2: Update selectors if DOM differs**
If React uses different HTML structure, update data-testid selectors in the spec file. Most selectors should remain the same if both apps implement the same test IDs.
- [ ] **Step 3: Run tests against React**
```bash
npm run test:e2e -- --spec "cypress/integration/features/{feature}.cy.ts"
```
- [ ] **Step 4: Fix failures**
For each failure:
1. Check console for selector errors
2. Update selectors in page-objects or spec file
3. Check for timing issues (add explicit waits)
4. Verify API responses match expected structure
- [ ] **Step 5: Commit adapted tests**
```bash
git add cypress/integration/features/{feature}.cy.ts
git commit -m "feat: adapt {feature} tests to react app"
```
**Repeat Steps 1-5 for each feature:**
- online-board
- schedule
- flights-map
- popular-requests
- i18n
- error-states
- responsive
---
**Task 17: Validate React Suite with Mocked API**
- [ ] **Step 1: Run full React test suite with mocked API**
```bash
cd react-app
npm run test:e2e
```
Expected: All 200-300 tests pass, <15 minutes
- [ ] **Step 2: Identify selector/timing issues**
For any failures:
- Check if selectors exist in React DOM (may differ from Angular)
- Add explicit cy.wait() if async operations need more time
- Update fixtures if mock data format differs
- [ ] **Step 3: Fix and rerun**
After fixes, rerun until 100% pass rate
- [ ] **Step 4: Commit React baseline**
```bash
git add cypress/integration/features/
git commit -m "feat: complete react e2e test suite with mocked api (100% pass rate)"
```
---
**Task 18: Validate React Suite with Real API**
- [ ] **Step 1: Run React test suite against staging backend**
```bash
cd react-app
npm run test:e2e:real
```
Expected: All tests pass, ~10-20 minutes (slower due to network)
- [ ] **Step 2: Handle network-related test flakiness**
Some tests may fail due to:
- Actual backend returning different data than mocks
- Network delays exceeding Cypress timeout
- Actual backend validation rules
Fix by:
1. Adjusting timeouts in cypress.config.ts
2. Updating assertions to match real data
3. Adding retry logic for flaky tests
- [ ] **Step 3: Validate feature parity**
Verify React and Angular behave identically:
- Same success paths work
- Same error messages shown
- Same validation rules applied
- [ ] **Step 4: Final commit**
```bash
git add cypress/
git commit -m "feat: react e2e test validation complete (mocked + real api, 100% pass rate)"
```
---
## Phase 4: Documentation & CI/CD
**Task 19: Add CI/CD Pipeline**
Files:
- Create: `.github/workflows/e2e-tests.yml`
This task ensures tests run automatically on every push/PR.
---
## Success Criteria Checklist
- [ ] **Angular Tests:** 200-300 tests, 100% pass rate, <15 min execution
- [ ] **React Tests (Mocked API):** 200-300 tests, 100% pass rate, <15 min execution
- [ ] **React Tests (Real API):** All tests pass against staging backend
- [ ] **Feature Parity:** Angular and React behave identically for all tested features
- [ ] **No Flaky Tests:** Tests pass consistently when rerun 3x
- [ ] **Performance:** No test takes >10 seconds
- [ ] **Code Coverage:** 80%+ for tested components
---
## Timeline Summary
| Phase | Tasks | Est. Time |
|-------|-------|-----------|
| 1. Setup | Task 1 | 30 mins |
| 2. Angular Tests | Tasks 2-8 | 4 hours |
| 2. Angular Validation | Task 9 | 1 hour |
| 3. React Setup | Task 10 | 30 mins |
| 3. React Adaptation | Tasks 11-16 | 3 hours |
| 3. React Validation (Mocked) | Task 17 | 1 hour |
| 3. React Validation (Real) | Task 18 | 1 hour |
| 4. CI/CD | Task 19 | 30 mins |
| **Total** | | **11-12 hours** |
Work continues until all success criteria are met.
---
## Implementation Notes
### Selector Strategy
If React app has different HTML structure, selectors will differ. Use `data-testid` attributes consistently across both apps:
```typescript
// Both Angular and React must implement these selectors:
[data-testid="arrival-city-input"]
[data-testid="search-button"]
[data-testid="flight-result"]
// etc.
```
### Fixture Updates
If real API returns different data structure, update fixtures in `cypress/support/fixtures.ts` to match.
### Timing Adjustments
If React has slower load times:
1. Increase `pageLoadTimeout` in cypress.config.ts
2. Add explicit `cy.wait()` for specific API calls
3. Use `cy.intercept(...).as('name')` and `cy.wait('@name')`
### Parallel Execution (Optional)
If <15 min time is not acceptable, enable parallel execution:
```bash
npm run cypress:run -- --parallel
```
Requires Cypress Dashboard account.
---
## Key Files Reference
**Angular Tests:**
- `ClientApp/cypress.config.ts` - Configuration
- `ClientApp/cypress/support/commands.ts` - Custom commands (reused by React)
- `ClientApp/cypress/support/fixtures.ts` - Mock data (shared with React)
- `ClientApp/cypress/integration/features/*.cy.ts` - Feature test specs
**React Tests:**
- `react-app/cypress.config.ts` - Same as Angular, different baseUrl
- `react-app/cypress/support/` - Copy of Angular support folder
- `react-app/cypress/integration/features/*.cy.ts` - Adapted feature tests
---
## Execution Workflow (Recommended: Subagent-Driven Development)
```
Task 1 (Setup) → Validate
Tasks 2-8 (Write Angular Tests) → Run in parallel via subagents
Task 9 (Validate Angular) → All tests pass?
Tasks 10-16 (React Setup + Adaptation) → Run in parallel
Task 17 (Validate React/Mocked) → All tests pass?
Task 18 (Validate React/Real API) → All tests pass?
Task 19 (CI/CD) → Pipeline working?
DONE: 200-300 tests passing on both Angular and React
```
Use `superpowers:subagent-driven-development` to parallelize tasks 2-8, 11-16.
@@ -0,0 +1,525 @@
# Aeroflot Flights Web: Angular to React Migration -- Design Spec
## Overview
Rewrite the Aeroflot Flights Web application from Angular 12 to React 18+, deployed as a Module Federation 2.0 remote micro-frontend. The React app runs standalone (own routing, own layout) and exposes features for the customer's host apps (Web, PWA) to consume via `mf-manifest.json`.
## Technology Stack
| Component | Technology |
|---|---|
| Framework | ModernJS (SSR, streaming mode) |
| Bundler | Rspack (via Rsbuild) |
| Module Federation | MF 2.0 with `mf-manifest.json` |
| UI Library | React 18+ with Concurrent Mode |
| Component Library | PrimeReact (port of existing PrimeNG look) |
| State (server) | TanStack Query v5 |
| State (client) | Zustand |
| Styling | CSS Modules + postcss-prefix-selector |
| Maps | React-Leaflet |
| i18n | react-i18next (reuse existing JSON files) |
| Real-time | @microsoft/signalr |
| Analytics | analytics (David Wells) with plugins |
## 1. Project Structure & Module Federation
```
react-app/
+-- modern.config.ts
+-- module-federation.config.ts
+-- src/
| +-- entry.server.tsx
| +-- entry.client.tsx
| +-- App.tsx
| +-- routes/
| | +-- layout.tsx
| | +-- onlineboard/
| | | +-- page.tsx
| | | +-- flight.[params].tsx
| | | +-- departure.[params].tsx
| | | +-- arrival.[params].tsx
| | | +-- route.[params].tsx
| | | +-- [params].tsx
| | +-- schedule/
| | | +-- page.tsx
| | | +-- [params].tsx
| | +-- flights-map/
| | | +-- page.tsx
| | +-- error/
| | +-- 404.tsx
| +-- features/
| | +-- online-board/
| | | +-- components/
| | | +-- hooks/
| | | +-- services/
| | | +-- types.ts
| | | +-- index.ts
| | +-- schedule/
| | | +-- components/
| | | +-- hooks/
| | | +-- services/
| | | +-- types.ts
| | | +-- index.ts
| | +-- flights-map/
| | | +-- index.ts
| | +-- popular-requests/
| | +-- index.ts
| +-- shared/
| | +-- api/
| | +-- hooks/
| | +-- stores/
| | +-- types/
| | +-- utils/
| | +-- seo/
| | +-- analytics/
| +-- ui/
| | +-- calendar-input/
| | +-- card/
| | +-- date-tabs/
| | +-- time-selector/
| | +-- toggle-switch/
| | +-- icons/
| +-- i18n/
| | +-- config.ts
| | +-- locales/
| +-- styles/
| +-- theme/
| +-- global.module.css
+-- public/
+-- package.json
+-- tsconfig.json
```
### Module Federation Configuration
```ts
// module-federation.config.ts
exposes: {
'./App': './src/App.tsx',
'./OnlineBoard': './src/features/online-board/index.ts',
'./Schedule': './src/features/schedule/index.ts',
'./FlightsMap': './src/features/flights-map/index.ts',
'./PopularRequests': './src/features/popular-requests/index.ts',
}
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
'@microsoft/signalr': { singleton: true, requiredVersion: '^9.0.0' },
}
```
### Two Modes of Operation
- **Standalone**: Full React app with own routing -- used for development, testing, and direct access.
- **Remote**: Host apps import individual features or `./App` via `mf-manifest.json`. Each feature is a self-contained React component with props-based API.
### Feature Isolation Rule
Features never import from each other. They only import from `shared/`, `ui/`, and `i18n/`. Each feature exposes a single entry component with well-defined props. This makes future MF extraction (breaking a feature into its own remote) a build/deployment change, not a code change.
## 2. Data Flow & State Architecture
### Zustand Stores (client/UI state)
```ts
// Per-feature filter stores
useOnlineBoardFilters // flightNumber, suffix, timeRange, date, departure, arrival
useScheduleFilters // departure, arrival, dateRange, directOnly, withReturn
useFlightsMapFilters // departure
// App-level stores
useSettingsStore // environment config, feature flags, refresh intervals
useUserLocationStore // geolocation result
useSearchHistoryStore // persisted search history (localStorage)
```
### TanStack Query (server state)
```ts
// Query keys: [feature, entity, params]
useFlightsQuery({ date, departure, arrival })
useFlightDetailsQuery({ flightNumber, date })
useFlightDaysQuery({ date, param, scope })
useScheduleQuery({ departure, arrival, dates })
useScheduleDaysQuery({ date, param })
useDestinationsQuery({ departure })
usePopularRequestsQuery()
```
TanStack Query replaces Angular's `CacheService` (response caching), manual `_loading` flags (loading/error states), and background refetching logic.
### Data Flow
1. User selects filters -> Zustand store updates
2. URL updates to reflect filter state (URL is source of truth)
3. Route component reads URL params -> passes to TanStack Query hook
4. Query fetches from REST API, caches result
5. SignalR subscribes to relevant hub events
6. Real-time update arrives -> query invalidated -> silent background refetch -> UI updates
## 3. Routing & URL Structure
### URL Patterns (unchanged from Angular)
```
/onlineboard/ # Start page
/onlineboard/flight/{flightNumber}-{flightDate} # Flight number search
/onlineboard/departure/{departure}-{date}-{from}{to} # Departure search
/onlineboard/arrival/{arrival}-{date}-{from}{to} # Arrival search
/onlineboard/route/{dep}-{arr}-{date}-{from}{to} # Route search
/onlineboard/{flightNumber}-{flightDate} # Flight details
/schedule/ # Start page
/schedule/{departure}-{arrival}-{dateRange} # Search results
/schedule/{flightNumber}-{flightDate} # Flight details
/flights-map/ # Map view (feature-flagged)
/error/404 # Not found
```
### Language Handling
Language detected from: (1) URL prefix if host provides one (remote mode), (2) browser locale / stored preference (standalone mode), (3) configurable via props.
Language prefixes (`/ru`, `/en`, `/es`, `/fr`, `/it`, `/ja`, `/ko`, `/zh`, `/de`) redirect to `/onlineboard`.
### URL <-> State Synchronization
URL is the source of truth for search state. `useUrlParams()` parses route params into typed filter objects. `useNavigateWithParams()` builds URL from filter state and navigates.
### Route Validation
Wrapper components validate URL params before rendering (replaces Angular route guards):
- `isValidDate(date)`
- `isValidStation(code)` -- 3-letter IATA
- `isValidFlightNumber(num)` -- 1-4 digits + optional letter suffix
- `isValidTimeRange(from, to)`
Invalid params redirect to `/error/404`.
### Feature Flag Guard
`flights-map` route is gated by `settingsStore.features.flightsMap`. If disabled, redirects to `/onlineboard`.
### Settings Resolution
Root layout fetches settings via TanStack Query before rendering any feature (replaces Angular's `SettingsResolver`).
## 4. SSR & SEO
### SSR Strategy
ModernJS streaming SSR with Data Loader pattern (Remix-inspired):
- Each route defines a `page.data.ts` exporting a `loader` function that runs on the server only.
- Loaders prefetch TanStack Query data with `queryClient.prefetchQuery()` and return `dehydrate(queryClient)`.
- Page components wrap content in `<HydrationBoundary state={dehydratedState}>` -- client picks up cached data without refetch.
- New `QueryClient` per request to prevent data leaks between users.
- `staleTime` set to match cache TTL (10-30s) to prevent immediate client refetch after hydration.
- Bot detection: ModernJS detects crawlers and serves full (non-streamed) HTML.
- Fallback: if SSR fails, ModernJS auto-falls back to CSR.
### Head Management
React 19 native metadata hoisting -- no library needed. `<title>`, `<meta>`, `<link>` rendered anywhere in the component tree are automatically hoisted to `<head>`.
When running as MF remote: the remote exposes a metadata contract for the host to read during SSR.
### JSON-LD Structured Data
`schema.org/Flight` with `Airline` and `Airport` sub-objects. For list pages, wrapped in `ItemList`.
Fields: `flightNumber`, `airline` (name, iataCode), `departureAirport` / `arrivalAirport` (name, iataCode), `departureTime`, `arrivalTime` (ISO 8601 with timezone), `estimatedFlightDuration`, `departureTerminal`, `arrivalTerminal`.
Rendered server-side via `<script type="application/ld+json">`.
### OpenGraph
Per-page OG tags: `og:title`, `og:description`, `og:url`, `og:type`, `og:locale`, `og:locale:alternate` (for all 9 languages). Twitter Cards with `summary_large_image`.
### Per-Page SEO
| Page | Title | Structured Data | noRobots |
|---|---|---|---|
| Board start | "Online Timetable -- Aeroflot" | None | No |
| Flight search results | "Flights {dep} -> {arr} -- {date}" | ItemList of Flight | No |
| Flight details | "Flight SU-{num}: {dep} -> {arr}" | Flight | Yes |
| Schedule start | "Flight Schedule -- Aeroflot" | None | No |
| Schedule results | "Schedule {dep} -- {arr}" | ItemList of Flight | No |
| Flights map | "Flight Map -- Aeroflot" | None | No |
## 5. Analytics, Logging & Monitoring
### Analytics Abstraction
[`analytics`](https://github.com/DavidWells/analytics) library as unified dispatcher with custom plugins per provider. Page views tracked automatically on route changes via `useLocation()`.
### Per-System Integration
**Yandex.Metrika**: `Modern-Yandex-Metrika` with `delay: 'onload'` for LCP optimization. Client-only. SPA page views via `ym(COUNTER_ID, 'hit', url)`. Goals via `ym(COUNTER_ID, 'reachGoal', 'goalName', params)`.
**Dynatrace (Klyuch-Astrom)**: Host app injects OneAgent -- remote does NOT load its own copy. OneAgent auto-captures XHR/fetch, DOM mutations, route changes. Custom business actions via `dtrum` API. TypeScript types via `@dynatrace/dtrum-api-types`.
**Variocube (Variokub)**: Server-side variant resolution via Varioqub usersplit API in ModernJS loader. Variant resolved before render -- zero layout shift. Variant assignment cached per visitor cookie.
**CTM**: Async script injection. Re-trigger `CallTrackingMetrics.swapNumbers()` on route changes. SSR HTML contains original business number (correct for SEO).
### Logging
Custom structured logger with batch shipping:
- Batch size: 50 entries, flush every 5 seconds
- `sendBeacon` on `visibilitychange` (when `hidden`) for reliable delivery on page unload
- Pluggable format -- customer's log format spec TBD (will be provided by customer separately)
- Sampling: 100% errors, 100% warnings, 20% info in production
- `traceId` for correlation with backend OpenTelemetry traces
- No PII without explicit consent
### Web Vitals
Google's `web-vitals` v5 library, client-only after hydration. Tracks: LCP, INP, CLS, FCP, TTFB. Reports via `sendBeacon` to `/api/vitals`.
## 6. Style Isolation & Theming
### Three-Layer Isolation
**Layer 1 -- CSS Modules**: All custom components use `.module.css` files. Class names hashed at build time. Zero runtime cost, SSR-safe.
**Layer 2 -- postcss-prefix-selector**: All global/third-party CSS (PrimeReact) prefixed with `.afl-flights`. Every PrimeReact selector becomes `.afl-flights .p-button`, etc.
**Layer 3 -- PrimeReact overlay containment**: `PrimeReactProvider` with `appendTo: 'self'` globally. All overlays (dropdowns, dialogs, tooltips) render inside the scoped wrapper.
### Boundary Reset
Explicitly set inherited properties on boundary element (NOT `all: initial` which breaks `display`, `box-sizing`, etc.):
```css
.afl-flights {
font-family: 'Aeroflot Sans', sans-serif;
font-size: 16px;
line-height: 1.5;
color: var(--afl-text-color, #1a1a1a);
direction: ltr;
text-align: left;
isolation: isolate; /* New stacking context -- prevents z-index conflicts */
}
```
### Responsive Layout: Container Queries
Container queries (not media queries) for all component-level responsiveness. A MF remote doesn't control the viewport -- it might be full-width or in a sidebar. Container queries respond to actual available space.
```css
.afl-flights { container-type: inline-size; container-name: flights-root; }
@container flights-root (min-width: 768px) { ... }
@container flights-root (min-width: 1024px) { ... }
```
Nested containers for fine-grained responsiveness (e.g., `.flightCard` as its own container).
Media queries reserved only for: `prefers-color-scheme`, `prefers-reduced-motion`, `print`.
Browser support: 95%+ (Chrome 105+, Firefox 110+, Safari 16+).
### Theming: Three-Tier CSS Custom Properties
```
Tier 1 -- Primitive tokens: --afl-blue-500, --afl-gray-100, etc.
Tier 2 -- Semantic tokens: --afl-primary, --afl-surface, --afl-text-color
Tier 3 -- Component tokens: --afl-btn-bg, --afl-card-bg, --afl-card-shadow
```
Host app overrides any tier by setting variables on `.afl-flights`. Theme variants via `data-theme` attribute (`light`, `dark`).
PrimeReact theme integration: map tokens to PrimeReact design token system (`--p-primary-color: var(--afl-primary)`).
### ModernJS/Rspack Config
- `output.cssModules.localIdentName: '[local]--[hash:base64:5]'` -- deterministic for SSR hydration
- `output.cssModules.namedExport: true` -- better tree-shaking
- Streaming SSR mode (required for MF with ModernJS)
## 7. SignalR Real-Time Integration
### Connection Management
React Context + singleton `HubConnection`. Dynamic `import('@microsoft/signalr')` inside `useEffect` -- the library references `XMLHttpRequest`/`WebSocket` at module level, which crashes during SSR.
### Reconnection Policy
Custom `IRetryPolicy` with exponential backoff (1s, 2s, 4s, 8s... capped at 30s), **never gives up**. The default policy stops after 4 attempts -- unacceptable for a 24/7 flight board.
### Event Handling
`useSignalREvent` hook subscribes to hub events with a ref-based callback to avoid stale closures.
Feature-level hooks:
- `useFlightBoardRealtime(date, departure?, arrival?)` -- subscribes to `RefreshDate`, invalidates flight board query
- `useFlightDetailsRealtime(flightId)` -- subscribes to `Refresh`, invalidates flight details query
### Re-Subscribe After Reconnect
SignalR does not persist subscriptions across reconnects. A subscription registry in the provider tracks active subscriptions. `onreconnected` callback re-invokes all registered subscriptions.
### Inactive Tab Handling
Chrome throttles background tabs, which can drop SignalR connections. Rely on `withAutomaticReconnect` (infinite retry) + TanStack Query's `refetchOnWindowFocus: true`. When user returns to tab: auto-reconnect fires, subscriptions re-registered, stale data refetched.
### TanStack Query Config
Flight queries use `staleTime: Infinity` -- don't auto-refetch since all updates come via SignalR. `refetchOnWindowFocus: true` handles tab-return freshness.
### Module Federation
Remote owns the connection. `@microsoft/signalr` marked `singleton: true` in MF shared config to avoid duplicate bundles.
### Connection Status UI
Non-blocking banner: "Real-time updates paused. Reconnecting..." (reconnecting state), "Connection lost. Data may be outdated." (disconnected state).
## 8. Performance & Scaling
### Target: 100 RPS
### Three-Tier Caching
```
Client -> CDN (10s cache + 50s stale-while-revalidate)
| MISS
v
Redis (full-page HTML cache, 10-15s TTL)
| MISS
v
ModernJS SSR (streaming render)
| prefetch
v
TanStack Query server-side (API data cache, 10-30s)
| MISS
v
REST API
```
With caching, actual SSR renders happen once per 10-15 seconds per unique URL. 100 RPS is met by CDN/Redis alone.
### CDN Headers
```
Cache-Control: public, s-maxage=10, stale-while-revalidate=50, max-age=0
Vary: Accept-Language
```
### Code Splitting
Route-based splitting via ModernJS (not `React.lazy()` which does not work in SSR). Each route directory = separate chunk. MF remote loading handled by federation runtime.
### Bundle Size Budget
| Asset | Budget (gzipped) |
|---|---|
| MF remote entry | < 50 KB |
| Total remote bundle | < 200 KB |
| Per-page JS | < 400 KB |
Optimization: individual PrimeReact imports, lazy-load React-Leaflet behind feature flag, MF 2.0 tree shaking with `federationRuntime: 'hoisted'`, only share `react`, `react-dom`, `@microsoft/signalr`.
### Node.js Scaling
- SSR render: ~50ms per request
- Single process: ~20 RPS
- Target: 2 containers x 4 cores = 8 processes -> ~160 RPS (60% headroom)
- `--max-old-space-size=1536` (1.5 GB heap on 2 GB container)
- PM2 cluster mode with `--max-memory-restart 1G`
### Geographic Distribution
App is fully stateless -- no server-side sessions, no sticky sessions. Deploy to multiple regions behind global load balancer. Redis per region for SSR cache.
### Performance Monitoring
**CI/CD**: `size-limit` for bundle budget, Rspack `performance.hints: 'error'`, Lighthouse CI for Web Vitals.
**Production**: SSR render time p50/p95/p99 (alert p95 > 200ms), cache hit rate (target > 90%), Web Vitals (LCP <= 2.5s, INP <= 200ms, CLS <= 0.1, TTFB <= 800ms), memory per pod (alert 80%), error rate (alert > 1%).
## 9. Features to Port
### Online Board
- Real-time flight status display with search by flight number, route, departure, arrival
- SignalR integration for live updates
- Flight details view with boarding status, leg information, codeshare data
- Day navigation, time range filtering
- Popular requests widget
### Schedule
- Flight schedule search by route over date ranges (up to 330 days)
- Return flight support
- Direct-only filter
- Accordion-based results display
- Flight details view
### Flights Map
- Interactive Leaflet map showing available destinations from departure point
- Feature-flag gated (`features.flightsMap`)
- Destination list with connection details
### Popular Requests
- Shared widget showing trending searches across all features
- Generic request components (flight number, route, departure, arrival)
## 10. Data Types
Port the existing TypeScript types from Angular's `/typings/` directory:
- `IFlight = ISimpleFlight | IConnectingFlight` (discriminated union)
- `ISimpleFlight = IDirectFlight | IMultiLegFlight`
- `RouteType`: DIRECT, MULTI_LEG, CONNECTING
- `FlightStatus`: SCHEDULED, SENT, IN_FLIGHT, LANDED, ARRIVED, DELAYED, CANCELLED, UNKNOWN
- `RequestMode`: FLIGHT_NUMBER, ROUTE, SCHEDULE_ROUTE_BOTH_DIRECTIONS, DEPARTURE, ARRIVAL
- API response types: `IBoardResponse`, `IScheduleResponse`, `IDaysResponse`, `IDestinationsResponse`
## 11. API Endpoints
All endpoints proxied under `/api` in development, real URLs in production.
```
GET /api/flights/v1.1/{lang}/board # Flight board
GET /api/flights/v1.1/{lang}/onlineboard/details # Flight details
GET /api/flights/v1.1/{lang}/days/{date}/31/{param}/board # Available days (board)
GET /api/flights/v1.1/{lang}/schedule/1 # Schedule search
GET /api/flights/v1.1/{lang}/days/{date}/382/{param}/schedule # Available days (schedule)
GET /api/flights/v1.1/{lang}/destinations/1 # Map destinations
GET /api/flights/v1.1/{lang}/days/{date}/200/{param}/flights-map # Map days
GET /api/Requests/1/getpopular # Popular requests
```
## 12. i18n
9 languages: ru, en, de, fr, es, it, ja, ko, zh.
Existing JSON translation files reused directly -- simple nested key-value format compatible with react-i18next without reformatting.
Calendar locale data registered per language.
## 13. Environment Configuration
Per-environment settings (mirroring Angular's `environment*.ts`):
- `apiRootUrl`, `wsRootUrl`, `mapApiUrl`
- `urlForTrackerHub` (SignalR hub)
- `urlForChatBot`
- `appInsights` (instrumentationKey, application, category, env)
- `refreshPauseMin: 15`, `refreshStopMin: 60`
- `boardCalendarDatesEnabledCountBack: 1`, `boardCalendarDatesEnabledCountForward: 14`
- `scheduleCalendarDatesEnabledCountBack: 1`, `scheduleCalendarDatesEnabledCountForward: 330`
- `features: { flightsMap: boolean }`
- Ticket purchase time windows (prod only)
## 14. Non-Functional Requirements
| Requirement | Implementation |
|---|---|
| 100 RPS | Three-tier caching (CDN -> Redis -> SSR), horizontal scaling |
| 24/7 availability | Stateless app, geo-distributed VMs, < 6h recovery |
| Security/isolation | CSS Modules + prefix scoping, `isolation: isolate`, `appendTo: 'self'` |
| SEO | SSR, JSON-LD, OpenGraph, canonical URLs |
| Responsive | Container queries, fluid layout |
| Logging | Structured frontend logs -> customer's logging system |
| Monitoring | Web Vitals, SSR metrics, error rates -> aggregator |
| Multi-platform | Web + PWA embedding via Module Federation |
@@ -0,0 +1,781 @@
# E2E Test Suite Design: Aeroflot Flights Web (Angular → React)
**Date:** 2026-04-04
**Status:** Design Approved
**Scope:** 200-300 comprehensive e2e tests covering all UI elements and interactions
---
## 1. Overview
This document specifies a comprehensive end-to-end test suite for the Aeroflot Flights Web application, covering both the current Angular implementation (ClientApp/) and the new React implementation (react-app/). The tests verify feature parity between the two versions and ensure all UI elements, interactions, and edge cases function correctly.
**Approach:** Write all tests against Angular first, validate 100% pass rate, then adapt and run against React (with both mocked and real APIs).
**Total Test Count:** 200-300 tests
**Execution Time:** 8-15 minutes per suite (mocked API), 10-20 minutes (real API)
---
## 2. Test Architecture & File Organization
### 2.1 Directory Structure
```
ClientApp/cypress/
├── integration/
│ ├── features/
│ │ ├── online-board.cy.ts (~60-70 tests)
│ │ ├── schedule.cy.ts (~50-60 tests)
│ │ ├── flights-map.cy.ts (~30-40 tests)
│ │ ├── popular-requests.cy.ts (~20-30 tests)
│ │ ├── i18n.cy.ts (~15-20 tests)
│ │ └── error-states.cy.ts (~25-30 tests)
│ └── responsive.cy.ts (~30-40 tests)
├── support/
│ ├── commands.ts (custom Cypress commands)
│ ├── page-objects/
│ │ ├── online-board.po.ts
│ │ ├── schedule.po.ts
│ │ ├── flights-map.po.ts
│ │ ├── common.po.ts
│ │ └── index.ts
│ ├── fixtures.ts (mock data, test cities, flights)
│ └── index.ts
└── tsconfig.json
react-app/cypress/
├── integration/
│ ├── features/
│ │ ├── online-board.cy.ts (adapted from Angular)
│ │ ├── schedule.cy.ts
│ │ ├── flights-map.cy.ts
│ │ ├── popular-requests.cy.ts
│ │ ├── i18n.cy.ts
│ │ └── error-states.cy.ts
│ └── responsive.cy.ts
├── support/
│ ├── commands.ts (same as Angular)
│ ├── page-objects/ (may differ from Angular if DOM differs)
│ └── fixtures.ts (same as Angular)
└── tsconfig.json
```
### 2.2 Test Organization
Each spec file is organized as:
```typescript
// Example: online-board.cy.ts
describe('Online Board Feature', () => {
describe('Arrival Tab', () => {
describe('City Input', () => {
it('should accept manual city entry');
it('should show validation error for empty input');
it('should handle special characters gracefully');
// ... more tests
});
describe('Date Picker', () => {
it('should accept valid future dates');
it('should reject past dates');
// ... more tests
});
describe('Search & Results', () => {
it('should display flight results after successful search');
it('should show loading state during API call');
// ... more tests
});
});
describe('Departure Tab', () => {
// ... similar structure
});
// ... more features
});
```
---
## 3. Test Scope by Feature
### 3.1 Online Board (~60-70 tests)
**Arrival Tab (~20 tests):**
- City input: manual entry, dropdown selection, validation errors, empty input, special characters
- Date picker: valid dates, future dates, past dates, invalid formats, today, edge dates
- Search button: valid search, missing fields, network error, loading state, disabled state
- Results display: flight list rendering, correct count, flight details modal, pagination
**Departure Tab (~20 tests):**
- Same test coverage as Arrival (mirror feature)
**Flight Search/Filter (~15 tests):**
- Flight number input: valid format (e.g., "SU 123"), invalid format, special characters, clear button
- Autocomplete behavior: suggestions appear, filtering works, arrow key navigation, tab navigation
- Form submission with partial filters
**Flight Details Panel (~10 tests):**
- Modal opens on flight click, displays all correct flight info
- Modal closes on X button, escape key, outside click
- Links (airline, gate, terminal) navigate correctly
- Back button returns to results with filters preserved
### 3.2 Schedule (~50-60 tests)
**Search Page (~25 tests):**
- Origin autocomplete: manual entry, dropdown, validation
- Destination autocomplete: same as origin
- Date range picker: start date, end date, single day, full range, invalid ranges
- Passenger count: input, increment/decrement, max/min bounds
- Form submission: valid, incomplete, network error, empty results
**Flight Details Page (~20 tests):**
- Flight info displays: departure, arrival, duration, airline, flight number
- Timing details: gate, terminal, check-in time, boarding time
- Seat map: renders, interactive, can select seat
- Price info: base price, taxes, total, currency formatting
- Navigation: previous flight, next flight, back to search
**Filters & Sorting (~10-15 tests):**
- Time range filter: apply, clear, validate boundaries
- Airline filter: select multiple, deselect all, apply
- Price range filter: min/max sliders, apply
- Sorting: by departure time, duration, price (ascending/descending)
### 3.3 Flights Map (~30-40 tests)
**Map Rendering (~15 tests):**
- Map loads and is interactive
- Flight destination markers display
- Marker clustering at certain zoom levels
- Pan and zoom work correctly
- Geolocation button works (if enabled)
**Destination List (~15 tests):**
- List items render with destination name, flight count
- Click destination: highlights on map, center map on marker
- Search/filter in list: filters by city name
- Empty state when no destinations
**Map Interactions (~10 tests):**
- Click marker: shows popup with flight info
- Click destination in list: highlights on map, centers view
- Hover effects on markers and list items
- Popup contains: destination name, flight count, quick link to search
### 3.4 Popular Requests Widget (~20-30 tests)
- Widget renders on initial page load
- Displays all popular request items (mock data)
- Click item: navigates to search with correct parameters
- API fallback to mock data works
- Empty state handling (no popular requests)
- Widget positioning and styling correct
### 3.5 Internationalization (i18n) (~15-20 tests)
- Language switcher visible and functional (all 9 languages: ru, en, es, fr, it, ja, ko, zh, de)
- Switching language: page updates all text, no hard-coded strings visible
- Language persistence: localStorage remembers selection
- Date formats match locale (e.g., DD.MM.YYYY for ru, MM/DD/YYYY for en)
- Number formatting matches locale (decimal separator, thousands separator)
- Text truncation on narrow screens doesn't break layout
- All UI elements have translations (no missing keys)
### 3.6 Error States (~25-30 tests)
- Network errors: 404 (not found), 500 (server error), 503 (service unavailable)
- Timeout handling: API call exceeds timeout threshold
- Empty results: no flights found for search
- Validation errors: required fields missing, invalid input format
- Loading states: loader visible, correct messaging
- Recovery: retry button works, clears error after successful retry
- SignalR connection failures: connection lost message, auto-reconnect attempt, manual reconnect button
### 3.7 Responsive/Mobile (~30-40 tests)
**Mobile Viewport (375x667 - iPhone SE):**
- All text is readable (no overflow, proper line breaks)
- Touch targets are at least 44x44px
- Forms are usable (inputs accessible, not hidden behind keyboard)
- Navigation: hamburger menu or drawer opens/closes
- Accordion sections: collapse/expand works on tap
- Carousel/swipes: work with touch events
**Tablet Viewport (768x1024 - iPad):**
- Layout is optimized for tablet (not stretched, not too narrow)
- Multi-column layouts work correctly
- Touch interactions work
**Desktop Viewport (1920x1080 - Large screen):**
- Layout scales correctly
- No horizontal scrolling
- All content is accessible without zooming
---
## 4. Test Categories & Patterns
### 4.1 Happy Path Tests (~30-40% of total: 60-120 tests)
User performs the intended action and succeeds. Example:
```typescript
it('should search flights by arrival city and date', () => {
cy.selectArrivalCity('Москва');
cy.setArrivalDate('15.04.2026');
cy.clickSearchButton();
cy.getFlightResults().should('have.length.greaterThan', 0);
cy.getFirstFlightResult().should('be.visible');
});
```
### 4.2 Edge Case Tests (~20-25% of total: 40-75 tests)
Boundary conditions, extreme inputs, special characters. Examples:
```typescript
it('should handle special characters in flight number', () => {
cy.typeFlightNumber('SU-123@#$%');
cy.shouldShowValidationError('Invalid format');
});
it('should allow searching 1 year in the future', () => {
cy.setDepartureDate(moment().add(365, 'days').format('DD.MM.YYYY'));
cy.clickSearch();
cy.shouldLoadResults();
});
it('should handle empty autocomplete results', () => {
cy.typeArrivalCity('ZZZZZZ');
cy.shouldShowEmptyState('No cities found');
});
```
### 4.3 Error Handling Tests (~15-20% of total: 30-60 tests)
Network failures, invalid responses, timeouts, server errors. Examples:
```typescript
it('should show error message on 500 API failure', () => {
cy.intercept('GET', '**/api/flights/**', { statusCode: 500 });
cy.clickSearch();
cy.getErrorMessage().should('contain', 'Server error');
});
it('should handle network timeout gracefully', () => {
cy.intercept('GET', '**/api/flights/**', { delay: 10000 });
cy.clickSearch();
cy.getLoader().should('be.visible');
cy.getRetryButton().should('be.visible');
});
it('should recover from SignalR connection loss', () => {
cy.window().then(win => {
win.signalRConnection.stop();
});
cy.getConnectionStatusBanner().should('be.visible');
cy.getReconnectButton().click();
cy.getConnectionStatusBanner().should('not.exist');
});
```
### 4.4 State Management Tests (~10-15% of total: 20-45 tests)
Form state persistence, navigation state, localStorage/sessionStorage. Examples:
```typescript
it('should preserve search filters when navigating back', () => {
cy.selectArrivalCity('Москва');
cy.setDate('15.04.2026');
cy.clickFlightResult(0);
cy.goBack();
cy.getArrivalCityInput().should('have.value', 'Москва');
cy.getDateInput().should('have.value', '15.04.2026');
});
it('should remember language selection after page reload', () => {
cy.selectLanguage('en');
cy.reload();
cy.getCurrentLanguage().should('equal', 'en');
});
```
### 4.5 Accessibility & Interaction Tests (~10-15% of total: 20-45 tests)
Keyboard navigation, screen reader support, ARIA attributes, touch interactions. Examples:
```typescript
it('should navigate autocomplete with arrow keys', () => {
cy.typeInAutocomplete('Мос');
cy.get('body').type('{downarrow}');
cy.getFirstAutocompleteOption().should('have.focus');
cy.get('body').type('{enter}');
cy.shouldSelectOption('Москва');
});
it('should be keyboard navigable without mouse', () => {
cy.get('body').type('{tab}{tab}'); // Focus search button
cy.focused().should('have.attr', 'data-testid', 'search-button');
cy.get('body').type('{enter}');
cy.shouldLoadResults();
});
it('should be swipeable on mobile (right swipe)', () => {
cy.viewport('iphone-x');
cy.swipeRight();
cy.getDateInput().should('have.value', moment().subtract(1, 'day').format('DD.MM.YYYY'));
});
```
### 4.6 Localization Tests (~5-10% of total: 10-30 tests)
Language switching, date formats, number formatting. Examples:
```typescript
it('should display correct date format for Russian locale', () => {
cy.selectLanguage('ru');
cy.setDate('15.04.2026');
cy.getDateDisplay().should('contain', '15 апреля 2026');
});
it('should format currency for German locale', () => {
cy.selectLanguage('de');
cy.getFlightPrice().should('contain', '€');
});
it('should format numbers with correct decimal separator', () => {
cy.selectLanguage('de');
cy.getPrice().should('contain', ','); // German decimal separator
cy.selectLanguage('en');
cy.getPrice().should('contain', '.'); // English decimal separator
});
```
---
## 5. Data, Fixtures & Mocking Strategy
### 5.1 Fixture Architecture
```typescript
// cypress/support/fixtures.ts (shared between Angular & React)
export const CITIES = {
arrival: [
{ name: 'Москва', code: 'SVO', lat: 55.7558, lng: 37.62 },
{ name: 'Санкт-Петербург', code: 'LED', lat: 59.8011, lng: 30.2625 },
{ name: 'Анапа', code: 'AAQ', lat: 44.8972, lng: 37.3369 },
{ name: 'Екатеринбург', code: 'SVX', lat: 56.7431, lng: 60.8022 },
// ... 9+ cities total
],
departure: [
{ name: 'Москва', code: 'VKO', lat: 55.5917, lng: 37.2750 },
// ...
]
};
export const MOCK_FLIGHTS = {
arrival: [
{
id: 'SU123',
airline: 'Aeroflot',
number: 'SU 123',
departure: '10:15',
arrival: '11:45',
duration: '1h 30m',
status: 'landed',
gate: 'A5',
terminal: '1'
},
// ... 10+ flights for variety
],
departure: [ /* ... */ ]
};
export const TEST_USERS = {
default: { email: 'test@example.com' },
};
```
### 5.2 Intercept Strategy (Approach C: Real + Mocked)
**Test Suite 1: Mocked API** (deterministic, fast, runs in CI)
```typescript
beforeEach(() => {
cy.intercept('GET', '**/api/flights/v1.1/**', {
statusCode: 200,
body: MOCK_FLIGHTS.arrival
}).as('getFlights');
cy.intercept('GET', '**/api/cities/**', {
statusCode: 200,
body: CITIES.arrival
}).as('getCities');
cy.visit('http://localhost:4200'); // Angular
// OR cy.visit('http://localhost:3000'); // React
});
```
**Test Suite 2: Real API** (integration test, validates actual backend)
```typescript
beforeEach(() => {
// No intercepts — hits real backend
cy.visit('https://test.aeroflot.ru');
});
```
### 5.3 State Reset (Approach A: Full Reset per Test)
```typescript
beforeEach(() => {
// Clear all browser state
cy.clearAllLocalStorage();
cy.clearAllSessionStorage();
cy.clearCookies();
// Close any open WebSocket connections (SignalR)
cy.window().then(win => {
if (win.signalRConnection) {
win.signalRConnection.stop().catch(() => {});
}
});
// Reset to baseline URL
cy.visit('/');
cy.wait(500); // Allow page to fully load
});
afterEach(() => {
// Optional: take screenshot on failure
// (Cypress does this automatically by default)
});
```
### 5.4 Common Cypress Commands
```typescript
// cypress/support/commands.ts
Cypress.Commands.add('selectArrivalCity', (cityName: string) => {
cy.get('[data-testid="arrival-city-input"]').type(cityName);
cy.get(`[data-testid="city-option-${cityName}"]`).click();
});
Cypress.Commands.add('setArrivalDate', (date: string) => {
cy.get('[data-testid="arrival-date-input"]').clear().type(date);
});
Cypress.Commands.add('clickSearchButton', () => {
cy.get('[data-testid="search-button"]').click();
cy.get('[data-testid="loader"]').should('be.visible');
cy.get('[data-testid="loader"]').should('not.exist');
});
Cypress.Commands.add('getFlightResults', () => {
return cy.get('[data-testid="flight-result"]');
});
Cypress.Commands.add('shouldShowValidationError', (message: string) => {
cy.get('[data-testid="error-message"]').should('contain', message);
});
Cypress.Commands.add('swipeRight', () => {
cy.get('body').trigger('touchstart', { touches: [{ clientX: 0, clientY: 100 }] });
cy.get('body').trigger('touchmove', { touches: [{ clientX: 100, clientY: 100 }] });
cy.get('body').trigger('touchend');
});
```
---
## 6. Execution Strategy & Tooling
### 6.1 Cypress Configuration
```typescript
// cypress.config.ts (both Angular & React)
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: process.env.BASE_URL || 'http://localhost:4200',
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 5000,
requestTimeout: 10000,
responseTimeout: 10000,
pageLoadTimeout: 30000,
chromeWebSecurity: false, // for Module Federation
video: true,
videoUploadOnPasses: false,
screenshotOnRunFailure: true,
specPattern: 'cypress/integration/**/*.cy.ts',
supportFile: 'cypress/support/index.ts',
setupNodeEvents(on, config) {
// Example: video recording configuration
return config;
},
},
});
```
### 6.2 NPM Scripts
Add to both `ClientApp/package.json` and `react-app/package.json`:
```json
{
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"cypress:run:all": "cypress run --spec 'cypress/integration/**/*.cy.ts'",
"cypress:run:feature": "cypress run --spec 'cypress/integration/features/*.cy.ts'",
"cypress:run:responsive": "cypress run --spec 'cypress/integration/responsive.cy.ts'",
"cypress:report": "npm run cypress:run && npx mochawesome-report-generator",
"test:e2e": "npm run cypress:run -- --env API_MODE=mocked",
"test:e2e:real": "npm run cypress:run -- --env API_MODE=real BASE_URL=https://test.aeroflot.ru"
}
```
### 6.3 Execution Flow
**Phase 1: Write Angular Tests** (2-3 hours)
- Create all spec files with describe/it structure
- Implement helper functions in `cypress/support/commands.ts`
- Implement Page Object Models in `cypress/support/page-objects/`
- Run incrementally: `npm run cypress:open`
- Validate all tests pass: `npm run cypress:run:all`
**Phase 2: Validate Full Angular Suite** (30 mins)
- Full headless run with video/screenshots: `npm run cypress:run:all`
- Generate HTML report: `npm run cypress:report`
- Fix any flaky tests
**Phase 3: Adapt to React** (2-3 hours)
- Copy spec files to `react-app/cypress/integration/`
- Update selectors in page-objects if React DOM differs
- Update `cypress.config.ts` baseUrl to `:3000`
- Run against React with mocked API: `cd react-app && npm run test:e2e`
- Fix failures (likely selector or navigation changes)
**Phase 4: Run React Suite with Real API** (1-2 hours)
- Run full suite against staging: `npm run test:e2e:real`
- Validate all tests pass
- Fix any integration issues (timing, data, network)
### 6.4 CI/CD Integration
```yaml
# .github/workflows/e2e-tests.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test-angular:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: cd ClientApp && npm ci
- run: npm start > /dev/null 2>&1 &
- run: npx wait-on http://localhost:4200 --timeout 30000
- run: npm run cypress:run:all
- uses: actions/upload-artifact@v3
if: always()
with:
name: cypress-videos-angular
path: ClientApp/cypress/videos
test-react:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: cd react-app && npm ci
- run: npm start > /dev/null 2>&1 &
- run: npx wait-on http://localhost:3000 --timeout 30000
- run: npm run test:e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: cypress-videos-react
path: react-app/cypress/videos
```
---
## 7. Success Criteria & Validation
### 7.1 Definition of Done
**Angular Tests:**
- ✅ All 200-300 tests written and organized by feature
- ✅ All tests pass against Angular app (100% pass rate)
- ✅ No test takes >10 seconds (performance gate)
- ✅ All test categories represented: happy path, edge case, error handling, state, accessibility, i18n, responsive
- ✅ Code coverage: 80%+ for tested components
**React Tests:**
- ✅ All Angular tests adapted to React (selector/navigation updates only)
- ✅ All tests pass against React app with mocked API (100% pass rate)
- ✅ All tests pass against React app with real API (staging backend)
- ✅ No test takes >10 seconds
- ✅ Feature parity verified: React UI behaves identically to Angular
### 7.2 Metrics to Track
| Metric | Target |
|--------|--------|
| Total Tests (Angular) | 200-300 |
| Total Tests (React) | 200-300 (same) |
| Pass Rate (Angular) | 100% |
| Pass Rate (React, mocked) | 100% |
| Pass Rate (React, real API) | 100% |
| Avg Test Duration | 2-3 seconds |
| Total Suite Time (mocked) | 8-15 minutes |
| Total Suite Time (real API) | 10-20 minutes |
| Code Coverage (tested components) | 80%+ |
| Flaky Test Count | 0 |
### 7.3 Stopping Condition
Work continues until:
- ✅ All 200-300 tests pass on Angular
- ✅ All 200-300 tests pass on React (mocked API)
- ✅ All 200-300 tests pass on React (real API)
- ✅ No test takes >10 seconds
- ✅ Re-running test suite 3x produces consistent results (no flaky tests)
- ✅ Angular and React behavior is identical for all covered features
---
## 8. Implementation Notes
### 8.1 DOM Structure Assumptions
Tests assume the following data-testid attributes exist on UI elements (both Angular and React must implement these):
```
Online Board:
- data-testid="arrival-city-input"
- data-testid="arrival-date-input"
- data-testid="search-button"
- data-testid="flight-result"
- data-testid="loader"
- data-testid="error-message"
- data-testid="flight-details-modal"
Schedule:
- data-testid="origin-input"
- data-testid="destination-input"
- data-testid="date-range-picker"
- data-testid="passenger-count"
- data-testid="search-button"
And so on...
```
If either implementation uses different selectors, Page Object Models will be updated to translate.
### 8.2 Test Data Lifecycle
- **Setup:** Full state reset before each test (localStorage, cookies, connections)
- **Execution:** Test runs against isolated mocked data
- **Teardown:** Browser state cleared automatically
No test data persists between tests.
### 8.3 Handling Flaky Tests
If a test is flaky:
1. Add explicit waits for async operations
2. Retry the specific assertion (Cypress built-in)
3. Check for race conditions in test logic
4. If unsolvable, mark as skipped with comment explaining issue
### 8.4 Performance Constraints
- Each test: <10 seconds (includes setup, execution, teardown)
- Full suite: <30 minutes (8-15 min for mocked, 10-20 min for real API)
- If a test exceeds 10 seconds, it's split or optimized
---
## 9. Risks & Mitigation
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|-----------|
| React selectors differ from Angular | High | Medium | Page Object Model abstraction; update POM for React |
| Flaky network-dependent tests | Medium | High | Use mocked API for primary suite; real API as secondary |
| Test explosion (200-300 is large) | Medium | High | Phased execution; monitor suite time; parallelize if needed |
| Timing issues (async operations) | Medium | Medium | Explicit waits, retry logic, proper Cypress commands |
| Mobile tests on CI | Medium | Low | Use Cypress viewport, skip on CI if needed, test locally |
---
## 10. Timeline & Ownership
| Phase | Estimate | Owner |
|-------|----------|-------|
| 1. Write Angular Tests | 2-3 hours | Claude Code |
| 2. Validate Angular | 30 mins | Claude Code |
| 3. Adapt to React | 2-3 hours | Claude Code |
| 4. Validate React (mocked) | 1 hour | Claude Code |
| 5. Validate React (real API) | 1-2 hours | Claude Code |
| **Total** | **7-10 hours** | |
Work continues until **all tests pass** on both versions.
---
## Appendix: Reference
### A.1 Cypress Best Practices Used
- ✅ Page Object Model for selector abstraction
- ✅ Custom commands for common actions
- ✅ Explicit waits over implicit
- ✅ Data attributes (data-testid) for element selection
- ✅ Full state reset between tests
- ✅ Feature-based organization
- ✅ Mocking + real API testing
### A.2 Languages Supported (i18n)
1. Russian (ru)
2. English (en)
3. Spanish (es)
4. French (fr)
5. Italian (it)
6. Japanese (ja)
7. Korean (ko)
8. Chinese (zh)
9. German (de)
All 9 languages must have passing tests.
### A.3 Key Features Tested
1. Online Board (departure/arrival tabs, search, filters, flight details)
2. Schedule (search page, flight details, filters, sorting)
3. Flights Map (map rendering, markers, destination list, interactions)
4. Popular Requests (widget load, navigation, fallback)
5. Internationalization (language switching, formatting, persistence)
6. Error States (network failures, validation, loading states, recovery)
7. Responsive Design (mobile, tablet, desktop viewports)
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Aeroflot.Flights.Web",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}