From a29f780c037bbc3a9e5111a763cb55f2a5af9d2c Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Tue, 28 Jul 2026 21:46:54 +0300 Subject: [PATCH 01/12] feat: make parking zone cards height changeable on mobile --- .../zone-card/ui/MobileZoneCard.test.tsx | 96 +++++++++++++++++++ src/widgets/zone-card/ui/MobileZoneCard.tsx | 84 +++++++++++++++- 2 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/widgets/zone-card/ui/MobileZoneCard.test.tsx diff --git a/src/widgets/zone-card/ui/MobileZoneCard.test.tsx b/src/widgets/zone-card/ui/MobileZoneCard.test.tsx new file mode 100644 index 0000000..43c15b1 --- /dev/null +++ b/src/widgets/zone-card/ui/MobileZoneCard.test.tsx @@ -0,0 +1,96 @@ +import { createRef } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { Projection, YMap } from '@yandex/ymaps3-types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MapRefContext } from '@/widgets/map-canvas'; +import { mobileZoneMapCenter } from '../model/mobile-zone-center'; +import { MobileZoneCard } from './MobileZoneCard'; + +const zone = { + zone_id: 7, + is_active: true, + geometry: { + type: 'Polygon' as const, + coordinates: [ + [ + [30, 60], + [30, 60], + [30, 60], + [30, 60], + ], + ], + }, +}; + +vi.mock('@/features/select-zone', () => ({ + 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 && ( Date: Tue, 28 Jul 2026 22:07:40 +0300 Subject: [PATCH 02/12] tests: add tests workflow --- .github/workflows/tests.yml | 96 +++++++++++++++++++++++++++++++++++++ playwright.config.ts | 1 + 2 files changed, 97 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5c0bd78 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,96 @@ +name: Tests + +on: + push: + branches: + - '**' + +permissions: + contents: read + +env: + NODE_VERSION: '20' + +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/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', From 25ddf6006ecc60edf7d48ea8afc97a2473b2a502 Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Tue, 28 Jul 2026 23:45:58 +0300 Subject: [PATCH 03/12] tests: add VITE_YMAP_KEY --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5c0bd78..4b5d354 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,7 @@ permissions: env: NODE_VERSION: '20' + VITE_YMAP_KEY: test-yandex-maps-key jobs: unit-tests: From 843599a13aa2d2cd82b7516abf76723531e7b5f6 Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Tue, 28 Jul 2026 23:57:00 +0300 Subject: [PATCH 04/12] tests: Playwright e2e tests fix --- tests/e2e/time-selector.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/e2e/time-selector.spec.ts b/tests/e2e/time-selector.spec.ts index 3279708..001eb22 100644 --- a/tests/e2e/time-selector.spec.ts +++ b/tests/e2e/time-selector.spec.ts @@ -3,10 +3,8 @@ import { test, expect } from '@playwright/test'; test.describe('Phase 3 — TimeSelector URL serialization', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); - // Auth-ready ~500мс + TimeSelectorStrip mount - await expect(page.getByRole('toolbar', { name: 'Селектор времени' })).toBeVisible({ - timeout: 10_000, - }); + await page.getByRole('button', { name: /Время:/ }).click(); + await expect(page.getByTestId('time-selector-content')).toBeVisible({ timeout: 20_000 }); }); test('Прошлое → URL содержит ?t=past:ISO', async ({ page }) => { From ee754abab69313f6988419938973aa165d48bbe7 Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Wed, 29 Jul 2026 00:00:06 +0300 Subject: [PATCH 05/12] tests: accept created status for routing smoke --- tests/e2e/real-api.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'); From 6e6e6188fda0c5b9f62083ef8d5ee2fb423455be Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Wed, 29 Jul 2026 00:18:07 +0300 Subject: [PATCH 06/12] tests: stabilize time selector e2e --- .../time-selector/ui/TimeSelectorPopover.tsx | 1 + tests/e2e/time-selector.spec.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/widgets/time-selector/ui/TimeSelectorPopover.tsx b/src/widgets/time-selector/ui/TimeSelectorPopover.tsx index a009526..e7d31f4 100644 --- a/src/widgets/time-selector/ui/TimeSelectorPopover.tsx +++ b/src/widgets/time-selector/ui/TimeSelectorPopover.tsx @@ -23,6 +23,7 @@ export function TimeSelectorPopover() {