Files
ridebus-pwa/e2e/features.spec.ts
Zakhar Timoshenko 6fe039cefa feat: add StopsMap and WalkingDirections components for enhanced map functionality
- Implemented StopsMap component to display bus stops on a map using MapLibre GL.
- Integrated walking directions feature in WalkingDirections component to calculate routes from user location to selected bus stop.
- Added geo utility functions for coordinate validation and distance calculation.
- Created route grouping and stop filtering logic in routeGroups module.
- Developed custom hooks for managing stop routes with React Query.
- Added tests for routing and stop functionalities to ensure reliability.
2026-10-08 00:18:10 +03:00

201 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from './fixtures';
const time = Date.parse('2026-10-07T09:00:00.000Z');
const routes = [{ id: 1, number: '112С', name: 'Вокзал → Центр' }, { id: 2, number: '10', name: 'Вокзал → Больница' }];
const stops = [{ id: 101, name: 'Вокзал', direction: 'В центр', routesCount: 2 }, { id: 102, name: 'Вокзал', direction: 'В обратную сторону', routesCount: 1 }, { id: 103, name: 'Центр', direction: 'К вокзалу', routesCount: 1 }];
async function mockLive(page: Page, extra: (resource: string, params: URLSearchParams) => unknown = () => undefined) {
await page.route('**/api/data?**', route => {
const params = new URL(route.request().url()).searchParams;
const resource = params.get('resource')!;
const data = extra(resource, params) ?? (resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }, { id: 2, name: 'Новополоцк', slug: 'novopolotsk' }]
: resource === 'stops' ? stops : resource === 'routes' ? routes : resource === 'alerts' ? [] : { routes, partial: false, updatedAt: new Date(time).toISOString(), departures: routes.map((item, index) => ({ routeId: item.id, routeNumber: item.number, direction: item.name, scheduledTime: `12:${10 + index * 10}`, scheduledAt: new Date(time + (10 + index * 10) * 60000).toISOString() })) });
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
});
}
async function readyForOffline(page: Page) {
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
});
}
test('saved journeys retain route/direction, allow rename/delete, and remain available offline', async ({ page, context }, info) => {
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
await page.goto('/?demo=1&city=1&stop=102');
await expect(page.locator('.departureRow')).toHaveCount(8);
await page.getByRole('button', { name: 'Маршрут 4', exact: true }).click();
await page.getByRole('button', { name: 'Сохранить поездку', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сохранить поездку', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel('Название поездки')).toBeFocused();
await expect(dialog.getByLabel('Маршрут поездки')).toHaveValue('1');
await dialog.getByRole('button', { name: 'На работу', exact: true }).click();
await dialog.getByRole('button', { name: 'Сохранить поездку', exact: true }).click();
await expect(page.locator('.quickTripCard')).toHaveCount(1);
await expect(page.locator('.tripDirection')).toHaveText('В сторону центра');
await page.reload();
await expect(page.locator('.quickTripHeading h3')).toHaveText('На работу');
await page.getByRole('button', { name: 'Изменить', exact: true }).click();
await page.getByRole('dialog').getByLabel('Название поездки').fill('Домой');
await page.getByRole('dialog').getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('.quickTripHeading h3')).toHaveText('Домой');
await page.getByRole('link', { name: 'Открыть поездку', exact: true }).click();
await expect(page).toHaveURL(/stop=102.*route=1/);
await expect(page.getByRole('button', { name: 'Маршрут 4', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.setViewportSize({ width: 320, height: 780 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: `test-results/trips-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
await readyForOffline(page);
await context.setOffline(true); await page.reload();
await expect(page.locator('.quickTripHeading h3')).toHaveText('Домой');
await expect(page.locator('.tripSaved')).toHaveCount(0);
await expect(page.locator('.offlineBanner')).toHaveCount(1);
await page.goto('/favorites?demo=1');
await expect(page.locator('.quickTripCard')).toHaveCount(1);
await context.setOffline(false);
await page.goto('/favorites');
await expect(page.locator('.quickTripCard')).toHaveCount(0);
await page.goto('/favorites?demo=1');
await expect(page.locator('.quickTripCard')).toHaveCount(1);
await page.getByRole('button', { name: 'Удалить поездку Домой', exact: true }).click();
await expect(page.locator('.quickTripCard')).toHaveCount(0);
expect(errors).toEqual([]);
});
test('hour grid jumps to next departure and opens a cached trip with stop-by-stop times', async ({ page, context }, info) => {
await page.clock.install({ time: new Date(time) });
await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07');
await expect(page.locator('.hourDeparture')).toHaveCount(32);
await page.getByRole('button', { name: 'К ближайшему', exact: true }).click();
const next = page.locator('.nextDeparture');
await expect(next).toBeFocused();
await expect(next).toContainText('12:00');
await next.click();
await expect(page).toHaveURL(/\/route\?/);
await expect(page.locator('.journeyTimeline li')).toHaveCount(6);
await expect(page.locator('.selectedJourneyStop .journeyTime')).toHaveText('12:00');
await expect(page.locator('.journeyTime strong')).toHaveText(['12:00', '12:05', '12:10', '12:15', '12:20', '12:25']);
const journeyUrl = page.url();
await page.screenshot({ path: `test-results/journey-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
await page.getByRole('link', { name: 'К расписанию остановки', exact: true }).click();
await expect(page.locator('.hourDeparture')).toHaveCount(32);
await expect(page.getByLabel('Дата поездки')).toHaveValue('2026-10-07');
await readyForOffline(page); await context.setOffline(true);
await page.goto(journeyUrl); await page.reload();
await expect(page.locator('.journeyTimeline li')).toHaveCount(6);
await expect(page.locator('.routeTimelineCard .savedNotice')).toHaveCount(0);
await expect(page.locator('.offlineBanner')).toHaveCount(1);
await page.locator('.journeyTimeline a').nth(1).click();
await expect(page).toHaveURL(/stop=102/);
await expect(page).toHaveURL(/date=2026-10-07/);
});
test('save dialog fits narrow screens, closes with Escape, and restores button focus', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 780 });
await page.goto('/?demo=1&city=1&stop=101');
const open = page.getByRole('button', { name: 'Сохранить поездку', exact: true });
await open.click();
await expect(page.getByRole('dialog')).toBeVisible();
const bounds = await page.getByRole('dialog').boundingBox();
expect(bounds!.width).toBeLessThanOrEqual(320);
await page.screenshot({ path: `test-results/trip-dialog-${info.project.name}.png`, animations: 'disabled' });
const work = page.getByRole('dialog').getByRole('button', { name: 'На работу', exact: true });
await work.focus(); await work.press('Enter');
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByLabel('Название поездки')).toHaveValue('На работу');
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(open).toBeFocused();
await page.emulateMedia({ reducedMotion: 'reduce' }); await open.click();
expect(await page.getByRole('dialog').evaluate(element => getComputedStyle(element).animationName)).toBe('none');
});
test('nearby search requests location only on click, groups stops, and never persists coordinates', async ({ page, context }) => {
await context.grantPermissions(['geolocation']);
await context.setGeolocation({ latitude: 55.49, longitude: 28.783, accuracy: 25 });
let searches = 0;
await mockLive(page, (resource, params) => {
if (resource !== 'nearby') return;
searches++; expect(params.get('lat')).toBe('55.49000'); expect(params.get('lon')).toBe('28.78300');
return [{ ...stops[0], distance: 120 }, { ...stops[1], distance: 75 }, { ...stops[2], distance: 250 }];
});
await page.goto('/?city=1');
await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
await expect(page.locator('.stopItem')).toHaveCount(2);
expect(searches).toBe(0);
await page.getByRole('button', { name: 'Рядом', exact: true }).click();
await expect(page.locator('.nearbyResults button')).toHaveCount(2);
await expect(page.locator('.nearbyResults button').first()).toHaveText('Вокзал75 м');
await page.locator('.nearbyResults button').first().click();
await expect(page).toHaveURL(/stop=102/);
await expect(page.locator('#board-title')).toHaveText('Вокзал');
await expect(page.getByRole('group', { name: 'Направления', exact: true }).getByRole('button', { name: 'В обратную сторону', exact: true })).toHaveAttribute('aria-pressed', 'true');
expect(searches).toBe(1);
const persisted = await page.evaluate(async () => {
const request = indexedDB.open('ridebus-pwa');
const database = await new Promise<IDBDatabase>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
const keys = database.transaction('snapshots').objectStore('snapshots').getAllKeys();
const result = await new Promise<IDBValidKey[]>(resolve => { keys.onsuccess = () => resolve(keys.result); });
database.close(); return JSON.stringify({ keys: result, preferences: { ...localStorage } });
});
expect(persisted).not.toContain('nearby'); expect(persisted).not.toContain('55.49'); expect(persisted).not.toContain('28.783');
await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
await page.getByRole('dialog', { name: 'Выбрать остановку', exact: true }).getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
await expect(page.locator('.nearbyResults')).toHaveCount(0);
});
test('location denial leaves manual search usable', async ({ page }) => {
await page.addInitScript(() => Object.defineProperty(navigator, 'geolocation', { value: { getCurrentPosition: (_success: unknown, failure: (error: { code: number }) => void) => failure({ code: 1 }) } }));
await page.goto('/?demo=1&city=1');
await page.getByRole('button', { name: 'Остановки рядом', exact: true }).click();
await expect(page.locator('.nearbyMessage')).toContainText('Доступ к местоположению закрыт');
await page.getByRole('button', { name: 'Все остановки', exact: true }).click();
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Больница');
await page.locator('.stopItem').click();
await expect(page.locator('#board-title')).toHaveText('Больница');
await expect(page.locator('.departureRow')).toHaveCount(8);
});
test('targeted cancellation preserves details, filters by route, and expires offline', async ({ page, context }) => {
await page.clock.install({ time: new Date(time) });
const alert = { id: 1, cityId: 1, title: 'Отмена рейсов', message: 'Вокзал 12:10 — отменён\nЦентр 12:25 — отменён', type: 'cancellation', routeNumbers: ['112С', '27'], routeIds: [1], isCityWide: false, startTime: new Date(time).toISOString(), endTime: new Date(time + 3600000).toISOString() };
await mockLive(page, resource => resource === 'alerts' ? [alert, alert, { ...alert, id: 2, cityId: 2 }, { ...alert, id: 3, endTime: new Date(time - 1).toISOString() }] : undefined);
await page.goto('/?city=1&stop=101');
await expect(page.locator('.serviceAlert')).toHaveCount(1);
await expect(page.locator('.alertRoutes')).toHaveText('№112С · №27');
await expect(page.locator('.serviceAlert p')).toHaveText(alert.message);
await page.getByRole('button', { name: 'Маршрут 10', exact: true }).click();
await expect(page.locator('.serviceAlert')).toHaveCount(0);
await page.getByRole('button', { name: 'Маршрут 112С', exact: true }).click();
await expect(page.locator('.serviceAlert')).toHaveCount(1);
await context.setOffline(true);
await expect(page.locator('.alertSaved')).toHaveCount(0);
await page.clock.fastForward(3600001);
await expect(page.locator('.serviceAlert')).toHaveCount(0);
await expect(page.locator('.alertsUnavailable')).toBeVisible();
});
test('storage upgrade preserves existing favorites while adding journeys', async ({ page }) => {
await page.addInitScript(async () => {
// Dexie version 1 uses IndexedDB version 10.
const request = indexedDB.open('ridebus-pwa', 10);
request.onupgradeneeded = () => {
const database = request.result;
database.createObjectStore('snapshots', { keyPath: 'key' });
const favorites = database.createObjectStore('favorites', { keyPath: 'key' });
favorites.createIndex('cityId', 'cityId'); favorites.createIndex('mode', 'mode');
favorites.put({ key: 'demo:1:101', mode: 'demo', cityId: 1, cityName: 'Полоцк', id: 101, name: 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4, addedAt: 1 });
};
request.onsuccess = () => request.result.close();
});
await page.goto('/favorites?demo=1');
await expect(page.locator('.favoriteCard')).toHaveCount(1);
await page.getByRole('link', { name: 'Посмотреть расписание', exact: true }).click();
await page.getByRole('button', { name: 'Сохранить поездку', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Сохранить поездку', exact: true }).click();
await expect(page.locator('.quickTripCard')).toHaveCount(1);
await page.getByRole('navigation').getByRole('link', { name: /Избранное/ }).click();
await expect(page.locator('.favoriteCard')).toHaveCount(1);
await expect(page.locator('.quickTripCard')).toHaveCount(1);
});