Files
ridebus-pwa/e2e/interaction.spec.ts
Zakhar Timoshenko 6fe039cefa 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.
2026-10-08 00:18:10 +03:00

205 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from './fixtures';
async function seedTrips(page: Page, count: number) {
await page.evaluate(async count => {
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 transaction = database.transaction('quickTrips', 'readwrite');
const store = transaction.objectStore('quickTrips');
store.clear();
for (let index = 0; index < count; index++) {
const otherCity = index === 2;
store.put({ id: `test-trip-${index}`, name: ['Домой', 'На работу', 'К родителям', 'В гости'][index], mode: 'demo', cityId: otherCity ? 2 : 1, cityName: otherCity ? 'Новополоцк' : 'Полоцк',
stop: { id: otherCity ? 201 : 101, name: otherCity ? 'Площадь Строителей' : 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4 },
route: { id: index + 1, number: ['4', '10', '12', '24'][index], name: ['Автовокзал → Больница', 'Вокзал → Аэропорт', 'Боровуха → Автовокзал', 'Центр → Университет'][index] }, createdAt: index + 1 });
}
await new Promise<void>((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); });
database.close();
}, count);
// Native IndexedDB fixture writes are read when the application reloads.
await page.reload();
await expect(page.locator('.quickTripCard')).toHaveCount(count);
}
test('journeys use a mobile carousel and switch desktop layout at three cards', async ({ page }, info) => {
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
await seedTrips(page, 2);
if (info.project.name === 'mobile') await expect(page.locator('.tripsCarouselTrack')).toHaveCount(1);
else await expect(page.locator('.tripsCarouselTrack')).toHaveCount(0);
await seedTrips(page, 3);
const track = page.getByRole('region', { name: 'Сохранённые поездки', exact: true });
await expect(track).toBeVisible();
await expect(track).toHaveAttribute('data-fade-start', 'false');
await expect(track).toHaveAttribute('data-fade-end', 'true');
await expect(track).toHaveCSS('mask-image', /linear-gradient/);
await page.locator('.quickTrips').screenshot({ path: `test-results/carousel-fade-${info.project.name}.png`, animations: 'disabled' });
await expect(page.getByRole('button', { name: 'Предыдущая поездка' })).toBeDisabled();
await page.getByRole('button', { name: 'Следующая поездка' }).click();
await expect.poll(() => track.evaluate(element => element.scrollLeft)).toBeGreaterThan(100);
await expect(page.getByRole('button', { name: 'Предыдущая поездка' })).toBeEnabled();
await track.focus(); await track.press('Home');
await expect.poll(() => track.evaluate(element => element.scrollLeft)).toBeLessThan(3);
await page.setViewportSize({ width: 320, height: 780 });
await expect(track).toHaveCSS('scroll-snap-type', 'x mandatory');
await track.focus(); await track.press('End');
await expect(page.locator('.carouselControls > span')).toHaveText('3 из 3');
await expect(page.getByRole('button', { name: 'Следующая поездка' })).toBeDisabled();
await expect(track).toHaveAttribute('data-fade-start', 'true');
await expect(track).toHaveAttribute('data-fade-end', 'false');
await expect.poll(() => track.evaluate(element => element.clientHeight - (element.children[2] as HTMLElement).offsetHeight)).toBeLessThan(30);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.locator('.quickTrips').screenshot({ path: `test-results/carousel-${info.project.name}.png`, animations: 'disabled' });
await page.getByRole('button', { name: 'Удалить поездку К родителям', exact: true }).click();
await expect(page.locator('.quickTripCard')).toHaveCount(2);
await page.setViewportSize({ width: 1440, height: 1080 });
await expect(page.locator('.tripsCarouselTrack')).toHaveCount(0);
expect(errors).toEqual([]);
});
test('touch swipe moves the carousel without moving the whole page sideways', async ({ page, context }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
await seedTrips(page, 3);
const track = page.locator('.tripsCarouselTrack');
await track.scrollIntoViewIfNeeded();
const bounds = await track.boundingBox();
const session = await context.newCDPSession(page);
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
const start = bounds!.x + bounds!.width - 25;
const y = Math.min(600, bounds!.y + 110);
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: start, y }] });
for (let step = 1; step <= 8; step++) await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start - step * 28, y }] });
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect.poll(() => track.evaluate(element => element.scrollLeft)).toBeGreaterThan(100);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await session.detach();
});
test('opening a saved journey scrolls to the correct board across cities and from favorites', async ({ page }) => {
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
await seedTrips(page, 3);
await page.addInitScript(() => {
const original = HTMLElement.prototype.scrollIntoView;
(window as any).boardScrolls = [];
HTMLElement.prototype.scrollIntoView = function(options) {
if (this.id === 'departures') (window as any).boardScrolls.push(options);
original.call(this, options);
};
});
await page.goto('/favorites?demo=1');
const card = page.locator('.quickTripCard').filter({ has: page.getByRole('heading', { name: 'К родителям', exact: true }) });
await card.getByRole('link', { name: 'Открыть поездку', exact: true }).click();
await expect(page).toHaveURL(/city=2.*stop=201.*route=3/);
await expect(page.locator('#board-title')).toHaveText('Площадь Строителей');
await expect(page.locator('#departures')).toBeFocused();
await expect.poll(() => page.evaluate(() => (window as any).boardScrolls.at(-1)?.behavior)).toBe('smooth');
await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top < innerHeight - 100)).toBe(true);
await expect(page.getByRole('button', { name: 'Маршрут 12', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.emulateMedia({ reducedMotion: 'reduce' });
await card.getByRole('link', { name: 'Открыть поездку', exact: true }).click();
await expect.poll(() => page.evaluate(() => (window as any).boardScrolls.at(-1)?.behavior)).toBe('auto');
await expect(page.locator('#departures')).toBeFocused();
});
test('native sharing carries the exact departure and does not navigate or copy unnecessarily', async ({ page }) => {
await page.clock.install({ time: new Date('2026-10-07T09:00:00.000Z') });
await page.addInitScript(() => {
(window as any).shared = []; (window as any).copied = [];
Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { (window as any).shared.push(data); } });
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (value: string) => { (window as any).copied.push(value); } } });
});
await page.goto('/?demo=1&city=1&stop=101&extra=private');
const originalLink = await page.locator('.departureEntry').first().getByRole('link').getAttribute('href');
await page.locator('.departureEntry').first().getByRole('button', { name: /Поделиться отправлением/ }).click();
const shared = await page.evaluate(() => (window as any).shared);
expect(shared).toHaveLength(1);
expect(shared[0].text).toContain('Площадь Свободы'); expect(shared[0].text).toContain('12:04');
const params = new URL(shared[0].url).searchParams;
expect(params.get('city')).toBe('1'); expect(params.get('stop')).toBe('101'); expect(params.get('route')).toBe('2');
expect(params.get('at')).toBe(new URL(originalLink!, 'http://localhost').searchParams.get('at')); expect(params.get('extra')).toBeNull();
expect(await page.evaluate(() => (window as any).copied.length)).toBe(0);
await expect(page).toHaveURL(/extra=private/);
});
test('sharing cancellation stays silent and leaves clipboard unchanged', async ({ page }) => {
await page.addInitScript(() => {
(window as any).copies = 0;
Object.defineProperty(navigator, 'share', { configurable: true, value: async () => { throw new DOMException('Cancelled', 'AbortError'); } });
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { (window as any).copies++; } } });
});
await page.goto('/?demo=1&city=1&stop=101');
const button = page.locator('.departureEntry').first().getByRole('button', { name: /Поделиться отправлением/ });
await button.click(); await expect(button).toBeEnabled();
expect(await page.evaluate(() => (window as any).copies)).toBe(0);
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.locator('.toast')).toHaveCount(0);
});
test('copy fallback opens the same trip and works from the hourly timetable route screen', async ({ page }) => {
await page.clock.install({ time: new Date('2026-10-07T09:00:00.000Z') });
await page.addInitScript(() => {
(window as any).copied = [];
Object.defineProperty(navigator, 'share', { configurable: true, value: undefined });
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (value: string) => { (window as any).copied.push(value); } } });
});
await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07');
await page.locator('.hourDeparture').filter({ hasText: '12:00' }).click();
const button = page.getByRole('button', { name: 'Поделиться отправлением', exact: true });
await button.click();
await expect(page.locator('.toast')).toContainText('Ссылка на отправление скопирована');
const url = await page.evaluate(() => (window as any).copied[0]);
await page.goto(url);
await expect(page.locator('.selectedJourneyStop .journeyTime strong')).toHaveText('12:00');
await page.setViewportSize({ width: 320, height: 780 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await button.click();
expect(await page.evaluate(() => (window as any).copied[0])).toBe(url);
});
test('blocked native sharing and clipboard offer a selectable manual link', async ({ page }, info) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'share', { configurable: true, value: async () => { throw new DOMException('Blocked', 'NotAllowedError'); } });
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new DOMException('Blocked', 'NotAllowedError'); } } });
});
await page.setViewportSize({ width: 320, height: 780 });
await page.goto('/?demo=1&city=1&stop=101');
await page.locator('.departureEntry').first().getByRole('button', { name: /Поделиться отправлением/ }).click();
const dialog = page.getByRole('dialog', { name: 'Поделиться отправлением', exact: true });
await expect(dialog).toBeVisible();
const input = dialog.getByLabel('Ссылка на отправление');
await expect(input).toBeFocused();
expect(await input.evaluate((element: HTMLInputElement) => element.selectionEnd! - element.selectionStart!)).toBe((await input.inputValue()).length);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: `test-results/share-link-${info.project.name}.png`, animations: 'disabled' });
await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0);
});
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('/?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) => {
const list = getComputedStyle(element), row = getComputedStyle(element.querySelector('.stopItem')!), name = getComputedStyle(element.querySelector('.stopCopy strong')!);
return { scrollbarWidth: list.scrollbarWidth, scrollbarColor: list.scrollbarColor, maxHeight: list.maxHeight, padding: row.padding, radius: row.borderRadius, minHeight: row.minHeight, font: name.fontSize, weight: name.fontWeight };
};
const normalStyles = await all.evaluate(styles);
await page.getByRole('button', { name: 'Рядом', exact: true }).click();
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(page.locator('#board-title')).toHaveText('Больница');
await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
await expect(all.locator('.activeStop')).toHaveText('Больница');
await page.locator('.appDialog').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' });
});