From bd7903e191f389a2d9ad9132acb651bbdcd258d4 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 11:10:50 +0000 Subject: [PATCH 1/2] analyst(ET): auto-commit from analyst run_id=34 --- docs/work-items/ET-008/00-business-request.md | 51 ++ docs/work-items/ET-008/01-brd.md | 98 ++++ docs/work-items/ET-008/02-trz.md | 473 ++++++++++++++++++ .../ET-008/03-acceptance-criteria.md | 275 ++++++++++ docs/work-items/ET-008/04-test-plan.yaml | 424 ++++++++++++++++ docs/work-items/ET-008/04b-ui-test-cases.md | 395 +++++++++++++++ 6 files changed, 1716 insertions(+) create mode 100644 docs/work-items/ET-008/00-business-request.md create mode 100644 docs/work-items/ET-008/01-brd.md create mode 100644 docs/work-items/ET-008/02-trz.md create mode 100644 docs/work-items/ET-008/03-acceptance-criteria.md create mode 100644 docs/work-items/ET-008/04-test-plan.yaml create mode 100644 docs/work-items/ET-008/04b-ui-test-cases.md diff --git a/docs/work-items/ET-008/00-business-request.md b/docs/work-items/ET-008/00-business-request.md new file mode 100644 index 0000000..713e54d --- /dev/null +++ b/docs/work-items/ET-008/00-business-request.md @@ -0,0 +1,51 @@ +--- +type: business-request +work_item_id: ET-008 +title: "GPS-треки с публичных платформ на карте" +created_at: 2026-06-01 +source: plane +requester: Слава +--- + +# Бизнес-запрос — ET-008 + +## Исходная формулировка + +> Хочу видеть на карте GPS-треки с публичных платформ (OSM, чужие ссылки +> на GPX), а не только локальные файлы. Минимум: вставить ссылку на +> GPX-файл — увидеть трек. Дальше — поиск чужих публичных треков в +> видимой области карты, чтобы перед поездкой посмотреть, кто и где ездил. + +## Контекст и ограничения + +1. ET-006 уже даёт инфраструктуру отображения GPX-треков (модель, + рендеринг, sheet, профиль высот). Эту инфраструктуру переиспользуем. +2. В стеке нет авторизации пользователей и БД с user accounts — + платформы с обязательным OAuth (Strava, Komoot) **вне scope MVP**. +3. Платный API Wikiloc — **вне scope MVP**. +4. CORS не позволяет браузеру тянуть GPX напрямую с большинства + платформ — нужен прокси через FastAPI. +5. Rate limits публичных API (OSM, Overpass) — нужен server-side кэш. + +## Решения аналитика (по умолчанию, при отсутствии явных уточнений) + +| Вопрос | Решение | Обоснование | +|--------|---------|-------------| +| Платформы MVP | OSM Public GPS Traces + универсальный GPX-по-URL | Открытые API без авторизации, бесплатные, покрывают сценарии «свой трек по ссылке» и «чужие треки рядом» | +| Сценарии | (1) импорт по URL; (2) bbox-поиск треков в видимой области | Минимальный полезный набор, не требующий новых разделов UI | +| Хранение | Сессия (как ET-006) + server-side LRU-кэш на бэкенде | Не вводим БД и аккаунты; кэш защищает от rate limits | +| Auth | Нет | Все запросы — публичные данные | +| Платформы post-MVP | Wikiloc API, Strava OAuth, Komoot OAuth | Будут отдельными work item, когда появится система аккаунтов | + +## Уточнения + +1. URL-импорт должен работать с любой прямой ссылкой на `.gpx`-файл + (GitHub raw, gist, личный сайт, веб-сервер пользователя). +2. Поиск по OSM-трекам ограничен видимой областью карты (bbox). + Глобальный поиск не требуется. +3. Загруженные с публичных платформ треки попадают в тот же sheet + `#sheet-gpx`, что и локальные GPX, и ведут себя идентично (статистика, + профиль высот, удаление, fit bounds, переживание смены стиля). +4. Источник трека (URL / OSM trace id) сохраняется в модели и + отображается в карточке трека для пользователя. +5. Кэш на сервере — TTL 24 часа, не персистентный (in-memory). diff --git a/docs/work-items/ET-008/01-brd.md b/docs/work-items/ET-008/01-brd.md new file mode 100644 index 0000000..821f8bd --- /dev/null +++ b/docs/work-items/ET-008/01-brd.md @@ -0,0 +1,98 @@ +--- +type: brd +work_item_id: ET-008 +title: "BRD: GPS-треки с публичных платформ на карте" +version: 1 +status: draft +created_at: 2026-06-01 +updated_at: 2026-06-01 +authors: + - "agent:analyst" +--- + +# BRD — ET-008: GPS-треки с публичных платформ на карте + +## 1. Цель + +Дать пользователю возможность увидеть на карте Enduro Trails GPS-треки +с публичных источников без скачивания файлов вручную: либо вставив +прямую ссылку на GPX, либо найдя чужие публичные треки в видимой +области карты (через OSM Public GPS Traces). + +## 2. Контекст + +- ET-006 реализовал клиентский GPX-стек: парсер, модель + `window.gpxTracks`, sheet `#sheet-gpx`, статистика, профиль высот, + переживание `map.setStyle()` через `rebuildGpxOverlays()`. Источник + данных — только локальный файл пользователя. +- Roadmap-фаза PH-3 «Smart Route» включает работу с GPX (импорт/экспорт). +- В стеке нет пользовательских аккаунтов и БД пользователей. Платформы с + обязательным OAuth (Strava, Komoot) поэтому вне scope текущей итерации. +- Браузер не может тянуть GPX напрямую с большинства публичных платформ + из-за CORS. OSM API не разрешает кросс-доменные запросы → прокси + через FastAPI обязателен. +- OSM Public GPS Traces — открытый бесплатный источник публичных + GPS-треков, формат GPX, есть bbox-поиск, нет авторизации для чтения. + +## 3. Scope + +### In scope + +| # | Функция | +|------|---------| +| F-01 | Поле ввода URL прямой ссылки на GPX в `#sheet-gpx` | +| F-02 | Импорт GPX по URL через прокси-эндпоинт `/api/gpx/fetch` | +| F-03 | Кнопка «Найти публичные треки» в `#sheet-gpx` — поиск в bbox видимой области карты | +| F-04 | Прокси-эндпоинт `/api/gpx/osm/traces` для OSM Public GPS Traces | +| F-05 | Список найденных OSM-треков с метаданными (длина, точек, описание, автор) | +| F-06 | Импорт выбранного OSM-трека одним тапом | +| F-07 | Серверный LRU-кэш ответов внешних API (TTL 24 ч, in-memory) | +| F-08 | Источник трека (URL / OSM trace id + ссылка) виден в карточке трека | +| F-09 | Лимит размера загруженного по URL файла: 50 МБ (как ET-006) | +| F-10 | Внятные сообщения об ошибках (CORS-фейл, 404, лимит API, битый GPX) | +| F-11 | Импортированные треки попадают в общий список `window.gpxTracks` и неотличимы от локальных по поведению | + +### Out of scope + +- OAuth-интеграции (Strava, Komoot) +- Платный API Wikiloc +- Поиск треков глобально (без bbox) +- Сохранение треков в БД между сессиями +- Подписки на пользователей других платформ +- Загрузка собственных треков на публичные платформы + +## 4. Метрики успеха + +| Метрика | Критерий | +|---------|----------| +| URL-импорт | Прямая ссылка на GPX до 50 МБ загружается за ≤ 5 сек на средней сети | +| OSM-поиск bbox | Запрос видимой области возвращает результат за ≤ 3 сек (с кэшем — мгновенно) | +| Точность | OSM-трек после импорта визуально совпадает с тем же треком из osm.org | +| Кэш | Повторный запрос той же области/URL в течение 24 ч — без обращения к внешнему API | +| UX | Все ошибки (CORS, 404, лимит, формат) — внятные toast-уведомления, не падение | +| Совместимость с ET-006 | Локальные и удалённые треки в одном списке, поведение идентично | +| Сохранение при смене стиля | Импортированные треки переживают переключение тёмной темы и слоёв рельефа | + +## 5. Риски + +| Риск | Вероятность | Влияние | Митигация | +|------|-------------|---------|-----------| +| OSM API rate limit (1 запрос / IP / сек) | Высокая | Среднее | Серверный кэш по bbox + дебаунс на клиенте | +| URL-прокси превращается в open redirect / SSRF | Средняя | Высокое | Whitelist схем (http/https), блок приватных IP, лимит размера, таймаут | +| Большие OSM-страницы (1000+ треков) → длинный список | Средняя | Низкое | Пагинация: показывать первые N, кнопка «ещё» | +| GPX по URL не существует / 404 | Высокая | Низкое | Toast с понятной ошибкой | +| Content-Type не `application/gpx+xml` | Высокая | Низкое | Проверять по содержимому (DOMParser), не по заголовкам | +| Чужой публичный трек содержит вредоносный XML / XXE | Низкая | Высокое | DOMParser в браузере (XXE отключён), на бэкенде — `defusedxml` | +| Внешний API внезапно недоступен | Средняя | Низкое | Graceful degradation: показать сообщение, не блокировать другие функции | + +## 6. Зависимости + +- **ET-006** — модель `window.gpxTracks`, рендеринг, sheet `#sheet-gpx`, + парсер `parseGpx()`. Без ET-006 эта задача не имеет смысла. +- **Backend (FastAPI)** — новые эндпоинты `/api/gpx/fetch`, + `/api/gpx/osm/traces`, добавление `httpx` (уже есть) и `defusedxml` + (новая зависимость, опционально — для server-side валидации). +- Внешние сервисы: + - `https://api.openstreetmap.org/api/0.6/trackpoints` — публичный API + OSM, ограничения: 1 req/sec/IP, 5000 точек/страница, до 5 страниц. + - Произвольные HTTPS-хосты (для URL-импорта) — без SLA, fail-soft. diff --git a/docs/work-items/ET-008/02-trz.md b/docs/work-items/ET-008/02-trz.md new file mode 100644 index 0000000..a2ef347 --- /dev/null +++ b/docs/work-items/ET-008/02-trz.md @@ -0,0 +1,473 @@ +--- +type: trz +work_item_id: ET-008 +title: "ТЗ: GPS-треки с публичных платформ на карте" +version: 1 +status: draft +created_at: 2026-06-01 +updated_at: 2026-06-01 +authors: + - "agent:analyst" +--- + +# ТЗ — ET-008: GPS-треки с публичных платформ на карте + +## 1. Функциональные требования + +### REQ-F-01: Расширение sheet `#sheet-gpx` + +В верхней части `#sheet-gpx` (под header, над списком треков) добавить +секцию «Источники» с двумя вкладками-кнопками (segmented control): + +- **Из файла** — текущее поведение ET-006 (`#btn-gpx-upload`). +- **По ссылке** — поле ввода URL + кнопка «Загрузить». +- **Найти рядом** — кнопка «Найти публичные треки в этой области карты». + +При первом открытии активна вкладка **Из файла** (обратная совместимость). + +### REQ-F-02: Импорт по URL + +- Поле `` с placeholder + «https://example.com/track.gpx». +- Кнопка `#btn-gpx-fetch-url` рядом — «Загрузить». +- При нажатии: + 1. Клиентская валидация URL (`new URL()`, схема `https?:`). + 2. Запрос `GET /api/gpx/fetch?url=`. + 3. Полученный текст GPX парсится тем же `parseGpx()` из `gpx.js`. + 4. Результат добавляется в `window.gpxTracks` как обычно. Поле + `source` = `{kind: 'url', url: ''}`. + 5. `filename` для отображения: последний segment URL без `.gpx` или + `` если есть. +- Поддерживается также Enter в поле ввода. + +### REQ-F-03: Прокси-эндпоинт `/api/gpx/fetch` + +``` +GET /api/gpx/fetch?url= +``` + +- Валидация: + - Схема URL ∈ {`http`, `https`}. + - Хост резолвится в публичный IP (не RFC1918, не loopback, не link-local). + Проверка через `socket.getaddrinfo()` + `ipaddress.ip_address().is_global`. + - Запрет редиректов на приватные IP (`httpx.AsyncClient(follow_redirects=False)`, + ручная обработка max 3 редиректов с повторной валидацией хоста). +- Загрузка: + - Таймаут 15 секунд. + - Лимит размера ответа: 50 МБ (стримом, прервать при превышении). + - Заголовок `User-Agent: enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)`. +- Кэш: + - Ключ = SHA-256(url). + - In-memory LRU, max 64 записи, TTL 24 ч. + - При cache hit — отдаётся из кэша. +- Ответ: + - `200 OK`, `Content-Type: application/gpx+xml`, тело GPX. + - Заголовок `X-Cache: HIT|MISS`. +- Ошибки → JSON `{error: "..."}`: + - `400` — невалидный URL / приватный IP / запрещённая схема. + - `404` — внешний сервер вернул 404. + - `413` — превышен лимит размера. + - `502` — внешний сервер недоступен / таймаут. + - `504` — таймаут на нашей стороне. + +### REQ-F-04: Кнопка «Найти публичные треки» + +- Кнопка `#btn-gpx-find-nearby` в секции «Источники». +- Текст: «Найти треки в этой области». +- При нажатии: + 1. Получить bbox видимой области карты: `map.getBounds()`. + 2. Валидация: площадь bbox ≤ 0.25 deg² (OSM API limit — иначе ошибка). + Если больше — toast «Слишком большая область, увеличьте zoom». + 3. Запрос `GET /api/gpx/osm/traces?bbox=west,south,east,north`. + 4. Открыть подсекцию «Найденные треки» (REQ-F-05). + +### REQ-F-05: Прокси-эндпоинт `/api/gpx/osm/traces` + +``` +GET /api/gpx/osm/traces?bbox=,,,&page= +``` + +- Параметры: + - `bbox` — обязательный, 4 числа через запятую. + - `page` — опциональный, целое ≥ 0, default 0. +- Валидация: + - Каждая координата — валидный float, в допустимом диапазоне. + - Площадь bbox ≤ 0.25 deg² — иначе `400`. +- Запрос к OSM: + ``` + GET https://api.openstreetmap.org/api/0.6/trackpoints + ?bbox=&page= + ``` + - Таймаут 10 секунд. + - User-Agent как в REQ-F-03. +- Парсинг ответа: + - OSM возвращает GPX 1.0 с `` и атрибутом `gpx_id` у некоторых + точек (см. формат OSM API). Группируем точки по `gpx_id` → + массив треков-метаданных. + - Анонимные треки (без `gpx_id`) объединяются в один общий «Анонимные треки этой области». +- Кэш: + - Ключ = `(bbox_rounded_to_4_digits, page)`. + - In-memory LRU, max 256 записей, TTL 24 ч. +- Ответ (JSON): + ```json + { + "bbox": [w, s, e, n], + "page": 0, + "has_more": false, + "tracks": [ + { + "osm_id": 12345, + "name": "Trail in the woods", + "description": "...", + "user": "username", + "points_count": 320, + "distance_km": 12.4, + "url": "https://www.openstreetmap.org/user/.../traces/12345", + "gpx_url": "https://api.openstreetmap.org/api/0.6/gpx/12345/data" + } + ] + } + ``` +- Поле `distance_km` — посчитано на сервере (Haversine). +- Ошибки → JSON `{error: "..."}`: + - `400` — невалидный bbox / слишком большая область. + - `502` — OSM API недоступен. + - `504` — таймаут. + +### REQ-F-06: UI списка найденных треков + +В подсекции `#gpx-nearby-results` под кнопкой «Найти треки»: + +- Заголовок: «Найдено N треков в этой области». +- Список карточек, каждая: + - Иконка-индикатор источника (OSM-логотип маленький). + - Имя трека (или «Без названия»). + - Метаданные: длина (км, через `units.js`), автор (если есть). + - Кнопка «Показать» — импортирует трек на карту. + - Кнопка «↗» — открывает страницу трека на osm.org в новой вкладке. +- Если `has_more` — кнопка «Показать ещё» внизу списка (увеличивает page). +- Если треков нет — текст «В этой области нет публичных GPS-треков». + +### REQ-F-07: Импорт выбранного OSM-трека + +При клике на «Показать»: +1. Запрос `GET /api/gpx/fetch?url=` — тот же эндпоинт, что для + произвольного URL (переиспользование кэша и валидации). +2. После загрузки — те же шаги, что REQ-F-02 (парсинг → модель → рендеринг). +3. Поле `source` = `{kind: 'osm', osm_id: , url: }`. +4. Карточка в списке найденных треков получает индикатор «✓ Загружен». +5. Повторный клик «Показать» — no-op (toast «Уже загружен»). + +### REQ-F-08: Отображение источника в карточке трека + +В существующей карточке трека в списке `#gpx-list` (ET-006): + +- Под именем файла мелким шрифтом добавить строку «источник»: + - Локальный файл: «📁 локальный файл» (без изменения для ET-006). + - URL: «🔗 » (например, «🔗 github.com»). + - OSM: «🌍 OSM #» — кликабельная ссылка на страницу osm.org. + +### REQ-F-09: Расширение модели `window.gpxTracks` + +Каждый элемент `window.gpxTracks` дополнительно содержит: + +```javascript +{ + // ... существующие поля ET-006 (id, filename, color, tracks, waypoints, ...) + source: { + kind: 'file' | 'url' | 'osm', + url: string | null, // для kind='url' и 'osm' + osm_id: number | null, // для kind='osm' + } +} +``` + +Для треков ET-006 (загруженных из файла) `source.kind = 'file'` +(обратная совместимость через миграцию на лету: если `source` отсутствует, +читать как `{kind: 'file'}`). + +### REQ-F-10: Обработка ошибок и toast-уведомления + +| Ситуация | Toast | +|----------|-------| +| Невалидный URL | «Невалидная ссылка» | +| URL → приватный IP | «Эта ссылка недоступна» | +| Внешний 404 | «Файл не найден по этой ссылке» | +| Внешний таймаут / 502 | «Сервер не отвечает, попробуйте позже» | +| Файл > 50 МБ | «Файл слишком большой (макс. 50 МБ)» | +| Не GPX (DOMParser fail) | «По этой ссылке не GPX-файл» | +| OSM: bbox > 0.25 deg² | «Слишком большая область, увеличьте zoom» | +| OSM: 0 треков | «В этой области нет публичных GPS-треков» (не toast, а inline-сообщение) | +| OSM: rate limit (429) | «Слишком много запросов к OSM, попробуйте через минуту» | + +### REQ-F-11: Сохранение при смене стиля карты + +Импортированные треки переживают `map.setStyle()` через тот же механизм +`rebuildGpxOverlays()`, что и локальные ET-006. Никаких изменений в +этой функции не требуется — модель данных совместима. + +## 2. Нефункциональные требования + +### REQ-NF-01: Безопасность + +- Прокси `/api/gpx/fetch` защищён от SSRF (REQ-F-03): + - Whitelist схем. + - Резолв и проверка хоста на публичность. + - Ручная обработка редиректов с повторной валидацией. + - Лимит размера ответа стримом. +- Парсинг XML на бэкенде (если потребуется — для OSM-ответа) через + `defusedxml.ElementTree` — защита от XXE / billion laughs. +- Парсинг GPX на клиенте — нативный `DOMParser`, XXE отключён по умолчанию. +- CORS на новых эндпоинтах — наследуется от существующей конфигурации + (`allow_origins=["*"]`), отдельных правил не требуется. + +### REQ-NF-02: Производительность + +- Запрос OSM с кэш-хитом: ≤ 50 мс. +- Запрос OSM без кэша: ≤ 3 сек (зависит от OSM API). +- URL-импорт GPX 1 МБ: ≤ 2 сек. +- URL-импорт GPX 50 МБ: ≤ 10 сек (с учётом сети). +- Bbox-валидация и серилизация на бэкенде: ≤ 5 мс. + +### REQ-NF-03: Кэширование + +- LRU-кэш `/api/gpx/fetch`: 64 записи × до 50 МБ = до 3.2 ГБ памяти — + **слишком много**. Решение: хранить только treki ≤ 5 МБ, остальные не + кэшировать. Корректировка: кэш до 64 записей размером ≤ 5 МБ каждая. +- LRU-кэш `/api/gpx/osm/traces`: 256 записей × ≤ 200 КБ JSON ≈ 50 МБ. +- Оба кэша — in-memory, не персистентные, теряются при рестарте контейнера. +- TTL: 24 часа. +- Метрики кэша (`/api/health`): `gpx_fetch_cache_size`, `gpx_osm_cache_size`. + +### REQ-NF-04: Совместимость + +- Браузеры: те же, что ET-006 (Chrome 90+, Firefox 90+, Safari 15+). +- Мобильные: input type=url с режимом клавиатуры url. +- Backend: Python 3.12, FastAPI, httpx (уже есть), `defusedxml` (новая). + +### REQ-NF-05: UX + +- Во время сетевого запроса показывать индикатор (повторно используем + `#gpx-loading` из ET-006). +- Кнопка «Найти треки» дизейблится во время запроса. +- Все toast-уведомления — через существующий механизм `showToast()` из `gpx.js`. + +## 3. UI-спецификация + +### 3.1 Расширение `#sheet-gpx` — секция «Источники» + +``` +┌─────────────────────────────────────┐ +│ ═══ (handle) │ +│ 📄 GPX-треки [свернуть]│ +├─────────────────────────────────────┤ +│ ИСТОЧНИКИ │ +│ [📁 Из файла] [🔗 По ссылке] [🌍 Найти рядом] │ +│ │ +│ ─ если активна «По ссылке»: ─ │ +│ ┌──────────────────────────┐ ┌────┐ │ +│ │https://example.com/...gpx│ │Загр│ │ +│ └──────────────────────────┘ └────┘ │ +│ │ +│ ─ если активна «Найти рядом»: ─ │ +│ [ Найти треки в этой области карты ]│ +│ Найдено 5 треков: │ +│ ┌─────────────────────────────────┐ │ +│ │🌍 Trail in the woods [Показ.] │ │ +│ │ 12.4 км · автор: user42 [↗] │ │ +│ └─────────────────────────────────┘ │ +│ ┌─────────────────────────────────┐ │ +│ │🌍 Без названия [✓ Загр.]│ │ +│ │ 3.1 км · аноним [↗]│ │ +│ └─────────────────────────────────┘ │ +│ [ Показать ещё ] │ +├─────────────────────────────────────┤ +│ ЗАГРУЖЕННЫЕ ТРЕКИ (как в ET-006) │ +│ 🔴 morning.gpx [✕] │ +│ 📁 локальный файл │ +│ 🔵 trail_woods [✕] │ +│ 🌍 OSM #12345 │ +│ 🟢 strava-export [✕] │ +│ 🔗 github.com │ +└─────────────────────────────────────┘ +``` + +### 3.2 Segmented control «Источники» + +- Контейнер: `
`. +- Кнопки: ` + +
+ ``` + +### 3.4 Расширение карточки трека в `#gpx-list` + +Добавить под именем файла строку: + +```html +
+ + 📁 локальный файл + + 🔗 github.com + + 🌍 OSM #12345 +
+``` + +## 4. Данные + +### 4.1 Формат OSM Public GPS Traces API + +OSM возвращает GPX 1.0: + +```xml + + + + Anonymous tracks + + + + + ... + + + +``` + +`gpx_id` атрибут точек официально устарел; вместо группировки треков по +gpx_id отдаём весь bbox-ответ как «Публичные треки этой области (N точек)» +— **единая карточка**, импорт всей выборки как одного трека. +Метаданные индивидуальных треков (user, name) недоступны через +`trackpoints` endpoint без дополнительного запроса. + +**Уточнение требования REQ-F-05/F-06** (исходя из реального API): + +- Список найденных «треков» — это страницы trackpoints (page 0, 1, 2…). +- Карточка отображает: page N, количество точек, длину, bbox-центр. +- Импорт = загрузить эту страницу как один GPX-трек. +- Кнопка «Показать ещё» → следующая страница. + +Это упрощает реализацию и соответствует ограничениям OSM API. + +### 4.2 Внутренняя модель — расширение + +```javascript +window.gpxTracks = [ + { + // существующие поля ET-006 + id: 'gpx-1716336000000', + filename: 'trail_woods', + color: '#3cb44b', + tracks: [...], + waypoints: [...], + sourceId: 'gpx-source-...', + layerId: 'gpx-layer-...', + waypointLayerId: 'gpx-wpt-...', + // новое поле ET-008 + source: { + kind: 'file' | 'url' | 'osm', + url: 'https://...', // null для kind='file' + osm_page: 0, // только для kind='osm' + osm_bbox: [w, s, e, n] // только для kind='osm' + } + } +]; +``` + +## 5. Файловая структура изменений + +``` +src/api/ +├── main.py # + 2 эндпоинта, импорт нового модуля +├── gpx_proxy.py # НОВЫЙ: SSRF-валидация, fetch, кэш +├── osm_traces.py # НОВЫЙ: OSM trackpoints клиент, парсинг +├── requirements.txt # + defusedxml + +src/web/ +├── index.html # + секция «Источники» в #sheet-gpx +├── gpx.js # + URL-импорт, OSM-поиск, расширение модели +├── app.css # + стили .source-seg, .gpx-nearby-card, .gpx-source-row + +tests/ +├── api/test_gpx_proxy.py # НОВЫЙ +├── api/test_osm_traces.py # НОВЫЙ +├── web/gpx.test.js # + тесты на URL/OSM источники + +docs/work-items/ET-008/ +├── 06-adr/ +│ ├── ADR-001-ssrf-protection.md +│ └── ADR-002-osm-trackpoints-aggregation.md +``` + +## 6. Алгоритмы + +### 6.1 SSRF-защита `/api/gpx/fetch` + +```python +def is_safe_url(url: str) -> bool: + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https'): + return False + try: + infos = socket.getaddrinfo(parsed.hostname, None) + except socket.gaierror: + return False + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if not ip.is_global or ip.is_loopback or ip.is_private: + return False + return True +``` + +При следовании редиректам — повторная валидация хоста на каждом шаге. + +### 6.2 Bbox area check + +```python +def bbox_area_deg2(w, s, e, n): + return abs(e - w) * abs(n - s) + +if bbox_area_deg2(*bbox) > 0.25: + raise HTTPException(400, "bbox too large") +``` + +### 6.3 Кэш-ключ для bbox + +Округление до 4 знаков (≈ 11 метров на экваторе): +```python +key = (round(w, 4), round(s, 4), round(e, 4), round(n, 4), page) +``` + +Это обеспечивает попадание в кэш при незначительном движении карты. + +## 7. Взаимодействие с существующими модулями + +- **ET-006 `gpx.js`** — расширяем, не переписываем. Существующие функции + (`parseGpx`, `addGpxTrack`, `rebuildGpxOverlays`) остаются. Добавляются: + `importGpxFromUrl(url)`, `findOsmTracesInView()`, + `importOsmTrace(osm_url)`. +- **`units.js`** — используется для форматирования длины треков в списке. +- **`#sheet-gpx`** — единственный sheet для всех источников. Никаких + новых sheet не создаётся. +- **`#toolbar`** — кнопка `#tb-gpx` уже открывает `#sheet-gpx`. Не меняется. +- **`/api/health`** — расширить выдачей размеров кэшей (REQ-NF-03). diff --git a/docs/work-items/ET-008/03-acceptance-criteria.md b/docs/work-items/ET-008/03-acceptance-criteria.md new file mode 100644 index 0000000..6f556c8 --- /dev/null +++ b/docs/work-items/ET-008/03-acceptance-criteria.md @@ -0,0 +1,275 @@ +--- +type: acceptance-criteria +work_item_id: ET-008 +title: "AC: GPS-треки с публичных платформ на карте" +version: 1 +status: draft +created_at: 2026-06-01 +updated_at: 2026-06-01 +authors: + - "agent:analyst" +--- + +# Acceptance Criteria — ET-008: GPS-треки с публичных платформ на карте + +## AC-01: Секция «Источники» в `#sheet-gpx` + +```gherkin +Feature: Переключатель источников треков + + Scenario: Открытие GPX-панели + Given пользователь нажимает кнопку GPX в нижнем тулбаре + Then открывается панель #sheet-gpx + And в верхней части видна секция «Источники» с тремя кнопками: «Из файла», «По ссылке», «Найти рядом» + And по умолчанию активна кнопка «Из файла» + + Scenario: Переключение на «По ссылке» + Given панель #sheet-gpx открыта + When пользователь нажимает кнопку «По ссылке» + Then кнопка «По ссылке» становится активной + And отображается поле ввода URL и кнопка «Загрузить» + And контент других вкладок скрыт + + Scenario: Переключение на «Найти рядом» + Given панель #sheet-gpx открыта + When пользователь нажимает кнопку «Найти рядом» + Then отображается кнопка «Найти треки в этой области карты» +``` + +## AC-02: Импорт по URL — успешный сценарий + +```gherkin +Feature: Загрузка GPX по прямой ссылке + + Scenario: Валидная публичная ссылка + Given активна вкладка «По ссылке» + When пользователь вставляет https://example.com/test-track.gpx (валидный, 1 МБ) + And нажимает «Загрузить» + Then показывается индикатор загрузки + And через ≤ 5 сек трек появляется на карте + And карта выполняет fit bounds + And трек добавляется в список #gpx-list + And в карточке трека отображается «🔗 example.com» + + Scenario: Загрузка по Enter + Given активна вкладка «По ссылке» + When пользователь вставляет URL и нажимает Enter + Then загрузка начинается без клика по кнопке +``` + +## AC-03: Импорт по URL — ошибки + +```gherkin +Feature: Обработка ошибок URL-импорта + + Scenario: Невалидный URL (схема) + Given активна вкладка «По ссылке» + When пользователь вставляет ftp://example.com/file.gpx + Then показывается toast «Невалидная ссылка» + And запрос на бэкенд не отправляется + + Scenario: Приватный IP + Given пользователь вставляет http://192.168.1.1/file.gpx + Then бэкенд возвращает 400 + And показывается toast «Эта ссылка недоступна» + + Scenario: Несуществующий файл + Given URL ведёт на 404 + Then показывается toast «Файл не найден по этой ссылке» + + Scenario: Файл больше 50 МБ + Given URL ведёт на GPX > 50 МБ + Then показывается toast «Файл слишком большой (макс. 50 МБ)» + + Scenario: Не GPX (HTML по ссылке) + Given URL отдаёт HTML-страницу + Then показывается toast «По этой ссылке не GPX-файл» + + Scenario: Внешний сервер не отвечает + Given внешний сервер таймаутит + Then показывается toast «Сервер не отвечает, попробуйте позже» +``` + +## AC-04: Поиск OSM-треков + +```gherkin +Feature: Поиск публичных треков OSM в видимой области + + Scenario: Успешный поиск с результатами + Given активна вкладка «Найти рядом» + And карта показывает область с публичными треками + When пользователь нажимает «Найти треки в этой области карты» + Then показывается индикатор загрузки + And через ≤ 3 сек появляется список найденных треков + And каждая карточка содержит: иконку OSM, описание (page N), длину в км, кнопку «Показать», ссылку «↗» + + Scenario: Пустая область + Given карта показывает область без публичных треков + When пользователь нажимает «Найти треки» + Then отображается inline-сообщение «В этой области нет публичных GPS-треков» + + Scenario: Слишком большая область + Given карта показывает область с bbox > 0.25 deg² + When пользователь нажимает «Найти треки» + Then показывается toast «Слишком большая область, увеличьте zoom» + And запрос на бэкенд не отправляется (или возвращается 400) + + Scenario: Пагинация + Given поиск вернул has_more=true + Then в конце списка отображается кнопка «Показать ещё» + When пользователь нажимает «Показать ещё» + Then дозагружаются результаты следующей страницы + And они добавляются в конец списка +``` + +## AC-05: Импорт OSM-трека + +```gherkin +Feature: Импорт выбранного OSM-трека на карту + + Scenario: Импорт по кнопке «Показать» + Given найдено 3 OSM-трека в списке + When пользователь нажимает «Показать» у первого трека + Then показывается индикатор загрузки + And через ≤ 5 сек трек появляется на карте + And карта выполняет fit bounds + And трек добавляется в #gpx-list + And в карточке трека отображается «🌍 OSM #...» (кликабельная ссылка) + And карточка в #gpx-nearby-results получает индикатор «✓ Загружен» + + Scenario: Повторный импорт того же трека + Given OSM-трек уже импортирован + When пользователь нажимает «Показать» у этой же карточки в найденных + Then показывается toast «Уже загружен» + And новый трек НЕ добавляется + + Scenario: Внешняя ссылка на osm.org + Given в карточке найденного трека есть кнопка «↗» + When пользователь нажимает «↗» + Then открывается новая вкладка с страницей трека на openstreetmap.org +``` + +## AC-06: Отображение источника в карточке трека + +```gherkin +Feature: Источник трека виден пользователю + + Scenario: Локальный файл (ET-006 совместимость) + Given загружен GPX из локального файла + Then в карточке трека под именем файла отображается «📁 локальный файл» + + Scenario: Загружен по URL + Given загружен GPX по ссылке https://github.com/user/repo/track.gpx + Then в карточке трека отображается «🔗 github.com» + + Scenario: Загружен из OSM + Given загружен OSM-трек page 0 + Then в карточке трека отображается ссылка «🌍 OSM #..» которая ведёт на osm.org +``` + +## AC-07: Кэширование на бэкенде + +```gherkin +Feature: Серверный кэш ответов внешних API + + Scenario: Повторный запрос URL из кэша + Given URL запрашивался менее 24 часов назад + When клиент делает повторный GET /api/gpx/fetch?url=... + Then ответ возвращается с заголовком X-Cache: HIT + And время ответа ≤ 50 мс + And внешний запрос НЕ выполняется + + Scenario: Cache miss + Given URL запрашивается впервые + Then выполняется внешний запрос + And ответ возвращается с X-Cache: MISS + And следующий запрос того же URL — HIT + + Scenario: Повторный bbox-поиск из кэша + Given bbox запрашивался менее 24 часов назад + When клиент делает повторный GET /api/gpx/osm/traces?bbox=... + Then ответ из кэша + And внешний запрос к OSM API НЕ выполняется +``` + +## AC-08: Безопасность + +```gherkin +Feature: SSRF protection + + Scenario: Прямой запрос к loopback + When клиент шлёт GET /api/gpx/fetch?url=http://127.0.0.1/data + Then бэкенд возвращает 400 + And никакого запроса к 127.0.0.1 не делается + + Scenario: Запрос к приватной подсети + When клиент шлёт URL ведущий на 10.0.0.1, 192.168.x.x, 172.16.x.x + Then бэкенд возвращает 400 + + Scenario: Редирект на приватный IP + Given внешний URL отдаёт 302 redirect на http://127.0.0.1/... + When клиент шлёт GET /api/gpx/fetch?url= + Then редирект проверяется повторно и блокируется + And бэкенд возвращает 400 + + Scenario: Запрещённая схема + When клиент шлёт URL с file:// или gopher:// + Then бэкенд возвращает 400 + + Scenario: Размер ответа превышает лимит + Given внешний сервер начинает стримить файл > 50 МБ + Then бэкенд прерывает соединение + And возвращает 413 +``` + +## AC-09: Совместимость с ET-006 + +```gherkin +Feature: Локальные и удалённые треки в одной модели + + Scenario: Смешанный список + Given загружен 1 локальный файл, 1 по URL, 1 из OSM + Then в #gpx-list отображаются 3 карточки + And каждая имеет уникальный цвет из палитры + And каждая имеет свой индикатор источника + And любую можно активировать, удалить, увидеть профиль высот + + Scenario: Сохранение при смене темы + Given на карте 3 трека разных источников + When пользователь переключает тёмную тему + Then все 3 трека остаются на карте + And источники в карточках сохраняются + And статистика и профиль активного трека сохраняются + + Scenario: Сохранение при переключении слоёв рельефа + Given на карте 3 трека разных источников + When пользователь включает hillshade + Then все 3 трека видны поверх hillshade +``` + +## AC-10: Метрики кэша в `/api/health` + +```gherkin +Feature: Наблюдаемость кэшей + + Scenario: Размер кэшей в health-эндпоинте + When клиент шлёт GET /api/health + Then ответ содержит поля gpx_fetch_cache_size и gpx_osm_cache_size + And значения — целые числа ≥ 0 +``` + +## AC-11: Производительность + +```gherkin +Feature: Лимиты времени отклика + + Scenario: OSM bbox запрос с кэш-хитом + Given bbox в кэше + Then GET /api/gpx/osm/traces возвращается за ≤ 50 мс (p95) + + Scenario: URL-импорт малого файла (1 МБ) + Then GET /api/gpx/fetch для 1 МБ файла завершается за ≤ 2 сек + + Scenario: OSM bbox запрос без кэша + Then GET /api/gpx/osm/traces без кэша возвращается за ≤ 3 сек (p95) +``` diff --git a/docs/work-items/ET-008/04-test-plan.yaml b/docs/work-items/ET-008/04-test-plan.yaml new file mode 100644 index 0000000..53d3ac0 --- /dev/null +++ b/docs/work-items/ET-008/04-test-plan.yaml @@ -0,0 +1,424 @@ +--- +type: test-plan +work_item_id: ET-008 +title: "Test Plan: GPS-треки с публичных платформ на карте" +version: 1 +status: draft +created_at: 2026-06-01 +updated_at: 2026-06-01 +authors: + - "agent:analyst" + +test_suites: + + - name: unit-gpx-proxy-validation + type: unit + description: "SSRF-валидация URL в gpx_proxy.is_safe_url()" + cases: + - id: U-01 + name: "Принимает валидный публичный HTTPS URL" + input: "https://example.com/track.gpx (резолвится в публичный IP)" + expected: "is_safe_url() возвращает True" + + - id: U-02 + name: "Отклоняет схему ftp://" + input: "ftp://example.com/track.gpx" + expected: "is_safe_url() возвращает False" + + - id: U-03 + name: "Отклоняет схему file://" + input: "file:///etc/passwd" + expected: "is_safe_url() возвращает False" + + - id: U-04 + name: "Отклоняет loopback IP" + input: "http://127.0.0.1/x.gpx" + expected: "is_safe_url() возвращает False" + + - id: U-05 + name: "Отклоняет приватный IP (10.0.0.0/8)" + input: "http://10.1.2.3/x.gpx" + expected: "is_safe_url() возвращает False" + + - id: U-06 + name: "Отклоняет приватный IP (192.168.0.0/16)" + input: "http://192.168.1.1/x.gpx" + expected: "is_safe_url() возвращает False" + + - id: U-07 + name: "Отклоняет приватный IP (172.16.0.0/12)" + input: "http://172.16.0.1/x.gpx" + expected: "is_safe_url() возвращает False" + + - id: U-08 + name: "Отклоняет link-local IP (169.254.x.x)" + input: "http://169.254.169.254/metadata" + expected: "is_safe_url() возвращает False" + + - id: U-09 + name: "Отклоняет невалидный URL" + input: "not a url" + expected: "is_safe_url() возвращает False (без exception)" + + - id: U-10 + name: "Отклоняет хост, который не резолвится" + input: "http://nonexistent-host-xyz-12345.invalid/x.gpx" + expected: "is_safe_url() возвращает False" + + - name: unit-bbox-validation + type: unit + description: "Валидация bbox в osm_traces" + cases: + - id: U-20 + name: "Принимает малый bbox" + input: "bbox=[37.6, 55.7, 37.7, 55.8] (0.01 deg²)" + expected: "validate_bbox() возвращает True" + + - id: U-21 + name: "Отклоняет bbox > 0.25 deg²" + input: "bbox=[37.0, 55.0, 38.0, 56.0] (1.0 deg²)" + expected: "validate_bbox() возвращает False" + + - id: U-22 + name: "Отклоняет невалидные координаты" + input: "bbox=[200, 100, 250, 150]" + expected: "validate_bbox() возвращает False" + + - id: U-23 + name: "Отклоняет перевёрнутый bbox (west > east)" + input: "bbox=[38.0, 55.0, 37.0, 56.0]" + expected: "validate_bbox() возвращает False" + + - name: unit-cache + type: unit + description: "LRU кэш с TTL" + cases: + - id: U-30 + name: "TTL истёк → cache miss" + input: "Положить запись с TTL 1 сек, ждать 2 сек, запросить" + expected: "Возвращает None (или вызывает loader)" + + - id: U-31 + name: "LRU вытеснение при переполнении" + input: "Заполнить кэш max=4 записями, добавить 5-ю" + expected: "Первая (LRU) запись вытеснена" + + - id: U-32 + name: "Округление bbox-ключа до 4 знаков" + input: "bbox=[37.6172999, 55.7558001, ...] и bbox=[37.6173, 55.7558, ...]" + expected: "Один и тот же кэш-ключ → cache hit" + + - id: U-33 + name: "URL > 5 МБ не кэшируется" + input: "Положить запись размером 6 МБ" + expected: "Запись не попадает в кэш (cache.get → None)" + + - name: unit-osm-parser + type: unit + description: "Парсинг OSM trackpoints GPX → JSON" + cases: + - id: U-40 + name: "Извлечение точек из GPX 1.0" + input: "GPX с 1 , 1 , 50 " + expected: "JSON: {tracks: [{points_count: 50, distance_km: ~X}]}" + + - id: U-41 + name: "Расчёт длины через Haversine" + input: "GPX с 3 точками: [37.6,55.7], [37.7,55.8], [37.8,55.9]" + expected: "distance_km ≈ 28.3 (±0.5)" + + - id: U-42 + name: "Пустой GPX (нет trkpt)" + input: "GPX без точек" + expected: "JSON: {tracks: [], total_points: 0}" + + - id: U-43 + name: "Защита от XXE (defusedxml)" + input: "GPX с DOCTYPE и внешней entity" + expected: "Парсер не выполняет загрузку внешней entity (или бросает ошибку)" + + - name: unit-web-gpx-source + type: unit + description: "Расширение модели window.gpxTracks полем source" + cases: + - id: U-50 + name: "Импорт по URL: source.kind='url'" + input: "importGpxFromUrl('https://github.com/x/y.gpx', mockedFetch)" + expected: "Трек добавлен с source={kind:'url', url:'https://github.com/x/y.gpx'}" + + - id: U-51 + name: "Импорт OSM: source.kind='osm'" + input: "importOsmTrace({osm_page:0, osm_bbox:[...], gpx_url:'...'}, mockedFetch)" + expected: "Трек добавлен с source={kind:'osm', osm_page:0, osm_bbox:[...], url:'...'}" + + - id: U-52 + name: "Обратная совместимость: трек без source читается как 'file'" + input: "window.gpxTracks[0] без поля source" + expected: "renderSourceRow() возвращает '📁 локальный файл'" + + - id: U-53 + name: "Hostname extraction для URL-источника" + input: "source.url='https://raw.githubusercontent.com/user/repo/main/track.gpx'" + expected: "renderSourceRow() возвращает '🔗 raw.githubusercontent.com'" + + - name: integration-gpx-fetch + type: integration + description: "GET /api/gpx/fetch — прокси с реальным HTTP" + cases: + - id: I-01 + name: "Успешная загрузка GPX по URL (mock-сервер)" + input: "GET /api/gpx/fetch?url=http://test-server/track.gpx" + expected: "200, Content-Type: application/gpx+xml, тело = GPX, X-Cache: MISS" + + - id: I-02 + name: "Повторный запрос — cache hit" + input: "GET тот же URL" + expected: "200, X-Cache: HIT, время ≤ 50 мс" + + - id: I-03 + name: "Отклонение приватного IP" + input: "GET /api/gpx/fetch?url=http://127.0.0.1/x.gpx" + expected: "400, JSON {error: ...}" + + - id: I-04 + name: "Отклонение редиректа на приватный IP" + input: "Внешний URL → 302 на http://127.0.0.1/x.gpx" + expected: "400, JSON {error: ...}" + + - id: I-05 + name: "Внешний 404" + input: "URL ведёт на несуществующий путь" + expected: "404, JSON {error: ...}" + + - id: I-06 + name: "Лимит размера 50 МБ" + input: "Mock-сервер стримит 60 МБ" + expected: "413, соединение прервано до конца" + + - id: I-07 + name: "Таймаут" + input: "Mock-сервер ничего не отвечает" + expected: "504 после 15 сек" + + - id: I-08 + name: "URL > 5 МБ не попадает в кэш" + input: "Запросить URL с ответом 6 МБ дважды" + expected: "Второй запрос: X-Cache: MISS, внешний запрос повторно выполнен" + + - name: integration-osm-traces + type: integration + description: "GET /api/gpx/osm/traces — OSM API клиент" + cases: + - id: I-20 + name: "Bbox-запрос с результатами" + input: "GET /api/gpx/osm/traces?bbox=37.6,55.7,37.65,55.75 (mock OSM API)" + expected: "200, JSON с tracks[], каждый имеет points_count, distance_km, gpx_url" + + - id: I-21 + name: "Bbox > 0.25 deg² → 400" + input: "bbox=37,55,38,56" + expected: "400, error 'bbox too large'" + + - id: I-22 + name: "OSM API недоступен → 502" + input: "OSM mock возвращает 500" + expected: "502, JSON error" + + - id: I-23 + name: "Cache hit на повторный bbox" + input: "Тот же bbox дважды" + expected: "Второй запрос: внешний запрос НЕ выполнен, ответ из кэша" + + - id: I-24 + name: "Пустой bbox → пустой список" + input: "bbox в океане" + expected: "200, tracks=[], has_more=false" + + - id: I-25 + name: "Пагинация" + input: "page=0 возвращает has_more=true, page=1 возвращает следующие" + expected: "Корректное смещение, оба запроса валидны" + + - name: integration-health-metrics + type: integration + description: "Метрики кэшей в /api/health" + cases: + - id: I-30 + name: "Health возвращает размеры кэшей" + input: "GET /api/health" + expected: "JSON содержит gpx_fetch_cache_size, gpx_osm_cache_size (числа ≥ 0)" + + - id: I-31 + name: "Счётчики растут после запросов" + input: "После N успешных fetch и M osm_traces запросов" + expected: "Размеры кэшей отражают добавленные записи" + + - name: e2e-url-import + type: e2e + description: "Импорт GPX по ссылке — полный сценарий" + cases: + - id: E-01 + name: "URL-импорт валидного трека" + steps: + - "Открыть приложение" + - "Нажать кнопку GPX в нижнем тулбаре" + - "Переключиться на вкладку «По ссылке»" + - "Вставить URL валидного GPX (тестовый mock)" + - "Нажать «Загрузить»" + - "Убедиться: индикатор показан, через ≤ 5 сек трек на карте" + - "Убедиться: в #gpx-list появилась карточка с источником «🔗 host»" + - "Кликнуть на трек → отображается статистика и профиль высот" + + - id: E-02 + name: "URL-импорт по Enter" + steps: + - "Активировать «По ссылке»" + - "Вставить URL, нажать Enter" + - "Убедиться: трек загружен (как при клике)" + + - id: E-03 + name: "Невалидный URL → toast" + steps: + - "Вставить ftp://x.com/y" + - "Нажать «Загрузить»" + - "Убедиться: toast «Невалидная ссылка»" + - "Убедиться: на карте ничего нового" + + - id: E-04 + name: "Приватный IP блокируется" + steps: + - "Вставить http://192.168.1.1/x.gpx" + - "Нажать «Загрузить»" + - "Убедиться: toast «Эта ссылка недоступна»" + + - id: E-05 + name: "Не GPX по ссылке" + steps: + - "Вставить URL HTML-страницы" + - "Нажать «Загрузить»" + - "Убедиться: toast «По этой ссылке не GPX-файл»" + + - name: e2e-osm-search + type: e2e + description: "Поиск и импорт OSM треков" + cases: + - id: E-10 + name: "Поиск треков в области и импорт" + steps: + - "Открыть приложение, отзумиться к области Москвы (zoom 12)" + - "Открыть #sheet-gpx, активировать «Найти рядом»" + - "Нажать «Найти треки в этой области карты»" + - "Убедиться: индикатор, потом список карточек" + - "Нажать «Показать» у первой карточки" + - "Убедиться: трек появился на карте, fit bounds" + - "Убедиться: карточка в найденных получила «✓ Загружен»" + - "Убедиться: в #gpx-list появилась карточка с «🌍 OSM #...»" + + - id: E-11 + name: "Слишком большая область" + steps: + - "Отзумиться на всю Россию" + - "Активировать «Найти рядом»" + - "Нажать «Найти»" + - "Убедиться: toast «Слишком большая область, увеличьте zoom»" + + - id: E-12 + name: "Пустая область" + steps: + - "Перейти к области без треков (океан)" + - "Активировать «Найти рядом»" + - "Нажать «Найти»" + - "Убедиться: сообщение «В этой области нет публичных GPS-треков»" + + - id: E-13 + name: "Пагинация" + steps: + - "Найти треки в области с большим количеством" + - "Убедиться: кнопка «Показать ещё» внизу" + - "Нажать «Показать ещё»" + - "Убедиться: список расширился" + + - id: E-14 + name: "Повторный импорт → toast" + steps: + - "Импортировать трек по «Показать»" + - "Нажать «Показать» у той же карточки ещё раз" + - "Убедиться: toast «Уже загружен»" + + - id: E-15 + name: "Внешняя ссылка на osm.org" + steps: + - "Найти треки, нажать «↗» у карточки" + - "Убедиться: новая вкладка открыта на openstreetmap.org" + + - name: e2e-mixed-sources + type: e2e + description: "Совместимость трёх источников в одной сессии" + cases: + - id: E-20 + name: "3 трека разных источников" + steps: + - "Загрузить 1 локальный файл" + - "Загрузить 1 по URL" + - "Загрузить 1 из OSM" + - "Убедиться: 3 карточки в #gpx-list, разные цвета, разные источники" + - "Удалить URL-трек" + - "Убедиться: 2 трека на карте, корректные источники" + + - id: E-21 + name: "Сохранение при смене темы" + steps: + - "Загрузить 3 трека разных источников" + - "Переключить тёмную тему" + - "Убедиться: все 3 трека на карте" + - "Убедиться: источники в карточках сохранены" + + - id: E-22 + name: "Сохранение при включении hillshade" + steps: + - "Загрузить 3 трека" + - "Включить hillshade" + - "Убедиться: все 3 трека видны поверх hillshade" + + - name: e2e-cache + type: e2e + description: "Поведение кэша через API" + cases: + - id: E-30 + name: "Кэш URL-fetch снижает время" + steps: + - "GET /api/gpx/fetch?url= — измерить t1" + - "GET /api/gpx/fetch?url=<тот же url> — измерить t2" + - "Убедиться: t2 < 100 мс, заголовок X-Cache: HIT" + + - id: E-31 + name: "Размеры кэша в health" + steps: + - "Сделать N запросов /api/gpx/fetch" + - "GET /api/health" + - "Убедиться: gpx_fetch_cache_size == N (или min(N, лимит))" + +test_data: + - name: "test-track-public.gpx" + description: "Валидный GPX 1.1, 1 МБ, для URL-импорта (mock-сервер)" + - name: "test-track-large.gpx" + description: "GPX 60 МБ — для проверки лимита размера" + - name: "test-osm-trackpoints.gpx" + description: "Реальный ответ OSM trackpoints API (зафиксирован для mock)" + - name: "test-html-page.html" + description: "HTML вместо GPX — для проверки валидации формата" + - name: "test-xxe-payload.gpx" + description: "GPX с DOCTYPE и внешней entity — для проверки defusedxml" + - name: "bbox-moscow-small" + description: "[37.6, 55.7, 37.65, 55.75] — реальная область с публичными треками OSM" + - name: "bbox-too-large" + description: "[37.0, 55.0, 38.0, 56.0] — > 0.25 deg² для проверки 400" + +test_environment: + mock_servers: + - "Mock HTTP-сервер для /api/gpx/fetch тестов (отдаёт фиксированные ответы)" + - "Mock OSM API для /api/gpx/osm/traces тестов" + fixtures_dir: "tests/fixtures/gpx-public/" + notes: + - "OSM API в e2e тестах должен мокироваться, чтобы не зависеть от внешней доступности" + - "Для нагрузочных тестов кэша использовать pytest-benchmark" diff --git a/docs/work-items/ET-008/04b-ui-test-cases.md b/docs/work-items/ET-008/04b-ui-test-cases.md new file mode 100644 index 0000000..0ce0d52 --- /dev/null +++ b/docs/work-items/ET-008/04b-ui-test-cases.md @@ -0,0 +1,395 @@ +--- +type: ui-test-cases +work_item_id: ET-008 +title: "UI Test Cases: GPS-треки с публичных платформ" +version: 1 +status: draft +created_at: 2026-06-01 +updated_at: 2026-06-01 +authors: + - "agent:analyst" +--- + +# UI Test Cases — ET-008: GPS-треки с публичных платформ + +Базовый URL: `https://openclaw.mva154.duckdns.org/enduro/` + +Все тесты проверяют появление и поведение секции «Источники» в +`#sheet-gpx`, импорта по URL и поиска OSM-треков. Внешние сетевые +запросы в test-окружении мокаются (см. test-plan). + +Селекторы (новые, добавляются ET-008): +- `#source-seg` — segmented control «Источники» +- `#source-btn-file`, `#source-btn-url`, `#source-btn-nearby` — кнопки вкладок +- `#gpx-source-pane-file`, `#gpx-source-pane-url`, `#gpx-source-pane-nearby` — контент-блоки +- `#gpx-url-input` — поле ввода URL +- `#btn-gpx-fetch-url` — кнопка «Загрузить» URL +- `#btn-gpx-find-nearby` — кнопка «Найти треки в этой области» +- `#gpx-nearby-results` — контейнер списка найденных +- `.gpx-nearby-card` — карточка найденного OSM-трека +- `.gnc-import` — кнопка «Показать» +- `.gnc-external` — ссылка «↗» +- `.gpx-source-row` — индикатор источника в карточке трека + +Существующие селекторы (ET-006): `#tb-gpx`, `#sheet-gpx`, `#gpx-list`. + +--- + +### TC-UI-01 — Секция «Источники» видна в #sheet-gpx + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. screenshot: "01-sheet-gpx-sources-section" +6. check-visual: "В верхней части #sheet-gpx (под заголовком, над списком треков) видна секция «ИСТОЧНИКИ» с тремя кнопками segmented control: «Из файла», «По ссылке», «Найти рядом». По умолчанию активна (подсвечена оранжевым) кнопка «Из файла»." + +--- + +### TC-UI-02 — Переключение на вкладку «По ссылке» + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-url" +6. wait: 500 +7. screenshot: "02-source-url-pane" +8. check-visual: "Кнопка «По ссылке» подсвечена оранжевым, «Из файла» и «Найти рядом» — нет. Под кнопками видно поле ввода с placeholder «https://example.com/track.gpx» и кнопка «Загрузить» справа." + +--- + +### TC-UI-03 — Переключение на вкладку «Найти рядом» + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-nearby" +6. wait: 500 +7. screenshot: "03-source-nearby-pane" +8. check-visual: "Кнопка «Найти рядом» подсвечена оранжевым. Под кнопками видна крупная кнопка «Найти треки в этой области карты». Список найденных треков пуст или отсутствует." + +--- + +### TC-UI-04 — Поле URL принимает ввод + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-url" +6. wait: 500 +7. click: "#gpx-url-input" +8. wait: 200 +9. screenshot: "04-url-input-focused" +10. check-visual: "Поле #gpx-url-input получило фокус (видна рамка/каретка), placeholder виден если поле пустое. Кнопка «Загрузить» рядом, активна (не дизейблена)." + +--- + +### TC-UI-05 — Невалидный URL: toast об ошибке + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-url" +6. wait: 500 +7. click: "#gpx-url-input" +8. wait: 200 +9. type: "not-a-url" +10. click: "#btn-gpx-fetch-url" +11. wait: 1000 +12. screenshot: "05-invalid-url-toast" +13. check-visual: "Сверху по центру экрана отображается toast-уведомление с текстом «Невалидная ссылка» (или похожим). Никаких изменений на карте." + +--- + +### TC-UI-06 — Кнопка «Найти треки» дизейблится во время запроса + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-nearby" +6. wait: 500 +7. click: "#btn-gpx-find-nearby" +8. wait: 300 +9. screenshot: "06-finding-tracks-loading" +10. check-visual: "Кнопка «Найти треки в этой области карты» визуально дизейблена (серая / opacity снижен). Виден индикатор загрузки (spinner или moto-wheel)." + +--- + +### TC-UI-07 — Список найденных треков + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-nearby" +6. wait: 500 +7. click: "#btn-gpx-find-nearby" +8. wait: 4000 +9. screenshot: "07-nearby-tracks-list" +10. check-visual: "Под кнопкой поиска появился список карточек .gpx-nearby-card. Каждая карточка содержит: иконку 🌍 (или OSM-логотип) слева, имя/описание трека и метаданные (км, аноним/автор), кнопку «Показать» справа, маленькую ссылку «↗». Карточки разделены тонкими линиями." + +--- + +### TC-UI-08 — Импорт OSM-трека: трек на карте, индикатор в карточке + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-nearby" +6. wait: 500 +7. click: "#btn-gpx-find-nearby" +8. wait: 4000 +9. click: ".gnc-import" +10. wait: 4000 +11. screenshot: "08-osm-track-imported" +12. check-visual: "На карте видна цветная линия импортированного трека. В списке найденных карточка первого трека показывает индикатор «✓ Загружен» вместо кнопки «Показать». В нижней части #sheet-gpx (в #gpx-list) появилась новая карточка трека с источником «🌍 OSM #...»." + +--- + +### TC-UI-09 — Источник «OSM» — кликабельная ссылка в #gpx-list + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-nearby" +6. wait: 500 +7. click: "#btn-gpx-find-nearby" +8. wait: 4000 +9. click: ".gnc-import" +10. wait: 4000 +11. screenshot: "09-gpx-list-source-osm" +12. check-visual: "В нижнем списке #gpx-list карточка импортированного трека под именем содержит строку «.gpx-source-row» с текстом «🌍 OSM #<число>». Текст оформлен как ссылка (подчёркнут или другой цвет)." + +--- + +### TC-UI-10 — Смешанные источники в #gpx-list + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-url" +6. wait: 500 +7. click: "#gpx-url-input" +8. wait: 200 +9. type: "https://example.test/mock-track.gpx" +10. click: "#btn-gpx-fetch-url" +11. wait: 4000 +12. click: "#source-btn-nearby" +13. wait: 500 +14. click: "#btn-gpx-find-nearby" +15. wait: 4000 +16. click: ".gnc-import" +17. wait: 4000 +18. screenshot: "10-mixed-sources-list" +19. check-visual: "В #gpx-list 2 карточки: одна с источником «🔗 example.test», вторая с «🌍 OSM #...». Карточки имеют разные цветовые индикаторы слева. Обе видны на карте как линии разных цветов." + +--- + +### TC-UI-11 — Импорт по URL: трек появляется на карте + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-url" +6. wait: 500 +7. click: "#gpx-url-input" +8. wait: 200 +9. type: "https://example.test/mock-track.gpx" +10. click: "#btn-gpx-fetch-url" +11. wait: 5000 +12. screenshot: "11-url-track-loaded" +13. check-visual: "На карте видна цветная линия загруженного трека. В #gpx-list появилась карточка с именем «mock-track» и источником «🔗 example.test». Карта выполнила fit bounds — трек по центру экрана." + +--- + +### TC-UI-12 — Секция «Источники» на мобильном + +- тип: ui +- viewport: mobile + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. screenshot: "12-sources-mobile-default" +6. check-visual: "На мобильном viewport секция «Источники» помещается по ширине экрана. Три кнопки segmented control видны и нажимаемы, не выходят за экран. Активна «Из файла»." + +--- + +### TC-UI-13 — Поле URL на мобильном + +- тип: ui +- viewport: mobile + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-url" +6. wait: 500 +7. screenshot: "13-url-pane-mobile" +8. check-visual: "На мобильном поле #gpx-url-input занимает большую часть ширины, кнопка «Загрузить» справа. Оба элемента не перекрываются, нажимаемы, помещаются в экран." + +--- + +### TC-UI-14 — Список найденных OSM треков на мобильном + +- тип: ui +- viewport: mobile + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-nearby" +6. wait: 500 +7. click: "#btn-gpx-find-nearby" +8. wait: 4000 +9. screenshot: "14-nearby-list-mobile" +10. check-visual: "На мобильном карточки .gpx-nearby-card отображаются вертикально, занимают всю ширину. Кнопка «Показать» и ссылка «↗» в каждой карточке нажимаемы, не перекрываются. Список скроллится." + +--- + +### TC-UI-15 — Совместимость со спутниковой подложкой + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#terrain-toggle" +4. wait: 500 +5. click: "#base-btn-satellite" +6. wait: 5000 +7. click: "#tb-gpx" +8. wait: 1000 +9. click: "#source-btn-nearby" +10. wait: 500 +11. click: "#btn-gpx-find-nearby" +12. wait: 4000 +13. click: ".gnc-import" +14. wait: 4000 +15. screenshot: "15-osm-track-on-satellite" +16. check-visual: "На спутниковой подложке видна цветная линия импортированного OSM-трека. Линия имеет hover-видимость (контрастная для спутника). Панель #sheet-gpx не конфликтует со спутником визуально." + +--- + +### TC-UI-16 — Сохранение треков при переключении тёмной темы + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-nearby" +6. wait: 500 +7. click: "#btn-gpx-find-nearby" +8. wait: 4000 +9. click: ".gnc-import" +10. wait: 4000 +11. click: "#btn-theme" +12. wait: 3000 +13. screenshot: "16-osm-track-after-theme-switch" +14. check-visual: "После переключения тёмной темы цветная линия импортированного OSM-трека остаётся на карте. В #gpx-list карточка трека с источником «🌍 OSM #...» сохранилась." + +--- + +### TC-UI-17 — Сохранение треков при переключении источника «Из файла» + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-nearby" +6. wait: 500 +7. click: "#btn-gpx-find-nearby" +8. wait: 4000 +9. click: ".gnc-import" +10. wait: 4000 +11. click: "#source-btn-file" +12. wait: 500 +13. screenshot: "17-back-to-file-tab" +14. check-visual: "Активна вкладка «Из файла», секция «Найти рядом» свернута. Импортированный OSM-трек остаётся в нижнем списке #gpx-list (карточка с «🌍 OSM #...»). Сам трек по-прежнему видим на карте." + +--- + +### TC-UI-18 — Внешняя ссылка ↗ на osm.org + +- тип: ui +- viewport: desktop + +шаги: +1. navigate: https://openclaw.mva154.duckdns.org/enduro/ +2. wait: 5000 +3. click: "#tb-gpx" +4. wait: 1000 +5. click: "#source-btn-nearby" +6. wait: 500 +7. click: "#btn-gpx-find-nearby" +8. wait: 4000 +9. screenshot: "18-external-link-button" +10. check-visual: "В каждой карточке .gpx-nearby-card в правом углу видна кнопка «↗» (.gnc-external). Кнопка имеет hover-состояние (cursor:pointer), визуально отличима от основной кнопки «Показать»." From 019d944557d0a326e06e34fef3fd63c3a36d9677 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 14:27:29 +0300 Subject: [PATCH 2/2] fix(analyst): add explicit Write tool instruction --- .openclaw/agents/analyst.md | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/.openclaw/agents/analyst.md b/.openclaw/agents/analyst.md index 416b2eb..62c19f8 100644 --- a/.openclaw/agents/analyst.md +++ b/.openclaw/agents/analyst.md @@ -12,22 +12,34 @@ tools: Ты — бизнес-аналитик проекта enduro-trails. По бизнес-запросу создаёшь полный пакет документов для разработки. +## КРИТИЧЕСКИ ВАЖНО: Используй Write tool! + +**Ты ОБЯЗАН создавать файлы через Write tool.** Не описывай содержимое в тексте ответа — +ЗАПИСЫВАЙ каждый артефакт в файл. Orchestrator проверяет наличие файлов на диске. + +Порядок работы: +1. Прочитай входные данные (Read tool) +2. Создай КАЖДЫЙ deliverable через Write tool (полное содержимое файла) +3. В конце выведи краткий summary что создано + +Если ты просто напишешь текст без вызова Write — артефакты будут потеряны! + ## Что прочесть 1. CLAUDE.md — паспорт проекта 2. docs/work-items//00-business-request.md — входные данные 3. docs/phases/ — текущий roadmap 4. src/web/index.html, src/api/main.py — текущий стейт приложения -## Deliverables (создать в docs/work-items//) +## Deliverables (создать через Write tool в docs/work-items//) ### Обязательные -- `01-brd.md` — Business Requirements Document -- `02-trz.md` — Техническое задание -- `03-acceptance-criteria.md` — Критерии приёмки -- `04-test-plan.yaml` — план функциональных тестов (unit, integration, e2e) +- 01-brd.md — Business Requirements Document +- 02-trz.md — Техническое задание +- 03-acceptance-criteria.md — Критерии приёмки +- 04-test-plan.yaml — план функциональных тестов (unit, integration, e2e) ### UI тест-кейсы (обязательно если задача затрагивает UI) -- `04b-ui-test-cases.md` — Playwright UI тест-кейсы для визуального тестирования +- 04b-ui-test-cases.md — Playwright UI тест-кейсы для визуального тестирования **Когда создавать 04b-ui-test-cases.md:** - Задача добавляет новый UI элемент (кнопка, панель, слой на карте) @@ -40,12 +52,12 @@ tools: Каждый тест-кейс — заголовок ### TC-UI-XX — Название, тип ui, viewport desktop|mobile|both. Шаги — нумерованный список: -- navigate: -- wait: (3000-5000 для карты) -- click: "" -- scroll: -- screenshot: "" -- check-visual: "<что проверяем>" +- navigate: url +- wait: ms (3000-5000 для карты) +- click: css-selector +- scroll: pixels +- screenshot: name +- check-visual: что проверяем URL: всегда https://openclaw.mva154.duckdns.org/enduro/ CSS-селекторы: проверяй по src/web/index.html. Типичные ID: #sheet-gpx, #unit-toggle, #terrain-toggle, #poi-checkbox, #map. @@ -54,3 +66,4 @@ CSS-селекторы: проверяй по src/web/index.html. Типичны - Предлагать архитектурные решения - Писать код - Изменять артефакты других work item +- Выводить содержимое файлов в stdout вместо записи через Write tool