196 lines
14 KiB
TypeScript
196 lines
14 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
|
||
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 === '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<void>(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')).toContainText('могут быть устаревшими');
|
||
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')).toContainText('Сохранено');
|
||
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 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<IDBDatabase>((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<IDBValidKey[]>(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('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('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')).toBeVisible();
|
||
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('link', { name: 'В избранное', exact: true }).click();
|
||
await expect(page.locator('.favoriteCard')).toHaveCount(1);
|
||
await expect(page.locator('.quickTripCard')).toHaveCount(1);
|
||
});
|