- 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.
215 lines
16 KiB
TypeScript
215 lines
16 KiB
TypeScript
import { test, expect } from './fixtures';
|
||
|
||
test('stop search, route filter and city-scoped favorites survive reload', async ({ page }, info) => {
|
||
const errors: string[] = [];
|
||
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 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);
|
||
await directions.getByRole('button', { name: 'В сторону центра', exact: true }).click();
|
||
await expect(page).toHaveURL(/stop=102/);
|
||
await expect(directions.getByRole('button', { name: 'В сторону центра', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||
await directions.getByRole('button', { name: 'В сторону автовокзала', exact: true }).click();
|
||
await expect(page).toHaveURL(/stop=101/);
|
||
await page.getByRole('button', { name: 'Маршрут 4', exact: true }).click();
|
||
await expect(page.locator('.departureRow')).toHaveCount(2);
|
||
await page.getByRole('button', { name: 'Все маршруты', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
|
||
if (info.project.name === 'mobile') {
|
||
const mobileFavorite = page.getByRole('navigation', { name: 'Навигация на телефоне' }).getByRole('link', { name: /Избранное/ });
|
||
await expect(mobileFavorite.locator('.mobileBadge')).toHaveText('1');
|
||
await expect(mobileFavorite).toHaveText('1Избранное');
|
||
const badge = await mobileFavorite.locator('.mobileBadge').boundingBox();
|
||
const icon = await mobileFavorite.locator('.navIcon').boundingBox();
|
||
expect(badge!.y).toBeLessThan(icon!.y);
|
||
expect(badge!.x + badge!.width).toBeGreaterThan(icon!.x + icon!.width);
|
||
}
|
||
await page.getByRole('button', { name: 'Закрыть уведомление', exact: true }).click();
|
||
await page.screenshot({ path: `test-results/ridebus-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||
await page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация', exact: true }).getByRole('link', { name: /Избранное/ }).click();
|
||
await expect(page.locator('.favoriteCard')).toHaveCount(1);
|
||
await expect(page.locator('.favoriteDepartures li')).toHaveCount(3);
|
||
await page.reload();
|
||
await expect(page.locator('.favoriteCard')).toHaveCount(1);
|
||
await page.getByRole('link', { name: 'Посмотреть расписание', exact: true }).click();
|
||
await expect(page.locator('#board-title')).toHaveText('Площадь Свободы');
|
||
await expect(page.getByRole('group', { name: 'Направления' }).getByRole('button', { name: 'В сторону автовокзала', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
|
||
await expect(page.locator('#board-title')).toHaveText('Ваша остановка');
|
||
await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
|
||
const picker = page.getByRole('dialog', { name: 'Выбрать остановку', exact: true });
|
||
await picker.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Площадь');
|
||
await picker.getByRole('button', { name: /Площадь Строителей/ }).click();
|
||
await expect(page.locator('#board-title')).toHaveText('Площадь Строителей');
|
||
await expect(page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true })).toBeVisible();
|
||
expect(errors).toEqual([]);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||
});
|
||
|
||
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' }] : 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('.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('Показаны первые');
|
||
});
|
||
|
||
test('text is readable on narrow screens and contains no technical API wording', async ({ page }) => {
|
||
await page.setViewportSize({ width: 320, height: 780 });
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||
const tiny = await page.locator('main, footer, .mobileNav').evaluateAll(roots => roots.flatMap(root => [...root.querySelectorAll<HTMLElement>('p, label, strong, button, small, span')]).filter(element => {
|
||
const visible = element.getClientRects().length > 0;
|
||
return visible && element.textContent?.trim() && Number.parseFloat(getComputedStyle(element).fontSize) < 13 && !element.classList.contains('mobileBadge');
|
||
}).map(element => ({ text: element.textContent, size: getComputedStyle(element).fontSize })));
|
||
expect(tiny).toEqual([]);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||
await expect(page.locator('main')).not.toContainText('API');
|
||
await expect(page.locator('body')).not.toContainText('МЕНЬШЕ ОЖИДАНИЯ');
|
||
await expect(page.getByRole('contentinfo')).toContainText('Расписание в вашем кармане');
|
||
});
|
||
|
||
test('installed service worker serves shell, favorites and saved departures offline', async ({ page, context }) => {
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||
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')).toBeVisible();
|
||
await expect(page.locator('.offlineBanner')).toBeVisible();
|
||
await expect(page.locator('.savedNotice')).toHaveCount(0);
|
||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
|
||
await context.setOffline(false);
|
||
});
|
||
|
||
test('API failure is explicit and does not silently substitute demo data', async ({ page }) => {
|
||
await page.route('**/api/data?**', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'api_not_configured' }) }));
|
||
await page.goto('/');
|
||
await expect(page.getByRole('heading', { name: 'Подключаем расписание' })).toBeVisible();
|
||
await expect(page.locator('.demoBanner')).toHaveCount(0);
|
||
await expect(page.locator('.departureRow')).toHaveCount(0);
|
||
await expect(page.getByRole('button', { name: /Посмотреть пример/ })).toHaveCount(0);
|
||
await expect(page.locator('.stopsList[aria-label="Остановки"]')).not.toContainText('на примере');
|
||
await page.goto('/?demo=1');
|
||
await expect(page.locator('.demoBanner')).toBeVisible();
|
||
});
|
||
|
||
test('a stale timetable loses expired departures across the date boundary', async ({ page, context }) => {
|
||
await page.clock.install({ time: new Date('2026-10-06T20:59:00Z') });
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||
await context.setOffline(true);
|
||
await page.clock.fastForward('02:00:00');
|
||
await expect(page.locator('.departureRow')).toHaveCount(0);
|
||
await expect(page.getByText('Ближайшие отправления закончились.', { exact: false })).toBeVisible();
|
||
});
|
||
|
||
test('full timetable keeps past trips, switches date and direction, and restores its URL', async ({ page }, info) => {
|
||
await page.clock.install({ time: new Date('2026-10-07T09:00:00Z') });
|
||
await page.goto('/?demo=1&city=1&stop=101');
|
||
await page.getByRole('button', { name: 'На дату', exact: true }).click();
|
||
await expect(page.locator('.timetableSummary')).toContainText('32 рейса');
|
||
await expect(page.locator('.pastDeparture')).not.toHaveCount(0);
|
||
await expect(page.locator('.hourDeparture')).toHaveCount(32);
|
||
await page.getByLabel('Дата поездки', { exact: true }).fill('2026-10-08');
|
||
await expect(page.locator('.timetableSummary')).toContainText('8 октября');
|
||
await expect(page.locator('.hourDeparture')).toHaveCount(32);
|
||
await page.getByRole('button', { name: 'Маршрут 4', exact: true }).click();
|
||
await expect(page.locator('.timetableSummary')).toContainText('8 рейсов');
|
||
await expect(page.locator('.hourDeparture')).toHaveCount(8);
|
||
await expect(page.locator('.hourRoute')).toHaveCount(0);
|
||
await page.getByRole('group', { name: 'Направления', exact: true }).getByRole('button', { name: 'В сторону центра', exact: true }).click();
|
||
await expect(page).toHaveURL(/stop=102/);
|
||
await expect(page).toHaveURL(/date=2026-10-08/);
|
||
await page.reload();
|
||
await expect(page.getByLabel('Дата поездки', { exact: true })).toHaveValue('2026-10-08');
|
||
await expect(page.getByRole('button', { name: 'На дату', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(page.locator('.timetableSummary')).toContainText('32 рейса');
|
||
await page.screenshot({ path: `test-results/timetable-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||
await page.getByRole('button', { name: 'Ближайшие', exact: true }).click();
|
||
await expect(page.locator('.timetableSummary')).toHaveCount(0);
|
||
await expect(page.locator('.departureRow')).toHaveCount(8);
|
||
});
|
||
|
||
test('saved full timetable works offline and does not substitute another date', async ({ page, context }) => {
|
||
await page.goto('/?demo=1&city=1&stop=101&view=day&date=2026-10-07');
|
||
await expect(page.locator('.timetableSummary')).toContainText('32 рейса');
|
||
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')).toBeVisible();
|
||
await expect(page.locator('.savedNotice')).toHaveCount(0);
|
||
await expect(page.locator('.hourDeparture')).toHaveCount(32);
|
||
await page.getByLabel('Дата поездки', { exact: true }).fill('2026-10-09');
|
||
await expect(page.getByRole('heading', { name: 'Нет сохранённого расписания', exact: true })).toBeVisible();
|
||
await expect(page.locator('.departureRow')).toHaveCount(0);
|
||
});
|
||
|
||
test('hours and minutes are consistent in departures and favorites, which expire offline', async ({ page, context }, info) => {
|
||
const time = Date.parse('2026-10-07T09:00:00Z');
|
||
await page.clock.install({ time: new Date(time) });
|
||
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' }]
|
||
: resource === 'stops' ? [{ id: 101, name: 'Вокзал', direction: 'В центр', routesCount: 1 }]
|
||
: resource === 'alerts' ? [] : { routes: [{ id: 1, number: '112С', name: 'В центр' }], partial: false, updatedAt: new Date(time).toISOString(), departures: [{ routeId: 1, routeNumber: '112С', direction: 'В центр', scheduledTime: '14:01', scheduledAt: new Date(time + 121 * 60000).toISOString() }] };
|
||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||
});
|
||
await page.goto('/?city=1&stop=101');
|
||
await expect(page.locator('.departureTime')).toContainText('через 2 ч 1 мин');
|
||
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
|
||
await page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация' }).getByRole('link', { name: /Избранное/ }).click();
|
||
await expect(page.locator('.favoriteDepartures')).toContainText('через 2 ч 1 мин');
|
||
await page.setViewportSize({ width: 320, height: 780 });
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||
await page.screenshot({ path: `test-results/favorites-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||
await context.setOffline(true);
|
||
await page.clock.fastForward('03:00:00');
|
||
await expect(page.locator('.favoriteDepartures li')).toHaveCount(0);
|
||
await expect(page.locator('.favoriteStatus')).toContainText('Расписание давно не обновлялось');
|
||
});
|
||
|
||
test('about page is accessible from the footer and fits a narrow screen', async ({ page }, info) => {
|
||
await page.setViewportSize({ width: 320, height: 780 });
|
||
const errors: string[] = [];
|
||
page.on('pageerror', error => errors.push(error.message));
|
||
await page.goto('/?demo=1');
|
||
await page.getByRole('contentinfo').getByRole('link', { name: 'О проекте', exact: true }).click();
|
||
await expect(page).toHaveURL('/about?demo=1');
|
||
await expect(page.getByRole('heading', { name: 'Привет, я Захар.', exact: true })).toBeVisible();
|
||
await expect(page.locator('.aboutSupport')).toContainText('хостинг, домен');
|
||
await expect(page.locator('.supportNote')).toContainText('Ссылку для поддержки добавлю чуть позже');
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||
await page.screenshot({ path: `test-results/about-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
|
||
await page.getByRole('link', { name: 'К расписанию', exact: true }).click();
|
||
await expect(page).toHaveURL('/?demo=1');
|
||
expect(errors).toEqual([]);
|
||
});
|