- 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.
119 lines
7.8 KiB
TypeScript
119 lines
7.8 KiB
TypeScript
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([]);
|
|
});
|