Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac499a3fb5 | |||
| d6c6634563 | |||
| 77c93fa061 | |||
| 2842bbd522 | |||
| 2caa5c81fe | |||
| 0ca49b9bf3 | |||
| 393ccfea39 | |||
| 907ea7503b | |||
| 91b4cd7db7 | |||
| 0e973d1317 | |||
| a9b2f4ac5c | |||
| 5ef60539ce | |||
| dfb9fed99a | |||
| 729603d27c |
@@ -0,0 +1 @@
|
||||
{"sessionId":"c5ca7b97-b8fb-464c-9bb2-52aa0dc335be","pid":44074,"acquiredAt":1775396071197}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,8 @@
|
||||
/// <reference types="." />
|
||||
|
||||
// ***********************************************
|
||||
// This example namespace declaration will help
|
||||
// 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) => { ... })
|
||||
/**
|
||||
* Custom Cypress commands for Aeroflot Flights Web testing
|
||||
*/
|
||||
|
||||
Cypress.Commands.add('getByTestId', (id: string, timeout = 8000) => {
|
||||
return cy.get(`[data-testid="${id}"]`, { timeout });
|
||||
@@ -71,3 +31,74 @@ Cypress.Commands.add('forbidGeolocation', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
Vendored
+13
-1
@@ -3,6 +3,18 @@ declare namespace Cypress {
|
||||
interface Chainable {
|
||||
getByTestId(id: string, timeout?: number): Chainable;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
// This support file is processed and loaded automatically
|
||||
// before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// 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';
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+33301
-21446
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -15,11 +15,16 @@
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"test": "ng test --code-coverage",
|
||||
"test:ci": "ng test --watch=false --reporters=teamcity",
|
||||
"test:e2e": "cypress run",
|
||||
"pretty": "prettier --write \"./**/*.{ts,html}\"",
|
||||
"analyze": "webpack-bundle-analyzer dist/stats.json",
|
||||
"docs:json": "compodoc -p ./tsconfig.json -e json -d .",
|
||||
"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": {
|
||||
"@angular/animations": "~12.2.13",
|
||||
@@ -63,11 +68,12 @@
|
||||
"@storybook/manager-webpack5": "^6.4.20",
|
||||
"@storybook/testing-library": "0.0.9",
|
||||
"@types/jasmine": "^3.10.2",
|
||||
"@types/leaflet": "^1.7.1",
|
||||
"@types/node": "^12.11.1",
|
||||
"@types/leaflet": "^1.7.11",
|
||||
"@types/node": "^12.20.55",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
||||
"@typescript-eslint/parser": "^5.4.0",
|
||||
"babel-loader": "^8.2.4",
|
||||
"cypress": "^13.17.0",
|
||||
"eslint": "^8.2.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-storybook": "^0.5.7",
|
||||
@@ -82,6 +88,7 @@
|
||||
"prettier": "2.4.1",
|
||||
"start-server-and-test": "~1.14.0",
|
||||
"timezone-mock": "^1.3.2",
|
||||
"ts-loader": "^9.5.7",
|
||||
"typescript": "~4.3.5",
|
||||
"webpack-bundle-analyzer": "^4.5.0"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<label class="city-autocomplete__label" data-testid="city-code">{{ city?.code }}</label>
|
||||
</div>
|
||||
|
||||
<tooltip *ngIf="error">
|
||||
<tooltip *ngIf="error" data-testid="validation-error">
|
||||
{{ error | translate }}
|
||||
</tooltip>
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<div class="map-wrapper">
|
||||
<div id="map" class="map"></div>
|
||||
<div class="map-wrapper" data-testid="flights-map-container">
|
||||
<div id="map" class="map" data-testid="leaflet-map"></div>
|
||||
<loader-sheet *ngIf="isLoading"></loader-sheet>
|
||||
<no-directions-sheet
|
||||
*ngIf="isNoDirections && !isLoading"
|
||||
|
||||
+3
-4
@@ -2,7 +2,7 @@
|
||||
<p-accordion expandIcon="" collapseIcon="" [activeIndex]="0">
|
||||
<p-accordionTab [selected]="true" [disabled]="true">
|
||||
<div class="flights-map-filter-content">
|
||||
|
||||
|
||||
<div class="flights-map-filter-header">
|
||||
<h3>{{ 'FLIGHTS-MAP.ROUTE' | translate }}</h3>
|
||||
</div>
|
||||
@@ -12,9 +12,9 @@
|
||||
label="SHARED.DEPARTURE_CITY"
|
||||
[(ngModel)]="departure"
|
||||
[placeholder]="departurePlaceholder"
|
||||
data-testid="route-departure-city-input">
|
||||
data-testid="destination-search-input">
|
||||
</city-autocomplete>
|
||||
|
||||
|
||||
<div class="change-container">
|
||||
<button
|
||||
class="button-change"
|
||||
@@ -31,7 +31,6 @@
|
||||
label="SHARED.ARRIVAL_CITY"
|
||||
[(ngModel)]="arrival"
|
||||
[placeholder]="arrivalPlaceholder"
|
||||
data-testid="route-arrival-city-input"
|
||||
></city-autocomplete>
|
||||
</div>
|
||||
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
<label class="label--filter">{{
|
||||
'SHARED.FLIGHT_NUMBER' | translate
|
||||
}}</label>
|
||||
<tooltip *ngIf="validationService.flightNumberError">{{
|
||||
<tooltip *ngIf="validationService.flightNumberError" data-testid="validation-error">{{
|
||||
validationService.flightNumberError | translate
|
||||
}}</tooltip>
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
placeholder="{{
|
||||
'SHARED.FLIGHT_NUMBER_PLACEHOLDER' | translate
|
||||
}}"
|
||||
data-testid="flight-number-input"
|
||||
data-testid="flight-number-filter"
|
||||
/>
|
||||
<button
|
||||
pButton
|
||||
label=" "
|
||||
class="button-clear"
|
||||
(click)="clearInput()"
|
||||
data-testid="flight-number-clear-button"
|
||||
data-testid="flight-number-clear"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-4
@@ -4,7 +4,7 @@
|
||||
[(ngModel)]="departure"
|
||||
[(error)]="validationService.departureError"
|
||||
[placeholder]="departurePlaceholder"
|
||||
data-testid="route-departure-city-input"
|
||||
data-testid="departure-city-input"
|
||||
></city-autocomplete>
|
||||
|
||||
<div class="change-container">
|
||||
@@ -24,7 +24,7 @@
|
||||
[(ngModel)]="arrival"
|
||||
[(error)]="validationService.arrivalError"
|
||||
[placeholder]="arrivalPlaceholder"
|
||||
data-testid="route-arrival-city-input"
|
||||
data-testid="arrival-city-input"
|
||||
></city-autocomplete>
|
||||
|
||||
<calendar-input
|
||||
@@ -34,7 +34,7 @@
|
||||
[minDate]="minDate"
|
||||
[maxDate]="maxDate"
|
||||
[disabledDates]="disabledDates"
|
||||
data-testid="route-calendar-input"
|
||||
data-testid="departure-date-input"
|
||||
>
|
||||
</calendar-input>
|
||||
</div>
|
||||
@@ -43,6 +43,7 @@
|
||||
[fullView]="false"
|
||||
[(ngModel)]="timeRange"
|
||||
label="{{ 'SHARED.FLIGHT_TIME' | translate }}"
|
||||
data-testid="time-range-slider"
|
||||
>
|
||||
</time-selector>
|
||||
|
||||
@@ -53,6 +54,6 @@
|
||||
type="button"
|
||||
label="{{ 'SHARED.SEARCH' | translate }}"
|
||||
(click)="search()"
|
||||
data-testid="route-search-button"
|
||||
data-testid="search-button"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
<div *ngIf="flightLegacy">
|
||||
<div *ngIf="flightLegacy" data-testid="flight-details-modal">
|
||||
<page-layout scrollUp [withScrollUp]="false">
|
||||
<ng-container title>
|
||||
<ng-content select="[title]"></ng-content>
|
||||
@@ -7,6 +7,7 @@
|
||||
header-left
|
||||
class="p-print-none"
|
||||
[viewType]="ViewType.Onlineboard"
|
||||
data-testid="modal-close-button"
|
||||
></details-back>
|
||||
<online-board-flights-mini-list
|
||||
content-left
|
||||
|
||||
+1
@@ -7,6 +7,7 @@
|
||||
[searchDate]="searchDate"
|
||||
(open)="handleOpenEvent($event)"
|
||||
(dateChange)="handleDateChange($event)"
|
||||
data-testid="flight-details-page"
|
||||
>
|
||||
<online-board-flight-details-title
|
||||
title
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{{ 'BOARD.DEPARTURE' | translate }}:
|
||||
<request-info (click)="onRequestInfoClick()">{{
|
||||
<request-info (click)="onRequestInfoClick()" data-testid="popular-request-departure">{{
|
||||
request.departure | cityName
|
||||
}}</request-info>
|
||||
|
||||
+2
@@ -3,11 +3,13 @@
|
||||
*ngSwitchCase="RequestMode.ARRIVAL"
|
||||
[request]="$any(request)"
|
||||
(onClick)="onRequestClick($event)"
|
||||
data-testid="popular-request-arrival"
|
||||
></arrival-request>
|
||||
<departure-request
|
||||
*ngSwitchCase="RequestMode.DEPARTURE"
|
||||
[request]="$any(request)"
|
||||
(onClick)="onRequestClick($event)"
|
||||
data-testid="popular-request-departure"
|
||||
></departure-request>
|
||||
<flight-number-request
|
||||
*ngSwitchCase="RequestMode.FLIGHT_NUMBER"
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
<div class="popular-requests">
|
||||
<div class="popular-requests" data-testid="popular-requests-widget">
|
||||
<h3 class="popular-requests__title">
|
||||
{{ 'BOARD.POPULAR-CHAPTERS' | translate }}
|
||||
</h3>
|
||||
@@ -7,23 +7,27 @@
|
||||
[request]="requests[0]"
|
||||
(onClick)="handleRequestClick($event)"
|
||||
class="popular-requests__item"
|
||||
data-testid="popular-request-item"
|
||||
></popular-request>
|
||||
<popular-request
|
||||
*ngIf="requests[1]"
|
||||
[request]="requests[1]"
|
||||
(onClick)="handleRequestClick($event)"
|
||||
class="popular-requests__item"
|
||||
data-testid="popular-request-item"
|
||||
></popular-request>
|
||||
<popular-request
|
||||
*ngIf="requests[2]"
|
||||
[request]="requests[2]"
|
||||
(onClick)="handleRequestClick($event)"
|
||||
class="popular-requests__item"
|
||||
data-testid="popular-request-item"
|
||||
></popular-request>
|
||||
<popular-request
|
||||
*ngIf="requests[3]"
|
||||
[request]="requests[3]"
|
||||
(onClick)="handleRequestClick($event)"
|
||||
class="popular-requests__item"
|
||||
data-testid="popular-request-item"
|
||||
></popular-request>
|
||||
</div>
|
||||
|
||||
+7
-3
@@ -10,7 +10,7 @@
|
||||
[(ngModel)]="departure"
|
||||
[(error)]="validationService.departureError"
|
||||
placeholder="SHARED.CITY_PLACEHOLDER"
|
||||
data-testid="schedule-departure-city-input"
|
||||
data-testid="origin-input"
|
||||
></city-autocomplete>
|
||||
|
||||
<div class="change-container">
|
||||
@@ -34,7 +34,7 @@
|
||||
[(ngModel)]="arrival"
|
||||
[(error)]="validationService.arrivalError"
|
||||
placeholder="SHARED.CITY_PLACEHOLDER"
|
||||
data-testid="schedule-arrival-city-input"
|
||||
data-testid="destination-input"
|
||||
>
|
||||
</city-autocomplete>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
[minDate]="settings.scheduleMinDate"
|
||||
[maxDate]="maxScheduleDate"
|
||||
[disabledDates]="disabledDates"
|
||||
data-testid="schedule-calendar"
|
||||
data-testid="date-range-picker"
|
||||
>
|
||||
</calendar-input-week>
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
[fullView]="false"
|
||||
[(ngModel)]="timeRange"
|
||||
label="{{ 'SHARED.DEPARTURE_TIME' | translate }}"
|
||||
data-testid="time-range-slider"
|
||||
></time-selector>
|
||||
</div>
|
||||
|
||||
@@ -71,12 +72,14 @@
|
||||
[binary]="true"
|
||||
[(ngModel)]="directOnly"
|
||||
label="{{ 'SHARED.DIRECT_FLIGHT_ONLY' | translate }}"
|
||||
data-testid="direct-flights-checkbox"
|
||||
></p-checkbox>
|
||||
<p-checkbox
|
||||
[binary]="true"
|
||||
[(ngModel)]="withReturn"
|
||||
(ngModelChange)="resetReturnDateRange()"
|
||||
label="{{ 'SHARED.RETURN_FLIGHT_VIEW' | translate }}"
|
||||
data-testid="return-flight-checkbox"
|
||||
>
|
||||
</p-checkbox>
|
||||
</div>
|
||||
@@ -100,6 +103,7 @@
|
||||
[fullView]="false"
|
||||
label="{{ 'SHARED.RETURN_FLIGHT_TIME' | translate }}"
|
||||
[(ngModel)]="returnTimeRange"
|
||||
data-testid="return-time-range-slider"
|
||||
>
|
||||
</time-selector>
|
||||
</div>
|
||||
|
||||
+1
@@ -8,6 +8,7 @@
|
||||
[detailsLoading]="dataSource.detailsLoading"
|
||||
(toFlightDetails)="handleRedirectToFlightDetails($event)"
|
||||
(toScheduleDate)="handleRedirectToScheduleDate($event)"
|
||||
data-testid="flight-details-page"
|
||||
>
|
||||
<schedule-flight-details-title
|
||||
[flight]="dataSource.flight"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<section class="page-empty">
|
||||
<div class="page-empty__title">
|
||||
<section class="page-empty" data-testid="empty-results">
|
||||
<div class="page-empty__title" data-testid="empty-state-message">
|
||||
{{ 'SHARED.FLIGHTS-NOT-FOUND' | translate }}
|
||||
</div>
|
||||
<div class="page-empty__text">
|
||||
<div class="page-empty__text" data-testid="empty-results-message">
|
||||
{{ 'SHARED.FLIGHTS-NOT-FOUND-TEXT' | translate }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
type="button"
|
||||
label="{{ 'SHARED.SEARCH-CANCEL' | translate }}"
|
||||
(click)="handleClick()"
|
||||
data-testid="loader-cancel-button"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
<terminal-link
|
||||
class="station__terminal"
|
||||
[station]="station"
|
||||
data-testid="terminal"
|
||||
></terminal-link>
|
||||
<terminal-link
|
||||
*ngIf="oldStation"
|
||||
class="station__terminal"
|
||||
[station]="oldStation"
|
||||
[oldValue]="true"
|
||||
data-testid="terminal"
|
||||
></terminal-link>
|
||||
|
||||
<text
|
||||
|
||||
+6
-5
@@ -1,16 +1,16 @@
|
||||
<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 class="status description">
|
||||
{{ 'FLIGHT-STATUSES.' + flight.status | translate }}
|
||||
</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">
|
||||
<flight-status-icon [status]="flight.status"></flight-status-icon>
|
||||
@@ -25,9 +25,10 @@
|
||||
align="mobile-right"
|
||||
[actual]="arrivalBlockOnTimes"
|
||||
[scheduled]="arrival._times.scheduledArrival"
|
||||
data-testid="arrival-time"
|
||||
></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>
|
||||
</div>
|
||||
|
||||
+1
@@ -14,6 +14,7 @@
|
||||
(click)="toggle(index)"
|
||||
[flight]="$flight"
|
||||
[expanded]="$flight.expanded"
|
||||
data-testid="flight-result-header"
|
||||
></board-flight-header>
|
||||
|
||||
<ng-container *ngIf="$flight.expanded">
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
{{ 'DISPATCH.' + departure.dispatch | translate }}
|
||||
</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 }}
|
||||
</property>
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
{{ 'DISPATCH.' + arrival.dispatch | translate }}
|
||||
</property>
|
||||
|
||||
<property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="arrival.gate">
|
||||
<property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="arrival.gate" data-testid="gate">
|
||||
{{ arrival.gate }}
|
||||
</property>
|
||||
|
||||
|
||||
+2
-2
@@ -22,8 +22,8 @@
|
||||
<section-number
|
||||
[number]="leg.crossIndex"
|
||||
></section-number>
|
||||
<div class="flight-number">
|
||||
<div class="flight-number__code">
|
||||
<div class="flight-number" data-testid="flight-details-number">
|
||||
<div class="flight-number__code" data-testid="flight-number">
|
||||
{{ flight | flightNumber }}
|
||||
</div>
|
||||
<div class="flight-number__code-sharing">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<section class="frame">
|
||||
<div class="error-page-girl" [ngClass]="'errorCode-' + errorCode"></div>
|
||||
<div class="error-page-content">
|
||||
<div class="error-page-code">{{ errorCode }}</div>
|
||||
<div class="error-page-title">{{ title || 'PAGE500.HEADER' | translate }}</div>
|
||||
<div class="error-page-description">{{ description || 'PAGE500.DESCRIPTION' | translate }}</div>
|
||||
<div class="error-page-code" data-testid="error-code">{{ errorCode }}</div>
|
||||
<div class="error-page-title" data-testid="error-message">{{ title || 'PAGE500.HEADER' | 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-->
|
||||
<div class="error-page-search">
|
||||
|
||||
+6
-6
@@ -15,13 +15,13 @@
|
||||
<div class="sort-note">{{ footnotes }}</div>
|
||||
</div>
|
||||
<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">
|
||||
<use xlink:href="/assets/img/sprite.svg#arrow-up" />
|
||||
</svg>
|
||||
</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">
|
||||
<use xlink:href="/assets/img/sprite.svg#arrow-down" />
|
||||
</svg>
|
||||
@@ -33,13 +33,13 @@
|
||||
{{ 'SCHEDULE.SEARCH-RESULT-TIME' | translate }}
|
||||
</div>
|
||||
<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">
|
||||
<use xlink:href="/assets/img/sprite.svg#arrow-up" />
|
||||
</svg>
|
||||
</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">
|
||||
<use xlink:href="/assets/img/sprite.svg#arrow-down" />
|
||||
</svg>
|
||||
@@ -52,13 +52,13 @@
|
||||
<div class="sort-note">{{ footnotes }}</div>
|
||||
</div>
|
||||
<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">
|
||||
<use xlink:href="/assets/img/sprite.svg#arrow-up" />
|
||||
</svg>
|
||||
</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">
|
||||
<use xlink:href="/assets/img/sprite.svg#arrow-down" />
|
||||
</svg>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<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'">
|
||||
{{ 'DAYS.' + scheduleItem.dayOfWeek | translate }}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="calendar">
|
||||
<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 }">
|
||||
<input
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="calendar">
|
||||
<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">
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"module": "es2020",
|
||||
"moduleResolution": "node",
|
||||
"target": "es2017",
|
||||
"skipLibCheck": true,
|
||||
"typeRoots": [
|
||||
"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)
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Aeroflot.Flights.Web",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user