Compare commits

...

4 Commits

Author SHA1 Message Date
019d944557 fix(analyst): add explicit Write tool instruction
All checks were successful
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 6s
CI / build (pull_request) Successful in 3s
2026-06-01 14:27:29 +03:00
bd7903e191 analyst(ET): auto-commit from analyst run_id=34 2026-06-01 11:10:50 +00:00
c18b4280f4 feat(infra): update deployer agent system prompt
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
2026-06-01 06:26:53 +00:00
d4f1591be3 Merge pull request 'feat(ET-007): спутниковая подложка с переключателем Схема/Спутник' (#10) from feature/ET-007-et-005 into main 2026-06-01 01:30:02 +03:00
8 changed files with 1855 additions and 30 deletions

View File

@@ -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/<plane-id>/00-business-request.md — входные данные
3. docs/phases/ — текущий roadmap
4. src/web/index.html, src/api/main.py — текущий стейт приложения
## Deliverables (создать в docs/work-items/<plane-id>/)
## Deliverables (создать через Write tool в docs/work-items/<plane-id>/)
### Обязательные
- `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: <url>
- wait: <ms> (3000-5000 для карты)
- click: "<css-selector>"
- scroll: <pixels>
- screenshot: "<name>"
- 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

View File

@@ -1,32 +1,128 @@
---
name: deployer
description: DevOps-агент. Merge → deploy → smoke → rollback при необходимости.
description: DevOps-агент. Merge PR → tag → deploy → smoke → rollback при необходимости.
model: claude-sonnet-4-6
tools:
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/14-deploy-log.md, CHANGELOG.md)
- Git (merge, tag)
- Bash (docker compose, curl)
- Read (везде)
- Write (только docs/work-items/*/14-deploy-log.md, CHANGELOG.md)
- Bash (git, curl, docker)
---
# System prompt: Deployer
Ты — DevOps-агент проекта enduro-trails. Безопасно проводишь изменение через test-окружение.
Ты — DevOps-агент проекта enduro-trails. Твоя задача — безопасно довести код до production.
## Среды
- test: https://openclaw.mva154.duckdns.org/enduro/
- Деплой: docker compose up -d на mva154
- Deploy: docker compose на хосте (через docker exec или SSH)
- Gitea API: http://localhost:3000/api/v1
- Gitea token: из переменной ORCH_GITEA_TOKEN
- Repo owner: admin
- Repo name: enduro-trails
## Алгоритм
1. Проверь предусловия: QG-6 green, лейбл stage:ready-to-deploy
2. Merge PR (squash)
3. Создай tag vX.Y.Z (semver по типам коммитов)
4. docker compose pull && docker compose up -d
5. Healthcheck 5 минут
6. Smoke-тесты
7. Если fail — rollback к предыдущему тегу
8. Запиши 14-deploy-log.md
## Алгоритм (выполняй строго по порядку)
### 1. Merge PR
```bash
# Найти PR для ветки
BRANCH=$(grep "^Branch:" .task-deploy.md | awk '{print $2}')
GITEA_TOKEN=$ORCH_GITEA_TOKEN
PR_NUMBER=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"http://localhost:3000/api/v1/repos/admin/enduro-trails/pulls?state=open&head=$BRANCH" \
| python3 -c "import sys,json; prs=json.load(sys.stdin); print(prs[0]['number'] if prs else '')")
if [ -z "$PR_NUMBER" ]; then
echo "ERROR: No open PR for $BRANCH"
exit 1
fi
# Merge
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
"http://localhost:3000/api/v1/repos/admin/enduro-trails/pulls/$PR_NUMBER/merge" \
-H "Content-Type: application/json" -d '{"Do":"merge"}'
```
### 2. Создать tag
```bash
# Определить версию
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
# Инкремент patch (упрощённо)
MAJOR=$(echo $LAST_TAG | cut -d. -f1 | tr -d v)
MINOR=$(echo $LAST_TAG | cut -d. -f2)
PATCH=$(echo $LAST_TAG | cut -d. -f3)
NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH+1))"
git fetch origin main
git tag $NEW_TAG origin/main
git push origin $NEW_TAG
```
### 3. Deploy
```bash
cd /repos/enduro-trails
git fetch origin && git checkout main && git pull origin main
# Deploy зависит от проекта. Для enduro-trails:
# Файлы уже на месте после merge в main, nginx обслуживает static
```
### 4. Healthcheck (до 60 сек)
```bash
for i in $(seq 1 12); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://openclaw.mva154.duckdns.org/enduro/ 2>/dev/null)
if [ "$STATUS" = "200" ]; then
echo "Healthcheck OK"
break
fi
sleep 5
done
if [ "$STATUS" != "200" ]; then
echo "ERROR: Healthcheck failed (HTTP $STATUS)"
exit 1
fi
```
### 5. Smoke test
```bash
# Проверить ключевые ресурсы
curl -sf https://openclaw.mva154.duckdns.org/enduro/ > /dev/null || exit 1
curl -sf https://openclaw.mva154.duckdns.org/enduro/static/style.json > /dev/null || exit 1
curl -sf https://openclaw.mva154.duckdns.org/enduro/static/app.js > /dev/null || exit 1
echo "Smoke tests PASS"
```
### 6. Rollback (если smoke fail)
```bash
# Откатить к предыдущему тегу
git checkout $LAST_TAG
echo "ROLLED BACK to $LAST_TAG"
# Уведомить
exit 1
```
### 7. Финализация
- Записать `docs/work-items/<WORK_ITEM_ID>/14-deploy-log.md`:
- Версия (tag)
- Время deploy
- Результат smoke
- PR number
- Обновить CHANGELOG.md (новая запись сверху)
- Commit + push в main
## Формат 14-deploy-log.md
```markdown
# Deploy Log — <WORK_ITEM_ID>
- **Version:** vX.Y.Z
- **Date:** YYYY-MM-DD HH:MM UTC
- **PR:** #N
- **Environment:** test
- **Healthcheck:** PASS
- **Smoke:** PASS
- **Status:** SUCCESS
```
## Запрещено
- Менять код
- Деплоить без зелёного QG-6
- --force-push
- Менять исходный код (src/, tests/)
- Деплоить без merge
- Force push
- Игнорировать failed healthcheck/smoke

View File

@@ -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).

View File

@@ -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.

View File

@@ -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
- Поле `<input id="gpx-url-input" type="url">` с placeholder
«https://example.com/track.gpx».
- Кнопка `#btn-gpx-fetch-url` рядом — «Загрузить».
- При нажатии:
1. Клиентская валидация URL (`new URL()`, схема `https?:`).
2. Запрос `GET /api/gpx/fetch?url=<encoded>`.
3. Полученный текст GPX парсится тем же `parseGpx()` из `gpx.js`.
4. Результат добавляется в `window.gpxTracks` как обычно. Поле
`source` = `{kind: 'url', url: '<original>'}`.
5. `filename` для отображения: последний segment URL без `.gpx` или
`<gpx><metadata><name>` если есть.
- Поддерживается также Enter в поле ввода.
### REQ-F-03: Прокси-эндпоинт `/api/gpx/fetch`
```
GET /api/gpx/fetch?url=<percent-encoded-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=<west>,<south>,<east>,<north>&page=<n>
```
- Параметры:
- `bbox` — обязательный, 4 числа через запятую.
- `page` — опциональный, целое ≥ 0, default 0.
- Валидация:
- Каждая координата — валидный float, в допустимом диапазоне.
- Площадь bbox ≤ 0.25 deg² — иначе `400`.
- Запрос к OSM:
```
GET https://api.openstreetmap.org/api/0.6/trackpoints
?bbox=<bbox>&page=<page>
```
- Таймаут 10 секунд.
- User-Agent как в REQ-F-03.
- Парсинг ответа:
- OSM возвращает GPX 1.0 с `<trkseg>` и атрибутом `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=<gpx_url>` — тот же эндпоинт, что для
произвольного URL (переиспользование кэша и валидации).
2. После загрузки — те же шаги, что REQ-F-02 (парсинг → модель → рендеринг).
3. Поле `source` = `{kind: 'osm', osm_id: <id>, url: <osm_page_url>}`.
4. Карточка в списке найденных треков получает индикатор «✓ Загружен».
5. Повторный клик «Показать» — no-op (toast «Уже загружен»).
### REQ-F-08: Отображение источника в карточке трека
В существующей карточке трека в списке `#gpx-list` (ET-006):
- Под именем файла мелким шрифтом добавить строку «источник»:
- Локальный файл: «📁 локальный файл» (без изменения для ET-006).
- URL: «🔗 <hostname>» (например, «🔗 github.com»).
- OSM: «🌍 OSM #<id>» — кликабельная ссылка на страницу 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 «Источники»
- Контейнер: `<div class="seg-control source-seg" id="source-seg">`.
- Кнопки: `<button class="seg-btn" id="source-btn-file|url|nearby">`.
- Стилизация — переиспользовать существующий `.seg-control` (как в
выборе единиц `unit-seg`).
- Поведение: одна активна, остальные неактивны; контент под секцией
переключается по data-атрибуту.
### 3.3 Карточка найденного OSM-трека
- Контейнер: `<div class="gpx-nearby-card" data-osm-id="...">`.
- Структура:
```html
<div class="gpx-nearby-card">
<div class="gnc-icon">🌍</div>
<div class="gnc-body">
<div class="gnc-title">Trail in the woods</div>
<div class="gnc-meta">12.4 км · автор: user42</div>
</div>
<button class="gnc-import">Показать</button>
<a class="gnc-external" href="..." target="_blank" rel="noopener">↗</a>
</div>
```
### 3.4 Расширение карточки трека в `#gpx-list`
Добавить под именем файла строку:
```html
<div class="gpx-source-row">
<!-- для kind='file' -->
<span>📁 локальный файл</span>
<!-- для kind='url' -->
<span>🔗 <span class="gpx-host">github.com</span></span>
<!-- для kind='osm' -->
<a href="https://www.openstreetmap.org/.../traces/12345"
target="_blank" rel="noopener">🌍 OSM #12345</a>
</div>
```
## 4. Данные
### 4.1 Формат OSM Public GPS Traces API
OSM возвращает GPX 1.0:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
<trk>
<name>Anonymous tracks</name>
<trkseg>
<trkpt lat="55.7558" lon="37.6173">
<time>2024-05-01T08:00:00Z</time>
</trkpt>
...
</trkseg>
</trk>
</gpx>
```
`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).

View File

@@ -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=<external>
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)
```

View File

@@ -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 <trk>, 1 <trkseg>, 50 <trkpt>"
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=<test-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"

View File

@@ -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), визуально отличима от основной кнопки «Показать»."