diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..97efb08 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,99 @@ +name: Tests + +on: + push: + branches: + - '**' + +permissions: + contents: read + +env: + NODE_VERSION: '20' + # The app schema requires a non-empty key; map-focused E2E tests intercept + # the SDK request with a deterministic local stub. + VITE_YMAP_KEY: test-yandex-maps-key + +jobs: + unit-tests: + name: Unit and component tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run Vitest + run: npm test + + e2e-tests: + name: Playwright e2e tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright e2e + run: npm run test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + + real-api-tests: + name: Playwright real API smoke tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run Playwright real API smoke + run: npm run test:e2e:real-api + + - name: Upload Playwright real API report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-real-api-report + path: | + phase-05-uat/real-api-report/ + test-results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index d7d072b..a3d06e3 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,9 @@ dist-ssr .env +# Local Git worktrees +.worktrees/ + # Playwright artefacts playwright-report test-results - diff --git a/playwright.config.ts b/playwright.config.ts index 51bfcb7..c6aabc9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests/e2e', + testIgnore: 'real-api.spec.ts', fullyParallel: true, retries: process.env.CI ? 2 : 0, reporter: 'html', diff --git a/src/main.tsx b/src/main.tsx index 07ec4fd..6b510ed 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter, Routes, Route } from 'react-router'; import { AppProviders } from '@/app/providers'; +import { AppPage } from '@/pages/app'; import { MapPage } from '@/pages/map'; import '@/index.css'; @@ -27,6 +28,7 @@ function renderApp() { } /> } /> + } /> diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 425e21f..7b729c7 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,4 +1,4 @@ -import { http, HttpResponse } from 'msw'; +import { delay, http, HttpResponse } from 'msw'; import { env, MAX_PAST_DAYS, MAX_FUTURE_HOURS } from '@/shared/config'; import { generateMockUserProfile } from './generators/users'; import { generateMockAuthUser } from './generators/auth'; @@ -338,7 +338,13 @@ export const handlers = [ // hide_location_types) и применяет их через applyMockFilters после filterByBbox. // Это эмулирует server-side filter path D-12 — E2E тест видит реальное // изменение количества зон при переключении фильтров. - http.get(`${baseUrl}/zones`, ({ request }) => { + http.get(`${baseUrl}/zones`, async ({ request }) => { + // Keep the browser mock asynchronous enough for rapid viewport/filter + // changes to exercise React Query's AbortSignal cascade. An immediate + // in-memory response always wins the race and makes cancellation E2E tests + // measure mock speed instead of application behaviour. + await delay(250); + const url = new URL(request.url); const bboxRaw = url.searchParams.get('bbox'); const view = url.searchParams.get('view') ?? 'full'; diff --git a/src/pages/app/AppPage.test.tsx b/src/pages/app/AppPage.test.tsx new file mode 100644 index 0000000..b66b691 --- /dev/null +++ b/src/pages/app/AppPage.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { AppPage } from './AppPage'; + +describe('AppPage', () => { + it('offers Android and iOS installation options with their download links', () => { + render(); + + expect(screen.getByRole('link', { name: /google play/i })).toHaveAttribute( + 'href', + 'https://play.google.com/store/apps/details?id=com.parktrack.mobile', + ); + expect(screen.getByRole('link', { name: /скачать apk/i })).toHaveAttribute( + 'href', + 'https://github.com/ParkTrack-Project/mobile-app/releases/latest', + ); + expect(screen.getByRole('link', { name: /открыть pwa/i })).toHaveAttribute( + 'href', + 'https://m.parktrack.live', + ); + }); +}); diff --git a/src/pages/app/AppPage.tsx b/src/pages/app/AppPage.tsx new file mode 100644 index 0000000..3a00acd --- /dev/null +++ b/src/pages/app/AppPage.tsx @@ -0,0 +1,69 @@ +import { Apple, Download, ExternalLink, Play } from 'lucide-react'; + +type StoreLinkProps = { + description: string; + href: string; + icon: typeof Play; + title: string; +}; + +function StoreLink({ description, href, icon: Icon, title }: StoreLinkProps) { + return ( + + + + + + {title} + {description} + + + + ); +} + +export function AppPage() { + return ( + + + + + P + + + ParkTrack в вашем телефоне + + + Выберите удобный способ установить или открыть мобильное приложение. + + + + + + + + + + + ); +} diff --git a/src/pages/app/index.ts b/src/pages/app/index.ts new file mode 100644 index 0000000..477de08 --- /dev/null +++ b/src/pages/app/index.ts @@ -0,0 +1 @@ +export { AppPage } from './AppPage'; diff --git a/src/shared/lib/yandex/suggest.ts b/src/shared/lib/yandex/suggest.ts index 5822906..6ed0e0b 100644 --- a/src/shared/lib/yandex/suggest.ts +++ b/src/shared/lib/yandex/suggest.ts @@ -1,4 +1,3 @@ -import { searchGeo } from '@/shared/lib/ymaps'; import { SUGGEST_MIN_QUERY_LENGTH } from '@/shared/config'; export interface SuggestResult { @@ -44,6 +43,11 @@ export async function suggestAddresses( ): Promise { if (text.trim().length < SUGGEST_MIN_QUERY_LENGTH) return []; try { + // Loading the Yandex Maps runtime at module evaluation time blocks the + // whole application shell when the external SDK is unavailable. Address + // search is user-initiated, so load its runtime only when it is needed. + const { searchGeo } = await import('@/shared/lib/ymaps'); + // bbox = [west, south, east, north] (наш канонический формат) → bounds // [[swLon, swLat], [neLon, neLat]] для ymaps3.search. Передаём viewport // как bias: улицы рядом с тем, что юзер видит на карте, идут первыми. diff --git a/src/widgets/map-canvas/index.ts b/src/widgets/map-canvas/index.ts index 7b70749..57075bb 100644 --- a/src/widgets/map-canvas/index.ts +++ b/src/widgets/map-canvas/index.ts @@ -1,9 +1,5 @@ -export { MapCanvas } from './ui/MapCanvas'; -export { MapSkeleton } from './ui/MapSkeleton'; -export { ZoneLayer } from './ui/ZoneLayer'; -export { ParallelZoneLayer } from './ui/ParallelZoneLayer'; -export { ZoneBadgesLayer } from './ui/ZoneBadgesLayer'; -export { ZoneClusterLayer } from './ui/ZoneClusterLayer'; -export { ZoneStateOverlay } from './ui/ZoneStateOverlay'; +// Keep this public entry point free of map UI exports. MapCanvas loads the +// external Yandex Maps runtime with top-level await and must only be imported +// through the lazy boundaries in the page layouts. export { MapRefContext } from './model/map-ref-context'; export { useZoomToZone } from './model/useZoomToZone'; diff --git a/src/widgets/time-selector/ui/TimeSelectorChip.tsx b/src/widgets/time-selector/ui/TimeSelectorChip.tsx index 257b825..b50dd40 100644 --- a/src/widgets/time-selector/ui/TimeSelectorChip.tsx +++ b/src/widgets/time-selector/ui/TimeSelectorChip.tsx @@ -25,6 +25,7 @@ export function TimeSelectorChip({ onClick }: Props) { return ( ({ + useSelectedZone: () => ({ selectedZoneId: 7, closeCard: vi.fn() }), + useResultSelection: (selector: (state: { resultZoneIds: number[] }) => number[]) => + selector({ resultZoneIds: [7] }), +})); +vi.mock('@/features/select-time-mode', () => ({ + useTimeMode: () => ({ mode: { kind: 'now' } }), +})); +vi.mock('@/entities/zone', () => ({ + useZoneByIdQuery: () => ({ data: zone }), +})); +vi.mock('@/shared/lib/responsive', () => ({ useIsMobile: () => true })); +vi.mock('@/shared/lib/dom', () => ({ useVisualViewportHeight: () => 800 })); +vi.mock('@/shared/lib/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); +vi.mock('@/widgets/route-preview-summary', () => ({ + useRouteId: () => ({ clearRouteId: vi.fn() }), +})); +vi.mock('./ZoneCard', () => ({ + ZoneCardContent: () => zone content, +})); + +describe('MobileZoneCard', () => { + const setLocation = vi.fn(); + const projection = { + toWorldCoordinates: ([x, y]) => ({ x, y }), + fromWorldCoordinates: ({ x, y }) => [x, y], + } satisfies Projection; + const map = { + projection, + zoom: 0, + setLocation, + } as unknown as YMap; + + beforeEach(() => { + vi.useFakeTimers(); + setLocation.mockClear(); + HTMLElement.prototype.setPointerCapture = vi.fn(); + HTMLElement.prototype.hasPointerCapture = vi.fn(() => true); + HTMLElement.prototype.releasePointerCapture = vi.fn(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('lowers the card by its handle and keeps the selected zone centered above it', () => { + const mapRef = createRef(); + mapRef.current = map; + render( + + + , + ); + + const card = screen.getByTestId('mobile-zone-card'); + card.getBoundingClientRect = () => ({ height: 400 }) as DOMRect; + vi.advanceTimersByTime(320); + setLocation.mockClear(); + + const handle = screen.getByTestId('mobile-zone-card-drag-region'); + fireEvent.pointerDown(handle, { clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 200, pointerId: 1 }); + + expect(card).toHaveStyle({ height: '300px' }); + expect(setLocation).toHaveBeenLastCalledWith({ + center: mobileZoneMapCenter([30, 60], 0, 300, projection), + duration: 0, + }); + }); +}); diff --git a/src/widgets/zone-card/ui/MobileZoneCard.tsx b/src/widgets/zone-card/ui/MobileZoneCard.tsx index 37f8d14..62936f5 100644 --- a/src/widgets/zone-card/ui/MobileZoneCard.tsx +++ b/src/widgets/zone-card/ui/MobileZoneCard.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useRef } from 'react'; +import { useContext, useEffect, useRef, type PointerEvent as ReactPointerEvent } from 'react'; import { Drawer } from 'vaul'; import { useResultSelection, useSelectedZone } from '@/features/select-zone'; import { useTimeMode } from '@/features/select-time-mode'; @@ -16,6 +16,8 @@ interface MobileZoneCardProps { onBackToResults?: () => void; } +const MOBILE_ZONE_CARD_MIN_HEIGHT = 112; + export function MobileZoneCard({ onBackToResults }: MobileZoneCardProps) { const { t } = useI18n(); useVisualViewportHeight(); @@ -36,10 +38,75 @@ export function MobileZoneCard({ onBackToResults }: MobileZoneCardProps) { selectedZoneId !== null && resultZoneIds.includes(selectedZoneId) && !!onBackToResults; const mapRefHolder = useContext(MapRefContext); const contentRef = useRef(null); + const expandedHeight = useRef(0); + const pointerStartY = useRef(null); + const pointerStartHeight = useRef(0); + const latestDragHeight = useRef(0); const { mode } = useTimeMode(); const { data: zone } = useZoneByIdQuery(selectedZoneId, mode); + const centerZoneAboveCard = (sheetHeight: number, duration: number) => { + const map = mapRefHolder?.current; + if (!map || !zone || zone.is_active === false || !zone.geometry?.coordinates?.[0]?.length) + return; + + try { + map.setLocation({ + center: mobileZoneMapCenter( + zoneCentroid(zone.geometry), + map.zoom, + sheetHeight, + map.projection, + ), + duration, + }); + } catch (error) { + console.warn('[ptk] mobile pan failed:', error); + } + }; + + const setCardHeight = (height: number, duration = 0) => { + if (!contentRef.current) return; + contentRef.current.style.height = `${height}px`; + latestDragHeight.current = height; + document.documentElement.style.setProperty('--bottom-sheet-offset', `${height + 20}px`); + centerZoneAboveCard(height, duration); + }; + + const beginDrag = (event: ReactPointerEvent) => { + const height = contentRef.current?.getBoundingClientRect().height ?? 0; + if (height <= 0) return; + expandedHeight.current = Math.max(expandedHeight.current, height); + pointerStartY.current = event.clientY; + pointerStartHeight.current = height; + latestDragHeight.current = height; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const continueDrag = (event: ReactPointerEvent) => { + if (pointerStartY.current === null || expandedHeight.current <= 0) return; + const requestedHeight = pointerStartHeight.current - (event.clientY - pointerStartY.current); + const minHeight = Math.min(MOBILE_ZONE_CARD_MIN_HEIGHT, expandedHeight.current); + const nextHeight = Math.min(expandedHeight.current, Math.max(minHeight, requestedHeight)); + setCardHeight(nextHeight); + }; + + const endDrag = (event: ReactPointerEvent) => { + if (pointerStartY.current === null) return; + pointerStartY.current = null; + centerZoneAboveCard(latestDragHeight.current, 0); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + useEffect(() => { + expandedHeight.current = 0; + pointerStartY.current = null; + if (contentRef.current) contentRef.current.style.removeProperty('height'); + }, [selectedZoneId]); + useEffect(() => { if (!isOpen || !zone || !mapRefHolder?.current) return; if (zone.is_active === false) return; @@ -59,6 +126,9 @@ export function MobileZoneCard({ onBackToResults }: MobileZoneCardProps) { const map = mapRefHolder.current; const sheetHeight = contentRef.current?.getBoundingClientRect().height ?? 0; if (!map) return; + expandedHeight.current = Math.max(expandedHeight.current, sheetHeight); + latestDragHeight.current = sheetHeight; + document.documentElement.style.setProperty('--bottom-sheet-offset', `${sheetHeight + 20}px`); try { map.setLocation({ center: mobileZoneMapCenter(zoneCenter, map.zoom, sheetHeight, map.projection), @@ -79,6 +149,7 @@ export function MobileZoneCard({ onBackToResults }: MobileZoneCardProps) { if (!open) handleClose(); }} dismissible + handleOnly modal={false} noBodyStyles disablePreventScroll @@ -93,7 +164,16 @@ export function MobileZoneCard({ onBackToResults }: MobileZoneCardProps) { style={{ maxHeight: 'calc(var(--keyboard-aware-height, 100dvh) - 80px)' }} > {t('zone.card')} - + + + {selectedZoneId != null && ( = [ { name: 'main-map', url: '/map' }, @@ -11,6 +12,7 @@ const flows: Array<{ name: string; url: string }> = [ test.describe('A11Y axe-core scan (D-25)', () => { for (const { name, url } of flows) { test(`${name}: critical violations === 0`, async ({ page }) => { + await mockYandexMaps(page); await page.goto(url); await page.waitForLoadState('networkidle'); diff --git a/tests/e2e/atomic-state.spec.ts b/tests/e2e/atomic-state.spec.ts index 16c0269..bf2c6b9 100644 --- a/tests/e2e/atomic-state.spec.ts +++ b/tests/e2e/atomic-state.spec.ts @@ -1,12 +1,22 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Request } from '@playwright/test'; +import { mockYandexMaps } from './support/mock-yandex-maps'; + +test.use({ viewport: { width: 1280, height: 720 }, locale: 'ru-RU' }); + +const isZonesListRequest = (url: string) => /\/zones(?:\?|$)/.test(url); test.describe('Atomic state transitions (D-35 NFR-08)', () => { test('parallel filter+time+zone change → no intermediate flash', async ({ page }) => { - await page.goto('/map'); - await page.waitForLoadState('networkidle'); + await mockYandexMaps(page); + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); - // Wait for initial zones rendered - await expect(page.locator('[class*="ymaps3"]').first()).toBeVisible({ timeout: 10_000 }); + const initialZones = page.waitForResponse( + (response) => isZonesListRequest(response.url()) && response.ok(), + { timeout: 30_000 }, + ); + await page.goto('/map'); + await initialZones; // Trigger 3 state changes near-simultaneously via URL state const url = new URL(page.url()); @@ -17,60 +27,77 @@ test.describe('Atomic state transitions (D-35 NFR-08)', () => { // Race: navigation + observe overlay appearance await page.goto(url.toString()); - // ModeTransitionOverlay should appear during transition - // Per Phase 3 D-08 + Phase 4 expansion: overlay subscribes to useIsFetching - // Either appears briefly (preferred) OR is gated below 200ms threshold - // Acceptance: page reaches stable state without runtime errors - const errors: string[] = []; - page.on('pageerror', (err) => errors.push(err.message)); - await page.waitForLoadState('networkidle', { timeout: 15_000 }); + await expect(page.locator('#root > *').first()).toBeAttached(); expect(errors, 'no runtime errors during atomic transition').toEqual([]); }); test('rapid filter toggle → AbortController cascades, only final requests complete', async ({ page, }) => { + await mockYandexMaps(page); + const initialZones = page.waitForResponse( + (response) => isZonesListRequest(response.url()) && response.ok(), + { timeout: 30_000 }, + ); await page.goto('/map'); - await page.waitForLoadState('networkidle'); + await initialZones; + + const filterTrigger = page.getByRole('button', { name: /Открыть фильтры/ }); + await expect(filterTrigger).toBeVisible({ timeout: 20_000 }); + await filterTrigger.click(); + const onlyFree = page.getByRole('checkbox', { name: 'Только свободные' }); + await expect(onlyFree).toBeVisible(); // Track all /zones requests AND their completion status - const requests: Array<{ url: string; aborted: boolean; completed: boolean }> = []; + const requests = new Map(); page.on('request', (req) => { - if (req.url().includes('/zones')) { - const entry = { url: req.url(), aborted: false, completed: false }; - requests.push(entry); - req - .response() - .then(() => { - entry.completed = true; - }) - .catch(() => { - entry.aborted = true; - }); + if (isZonesListRequest(req.url())) { + requests.set(req, { url: req.url(), aborted: false, completed: false }); } }); + page.on('requestfinished', (req) => { + const entry = requests.get(req); + if (entry) entry.completed = true; + }); + page.on('requestfailed', (req) => { + const entry = requests.get(req); + if (entry) entry.aborted = true; + }); - // Toggle filter 5 times rapidly via URL state - for (let i = 0; i < 5; i++) { - const url = new URL(page.url()); - url.searchParams.set('fNoFree', i % 2 === 0 ? 'true' : 'false'); - await page.goto(url.toString()); - // No wait — race - } + const finalZones = page.waitForResponse( + (response) => + isZonesListRequest(response.url()) && + new URL(response.url()).searchParams.get('min_free_count') === '1' && + response.ok(), + { timeout: 10_000 }, + ); - await page.waitForLoadState('networkidle', { timeout: 10_000 }); + // Toggle inside one mounted application. The mock /zones endpoint has a + // small realistic delay, so superseded React Query requests remain in + // flight long enough for AbortSignal cancellation to be observable. + await onlyFree.evaluate(async (element) => { + const input = element as HTMLInputElement; + for (let i = 0; i < 5; i += 1) { + input.click(); + await new Promise((resolve) => window.setTimeout(resolve, 25)); + } + }); - // I-2 fix: tightened heuristic. - // After 5 rapid toggles, AbortController should cancel earlier requests; - // only the LAST request per query-key should complete. - // Expected: ≤ 2 completed (final /zones list + possibly /zones/ for selected zone). - // If completed > 2 → AbortController is missing on filter changes → REGRESSION (NFR-08). - const completedRequests = requests.filter((r) => r.completed && !r.aborted); + const finalResponse = await finalZones; + await finalResponse.finished(); + await expect(page).toHaveURL(/fNoFree=true/); + + const requestEntries = [...requests.values()]; + expect( + requestEntries.length, + 'rapid toggles should start multiple /zones list requests', + ).toBeGreaterThan(1); + const completedRequests = requestEntries.filter((r) => r.completed && !r.aborted); expect( completedRequests.length, - `Expected ≤2 completed /zones requests after 5 rapid toggles (final list + final detail). Got ${completedRequests.length}. AbortController may be missing or misconfigured. Heuristic rationale: 5 toggles × 1 zones query + 1 settle slack = ≤6 raw; with abort cascade = ≤2 completed.`, - ).toBeLessThanOrEqual(2); + `Expected only the final /zones list request to complete; got ${completedRequests.length} of ${requestEntries.length}.`, + ).toBeLessThanOrEqual(1); }); }); diff --git a/tests/e2e/filters.spec.ts b/tests/e2e/filters.spec.ts index f6b89bd..e189191 100644 --- a/tests/e2e/filters.spec.ts +++ b/tests/e2e/filters.spec.ts @@ -1,61 +1,57 @@ import { test, expect } from '@playwright/test'; +test.use({ viewport: { width: 1280, height: 720 }, locale: 'ru-RU' }); + test.describe('Phase 2 filters — URL serialization (FILTER-12)', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); - // ждём mount FiltersToolbar - await expect(page.getByRole('toolbar', { name: 'Фильтры парковок' })).toBeVisible({ - timeout: 10_000, - }); + const trigger = page.getByRole('button', { name: /Открыть фильтры/ }); + await expect(trigger).toBeVisible({ timeout: 20_000 }); + await trigger.click(); + await expect(page.getByRole('heading', { name: 'Фильтры парковок' })).toBeVisible(); }); test('hideNoFree → ?fNoFree=true в URL (FILTER-01)', async ({ page }) => { - await page.getByRole('button', { name: /Только свободные/ }).click(); + await page.getByRole('checkbox', { name: 'Только свободные' }).check(); await expect(page).toHaveURL(/fNoFree=true/); }); test('hidePrivate → ?fNoPriv=true в URL (FILTER-04)', async ({ page }) => { - await page.getByRole('button', { name: /Без частных/ }).click(); + await page.getByRole('checkbox', { name: 'Скрыть частные' }).check(); await expect(page).toHaveURL(/fNoPriv=true/); }); test('hideAccessible → ?fNoAcc=true в URL (FILTER-05)', async ({ page }) => { - await page.getByRole('button', { name: /Без для инвалидов/ }).click(); + await page.getByRole('checkbox', { name: 'Инвалидные парковки' }).check(); await expect(page).toHaveURL(/fNoAcc=true/); }); test('hideInactive (default true) → toggle off → ?fInactive=false (FILTER-07)', async ({ page, }) => { - await page.getByRole('button', { name: /Скрыть неактивные/ }).click(); + await page.getByRole('checkbox', { name: 'Скрыть неактивные' }).uncheck(); await expect(page).toHaveURL(/fInactive=false/); }); test('locationType chip in popover → ?fLoc=street (FILTER-06)', async ({ page }) => { - // Sub-step: открыть popover (chip-trigger «Тип: все») → внутри отметить чек-бокс «Улица» - await page.getByRole('button', { name: /Тип расположения парковки/ }).click(); await page.getByRole('checkbox', { name: 'Улица' }).check(); await expect(page).toHaveURL(/fLoc=street/); }); test('minConf slider в popover → ?fMinConf=... в URL (FILTER-02)', async ({ page }) => { - // Sub-step: открыть popover «Уверенность ≥ 0%» → взаимодействовать со slider'ом - await page.getByRole('button', { name: /Минимальная уверенность данных/ }).click(); - // .nth(1): aria-label дублируется на trigger'е и на range-input'е внутри popover - const slider = page.getByLabel('Минимальная уверенность данных').nth(1); + const slider = page.getByRole('slider', { name: 'Минимальная уверенность данных' }); await slider.fill('0.5'); await expect(page).toHaveURL(/fMinConf=0\.5/); }); test('maxPay slider в popover → ?fMaxPay=... в URL (FILTER-03)', async ({ page }) => { - await page.getByRole('button', { name: /Максимальная цена в час/ }).click(); - const slider = page.getByLabel('Максимальная цена в час').nth(1); + const slider = page.getByRole('slider', { name: 'Максимальная цена в час' }); await slider.fill('200'); await expect(page).toHaveURL(/fMaxPay=200/); }); test('Сброс — кнопка появляется и очищает URL', async ({ page }) => { - await page.getByRole('button', { name: /Только свободные/ }).click(); + await page.getByRole('checkbox', { name: 'Только свободные' }).check(); await expect(page).toHaveURL(/fNoFree/); await page.getByRole('button', { name: /^Сбросить$/ }).click(); await expect(page).not.toHaveURL(/fNoFree/); @@ -68,11 +64,12 @@ test.describe('Phase 2 filters — URL serialization (FILTER-12)', () => { await expect(page).not.toHaveURL(/fNoFree/); // Toggle ON - await page.getByRole('button', { name: /Только свободные/i }).click(); + const onlyFree = page.getByRole('checkbox', { name: 'Только свободные' }); + await onlyFree.check(); await expect(page).toHaveURL(/fNoFree=true/); // Toggle OFF — должен удалить параметр (clearOnDefault через nuqs) - await page.getByRole('button', { name: /Только свободные/i }).click(); + await onlyFree.uncheck(); await expect(page).not.toHaveURL(/fNoFree/); }); }); diff --git a/tests/e2e/map.spec.ts b/tests/e2e/map.spec.ts index 21d7c87..c1a3e31 100644 --- a/tests/e2e/map.spec.ts +++ b/tests/e2e/map.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from '@playwright/test'; +import { mockYandexMaps } from './support/mock-yandex-maps'; test('карта монтируется и показывает зоны (badges visible at zoom >= 14)', async ({ page }) => { + await mockYandexMaps(page); await page.goto('/'); // MapPage → MapCanvas → ZoneLayer (после первого ответа /zones) + ZoneBadgesLayer. // Таймаут с запасом под загрузку ymaps3-CDN на медленных машинах. @@ -11,6 +13,8 @@ test('карта монтируется и показывает зоны (badges test('MAP-05: непрерывный пан 5с → не более 3 запросов /zones (debounce + AbortSignal)', async ({ page, }) => { + test.setTimeout(45_000); + await mockYandexMaps(page); const zonesRequests: string[] = []; page.on('request', (req) => { const url = req.url(); diff --git a/tests/e2e/real-api.spec.ts b/tests/e2e/real-api.spec.ts index 7d3e55a..00215f0 100644 --- a/tests/e2e/real-api.spec.ts +++ b/tests/e2e/real-api.spec.ts @@ -101,7 +101,7 @@ test.describe('Real API smoke (D-16)', () => { provider: 'yandex', }, }); - expect(r.status(), `POST /routing/new returned ${r.status()}`).toBe(200); + expect([200, 201], `POST /routing/new returned ${r.status()}`).toContain(r.status()); const data = await r.json(); // Per routing.mdx §8.5 Route model — `selected_candidate` is required. expect(data).toHaveProperty('selected_candidate'); diff --git a/tests/e2e/support/mock-yandex-maps.ts b/tests/e2e/support/mock-yandex-maps.ts new file mode 100644 index 0000000..844d938 --- /dev/null +++ b/tests/e2e/support/mock-yandex-maps.ts @@ -0,0 +1,104 @@ +import type { Page } from '@playwright/test'; + +const YANDEX_MAPS_STUB = String.raw` +(() => { + const controlsModule = { __parktrackModule: 'controls' }; + + function createReactify(React) { + const Empty = () => null; + const Container = ({ children }) => React.createElement(React.Fragment, null, children); + const Marker = ({ children }) => + React.createElement('div', { className: 'ymaps3-stub-marker' }, children); + const Control = ({ children }) => React.createElement('button', { type: 'button' }, children); + const Map = React.forwardRef(function YandexMapStub({ children, location }, ref) { + const locationRef = React.useRef(location); + locationRef.current = location; + + React.useImperativeHandle( + ref, + () => ({ + update(next) { + locationRef.current = { ...locationRef.current, ...next }; + }, + setLocation(next) { + locationRef.current = { ...locationRef.current, ...next }; + }, + get zoom() { + return locationRef.current?.zoom ?? 15; + }, + zoomRange: { min: 0, max: 21 }, + }), + [], + ); + + return React.createElement( + 'div', + { + className: 'ymaps3-stub', + 'data-testid': 'yandex-map-stub', + style: { position: 'absolute', inset: 0 }, + }, + children, + ); + }); + + const core = { + YMap: Map, + YMapDefaultSchemeLayer: Empty, + YMapDefaultFeaturesLayer: Empty, + YMapFeature: Empty, + YMapMarker: Marker, + YMapListener: Empty, + YMapFeatureDataSource: Container, + YMapLayer: Empty, + YMapControls: Container, + YMapControlButton: Control, + }; + const controls = { + YMapZoomControl: Empty, + YMapGeolocationControl: Empty, + YMapRotateTiltControl: Empty, + }; + + return { + module(value) { + return value?.__parktrackModule === 'controls' ? controls : core; + }, + useDefault(value) { + return value; + }, + }; + } + + const importPackage = async (packageName) => { + if (packageName === '@yandex/ymaps3-reactify') { + return { + reactify: { + bindTo(React) { + return createReactify(React); + }, + }, + }; + } + if (packageName === '@yandex/ymaps3-default-ui-theme') return controlsModule; + return {}; + }; + importPackage.registerCdn = () => {}; + + window.ymaps3 = { + __parktrackModule: 'core', + ready: Promise.resolve(), + import: importPackage, + search: async () => [], + }; +})(); +`; + +export async function mockYandexMaps(page: Page): Promise { + // The app starts MSW before rendering. A service worker handles requests + // before Playwright's page.route(), so routing the SDK URL here would let an + // unhandled MSW request reach Yandex with the fake CI key. Install the shim + // before any application script instead; loadYmaps() then sees ymaps3 and + // never creates the external SDK script element. + await page.addInitScript({ content: YANDEX_MAPS_STUB }); +} diff --git a/tests/e2e/time-selector.spec.ts b/tests/e2e/time-selector.spec.ts index 3279708..a28f93c 100644 --- a/tests/e2e/time-selector.spec.ts +++ b/tests/e2e/time-selector.spec.ts @@ -1,57 +1,118 @@ import { test, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +test.use({ viewport: { width: 1280, height: 720 } }); +test.setTimeout(60_000); + +const returnToNowButtonName = /Вернуться к Сейчас|Return to Now/i; +const hourAgoButtonName = /Час назад|An hour ago/i; +const inAnHourButtonName = /Через час|In an hour/i; + +function timeParam(page: Page): string | null { + return new URL(page.url()).searchParams.get('t'); +} + +async function openTimeSelector(page: Page) { + try { + await page.waitForSelector('#root > *', { state: 'attached', timeout: 30_000 }); + } catch (error) { + const title = await page.title().catch(() => ''); + const rootHtml = await page + .locator('#root') + .evaluate((root) => root.innerHTML.slice(0, 2000)) + .catch(() => ''); + const pageContent = await page + .content() + .then((content) => content.slice(0, 2000)) + .catch(() => ''); + throw new Error( + [ + 'Application did not render inside #root.', + `url=${page.url()}`, + `title=${title}`, + `rootHtml=${rootHtml}`, + `pageContentStart=${pageContent}`, + error instanceof Error ? error.message : String(error), + ].join('\n'), + ); + } + + const timeButton = page + .getByTestId('time-selector-trigger') + .or(page.getByRole('button', { name: /Время|Time|Сейчас|Now/i })) + .first(); + + try { + await expect(timeButton).toBeVisible({ timeout: 30_000 }); + } catch (error) { + const title = await page.title().catch(() => ''); + const rootHtml = await page + .locator('#root') + .evaluate((root) => root.innerHTML.slice(0, 2000)) + .catch(() => ''); + throw new Error( + [ + 'Time selector trigger did not render.', + `url=${page.url()}`, + `title=${title}`, + `rootHtml=${rootHtml}`, + error instanceof Error ? error.message : String(error), + ].join('\n'), + ); + } + + await timeButton.click(); + await expect(page.getByTestId('time-selector-content')).toBeVisible({ timeout: 20_000 }); +} test.describe('Phase 3 — TimeSelector URL serialization', () => { test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); await page.goto('/'); - // Auth-ready ~500мс + TimeSelectorStrip mount - await expect(page.getByRole('toolbar', { name: 'Селектор времени' })).toBeVisible({ - timeout: 10_000, - }); + await openTimeSelector(page); }); - test('Прошлое → URL содержит ?t=past:ISO', async ({ page }) => { - await page.getByRole('button', { name: 'Прошлое' }).click(); - await expect(page).toHaveURL(/[?&]t=past%3A/); + test('Прошлое → URL содержит ISO в прошлом', async ({ page }) => { + await page.getByRole('button', { name: hourAgoButtonName }).click(); + await expect.poll(() => timeParam(page)).toMatch(/^\d{4}-\d{2}-\d{2}T.+Z$/); + expect(Date.parse(timeParam(page)!)).toBeLessThan(Date.now()); }); - test('Будущее → URL содержит ?t=future:ISO', async ({ page }) => { - await page.getByRole('button', { name: 'Будущее' }).click(); - await expect(page).toHaveURL(/[?&]t=future%3A/); + test('Будущее → URL содержит ISO в будущем', async ({ page }) => { + await page.getByRole('button', { name: inAnHourButtonName }).click(); + await expect.poll(() => timeParam(page)).toMatch(/^\d{4}-\d{2}-\d{2}T.+Z$/); + expect(Date.parse(timeParam(page)!)).toBeGreaterThan(Date.now()); }); test('Сейчас (default) → URL не содержит ?t= (clearOnDefault)', async ({ page }) => { - await page.getByRole('button', { name: 'Прошлое' }).click(); - await expect(page).toHaveURL(/[?&]t=past/); - await page.getByRole('button', { name: 'Сейчас' }).click(); - await expect(page).not.toHaveURL(/[?&]t=/); + await page.getByRole('button', { name: hourAgoButtonName }).click(); + await expect.poll(() => timeParam(page)).not.toBeNull(); + await page.getByRole('button', { name: returnToNowButtonName }).click(); + await expect.poll(() => timeParam(page)).toBeNull(); }); test('Reset CTA «Вернуться к Сейчас» очищает URL', async ({ page }) => { - await page.getByRole('button', { name: 'Прошлое' }).click(); - await expect(page).toHaveURL(/[?&]t=past/); - await page - .getByRole('button', { name: /Вернуться к Сейчас/ }) - .first() - .click(); - await expect(page).not.toHaveURL(/[?&]t=/); + await page.getByRole('button', { name: inAnHourButtonName }).click(); + await expect.poll(() => timeParam(page)).not.toBeNull(); + await page.getByRole('button', { name: returnToNowButtonName }).first().click(); + await expect.poll(() => timeParam(page)).toBeNull(); }); test('Preset «Час назад» → URL обновлён', async ({ page }) => { - await page.getByRole('button', { name: 'Прошлое' }).click(); - await expect(page).toHaveURL(/[?&]t=past%3A/); const before = page.url(); - await page.getByRole('button', { name: 'Час назад' }).click(); + await page.getByRole('button', { name: hourAgoButtonName }).click(); // URL должен поменяться (новый ISO timestamp) await expect.poll(() => page.url(), { timeout: 2000 }).not.toBe(before); - await expect(page).toHaveURL(/[?&]t=past%3A/); + await expect.poll(() => timeParam(page)).toMatch(/^\d{4}-\d{2}-\d{2}T.+Z$/); }); - test('Deeplink ?t=past:ISO → segment «Прошлое» pressed при загрузке', async ({ page }) => { + test('Deeplink ?t=past:ISO → прошлый режим восстановлен при загрузке', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); await page.goto('/?t=past:2026-04-22T09:00:00.000Z'); - await expect(page.getByRole('button', { name: 'Прошлое' })).toHaveAttribute( - 'aria-pressed', - 'true', - { timeout: 10_000 }, - ); + await openTimeSelector(page); + await expect(page.getByRole('button', { name: returnToNowButtonName })).toBeVisible({ + timeout: 10_000, + }); + expect(timeParam(page)).toBe('past:2026-04-22T09:00:00.000Z'); }); });
+ Выберите удобный способ установить или открыть мобильное приложение. +