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:
@@ -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
118
e2e/discovery.spec.ts
Normal 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([]);
|
||||
});
|
||||
@@ -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
14
e2e/fixtures.ts
Normal 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';
|
||||
@@ -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
98
e2e/navigation.spec.ts
Normal 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('Расписание давно не обновлялось');
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user