Files
ridebus-pwa/e2e/routing.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

176 lines
13 KiB
TypeScript
Raw 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 stops = [
{ id: 1, name: 'Вокзал', direction: 'В центр', routesCount: 1, latitude: 55.486, longitude: 28.783 },
{ id: 2, name: 'Вокзал', direction: 'В район', routesCount: 2, latitude: 55.4861, longitude: 28.7831 },
{ id: 3, name: 'Центр', direction: 'На площадь', routesCount: 2, latitude: 55.4869, longitude: 28.7856 },
{ id: 4, name: 'Район', direction: 'К вокзалу', routesCount: 1, latitude: 55.495, longitude: 28.789 },
];
const routes = [{ id: 10, number: '4', name: 'Вокзал → Центр' }, { id: 11, number: '4', name: 'Район → Вокзал' }, { id: 24, number: '112С', name: 'Вокзал → Площадь' }];
const membership: Record<number, number[]> = { 10: [1, 3], 11: [4, 2], 24: [2, 3] };
async function mockTransit(page: Page, extra: (resource: string, params: URLSearchParams) => { status?: number; data: unknown } | undefined = () => undefined) {
await page.route('https://tiles.openfreemap.org/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ version: 8, sources: {}, layers: [{ id: 'base', type: 'background', paint: { 'background-color': '#f3f3fa' } }] }) }));
await page.route('**/api/data?**', route => {
const params = new URL(route.request().url()).searchParams, resource = params.get('resource')!;
const custom = extra(resource, params);
if (custom) return route.fulfill({ status: custom.status ?? 200, contentType: 'application/json', body: JSON.stringify(custom.data) });
const stopId = Number(params.get('stopId')), routeId = Number(params.get('routeId'));
const serving = routes.filter(route => membership[route.id]?.includes(stopId));
const now = Date.now();
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk', latitude: 55.486, longitude: 28.783 }, { id: 2, name: 'Другой город', slug: 'other' }]
: resource === 'stops' ? stops : resource === 'routes' ? routes : resource === 'routeStops' ? { route: routes.find(route => route.id === routeId), stops: membership[routeId].map((id, index) => ({ ...stops.find(stop => stop.id === id), sequence: index + 1 })) }
: resource === 'stop' ? { ...stops.find(stop => stop.id === stopId), routes: serving } : resource === 'alerts' ? []
: resource === 'departures' ? { routes: serving, partial: false, updatedAt: new Date(now).toISOString(), departures: serving.map((route, index) => ({ routeId: route.id, routeNumber: route.number, direction: route.name, scheduledTime: '12:30', scheduledAt: new Date(now + (index + 5) * 60000).toISOString() })) } : {};
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
});
}
test('desktop list card fills the detail pane and mobile stop cards are dialogs', async ({ page }) => {
await mockTransit(page);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/stops?city=1');
await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Вокзал' }).click();
const pane = (await page.locator('.explorerMapArea').boundingBox())!, card = (await page.locator('.stopPassport').boundingBox())!;
expect(Math.abs(pane.width - card.width)).toBeLessThan(2); expect(Math.abs(pane.height - card.height)).toBeLessThan(2);
await page.setViewportSize({ width: 320, height: 568 });
const dialog = page.getByRole('dialog', { name: 'Вокзал', exact: true });
await expect(dialog).toBeVisible();
const bounds = (await dialog.boundingBox())!;
expect(bounds.x).toBeGreaterThanOrEqual(0); expect(bounds.y).toBeGreaterThanOrEqual(0); expect(bounds.y + bounds.height).toBeLessThanOrEqual(568);
await expect(page.locator('.explorerBody .stopPassport')).toHaveCount(0);
await expect(page.locator('body')).toHaveCSS('overflow', 'hidden');
await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0);
await page.locator('.stopItem').filter({ hasText: 'Вокзал' }).click();
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Закрыть карточку остановки' }).click();
await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden');
});
test('route number groups directions, filters exact platforms and retains the filter on reload and schedule navigation', async ({ page }) => {
await mockTransit(page);
await page.goto('/stops?city=1');
const route = page.getByRole('combobox', { name: 'Маршрут', exact: true });
await expect(route.locator('option')).toHaveCount(3);
await route.selectOption('4');
await expect(page.locator('.stopItem')).toHaveCount(3);
await page.getByRole('combobox', { name: 'Направление маршрута' }).selectOption('10');
await expect(page.locator('.stopItem')).toHaveCount(2);
await expect(page.locator('.stopItem').first()).toContainText('Вокзал');
await expect(page.locator('.stopItem').last()).toContainText('Центр');
await expect(page).toHaveURL(/line=4.*direction=10/);
await page.reload();
await expect(route).toHaveValue('4');
await expect(page.getByRole('combobox', { name: 'Направление маршрута' })).toHaveValue('10');
await expect(page.locator('.stopItem')).toHaveCount(2);
await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Вокзал' }).click();
await expect(page).toHaveURL(/stop=1.*line=4.*direction=10/);
await expect(page.locator('.passportDirections')).toHaveCount(0);
await page.getByRole('button', { name: 'Открыть расписание', exact: true }).click();
await expect(page).toHaveURL(/\/\?city=1&stop=1&route=10/);
await expect(page.getByRole('button', { name: 'Маршрут 4', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#departures')).toBeFocused();
await expect(page.locator('.departureRow')).toHaveCount(1);
});
test('visited route filters survive offline and changing cities resets the filter', async ({ page, context }) => {
await mockTransit(page);
await page.goto('/stops?city=1&line=4&direction=11');
await expect(page.locator('.stopItem')).toHaveCount(2);
await expect(page.locator('.stopItem').first()).toContainText('Район');
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
});
await context.setOffline(true); await page.reload();
await expect(page.locator('.stopItem')).toHaveCount(2);
await expect(page.locator('.offlineBanner')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Направление маршрута' })).toHaveValue('11');
await context.setOffline(false);
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
await expect(page.getByRole('combobox', { name: 'Маршрут', exact: true })).toHaveValue('');
await expect(page.getByRole('combobox', { name: 'Направление маршрута' })).toHaveCount(0);
await expect(page.locator('.stopItem')).toHaveCount(3);
});
test('a failed route direction is marked as partial and never replaced with every city stop', async ({ page }) => {
await mockTransit(page, (resource, params) => resource === 'routeStops' && params.get('routeId') === '11' ? { status: 502, data: { error: 'data_unavailable' } } : undefined);
await page.goto('/stops?city=1&line=4');
await expect(page.locator('.routeFilterError')).toContainText('Часть направлений недоступна');
await expect(page.locator('.stopItem')).toHaveCount(2);
await expect(page.locator('.stopItem').filter({ hasText: 'Район' })).toHaveCount(0);
await page.getByRole('combobox', { name: 'Направление маршрута' }).selectOption('11');
await expect(page.locator('.listMessage')).toContainText('Остановки маршрута недоступны');
await page.getByRole('button', { name: 'Сбросить фильтр маршрута' }).click();
await expect(page.locator('.stopItem')).toHaveCount(3);
});
test('walking uses RideBus foot routing only after a click and never saves origin or path', async ({ page, context }) => {
const origin = { latitude: 55.487123, longitude: 28.782321, accuracy: 20 };
await context.grantPermissions(['geolocation']); await context.setGeolocation(origin);
const walks: URLSearchParams[] = [], errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await mockTransit(page, (resource, params) => {
if (resource !== 'walk') return;
walks.push(params);
return { data: { distance: 230, duration: 165.4, geometry: { type: 'LineString', coordinates: [[origin.longitude, origin.latitude], [28.7835, 55.4863], [28.7856, 55.4869]] } } };
});
await page.goto('/stops?city=1');
await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Центр' }).click();
expect(walks).toHaveLength(0);
await page.getByRole('button', { name: 'Как добраться пешком', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Пешком к остановке' });
await expect(dialog.locator('.walkingSummary')).toContainText('≈ 3 мин');
await expect(dialog.locator('.walkingSummary')).toContainText('230 м');
await expect(dialog.locator('.stopsMapCanvas')).toHaveAttribute('data-ready', 'true');
expect(walks).toHaveLength(1); expect(walks[0].get('stopId')).toBe('3'); expect(walks[0].get('lat')).toBe('55.487123'); expect(walks[0].get('lon')).toBe('28.782321');
const persisted = await page.evaluate(async () => {
const request = indexedDB.open('ridebus-pwa');
const database = await new Promise<IDBDatabase>(resolve => { request.onsuccess = () => resolve(request.result); });
const values = database.transaction('snapshots').objectStore('snapshots').getAll();
const snapshots = await new Promise(resolve => { values.onsuccess = () => resolve(values.result); });
database.close(); return JSON.stringify({ snapshots, preferences: { ...localStorage } });
});
expect(persisted).not.toContain('55.487123'); expect(persisted).not.toContain('28.782321'); expect(persisted).not.toContain('"geometry"');
expect(errors).toEqual([]);
await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0);
await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden');
});
test('walking permission denial offers retry and a map fallback without requesting a path', async ({ page }) => {
await page.addInitScript(() => Object.defineProperty(navigator, 'geolocation', { value: { getCurrentPosition: (_success: unknown, failure: (error: { code: number }) => void) => failure({ code: 1 }) } }));
let walks = 0;
await mockTransit(page, resource => { if (resource === 'walk') walks++; return undefined; });
await page.goto('/stops?city=1'); await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Центр' }).click();
await page.getByRole('button', { name: 'Как добраться пешком', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Пешком к остановке' });
await expect(dialog).toContainText('Доступ к местоположению закрыт');
const link = new URL((await dialog.getByRole('link', { name: 'Открыть в картах' }).getAttribute('href'))!);
expect(link.searchParams.get('destination')).toBe('55.4869,28.7856'); expect(walks).toBe(0);
await dialog.getByRole('button', { name: 'Повторить', exact: true }).click();
await expect(dialog).toContainText('Доступ к местоположению закрыт'); expect(walks).toBe(0);
});
test('walking route failure stays explicit and retry can show a recovered path', async ({ page, context }) => {
await context.grantPermissions(['geolocation']); await context.setGeolocation({ latitude: 55.486, longitude: 28.783 });
let attempts = 0;
await mockTransit(page, resource => {
if (resource !== 'walk') return;
return ++attempts === 1 ? { status: 404, data: { error: 'walking_not_found' } }
: { data: { distance: 230, duration: 165.4, geometry: { type: 'LineString', coordinates: [[28.783, 55.486], [28.7856, 55.4869]] } } };
});
await page.goto('/stops?city=1'); await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Центр' }).click();
await page.getByRole('button', { name: 'Как добраться пешком', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Пешком к остановке' });
await expect(dialog).toContainText('Не удалось найти пеший путь');
await expect(dialog.locator('.walkingMap')).toHaveCount(0);
await dialog.getByRole('button', { name: 'Повторить', exact: true }).click();
await expect(dialog.locator('.walkingSummary')).toContainText('≈ 3 мин'); expect(attempts).toBe(2);
await dialog.getByRole('button', { name: 'Закрыть окно', exact: true }).click();
await expect(page.locator('.walkingMap')).toHaveCount(0);
});