Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ dist-ssr

.env

# Local Git worktrees
.worktrees/

# Playwright artefacts
playwright-report
test-results

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
2 changes: 2 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -27,6 +28,7 @@ function renderApp() {
<Routes>
<Route path="/" element={<MapPage />} />
<Route path="/map" element={<MapPage />} />
<Route path="/app" element={<AppPage />} />
</Routes>
</AppProviders>
</BrowserRouter>
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
22 changes: 22 additions & 0 deletions src/pages/app/AppPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AppPage />);

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',
);
});
});
69 changes: 69 additions & 0 deletions src/pages/app/AppPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<a
className="group flex w-full items-center gap-4 rounded-2xl border border-zinc-200 bg-white p-5 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-500 hover:shadow-md"
href={href}
rel="noreferrer"
target="_blank"
>
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700">
<Icon aria-hidden="true" className="size-6" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-lg font-semibold text-zinc-900">{title}</span>
<span className="mt-0.5 block text-sm text-zinc-600">{description}</span>
</span>
<ExternalLink aria-hidden="true" className="size-5 shrink-0 text-zinc-400 transition group-hover:text-emerald-600" />
</a>
);
}

export function AppPage() {
return (
<main className="flex min-h-dvh items-center justify-center bg-zinc-50 px-4 py-10 sm:px-6">
<section aria-labelledby="app-page-title" className="w-full max-w-lg">
<div className="mb-8 text-center">
<div className="mx-auto mb-5 flex size-16 items-center justify-center rounded-2xl bg-emerald-600 text-2xl font-bold text-white shadow-lg shadow-emerald-600/20">
P
</div>
<h1 id="app-page-title" className="text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl">
ParkTrack в вашем телефоне
</h1>
<p className="mx-auto mt-3 max-w-md text-base leading-6 text-zinc-600">
Выберите удобный способ установить или открыть мобильное приложение.
</p>
</div>

<div className="space-y-3">
<StoreLink
description="Установите приложение из официального магазина"
href="https://play.google.com/store/apps/details?id=com.parktrack.mobile"
icon={Play}
title="Google Play — Android"
/>
<StoreLink
description="Загрузите последнюю версию приложения напрямую"
href="https://github.com/ParkTrack-Project/mobile-app/releases/latest"
icon={Download}
title="Скачать APK — Android"
/>
<StoreLink
description="Откройте веб-приложение в Safari и добавьте на экран «Домой»"
href="https://m.parktrack.live"
icon={Apple}
title="Открыть PWA — iOS"
/>
</div>
</section>
</main>
);
}
1 change: 1 addition & 0 deletions src/pages/app/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AppPage } from './AppPage';
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,
});
});
});
Loading
Loading