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.
This commit is contained in:
@@ -5,9 +5,10 @@ test('stop search, route filter and city-scoped favorites survive reload', async
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('/stops?demo=1');
|
||||
await expect(page.getByRole('combobox', { name: 'Ваш город' })).toHaveValue('1');
|
||||
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Площадь');
|
||||
await page.getByRole('searchbox', { name: 'Поиск остановки' }).fill('Площадь');
|
||||
await expect(page.locator('.stopItem')).toHaveCount(1);
|
||||
await page.locator('.stopItem').first().click();
|
||||
await page.getByRole('button', { name: 'Открыть расписание', exact: true }).click();
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
const directions = page.getByRole('group', { name: 'Направления', exact: true });
|
||||
await expect(directions.getByRole('button')).toHaveCount(2);
|
||||
@@ -56,13 +57,16 @@ test('stop search, route filter and city-scoped favorites survive reload', async
|
||||
test('every grouped stop is visible without an eighty-stop cap', async ({ page }) => {
|
||||
await page.route('**/api/data?**', route => {
|
||||
const resource = new URL(route.request().url()).searchParams.get('resource');
|
||||
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }]
|
||||
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }] : resource === 'routes' ? []
|
||||
: Array.from({ length: 115 }, (_, i) => ({ id: i + 1, name: `Остановка ${String(i + 1).padStart(3, '0')}`, direction: '', routesCount: 1 }));
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
});
|
||||
await page.goto('/stops');
|
||||
await page.getByRole('button', { name: 'Список', exact: true }).click();
|
||||
await expect(page.locator('.stopItem')).toHaveCount(115);
|
||||
await expect(page.locator('.stopsFooter')).toContainText('115 остановок');
|
||||
await expect(page.locator('.explorerListHeading')).toContainText('115 остановок');
|
||||
await expect(page.locator('.maplibregl-canvas')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Карта', exact: true })).toBeDisabled();
|
||||
await page.locator('.stopItem').last().scrollIntoViewIfNeeded();
|
||||
await expect(page.locator('.stopItem').last()).toBeVisible();
|
||||
await expect(page.locator('main')).not.toContainText('Показаны первые');
|
||||
|
||||
@@ -8,7 +8,7 @@ async function mockLive(page: Page, extra: (resource: string, params: URLSearchP
|
||||
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 === '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() })) });
|
||||
: 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) });
|
||||
});
|
||||
}
|
||||
@@ -119,7 +119,8 @@ test('nearby search requests location only on click, groups stops, and never per
|
||||
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('/stops?city=1');
|
||||
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();
|
||||
@@ -138,8 +139,8 @@ test('nearby search requests location only on click, groups stops, and never per
|
||||
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('combobox', { name: 'Ваш город' }).selectOption('2');
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -182,7 +182,8 @@ test('blocked native sharing and clipboard offer a selectable manual link', asyn
|
||||
|
||||
test('nearby and all stops share rows, selection, typography and scrollbar styles', async ({ page, context }, info) => {
|
||||
await context.grantPermissions(['geolocation']); await context.setGeolocation({ latitude: 55.49, longitude: 28.783 });
|
||||
await page.goto('/stops?demo=1&city=1&stop=101');
|
||||
await page.goto('/?demo=1&city=1&stop=101');
|
||||
await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
|
||||
const all = page.locator('.stopsList[aria-label="Остановки"]');
|
||||
await expect(all.locator('.stopItem')).toHaveCount(5);
|
||||
const styles = (element: HTMLElement) => {
|
||||
@@ -191,13 +192,13 @@ test('nearby and all stops share rows, selection, typography and scrollbar style
|
||||
};
|
||||
const normalStyles = await all.evaluate(styles);
|
||||
await page.getByRole('button', { name: 'Рядом', exact: true }).click();
|
||||
const nearby = page.getByLabel('Остановки рядом', { exact: true });
|
||||
const nearby = page.locator('.stopsList[aria-label="Остановки рядом"]');
|
||||
await expect(nearby.locator('.stopItem')).toHaveCount(3);
|
||||
await expect(nearby.locator('.activeStop')).toHaveText('Площадь Свободы120 м');
|
||||
expect(await nearby.evaluate(styles)).toEqual(normalStyles);
|
||||
await nearby.getByRole('button', { name: /Больница/ }).click();
|
||||
await expect(nearby.locator('.activeStop')).toContainText('Больница');
|
||||
await page.getByRole('button', { name: 'Все остановки', exact: true }).click();
|
||||
await expect(page.locator('#board-title')).toHaveText('Больница');
|
||||
await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
|
||||
await expect(all.locator('.activeStop')).toHaveText('Больница');
|
||||
await page.locator('.stopsCard').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' });
|
||||
await page.locator('.appDialog').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' });
|
||||
});
|
||||
|
||||
@@ -61,13 +61,16 @@ test('choosing a stop from the home dialog closes it and scrolls to departures o
|
||||
await expect.poll(() => page.evaluate(() => (window as any).stopScrolls.at(-1)?.behavior)).toBe('auto');
|
||||
});
|
||||
|
||||
test('catalog selection scrolls on a phone while retaining the current date', async ({ page }) => {
|
||||
test('catalog opens a stop card and its schedule on the home page while retaining the date', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 780 });
|
||||
await page.goto('/stops?demo=1&city=1&view=day&date=2026-10-08');
|
||||
await page.getByRole('searchbox').fill('Площадь');
|
||||
await page.locator('.stopItem').click();
|
||||
await expect(page.locator('.stopPassport')).toBeFocused();
|
||||
await expect(page.locator('#departures')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Открыть расписание', exact: true }).click();
|
||||
await expect(page.locator('#departures')).toBeFocused();
|
||||
await expect(page).toHaveURL(/\/stops\?.*stop=101.*date=2026-10-08/);
|
||||
await expect(page).toHaveURL(/\/\?.*stop=101.*date=2026-10-08/);
|
||||
await expect(page.getByLabel('Дата поездки', { exact: true })).toHaveValue('2026-10-08');
|
||||
await expect(page.locator('.hourDeparture')).toHaveCount(32);
|
||||
await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(180);
|
||||
|
||||
175
e2e/routing.spec.ts
Normal file
175
e2e/routing.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
});
|
||||
148
e2e/stops.spec.ts
Normal file
148
e2e/stops.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
async function localBasemap(page: Page) {
|
||||
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' } }] }) }));
|
||||
}
|
||||
|
||||
test('stop card distinguishes platforms, favorites the selected direction and opens its schedule', async ({ page }) => {
|
||||
await localBasemap(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/stops?demo=1&city=1');
|
||||
await expect(page.locator('#departures')).toHaveCount(0);
|
||||
await page.getByRole('searchbox', { name: 'Поиск остановки' }).fill('Площадь');
|
||||
await expect(page.locator('.stopItem')).toHaveCount(1);
|
||||
await page.locator('.stopItem').click();
|
||||
await expect(page.locator('#stop-passport-title')).toHaveText('Площадь Свободы');
|
||||
const directions = page.getByRole('group', { name: 'Направления остановки' });
|
||||
await expect(directions.getByRole('button')).toHaveCount(2);
|
||||
await directions.getByRole('button', { name: 'В сторону центра', exact: true }).click();
|
||||
await expect(page).toHaveURL(/stop=102/);
|
||||
await expect(page.locator('.passportRoute')).toHaveCount(3);
|
||||
await expect(page.getByRole('button', { name: 'Как добраться пешком', exact: true })).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Удалить остановку из избранного', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('button', { name: 'Открыть расписание', exact: true }).click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe('/');
|
||||
await expect(page.locator('#departures')).toBeFocused();
|
||||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||||
await expect(page.getByRole('group', { name: 'Направления', exact: true }).getByRole('button', { name: 'В сторону центра', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('map points are clickable and the map never requests departures before opening a schedule', async ({ page }) => {
|
||||
await localBasemap(page);
|
||||
const errors: string[] = [], resources: string[] = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
const stop = { id: 91, name: 'Место посадки', direction: 'В центр', routesCount: 1, latitude: 55.49, longitude: 28.78 };
|
||||
await page.route('**/api/data?**', route => {
|
||||
const resource = new URL(route.request().url()).searchParams.get('resource')!; resources.push(resource);
|
||||
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk', latitude: 55.49, longitude: 28.78 }]
|
||||
: resource === 'stops' ? [stop] : resource === 'routes' ? [] : { ...stop, routes: [{ id: 1, number: '112С', name: 'Вокзал → Центр' }] };
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
});
|
||||
await page.goto('/stops?city=1');
|
||||
await expect(page.locator('.stopsMapCanvas')).toHaveAttribute('data-ready', 'true');
|
||||
await expect(page.locator('.explorerMapMode .explorerCatalog')).toHaveCount(0);
|
||||
const canvas = page.locator('.maplibregl-canvas');
|
||||
const bounds = (await canvas.boundingBox())!;
|
||||
expect(bounds.height).toBeGreaterThan(350);
|
||||
await canvas.click({ position: { x: bounds.width / 2, y: bounds.height / 2 } });
|
||||
await expect(page.locator('#stop-passport-title')).toHaveText('Место посадки');
|
||||
await expect(page.locator('.passportRoute')).toHaveText('112С');
|
||||
expect(resources).toContain('stop'); expect(resources).not.toContain('departures'); expect(resources).not.toContain('timetable');
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('nearby discovery uses a single list, chooses the nearest platform and keeps location ephemeral', async ({ page, context }) => {
|
||||
await localBasemap(page);
|
||||
await context.grantPermissions(['geolocation']);
|
||||
await context.setGeolocation({ latitude: 55.48495, longitude: 28.7686, accuracy: 20 });
|
||||
await page.addInitScript(() => {
|
||||
const original = navigator.geolocation.getCurrentPosition.bind(navigator.geolocation);
|
||||
(window as any).locationRequests = 0;
|
||||
navigator.geolocation.getCurrentPosition = (...args) => { (window as any).locationRequests++; return original(...args); };
|
||||
});
|
||||
await page.goto('/stops?demo=1&city=1');
|
||||
await page.getByRole('button', { name: 'Список', exact: true }).click();
|
||||
await expect(page.locator('.stopItem')).toHaveCount(5);
|
||||
expect(await page.evaluate(() => (window as any).locationRequests)).toBe(0);
|
||||
await page.getByRole('button', { name: 'Рядом со мной', exact: true }).click();
|
||||
await expect(page.locator('.explorerHint')).toContainText('расстояния по прямой');
|
||||
await expect(page.locator('.stopItem').first()).toContainText('Площадь Свободы');
|
||||
await expect(page.locator('.stopItem').first()).toContainText('м');
|
||||
await page.locator('.stopItem').first().click();
|
||||
await expect(page).toHaveURL(/stop=101/);
|
||||
expect(await page.evaluate(() => (window as any).locationRequests)).toBe(1);
|
||||
const persisted = await page.evaluate(() => JSON.stringify({ ...localStorage }));
|
||||
expect(persisted).not.toContain('55.48495'); expect(persisted).not.toContain('28.7686');
|
||||
await page.getByRole('button', { name: 'Закрыть карточку остановки', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Все остановки', exact: true }).click();
|
||||
await expect(page.locator('.stopItem')).toHaveCount(5);
|
||||
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
|
||||
await expect(page.locator('.stopDistanceHint')).toHaveCount(0);
|
||||
await expect(page.locator('.explorerNearby')).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
test('stop catalog and visited cards remain usable offline without importing the map', async ({ page, context }) => {
|
||||
await localBasemap(page);
|
||||
await page.goto('/stops?demo=1&city=1&stop=101');
|
||||
if ((await page.viewportSize())!.width <= 760) { await page.getByRole('button', { name: 'Список', exact: true }).click(); await page.locator('.stopItem').filter({ hasText: 'Площадь Свободы' }).click(); }
|
||||
await expect(page.locator('.passportRoute')).toHaveCount(4);
|
||||
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('.offlineBanner')).toHaveCount(1);
|
||||
await expect(page.locator('.maplibregl-canvas')).toHaveCount(0);
|
||||
await expect(page.locator('.stopItem')).toHaveCount(5);
|
||||
if ((await page.viewportSize())!.width > 760) await expect(page.locator('.passportRoute')).toHaveCount(4);
|
||||
await page.getByRole('searchbox').fill('Площадь');
|
||||
await page.locator('.stopItem').click();
|
||||
await expect(page.locator('.passportRoute')).toHaveCount(4);
|
||||
await expect(page.locator('#stop-passport-title')).toHaveText('Площадь Свободы');
|
||||
await expect(page.locator('main')).not.toContainText('устарев');
|
||||
});
|
||||
|
||||
test('a basemap failure leaves search, stop information and the list usable', async ({ page }) => {
|
||||
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('https://tiles.openfreemap.org/**', route => route.abort());
|
||||
await page.goto('/stops?demo=1&city=1');
|
||||
await expect(page.locator('.mapProblem')).toContainText('Не удалось загрузить подложку карты');
|
||||
await page.getByRole('button', { name: 'К списку', exact: true }).click();
|
||||
await expect(page.locator('.maplibregl-canvas')).toHaveCount(0);
|
||||
await page.getByRole('searchbox').fill('Больница');
|
||||
await page.locator('.stopItem').click();
|
||||
await expect(page.locator('#stop-passport-title')).toHaveText('Больница');
|
||||
await expect(page.locator('.passportRoute')).toHaveCount(3);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('map and list fit narrow phones and respond to the app theme', async ({ page }, info) => {
|
||||
const styles: string[] = [];
|
||||
await page.route('https://tiles.openfreemap.org/**', route => {
|
||||
styles.push(route.request().url());
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ version: 8, sources: {}, layers: [{ id: 'base', type: 'background', paint: { 'background-color': '#f3f3fa' } }] }) });
|
||||
});
|
||||
await page.goto('/stops?demo=1&city=1&stop=101');
|
||||
await expect(page.locator('.stopsMapCanvas')).toHaveAttribute('data-ready', 'true');
|
||||
for (const width of [320, 390, 760, 761, 1050, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
if (width <= 760 && await page.locator('.stopPassport').count()) await expect(page.locator('.stopPassportDialog')).toBeVisible();
|
||||
if (await page.locator('.stopPassportDialog').count()) await page.getByRole('button', { name: 'Закрыть карточку остановки', exact: true }).click();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
const map = (await page.locator('.stopsMapCanvas').boundingBox())!;
|
||||
expect(map.height).toBeGreaterThan(350);
|
||||
await page.getByRole('button', { name: 'Список', exact: true }).click();
|
||||
if (width > 760) await expect(page.locator('.stopPassport')).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.getByRole('button', { name: 'Карта', exact: true }).click();
|
||||
await expect(page.locator('.stopsMapCanvas')).toHaveAttribute('data-ready', 'true');
|
||||
}
|
||||
await page.getByRole('navigation', { name: 'Основная навигация' }).getByRole('link', { name: 'Настройки' }).click();
|
||||
await page.getByRole('radio', { name: /^Тёмная/ }).check();
|
||||
await page.getByRole('navigation', { name: 'Основная навигация' }).getByRole('link', { name: 'Остановки' }).click();
|
||||
await expect.poll(() => styles.some(url => url.endsWith('/dark'))).toBe(true);
|
||||
await expect(page.locator('.stopPassport')).toHaveCSS('background-color', 'rgb(28, 35, 48)');
|
||||
await page.screenshot({ path: `test-results/stops-dark-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||||
});
|
||||
Reference in New Issue
Block a user