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 = { 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(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(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); });