Skip to content
Open
99 changes: 99 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 8 additions & 2 deletions src/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down
6 changes: 5 additions & 1 deletion src/shared/lib/yandex/suggest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { searchGeo } from '@/shared/lib/ymaps';
import { SUGGEST_MIN_QUERY_LENGTH } from '@/shared/config';

export interface SuggestResult {
Expand Down Expand Up @@ -44,6 +43,11 @@ export async function suggestAddresses(
): Promise<SuggestResult[]> {
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: улицы рядом с тем, что юзер видит на карте, идут первыми.
Expand Down
10 changes: 3 additions & 7 deletions src/widgets/map-canvas/index.ts
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions src/widgets/time-selector/ui/TimeSelectorChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function TimeSelectorChip({ onClick }: Props) {
return (
<button
type="button"
data-testid="time-selector-trigger"
onClick={onClick}
aria-label={ariaLabel}
className={
Expand Down
1 change: 1 addition & 0 deletions src/widgets/time-selector/ui/TimeSelectorPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function TimeSelectorPopover() {
<Popover.Trigger asChild>
<button
type="button"
data-testid="time-selector-trigger"
aria-label={t('time.aria', { label: display })}
className={
'hidden items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] font-medium shadow-sm ring-1 transition-colors active:scale-[0.98] lg:inline-flex ' +
Expand Down
96 changes: 96 additions & 0 deletions src/widgets/zone-card/ui/MobileZoneCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div>zone content</div>,
}));

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<YMap>();
mapRef.current = map;
render(
<MapRefContext.Provider value={mapRef}>
<MobileZoneCard onBackToResults={vi.fn()} />
</MapRefContext.Provider>,
);

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,
});
});
});
84 changes: 82 additions & 2 deletions src/widgets/zone-card/ui/MobileZoneCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +16,8 @@ interface MobileZoneCardProps {
onBackToResults?: () => void;
}

const MOBILE_ZONE_CARD_MIN_HEIGHT = 112;

export function MobileZoneCard({ onBackToResults }: MobileZoneCardProps) {
const { t } = useI18n();
useVisualViewportHeight();
Expand All @@ -36,10 +38,75 @@ export function MobileZoneCard({ onBackToResults }: MobileZoneCardProps) {
selectedZoneId !== null && resultZoneIds.includes(selectedZoneId) && !!onBackToResults;
const mapRefHolder = useContext(MapRefContext);
const contentRef = useRef<HTMLDivElement>(null);
const expandedHeight = useRef(0);
const pointerStartY = useRef<number | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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;
Expand All @@ -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),
Expand All @@ -79,6 +149,7 @@ export function MobileZoneCard({ onBackToResults }: MobileZoneCardProps) {
if (!open) handleClose();
}}
dismissible
handleOnly
modal={false}
noBodyStyles
disablePreventScroll
Expand All @@ -93,7 +164,16 @@ export function MobileZoneCard({ onBackToResults }: MobileZoneCardProps) {
style={{ maxHeight: 'calc(var(--keyboard-aware-height, 100dvh) - 80px)' }}
>
<Drawer.Title className="sr-only">{t('zone.card')}</Drawer.Title>
<div className="mx-auto my-2 h-1.5 w-12 shrink-0 rounded-full bg-zinc-300" aria-hidden />
<div
className="flex h-7 shrink-0 cursor-grab touch-none items-center justify-center select-none active:cursor-grabbing"
data-testid="mobile-zone-card-drag-region"
onPointerDown={beginDrag}
onPointerMove={continueDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<span className="h-1.5 w-12 rounded-full bg-zinc-300 dark:bg-zinc-600" aria-hidden />
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[15px]">
{selectedZoneId != null && (
<ZoneCardContent
Expand Down
Loading
Loading