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:
2026-10-08 00:18:10 +03:00
parent 1492f179eb
commit 6fe039cefa
43 changed files with 1518 additions and 59 deletions

View File

@@ -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('Показаны первые');

View File

@@ -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);
});

View File

@@ -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' });
});

View File

@@ -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
View 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
View 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' });
});