- 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.
176 lines
13 KiB
TypeScript
176 lines
13 KiB
TypeScript
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);
|
||
});
|