feat: add new components for departures, discovery, and navigation

- Implemented SCSS styles for compact board in departures.
- Created styles for feature discovery modal and its elements.
- Added navigation styles for desktop and mobile views.
- Developed FeatureDiscovery component with guided steps for user onboarding.
- Introduced freshness utility to manage data freshness based on timestamps.
- Added tests for freshness utility to ensure correct behavior across various scenarios.
This commit is contained in:
2026-10-07 21:51:05 +03:00
parent 81b2c7d675
commit 1492f179eb
35 changed files with 830 additions and 101 deletions

View File

@@ -1,9 +1,9 @@
import { test, expect } from '@playwright/test';
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('/?demo=1');
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);
@@ -43,8 +43,10 @@ test('stop search, route filter and city-scoped favorites survive reload', async
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('searchbox', { name: 'Ваша остановка' }).fill('Площадь');
await page.getByRole('button', { name: /Площадь Строителей/ }).click();
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([]);
@@ -58,7 +60,7 @@ test('every grouped stop is visible without an eighty-stop cap', async ({ page }
: 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('/');
await page.goto('/stops');
await expect(page.locator('.stopItem')).toHaveCount(115);
await expect(page.locator('.stopsFooter')).toContainText('115 остановок');
await page.locator('.stopItem').last().scrollIntoViewIfNeeded();
@@ -91,7 +93,8 @@ test('installed service worker serves shell, favorites and saved departures offl
await context.setOffline(true);
await page.reload();
await expect(page.locator('.offlineBanner')).toBeVisible();
await expect(page.locator('.savedNotice')).toContainText('Вы офлайн');
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();
@@ -105,7 +108,7 @@ test('API failure is explicit and does not silently substitute demo data', async
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.getByLabel('Остановки', { exact: true })).not.toContainText('на примере');
await expect(page.locator('.stopsList[aria-label="Остановки"]')).not.toContainText('на примере');
await page.goto('/?demo=1');
await expect(page.locator('.demoBanner')).toBeVisible();
});
@@ -117,7 +120,7 @@ test('a stale timetable loses expired departures across the date boundary', asyn
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();
await expect(page.getByText('Ближайшие отправления закончились.', { exact: false })).toBeVisible();
});
test('full timetable keeps past trips, switches date and direction, and restores its URL', async ({ page }, info) => {
@@ -156,7 +159,8 @@ test('saved full timetable works offline and does not substitute another date',
});
await context.setOffline(true);
await page.reload();
await expect(page.locator('.savedNotice')).toContainText('Вы офлайн');
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();
@@ -177,7 +181,7 @@ test('hours and minutes are consistent in departures and favorites, which expire
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('link', { name: 'В избранное', exact: true }).click();
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);
@@ -185,7 +189,7 @@ test('hours and minutes are consistent in departures and favorites, which expire
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('данные могут быть устаревшими');
await expect(page.locator('.favoriteStatus')).toContainText('Расписание давно не обновлялось');
});
test('about page is accessible from the footer and fits a narrow screen', async ({ page }, info) => {

118
e2e/discovery.spec.ts Normal file
View File

@@ -0,0 +1,118 @@
import { test, expect } from '@playwright/test';
test('first home visit teaches on the real page and dismissing is remembered', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto('/?demo=1&city=1&stop=101');
const guide = page.locator('.featureDiscovery');
await expect(guide).toBeVisible();
await expect(guide.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker');
await expect(page.locator('[data-discovery="stop-picker"]')).toBeVisible();
await expect(page.locator('#departures')).toBeVisible();
await guide.getByRole('button', { name: 'Пропустить', exact: true }).click();
await expect(guide).toHaveCount(0);
expect(await page.evaluate(() => localStorage.getItem('ridebus:discovery:v1'))).toBe('done');
await page.reload();
await expect(page.locator('#departures')).toBeVisible();
await expect(guide).toHaveCount(0);
expect(errors).toEqual([]);
});
test('all guide steps fit a narrow dark viewport, keep the target clear, and work from the keyboard', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 568 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addInitScript(() => localStorage.setItem('ridebus:theme', 'dark'));
await page.goto('/?demo=1&city=1&stop=101');
const guide = page.locator('.featureDiscovery');
const card = guide.locator('.discoveryCard');
const targets = ['stop-picker', 'departures', 'favorites', 'quick-trips', 'settings'];
for (const [index, target] of targets.entries()) {
await expect(card).toHaveAttribute('data-step', target);
await expect.poll(async () => page.evaluate(() => {
const hint = document.querySelector('.discoveryCard')!.getBoundingClientRect();
const light = document.querySelector('.discoverySpotlight')!.getBoundingClientRect();
const overlaps = hint.left < light.right && hint.right > light.left && hint.top < light.bottom && hint.bottom > light.top;
return { fits: hint.left >= 0 && hint.top >= 0 && hint.right <= innerWidth && hint.bottom <= innerHeight, overlaps };
})).toEqual({ fits: true, overlaps: false });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await expect(card).toHaveCSS('animation-name', 'none');
await expect(card.getByRole('button', { name: index === 4 ? 'Понятно' : 'Далее', exact: true })).toBeInViewport({ ratio: 1 });
await expect(card.getByRole('button', { name: 'Пропустить подсказки', exact: true })).toHaveCSS('min-width', '44px');
if (index === 0) {
await expect.poll(async () => card.evaluate(element => {
const bounds = element.getBoundingClientRect();
const copy = element.querySelector<HTMLElement>('.discoveryCopy')!;
const copyBounds = copy.getBoundingClientRect();
const description = copy.querySelector('p')!.getBoundingClientRect();
const actions = element.querySelector('.discoveryActions')!.getBoundingClientRect();
return copy.scrollHeight <= copy.clientHeight + 1 && description.bottom <= copyBounds.bottom + 1 && copyBounds.top >= bounds.top && copyBounds.bottom <= actions.top;
})).toBe(true);
await page.setViewportSize({ width: 320, height: 360 });
await expect(card.getByRole('button', { name: 'Далее', exact: true })).toBeInViewport({ ratio: 1 });
const body = card.locator('.discoveryCopy');
await expect.poll(async () => card.evaluate(element => {
const copy = element.querySelector<HTMLElement>('.discoveryCopy')!;
const overflowing = copy.scrollHeight > copy.clientHeight + 1;
return overflowing === Boolean(element.querySelector('.discoveryScrollHint'));
})).toBe(true);
expect(await body.evaluate(element => element.getBoundingClientRect().bottom <= element.closest('.discoveryCard')!.querySelector('.discoveryActions')!.getBoundingClientRect().top)).toBe(true);
const dimensions = await body.evaluate(element => ({ scroll: element.scrollHeight, visible: element.clientHeight }));
if (dimensions.scroll > dimensions.visible + 1) {
await expect(card.locator('.discoveryScrollHint')).toBeVisible();
await expect(body).toHaveAttribute('role', 'region');
await body.evaluate(element => { element.scrollTop = element.scrollHeight; });
expect(await body.evaluate(element => element.scrollTop)).toBeGreaterThan(0);
} else {
await expect(card.locator('.discoveryScrollHint')).toHaveCount(0);
expect(await body.evaluate(element => element.querySelector('p')!.getBoundingClientRect().bottom <= element.getBoundingClientRect().bottom + 1)).toBe(true);
}
await page.setViewportSize({ width: 320, height: 568 });
await expect(card.locator('.discoveryScrollHint')).toHaveCount(0);
}
if (index === 2) {
await page.keyboard.press('ArrowLeft');
await expect(card).toHaveAttribute('data-step', 'departures');
await page.keyboard.press('ArrowRight');
await expect(card).toHaveAttribute('data-step', target);
}
if (index === 0 || index === 4) await page.screenshot({ path: `test-results/discovery-${target}-${info.project.name}.png`, animations: 'disabled' });
await page.keyboard.press('ArrowRight');
}
await expect(guide).toHaveCount(0);
expect(await page.evaluate(() => localStorage.getItem('ridebus:discovery:v1'))).toBe('done');
});
test('guide can be replayed from settings and Escape dismisses it', async ({ page }, info) => {
await page.addInitScript(() => localStorage.setItem('ridebus:discovery:v1', 'done'));
await page.goto('/settings?demo=1');
await expect(page.locator('.featureDiscovery')).toHaveCount(0);
await page.getByRole('button', { name: 'Показать подсказки снова', exact: true }).click();
await expect(page.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker');
await page.keyboard.press('Escape');
await expect(page.locator('.featureDiscovery')).toHaveCount(0);
const nav = page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация' });
await nav.getByRole('link', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Показать подсказки снова', exact: true }).click();
await expect(page.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker');
});
test('blocked storage does not trap visitors in the guide or prevent a manual replay', async ({ page }, info) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(() => {
Storage.prototype.getItem = () => { throw new DOMException('Blocked', 'SecurityError'); };
Storage.prototype.setItem = () => { throw new DOMException('Blocked', 'SecurityError'); };
});
await page.goto('/?demo=1');
await expect(page.locator('.featureDiscovery')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.featureDiscovery')).toHaveCount(0);
const nav = page.getByRole('navigation', { name: info.project.name === 'mobile' ? 'Навигация на телефоне' : 'Основная навигация' });
await nav.getByRole('link', { name: 'Настройки', exact: true }).click();
await nav.getByRole('link', { name: 'Главная', exact: true }).click();
await expect(page.locator('.featureDiscovery')).toHaveCount(0);
await nav.getByRole('link', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Показать подсказки снова', exact: true }).click();
await expect(page.locator('.discoveryCard')).toHaveAttribute('data-step', 'stop-picker');
expect(errors).toEqual([]);
});

View File

@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
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: 'Вокзал → Больница' }];
@@ -48,7 +48,8 @@ test('saved journeys retain route/direction, allow rename/delete, and remain ava
await readyForOffline(page);
await context.setOffline(true); await page.reload();
await expect(page.locator('.quickTripHeading h3')).toHaveText('Домой');
await expect(page.locator('.tripSaved')).toContainText('могут быть устаревшими');
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);
@@ -82,7 +83,8 @@ test('hour grid jumps to next departure and opens a cached trip with stop-by-sto
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 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/);
@@ -117,10 +119,10 @@ test('nearby search requests location only on click, groups stops, and never per
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.goto('/stops?city=1');
await expect(page.locator('.stopItem')).toHaveCount(2);
expect(searches).toBe(0);
await page.getByRole('button', { name: 'Остановки рядом', exact: true }).click();
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();
@@ -136,6 +138,7 @@ test('nearby search requests location only on click, groups stops, and never per
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('combobox', { name: 'Ваш город' }).selectOption('2');
await expect(page.locator('.nearbyResults')).toHaveCount(0);
});
@@ -145,6 +148,7 @@ test('location denial leaves manual search usable', async ({ page }) => {
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('Больница');
@@ -164,7 +168,7 @@ test('targeted cancellation preserves details, filters by route, and expires off
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 expect(page.locator('.alertSaved')).toHaveCount(0);
await page.clock.fastForward(3600001);
await expect(page.locator('.serviceAlert')).toHaveCount(0);
await expect(page.locator('.alertsUnavailable')).toBeVisible();
@@ -189,7 +193,7 @@ test('storage upgrade preserves existing favorites while adding journeys', async
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 page.getByRole('navigation').getByRole('link', { name: /Избранное/ }).click();
await expect(page.locator('.favoriteCard')).toHaveCount(1);
await expect(page.locator('.quickTripCard')).toHaveCount(1);
});

14
e2e/fixtures.ts Normal file
View File

@@ -0,0 +1,14 @@
import { test as base } from '@playwright/test';
// Feature discovery has separate first-visit tests. Other tests use a returning
// visitor so a first-run overlay does not hide the interaction being verified.
export const test = base.extend({
page: async ({ page }, use) => {
await page.addInitScript(() => {
try { localStorage.setItem('ridebus:discovery:v1', 'done'); } catch {}
});
await use(page);
},
});
export { expect } from '@playwright/test';
export type { Page } from '@playwright/test';

View File

@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
async function seedTrips(page: Page, count: number) {
await page.evaluate(async count => {
@@ -97,7 +97,7 @@ test('opening a saved journey scrolls to the correct board across cities and fro
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)).toBeLessThan(350);
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();
@@ -182,19 +182,22 @@ test('blocked native sharing and clipboard offer a selectable manual link', asyn
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 nearby = page.getByLabel('Остановки рядом', { exact: true });
const all = page.getByLabel('Остановки', { exact: true });
await expect(nearby.locator('.stopItem')).toHaveCount(3);
await expect(nearby.locator('.activeStop')).toHaveText('Площадь Свободы120 м');
await nearby.getByRole('button', { name: /Больница/ }).click();
await expect(all.locator('.activeStop')).toHaveText('Больница');
await expect(nearby.locator('.activeStop')).toContainText('Больница');
await page.goto('/stops?demo=1&city=1&stop=101');
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 };
};
expect(await nearby.evaluate(styles)).toEqual(await all.evaluate(styles));
const normalStyles = await all.evaluate(styles);
await page.getByRole('button', { name: 'Рядом', exact: true }).click();
const nearby = page.getByLabel('Остановки рядом', { exact: true });
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(nearby.locator('.activeStop')).toContainText('Больница');
await page.getByRole('button', { name: 'Все остановки', exact: true }).click();
await expect(all.locator('.activeStop')).toHaveText('Больница');
await page.locator('.stopsCard').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' });
});

98
e2e/navigation.spec.ts Normal file
View File

@@ -0,0 +1,98 @@
import { test, expect, type Page } from './fixtures';
async function offlineReady(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('four sections fit phones and tablets and the home picker stays compact', async ({ page }) => {
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
await expect(page.locator('.compactPicker .stopItem')).toHaveCount(0);
for (const width of [320, 390, 760, 761, 820, 1050, 1120, 1440]) {
await page.setViewportSize({ width, height: 900 });
const nav = page.getByRole('navigation', { name: width <= 760 ? 'Навигация на телефоне' : 'Основная навигация' });
await expect(nav.getByRole('link')).toHaveCount(4);
for (const name of ['Главная', 'Остановки', 'Избранное', 'Настройки']) await expect(nav.getByRole('link', { name: new RegExp(`^${name}`) })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
expect((await page.locator('.compactPicker').boundingBox())!.height).toBeLessThan(300);
}
await page.setViewportSize({ width: 390, height: 844 });
const nav = page.getByRole('navigation', { name: 'Навигация на телефоне' });
for (const [name, path] of [['Остановки', '/stops'], ['Избранное', '/favorites'], ['Настройки', '/settings'], ['Главная', '/']]) {
await nav.getByRole('link', { name: new RegExp(`^${name}`) }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe(path);
await expect(nav.getByRole('link', { name: new RegExp(`^${name}`) })).toHaveAttribute('aria-current', 'page');
}
});
test('choosing a stop from the home dialog closes it and scrolls to departures on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
const original = HTMLElement.prototype.scrollIntoView;
(window as any).stopScrolls = [];
HTMLElement.prototype.scrollIntoView = function(options) {
if (this.id === 'departures') (window as any).stopScrolls.push(options);
original.call(this, options);
};
});
await page.goto('/?demo=1&city=1');
const trigger = page.getByRole('button', { name: 'Выбрать остановку', exact: true });
await trigger.click();
const dialog = page.getByRole('dialog', { name: 'Выбрать остановку', exact: true });
await expect(dialog).toBeVisible();
// Opening the chooser does not focus search or summon the phone keyboard.
await expect(dialog.getByRole('searchbox')).not.toBeFocused();
await dialog.getByRole('searchbox').fill('Площадь');
await dialog.locator('.stopItem').click();
await expect(dialog).toHaveCount(0);
await expect(page.locator('#board-title')).toHaveText('Площадь Свободы');
await expect(page.locator('#departures')).toBeFocused();
await expect.poll(() => page.evaluate(() => (window as any).stopScrolls.at(-1)?.behavior)).toBe('smooth');
await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(180);
await expect(trigger).toContainText('Площадь Свободы');
await page.emulateMedia({ reducedMotion: 'reduce' });
await trigger.click();
await dialog.getByRole('searchbox').fill('Площадь');
await dialog.locator('.stopItem').click();
await expect.poll(() => page.evaluate(() => (window as any).stopScrolls.length)).toBe(2);
await expect.poll(() => page.evaluate(() => (window as any).stopScrolls.at(-1)?.behavior)).toBe('auto');
});
test('catalog selection scrolls on a phone while retaining the current date', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 780 });
await page.goto('/stops?demo=1&city=1&view=day&date=2026-10-08');
await page.getByRole('searchbox').fill('Площадь');
await page.locator('.stopItem').click();
await expect(page.locator('#departures')).toBeFocused();
await expect(page).toHaveURL(/\/stops\?.*stop=101.*date=2026-10-08/);
await expect(page.getByLabel('Дата поездки', { exact: true })).toHaveValue('2026-10-08');
await expect(page.locator('.hourDeparture')).toHaveCount(32);
await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(180);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test('disconnecting now shows a single status and older departures receive a warning later', async ({ page, context }) => {
await page.clock.install({ time: new Date('2026-10-07T09:00:00Z') });
await page.goto('/?demo=1&city=1&stop=101');
await expect(page.locator('.departureRow')).toHaveCount(8);
await page.getByRole('button', { 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 offlineReady(page);
await context.setOffline(true);
await expect(page.locator('.offlineBanner')).toHaveCount(1);
await expect(page.locator('.savedNotice, .tripSaved, .alertSaved')).toHaveCount(0);
await expect(page.locator('main')).not.toContainText('устарев');
await page.reload();
await expect(page.locator('.departureRow')).toHaveCount(8);
await expect(page.locator('.savedNotice, .tripSaved, .alertSaved')).toHaveCount(0);
await page.clock.fastForward('00:31:00');
await expect(page.locator('.savedNotice')).toContainText('Расписание давно не обновлялось');
await expect(page.locator('.tripSaved')).toContainText('Расписание давно не обновлялось');
await page.getByRole('navigation').getByRole('link', { name: /Избранное/ }).click();
await expect(page.locator('.favoriteStatus')).toContainText('Расписание давно не обновлялось');
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
test('theme settings are reachable from navigation and preserve timetable and favorites', async ({ page }, info) => {
await page.emulateMedia({ colorScheme: 'light' });
@@ -30,7 +30,7 @@ test('theme settings are reachable from navigation and preserve timetable and fa
await expect(page.locator('.boardCard')).toHaveCSS('background-color', 'rgb(28, 35, 48)');
await expect(page.locator('.citySelect')).toHaveCSS('background-color', 'rgb(28, 35, 48)');
await page.screenshot({ path: `test-results/schedule-dark-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
await page.getByRole('link', { name: 'В избранное', exact: true }).click();
await page.getByRole('navigation', { name: 'Основная навигация' }).getByRole('link', { name: /Избранное/ }).click();
await expect(page.locator('.favoriteCard')).toHaveCount(1);
await expect(page.locator('.favoriteRoute').first()).toHaveCSS('background-color', 'rgb(28, 35, 48)');
});