import { test, expect, type Page } from './fixtures'; const time = Date.parse('2026-10-07T09:00:00.000Z'); const routes = [{ id: 1, number: '112С', name: 'Вокзал → Центр' }, { id: 2, number: '10', name: 'Вокзал → Больница' }]; const stops = [{ id: 101, name: 'Вокзал', direction: 'В центр', routesCount: 2 }, { id: 102, name: 'Вокзал', direction: 'В обратную сторону', routesCount: 1 }, { id: 103, name: 'Центр', direction: 'К вокзалу', routesCount: 1 }]; async function mockLive(page: Page, extra: (resource: string, params: URLSearchParams) => unknown = () => undefined) { await page.route('**/api/data?**', route => { 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 === '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) }); }); } async function readyForOffline(page: Page) { await page.evaluate(async () => { await navigator.serviceWorker.ready; if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true })); }); } test('saved journeys retain route/direction, allow rename/delete, and remain available offline', async ({ page, context }, info) => { const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); await page.goto('/?demo=1&city=1&stop=102'); await expect(page.locator('.departureRow')).toHaveCount(8); await page.getByRole('button', { name: 'Маршрут 4', exact: true }).click(); await page.getByRole('button', { name: 'Сохранить поездку', exact: true }).click(); const dialog = page.getByRole('dialog', { name: 'Сохранить поездку', exact: true }); await expect(dialog).toBeVisible(); await expect(dialog.getByLabel('Название поездки')).toBeFocused(); await expect(dialog.getByLabel('Маршрут поездки')).toHaveValue('1'); await dialog.getByRole('button', { name: 'На работу', exact: true }).click(); await dialog.getByRole('button', { name: 'Сохранить поездку', exact: true }).click(); await expect(page.locator('.quickTripCard')).toHaveCount(1); await expect(page.locator('.tripDirection')).toHaveText('В сторону центра'); await page.reload(); await expect(page.locator('.quickTripHeading h3')).toHaveText('На работу'); await page.getByRole('button', { name: 'Изменить', exact: true }).click(); await page.getByRole('dialog').getByLabel('Название поездки').fill('Домой'); await page.getByRole('dialog').getByRole('button', { name: 'Сохранить', exact: true }).click(); await expect(page.locator('.quickTripHeading h3')).toHaveText('Домой'); await page.getByRole('link', { name: 'Открыть поездку', exact: true }).click(); await expect(page).toHaveURL(/stop=102.*route=1/); await expect(page.getByRole('button', { name: 'Маршрут 4', exact: true })).toHaveAttribute('aria-pressed', 'true'); await page.setViewportSize({ width: 320, height: 780 }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: `test-results/trips-${info.project.name}.png`, fullPage: true, animations: 'disabled' }); await readyForOffline(page); await context.setOffline(true); await page.reload(); await expect(page.locator('.quickTripHeading h3')).toHaveText('Домой'); await expect(page.locator('.tripSaved')).toHaveCount(0); await expect(page.locator('.offlineBanner')).toHaveCount(1); await page.goto('/favorites?demo=1'); await expect(page.locator('.quickTripCard')).toHaveCount(1); await context.setOffline(false); await page.goto('/favorites'); await expect(page.locator('.quickTripCard')).toHaveCount(0); await page.goto('/favorites?demo=1'); await expect(page.locator('.quickTripCard')).toHaveCount(1); await page.getByRole('button', { name: 'Удалить поездку Домой', exact: true }).click(); await expect(page.locator('.quickTripCard')).toHaveCount(0); expect(errors).toEqual([]); }); test('hour grid jumps to next departure and opens a cached trip with stop-by-stop times', async ({ page, context }, info) => { await page.clock.install({ time: new Date(time) }); await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07'); await expect(page.locator('.hourDeparture')).toHaveCount(32); await page.getByRole('button', { name: 'К ближайшему', exact: true }).click(); const next = page.locator('.nextDeparture'); await expect(next).toBeFocused(); await expect(next).toContainText('12:00'); await next.click(); await expect(page).toHaveURL(/\/route\?/); await expect(page.locator('.journeyTimeline li')).toHaveCount(6); await expect(page.locator('.selectedJourneyStop .journeyTime')).toHaveText('12:00'); await expect(page.locator('.journeyTime strong')).toHaveText(['12:00', '12:05', '12:10', '12:15', '12:20', '12:25']); const journeyUrl = page.url(); await page.screenshot({ path: `test-results/journey-${info.project.name}.png`, fullPage: true, animations: 'disabled' }); await page.getByRole('link', { name: 'К расписанию остановки', exact: true }).click(); await expect(page.locator('.hourDeparture')).toHaveCount(32); await expect(page.getByLabel('Дата поездки')).toHaveValue('2026-10-07'); await readyForOffline(page); await context.setOffline(true); await page.goto(journeyUrl); await page.reload(); await expect(page.locator('.journeyTimeline li')).toHaveCount(6); await expect(page.locator('.routeTimelineCard .savedNotice')).toHaveCount(0); await expect(page.locator('.offlineBanner')).toHaveCount(1); await page.locator('.journeyTimeline a').nth(1).click(); await expect(page).toHaveURL(/stop=102/); await expect(page).toHaveURL(/date=2026-10-07/); }); test('save dialog fits narrow screens, closes with Escape, and restores button focus', async ({ page }, info) => { await page.setViewportSize({ width: 320, height: 780 }); await page.goto('/?demo=1&city=1&stop=101'); const open = page.getByRole('button', { name: 'Сохранить поездку', exact: true }); await open.click(); await expect(page.getByRole('dialog')).toBeVisible(); const bounds = await page.getByRole('dialog').boundingBox(); expect(bounds!.width).toBeLessThanOrEqual(320); await page.screenshot({ path: `test-results/trip-dialog-${info.project.name}.png`, animations: 'disabled' }); const work = page.getByRole('dialog').getByRole('button', { name: 'На работу', exact: true }); await work.focus(); await work.press('Enter'); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByLabel('Название поездки')).toHaveValue('На работу'); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(open).toBeFocused(); await page.emulateMedia({ reducedMotion: 'reduce' }); await open.click(); expect(await page.getByRole('dialog').evaluate(element => getComputedStyle(element).animationName)).toBe('none'); }); test('nearby search requests location only on click, groups stops, and never persists coordinates', async ({ page, context }) => { await context.grantPermissions(['geolocation']); await context.setGeolocation({ latitude: 55.49, longitude: 28.783, accuracy: 25 }); let searches = 0; await mockLive(page, (resource, params) => { if (resource !== 'nearby') return; 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('/?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(); await expect(page.locator('.nearbyResults button')).toHaveCount(2); await expect(page.locator('.nearbyResults button').first()).toHaveText('Вокзал75 м'); await page.locator('.nearbyResults button').first().click(); await expect(page).toHaveURL(/stop=102/); await expect(page.locator('#board-title')).toHaveText('Вокзал'); await expect(page.getByRole('group', { name: 'Направления', exact: true }).getByRole('button', { name: 'В обратную сторону', exact: true })).toHaveAttribute('aria-pressed', 'true'); expect(searches).toBe(1); const persisted = await page.evaluate(async () => { const request = indexedDB.open('ridebus-pwa'); const database = await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const keys = database.transaction('snapshots').objectStore('snapshots').getAllKeys(); const result = await new Promise(resolve => { keys.onsuccess = () => resolve(keys.result); }); 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('dialog', { name: 'Выбрать остановку', exact: true }).getByRole('combobox', { name: 'Ваш город' }).selectOption('2'); await expect(page.locator('.nearbyResults')).toHaveCount(0); }); test('location denial leaves manual search usable', async ({ page }) => { await page.addInitScript(() => Object.defineProperty(navigator, 'geolocation', { value: { getCurrentPosition: (_success: unknown, failure: (error: { code: number }) => void) => failure({ code: 1 }) } })); await page.goto('/?demo=1&city=1'); await page.getByRole('button', { name: 'Остановки рядом', exact: true }).click(); await expect(page.locator('.nearbyMessage')).toContainText('Доступ к местоположению закрыт'); await page.getByRole('button', { name: 'Все остановки', exact: true }).click(); await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Больница'); await page.locator('.stopItem').click(); await expect(page.locator('#board-title')).toHaveText('Больница'); await expect(page.locator('.departureRow')).toHaveCount(8); }); test('targeted cancellation preserves details, filters by route, and expires offline', async ({ page, context }) => { await page.clock.install({ time: new Date(time) }); const alert = { id: 1, cityId: 1, title: 'Отмена рейсов', message: 'Вокзал 12:10 — отменён\nЦентр 12:25 — отменён', type: 'cancellation', routeNumbers: ['112С', '27'], routeIds: [1], isCityWide: false, startTime: new Date(time).toISOString(), endTime: new Date(time + 3600000).toISOString() }; await mockLive(page, resource => resource === 'alerts' ? [alert, alert, { ...alert, id: 2, cityId: 2 }, { ...alert, id: 3, endTime: new Date(time - 1).toISOString() }] : undefined); await page.goto('/?city=1&stop=101'); await expect(page.locator('.serviceAlert')).toHaveCount(1); await expect(page.locator('.alertRoutes')).toHaveText('№112С · №27'); await expect(page.locator('.serviceAlert p')).toHaveText(alert.message); await page.getByRole('button', { name: 'Маршрут 10', exact: true }).click(); await expect(page.locator('.serviceAlert')).toHaveCount(0); await page.getByRole('button', { name: 'Маршрут 112С', exact: true }).click(); await expect(page.locator('.serviceAlert')).toHaveCount(1); await context.setOffline(true); await expect(page.locator('.alertSaved')).toHaveCount(0); await page.clock.fastForward(3600001); await expect(page.locator('.serviceAlert')).toHaveCount(0); await expect(page.locator('.alertsUnavailable')).toBeVisible(); }); test('storage upgrade preserves existing favorites while adding journeys', async ({ page }) => { await page.addInitScript(async () => { // Dexie version 1 uses IndexedDB version 10. const request = indexedDB.open('ridebus-pwa', 10); request.onupgradeneeded = () => { const database = request.result; database.createObjectStore('snapshots', { keyPath: 'key' }); const favorites = database.createObjectStore('favorites', { keyPath: 'key' }); favorites.createIndex('cityId', 'cityId'); favorites.createIndex('mode', 'mode'); favorites.put({ key: 'demo:1:101', mode: 'demo', cityId: 1, cityName: 'Полоцк', id: 101, name: 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4, addedAt: 1 }); }; request.onsuccess = () => request.result.close(); }); await page.goto('/favorites?demo=1'); await expect(page.locator('.favoriteCard')).toHaveCount(1); await page.getByRole('link', { name: 'Посмотреть расписание', exact: true }).click(); await page.getByRole('button', { name: 'Сохранить поездку', exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Сохранить поездку', exact: true }).click(); await expect(page.locator('.quickTripCard')).toHaveCount(1); await page.getByRole('navigation').getByRole('link', { name: /Избранное/ }).click(); await expect(page.locator('.favoriteCard')).toHaveCount(1); await expect(page.locator('.quickTripCard')).toHaveCount(1); });