Compare commits
23 Commits
fix/analys
...
docs/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37af99eb6b | ||
|
|
e2bf99d05f | ||
| 506ef2a6dc | |||
| 5769217cc5 | |||
| 04d9d3e028 | |||
|
|
af1a493cbf | ||
| 1ffa178b38 | |||
|
|
7c9cb37ecd | ||
| ba356ae317 | |||
|
|
3a6017cc82 | ||
| edbe9a3044 | |||
|
|
37190049db | ||
|
|
3734b98168 | ||
| 0060003f28 | |||
| a0284e046b | |||
| bd8f60879e | |||
|
|
f5fc8b121d | ||
|
|
d33f360a2f | ||
|
|
0840818c9a | ||
|
|
dc557ab884 | ||
| e8dbea6f13 | |||
| fd28a53e12 | |||
| 514490efd9 |
@@ -59,10 +59,17 @@ 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
|
||||
# Deploy через SSH на хост (orchestrator имеет SSH ключ)
|
||||
DEPLOY_USER=${DEPLOY_SSH_USER:-slin}
|
||||
DEPLOY_HOST=${DEPLOY_SSH_HOST:-127.0.0.1}
|
||||
HOOK=${DEPLOY_HOOK_SCRIPT:-/home/slin/bin/enduro-deploy-hook.sh}
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${DEPLOY_USER}@${DEPLOY_HOST} "bash ${HOOK}"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "ERROR: Deploy hook failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "Deploy OK"
|
||||
```
|
||||
|
||||
### 4. Healthcheck (до 60 сек)
|
||||
|
||||
5
.task-arch.md
Normal file
5
.task-arch.md
Normal file
@@ -0,0 +1,5 @@
|
||||
Work item: ET-008
|
||||
Repo: enduro-trails
|
||||
Branch: feature/ET-008-gps
|
||||
Stage: architecture
|
||||
Title: GPS-треки с публичных платформ на карте
|
||||
4
.task-dev.md
Normal file
4
.task-dev.md
Normal file
@@ -0,0 +1,4 @@
|
||||
Work item: ET-008
|
||||
Repo: enduro-trails
|
||||
Branch: feature/ET-008-gps
|
||||
Stage: development
|
||||
4
.task-review.md
Normal file
4
.task-review.md
Normal file
@@ -0,0 +1,4 @@
|
||||
Work item: ET-008
|
||||
Repo: enduro-trails
|
||||
Branch: feature/ET-008-gps
|
||||
Stage: review
|
||||
41
.task.md
41
.task.md
@@ -1,36 +1,5 @@
|
||||
Прочитай CLAUDE.md. Твоя задача — bootstrap проекта для CI:
|
||||
|
||||
1. Создай pyproject.toml в корне с секциями:
|
||||
- [project] name="enduro-trails", version="0.1.0", requires-python=">=3.12"
|
||||
- [project.optional-dependencies] dev = ["ruff>=0.4.0", "pytest>=8.0", "httpx>=0.27", "pytest-asyncio>=0.23"]
|
||||
- [tool.ruff] target-version="py312", line-length=120
|
||||
- [tool.pytest.ini_options] asyncio_mode="auto", testpaths=["tests"]
|
||||
|
||||
2. Создай tests/unit/test_health.py:
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from src.api.main import app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
3. Создай tests/__init__.py и tests/unit/__init__.py (пустые файлы)
|
||||
|
||||
4. Обнови .gitea/workflows/ci.yml:
|
||||
- Используй образ python:3.12 для всех job
|
||||
- Установка зависимостей: pip install -e ".[dev]"
|
||||
- lint: ruff check src/
|
||||
- test: pytest tests/
|
||||
- build: docker build .
|
||||
|
||||
5. Создай ветку feature/bootstrap, закоммить всё, запуш в origin.
|
||||
|
||||
Коммит message: "feat: add pyproject.toml, dev dependencies, first unit test"
|
||||
Push в ветку feature/bootstrap (НЕ в main).
|
||||
Git remote использует http://localhost:3000/admin/enduro-trails.git
|
||||
Work item: ET-008
|
||||
Repo: enduro-trails
|
||||
Branch: feature/ET-008-gps
|
||||
Stage: analysis
|
||||
Title: GPS-треки с публичных платформ на карте
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -3,6 +3,17 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
## [v0.0.1] — 2026-06-01
|
||||
|
||||
### Added
|
||||
- ET-008: GPS-треки с публичных платформ на карте — новый модуль `src/web/gps_tracks.js`
|
||||
с отображением публичных GPS-треков (OSM Traces, enduro_russia, ttrails) в виде
|
||||
MVT-тайлов (z 8–11) и GeoJSON (z ≥ 12); фильтрация по активности и источнику,
|
||||
попап с мета-данными трека, z-order ниже личных GPX-треков (AC-10).
|
||||
Backend: FastAPI-пакет `src/api/gps_tracks/` (endpoint, MVT, LRU-кэш, дедупликация),
|
||||
миграция `migrations/gps_tracks_001_init.sql`, pipeline-скрипт `scripts/gps_collect.py`,
|
||||
Docker-сервис `gps-collector`. PR #12, tag v0.0.1.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,6 +4,9 @@ COPY src/api/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY src/api/ ./src/api/
|
||||
COPY src/web/ ./src/web/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY migrations/ ./migrations/
|
||||
COPY docs/ ./docs/
|
||||
ENV STATIC_DIR=/app/src/web
|
||||
ENV PORT=5556
|
||||
EXPOSE 5556
|
||||
|
||||
12
config/gps_regions.yaml
Normal file
12
config/gps_regions.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
regions:
|
||||
- id: tsfo_plus_chuvashia
|
||||
name: "ЦФО + Чувашия"
|
||||
bbox: [29.0, 49.5, 47.5, 60.0]
|
||||
enabled: true
|
||||
sources: [osm, enduro_russia, ttrails]
|
||||
|
||||
- id: north_caucasus
|
||||
name: "Северный Кавказ"
|
||||
bbox: [37.0, 41.5, 49.0, 47.0]
|
||||
enabled: false
|
||||
sources: [osm, enduro_russia]
|
||||
34
config/gps_sources.yaml
Normal file
34
config/gps_sources.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
sources:
|
||||
- id: osm
|
||||
name: "OSM Public GPS Traces"
|
||||
enabled: true
|
||||
license_adr: "docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md"
|
||||
base_url: "https://api.openstreetmap.org/api/0.6"
|
||||
rate_limit_sec: 1
|
||||
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
|
||||
attribution: "© OpenStreetMap contributors (ODbL)"
|
||||
parser_module: "src.api.gps_tracks.sources.osm"
|
||||
save_user_field: true
|
||||
external_url_template: "https://www.openstreetmap.org/user/{user}/traces/{external_id_numeric}"
|
||||
|
||||
- id: enduro_russia
|
||||
name: "EnduroRussia.ru"
|
||||
enabled: false
|
||||
license_adr: "docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md"
|
||||
base_url: "https://enduro-russia.ru"
|
||||
rate_limit_sec: 5
|
||||
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
|
||||
attribution: "EnduroRussia.ru"
|
||||
parser_module: "src.api.gps_tracks.sources.enduro_russia"
|
||||
save_user_field: false
|
||||
|
||||
- id: ttrails
|
||||
name: "Тропинки.ру"
|
||||
enabled: false
|
||||
license_adr: "docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md"
|
||||
base_url: "https://ttrails.ru"
|
||||
rate_limit_sec: 5
|
||||
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
|
||||
attribution: "ttrails.ru"
|
||||
parser_module: "src.api.gps_tracks.sources.ttrails"
|
||||
save_user_field: false
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./src/web:/app/src/web
|
||||
- ./config:/app/config:ro
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///./data/enduro.db
|
||||
- DATA_PATH=/app/data/centralfederal.sqlite
|
||||
@@ -15,8 +16,25 @@ services:
|
||||
- STATIC_DIR=/app/src/web
|
||||
- OSRM_URL=http://172.22.0.1:5559
|
||||
- PORT=5556
|
||||
- GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite
|
||||
- GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml
|
||||
- GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
gps-collector:
|
||||
build: .
|
||||
profiles: ["batch"]
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./config:/app/config:ro
|
||||
- /var/log/enduro-trails:/var/log/enduro-trails
|
||||
environment:
|
||||
- GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite
|
||||
- GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml
|
||||
- GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml
|
||||
command: ["python", "-m", "scripts.gps_collect"]
|
||||
restart: "no"
|
||||
|
||||
@@ -18,3 +18,24 @@
|
||||
- [PH-7.barriers](./phases/PH-7.barriers/) — Шлагбаумы, тротуары, слой препятствий
|
||||
- [PH-8.elevation-profile](./phases/PH-8.elevation-profile/) — Профиль высот, режим «Горка»
|
||||
- [PH-9.pwa](./phases/PH-9.pwa/) — Офлайн режим
|
||||
|
||||
## Задачи (Work Items)
|
||||
|
||||
| ID | Название | Статус | Ветка |
|
||||
|----|----------|--------|-------|
|
||||
| ET-001 | Слой шлагбаумов | ✅ Done | main |
|
||||
| ET-002 | POI и маршруты | ✅ Done | main |
|
||||
| ET-005 | Переключатель единиц | ✅ Done | main |
|
||||
| ET-006 | Загрузка GPX-треков | ✅ Done | main |
|
||||
| ET-007 | Спутниковый слой | ✅ Done | main |
|
||||
| ET-008 | GPS-треки с публичных платформ | ✅ Done | main |
|
||||
|
||||
## Инфраструктура
|
||||
|
||||
- **URL:** https://openclaw.mva154.duckdns.org/enduro/
|
||||
- **Host:** mva154 (82.22.50.71)
|
||||
- **App container:** enduro-trails-app-1 (port 5558)
|
||||
- **GPS collector:** docker compose --profile batch run --rm gps-collector
|
||||
- **Deploy:** автоматически через orchestrator deployer (SSH hook)
|
||||
- **Логи deploy:** /var/log/enduro-trails/deploy-hook.log
|
||||
- **Pipeline:** Multi-Agent Orchestrator (port 8500)
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
- **Backend API** — FastAPI (Python 3.12), uvicorn
|
||||
- **Tile Server** — статические raster tiles (PNG), раздаются через FastAPI/nginx
|
||||
- **Routing Engine** — OSRM с кастомным эндуро-профилем
|
||||
- **Database** — SQLite + Spatialite (точки интереса, маршруты)
|
||||
- **Database** — SQLite + Spatialite (точки интереса, маршруты, публичные GPS-треки)
|
||||
- **GPS Tracks Pipeline** — `gps-collector` (docker-compose service, `profiles: [batch]`), запускается host cron'ом 1–2 раза в неделю; собирает публичные GPS-треки с внешних платформ в `data/gps_tracks.sqlite` (ET-008 / ADR-007)
|
||||
|
||||
## Слои карты
|
||||
- Base map: **Схема** (OpenStreetMap raster) либо **Спутник** (Esri World Imagery raster) — переключается в UI (ET-007 / ADR-004)
|
||||
@@ -30,6 +31,44 @@
|
||||
Атрибуция обоих провайдеров выводится MapLibre автоматически при
|
||||
активном source.
|
||||
|
||||
## GPS Tracks Pipeline (ET-008)
|
||||
|
||||
Серверный офлайн-pipeline сбора публичных GPS-треков. Не часть runtime
|
||||
API, изолирован отдельным docker-compose service'ом и отдельной БД.
|
||||
|
||||
### Компонент
|
||||
|
||||
- Сервис: `gps-collector` в `docker-compose.yml`, `profiles: ["batch"]`,
|
||||
тот же образ что `app`, не стартует при `docker compose up -d`.
|
||||
- Точка входа: `scripts/gps_collect.py` (см. `src/api/gps_tracks/`).
|
||||
- Расписание: cron на mva154, Mon + Thu 03:00 UTC; + ежемесячный GC.
|
||||
- БД: `data/gps_tracks.sqlite` (SQLite + Spatialite, отдельный файл от
|
||||
`centralfederal.sqlite`).
|
||||
|
||||
### Внешние источники pipeline
|
||||
|
||||
Скрейпинг/API только из контейнера `gps-collector`, при наличии
|
||||
accepted-ADR на источник.
|
||||
|
||||
| Источник | Доступ | Лицензия | ADR | MVP |
|
||||
|---|---|---|---|---|
|
||||
| OSM Public GPS Traces | API `api.openstreetmap.org/api/0.6/trackpoints` | ODbL | ADR-009 (accepted) | да |
|
||||
| EnduroRussia.ru | HTML + GPX-ссылки | требует review | ADR-010 (proposed/blocked) | условно |
|
||||
| ttrails.ru / Тропинки.ру | HTML + GPX-ссылки | требует review | ADR-011 (proposed/blocked) | условно |
|
||||
|
||||
Источник без `status: accepted` в ADR pipeline'ом **пропускается** (см.
|
||||
ADR-007 §6 licensing guard).
|
||||
|
||||
### Клиентский слой публичных треков
|
||||
|
||||
Двухрежимная отдача (см. ADR-008):
|
||||
- z=8..11 — MVT через `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` + сервер-LRU.
|
||||
- z≥12 — GeoJSON через `GET /api/gps-tracks?bbox=...&activity=...&source=...`.
|
||||
- z<8 — слой скрыт (защита от шторма запросов).
|
||||
|
||||
Health/observability: `GET /api/gps-tracks/health` — состояние БД,
|
||||
число треков по источникам, последний прогон.
|
||||
|
||||
## Деплой
|
||||
Один Docker Compose на mva154. Nginx проксирует /enduro/ на контейнер.
|
||||
|
||||
@@ -40,6 +79,7 @@
|
||||
| `app.js` | Главный модуль: MapLibre, роутинг, UI, тёмная тема | PH-1..PH-6 |
|
||||
| `units.js` | Централизованный форматтер расстояний (км/мили), localStorage, событие `unitchange` | ET-005 |
|
||||
| `gpx.js` | GPX 1.1 парсер (DOMParser), рендеринг треков/waypoints, canvas-профиль высот, `rebuildMapOverlays()` | ET-006 |
|
||||
| `gps_tracks.js` | Слой публичных GPS-треков (MVT + GeoJSON гибрид по zoom), фильтры по активности/источнику, popup с метаданными, halo на спутнике, `restorePublicTracksState()` | ET-008 |
|
||||
| `style.json` | MapLibre стиль (светлая тема) | PH-1/PH-5 |
|
||||
| `style-dark.json` | MapLibre стиль (тёмная тема) | PH-5 |
|
||||
|
||||
|
||||
@@ -8,3 +8,10 @@
|
||||
| ADR-002 | GPX-фича как отдельный модуль `gpx.js` | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md) |
|
||||
| ADR-003 | Парсинг GPX — `DOMParser` в основном потоке с чанковой конвертацией | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-003-gpx-parsing-strategy.md) |
|
||||
| ADR-004 | Спутниковая подложка: Esri World Imagery, ленивый raster-source, гибридное halo | accepted | 2026-05-31 | [ET-007](../../work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md) |
|
||||
| ADR-005 | Хранение публичных GPS-треков: отдельная БД `data/gps_tracks.sqlite`, единая таблица, sources как JSON-массив (arch:major-change) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-005-storage-schema.md) |
|
||||
| ADR-006 | Дедупликация GPS-треков: bbox+length+date bucket-hash, мерж sources при коллизии, без геометрических метрик | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md) |
|
||||
| ADR-007 | Pipeline сбора GPS-треков: docker-compose service `gps-collector` (profiles:[batch]), запуск host cron'ом, per-source изоляция (arch:major-change) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md) |
|
||||
| ADR-008 | Двухрежимная отдача публичных треков: MVT на z≤11, GeoJSON на z≥12, клиентский AbortController+debounce | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md) |
|
||||
| ADR-009 | OSM Public GPS Traces — licensing: ODbL, accepted для MVP | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-009-osm-licensing.md) |
|
||||
| ADR-010 | EnduroRussia.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md) |
|
||||
| ADR-011 | ttrails.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md) |
|
||||
|
||||
50
docs/operations/runbook.md
Normal file
50
docs/operations/runbook.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Runbook: Enduro Trails
|
||||
|
||||
## Сервисы
|
||||
|
||||
| Сервис | Команда | Порт |
|
||||
|--------|---------|------|
|
||||
| App (API + static) | `docker compose up -d app` | 5558 |
|
||||
| GPS Collector (разовый запуск) | `docker compose --profile batch run --rm gps-collector` | — |
|
||||
| GPS Collector (с регионом) | `docker compose --profile batch run --rm gps-collector python scripts/gps_collect.py --region tsfo_plus_chuvashia --source osm` | — |
|
||||
|
||||
## Deploy
|
||||
|
||||
Deploy выполняется автоматически через Multi-Agent Orchestrator.
|
||||
При ручном деплое:
|
||||
```bash
|
||||
cd /home/slin/repos/enduro-trails
|
||||
git pull origin main
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
## GPS Collector
|
||||
|
||||
Первичный сбор треков (ЦФО + Чувашия, OSM):
|
||||
```bash
|
||||
cd /home/slin/repos/enduro-trails
|
||||
nohup docker compose --profile batch run --rm gps-collector python scripts/gps_collect.py --region tsfo_plus_chuvashia --source osm > /tmp/gps-collector.log 2>&1 &
|
||||
```
|
||||
|
||||
Статус:
|
||||
```bash
|
||||
tail -f /tmp/gps-collector.log
|
||||
```
|
||||
|
||||
Активация EnduroRussia/ttrails источников — после юридического review ADR-010/ADR-011:
|
||||
1. Обновить статус ADR до `accepted`
|
||||
2. Установить `enabled: true` в `config/gps_sources.yaml`
|
||||
|
||||
## Healthcheck
|
||||
|
||||
```bash
|
||||
curl -s https://openclaw.mva154.duckdns.org/enduro/api/health
|
||||
curl -s https://openclaw.mva154.duckdns.org/enduro/api/gps-tracks/health
|
||||
```
|
||||
|
||||
## Логи
|
||||
|
||||
```bash
|
||||
docker logs enduro-trails-app-1 --tail 50
|
||||
tail -f /var/log/enduro-trails/deploy-hook.log
|
||||
```
|
||||
@@ -1,51 +1,26 @@
|
||||
---
|
||||
type: business-request
|
||||
work_item_id: ET-008
|
||||
title: "GPS-треки с публичных платформ на карте"
|
||||
created_at: 2026-06-01
|
||||
source: plane
|
||||
requester: Слава
|
||||
---
|
||||
# Business Request: GPS-треки с публичных платформ на карте
|
||||
|
||||
# Бизнес-запрос — ET-008
|
||||
## Цель
|
||||
Отобразить на карте enduro-trails реальные GPS-треки с публичных платформ, чтобы видеть дороги/тропы которых нет на OSM, понимать где реально ездят, и выявлять мёртвые дороги.
|
||||
|
||||
## Исходная формулировка
|
||||
## Требования
|
||||
- Отдельные линии треков (не heatmap)
|
||||
- Регион: ЦФО + Чувашия (расширяемо на другие регионы РФ)
|
||||
- Фильтрация по типу активности (enduro/moto/offroad приоритет)
|
||||
|
||||
> Хочу видеть на карте GPS-треки с публичных платформ (OSM, чужие ссылки
|
||||
> на GPX), а не только локальные файлы. Минимум: вставить ссылку на
|
||||
> GPX-файл — увидеть трек. Дальше — поиск чужих публичных треков в
|
||||
> видимой области карты, чтобы перед поездкой посмотреть, кто и где ездил.
|
||||
## Источники треков (РФ покрытие)
|
||||
- Wikiloc (enduro/Russia раздел, GPX)
|
||||
- Offmaps.ru (offroad специализация)
|
||||
- Тропинки.ру / ttrails.ru (GPX/KML, эндуро-категория)
|
||||
- EnduroRussia.ru (GPX по регионам, фильтр сложности)
|
||||
- OSM GPS Traces (публичные, API)
|
||||
- Nakarte.me (агрегатор)
|
||||
- Komoot (API)
|
||||
- Strava Metro (для валидации популярности)
|
||||
|
||||
## Контекст и ограничения
|
||||
|
||||
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).
|
||||
## Функционал
|
||||
1. Сбор GPX-треков по bbox региона из источников
|
||||
2. Хранение с дедупликацией и метаданными (источник, тип активности, дата, сложность)
|
||||
3. Визуализация отдельными линиями на карте (цвет по источнику или типу)
|
||||
4. Фильтр по типу активности и источнику
|
||||
5. Расширяемость на новые регионы
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
type: brd
|
||||
work_item_id: ET-008
|
||||
title: "BRD: GPS-треки с публичных платформ на карте"
|
||||
version: 1
|
||||
version: 2
|
||||
status: draft
|
||||
created_at: 2026-06-01
|
||||
updated_at: 2026-06-01
|
||||
changelog:
|
||||
- "v2 (2026-06-01): полная переработка под реальный business request — серверная агрегация из ≥3 источников по региону, дедупликация, фильтры по активности и источнику, расширяемость на регионы. Предыдущая v1 трактовала задачу как URL-импорт + OSM live-поиск, что не соответствовало бизнес-цели."
|
||||
authors:
|
||||
- "agent:analyst"
|
||||
---
|
||||
@@ -14,85 +16,213 @@ authors:
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Дать пользователю возможность увидеть на карте Enduro Trails GPS-треки
|
||||
с публичных источников без скачивания файлов вручную: либо вставив
|
||||
прямую ссылку на GPX, либо найдя чужие публичные треки в видимой
|
||||
области карты (через OSM Public GPS Traces).
|
||||
Показать пользователю Enduro Trails реальные GPS-треки, **заранее
|
||||
собранные с публичных платформ** (Wikiloc, Offmaps.ru, Тропинки.ру,
|
||||
EnduroRussia.ru, OSM Public GPS Traces, Nakarte.me, Komoot и т.п.) и
|
||||
сохранённые на сервере. Цель — три практические задачи мотоэндуриста:
|
||||
|
||||
1. **Видеть реальные дороги/тропы, которых нет в OSM.** Vector-тайлы
|
||||
`trails` показывают только OSM-данные; реальные грунтовки/тропы из
|
||||
GPS-логов дают информацию, которой в OSM никогда не было.
|
||||
2. **Понимать, где реально ездят.** Плотность публичных треков на
|
||||
участке — прямая прокси-метрика популярности и проходимости.
|
||||
3. **Выявлять «мёртвые» дороги.** OSM-грунтовка, не покрытая ни одним
|
||||
публичным треком за последние N лет — кандидат на «давно никто не
|
||||
ездит, может быть заросла».
|
||||
|
||||
ET-008 даёт **новый отдельный слой** (поверх `trails`, ниже маршрута
|
||||
OSRM) с отдельными линиями (не heatmap), цветом по источнику или типу
|
||||
активности, с UI-фильтрами.
|
||||
|
||||
## 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-поиск, нет авторизации для чтения.
|
||||
- Vector-тайлы из OSM (`/api/tiles/{z}/{x}/{y}.mvt`) уже отдают
|
||||
грунтовки/тропы/POI. ET-008 их **не заменяет** — добавляет
|
||||
параллельный слой публичных GPS-треков.
|
||||
- ET-006 реализовал клиентский импорт GPX-файлов пользователем
|
||||
(`window.gpxTracks`, `#sheet-gpx`). Это **другой сценарий**: ET-006 —
|
||||
«мой трек в памяти браузера», ET-008 — «треки сообщества с сервера».
|
||||
Модели данных не пересекаются.
|
||||
- Стек БД: SQLite + Spatialite. Для ET-008 заводится **отдельная** БД
|
||||
`data/gps_tracks.sqlite` — чтобы не смешивать данные с основной
|
||||
`centralfederal.sqlite` и иметь независимый цикл обновления / бэкапа.
|
||||
- Pipeline сбора — **офлайн-скрипт на cron**, не runtime. На запрос
|
||||
пользователя сервер отдаёт уже собранные данные.
|
||||
- Регион MVP: **ЦФО + Чувашия** (18 субъектов ЦФО + Чувашская
|
||||
Республика, площадь ≈ 670 тыс. км²). Расширение на другие регионы —
|
||||
через конфиг-файл.
|
||||
|
||||
## 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` и неотличимы от локальных по поведению |
|
||||
| # | Функция |
|
||||
| ----- | ----------------------------------------------------------------------------- |
|
||||
| F-01 | Pipeline сбора GPX-треков с ≥ 3 публичных источников |
|
||||
| F-02 | Хранение треков в SQLite + Spatialite: геометрия + метаданные |
|
||||
| F-03 | Дедупликация: один реальный трек = одна запись, даже если найден в N источниках |
|
||||
| F-04 | Метаданные трека: источник, URL, тип активности, дата, длина, кол-во точек, автор (если публичен) |
|
||||
| F-05 | API endpoint `GET /api/gps-tracks?bbox=…&activity=…&source=…` для отдачи треков клиенту |
|
||||
| F-06 | Векторные тайлы публичных треков `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` для эффективной отдачи на низких зумах |
|
||||
| F-07 | Визуализация **отдельными линиями** (не heatmap) на карте |
|
||||
| F-08 | Цветовая дифференциация: палитра по источнику (default) с возможностью переключения на палитру по типу активности |
|
||||
| F-09 | UI-чекбокс «Публичные треки» в `#terrain-popup`: включить/выключить весь слой |
|
||||
| F-10 | UI-фильтр по типу активности (enduro / moto / offroad / bicycle / hike / other), multi-select |
|
||||
| F-11 | UI-фильтр по источнику, multi-select |
|
||||
| F-12 | Конфиг-файл регионов: bbox + название + список активных источников |
|
||||
| F-13 | MVP-датасет: ЦФО + Чувашия, ≥ 5000 треков |
|
||||
| F-14 | Совместимость со сменой стиля карты (через `rebuildMapOverlays()` по аналогии с ET-006 REQ-F-13 и ET-007 REQ-F-06) |
|
||||
| F-15 | Совместимость со спутниковой подложкой (ET-007): треки видны на спутнике с halo для контраста |
|
||||
| F-16 | Клик по треку → popup с метаданными: имя/тип активности/источник/дата/длина и ссылка на оригинал |
|
||||
| F-17 | Health-эндпоинт `/api/gps-tracks/health`: дата последнего сбора, кол-во треков по источникам, ошибки последнего прогона |
|
||||
|
||||
### Out of scope
|
||||
|
||||
- OAuth-интеграции (Strava, Komoot)
|
||||
- Платный API Wikiloc
|
||||
- Поиск треков глобально (без bbox)
|
||||
- Сохранение треков в БД между сессиями
|
||||
- Подписки на пользователей других платформ
|
||||
- Загрузка собственных треков на публичные платформы
|
||||
- **Real-time сбор**: только периодический офлайн (cron, 1–2 раза в неделю).
|
||||
- **Wikiloc Premium / Komoot Premium / любые платные API**: используем
|
||||
только бесплатные публичные endpoints и публичные HTML-страницы там,
|
||||
где это разрешено ToS источника.
|
||||
- **Strava Metro как источник линий**: это heatmap, не отдельные треки —
|
||||
не соответствует бизнес-требованию «отдельные линии». Опционально в
|
||||
будущем — как метрика популярности для валидации, не для MVP.
|
||||
- **OAuth-интеграции** (вход пользователя в Strava/Komoot со своим
|
||||
аккаунтом): отдельный work item.
|
||||
- **Загрузка пользователем своих треков в общую базу**: отдельный work item.
|
||||
- **Редактирование/обрезка треков на стороне сервера**.
|
||||
- **Конвертация из KML/FIT/TCX**: pipeline принимает только GPX.
|
||||
- **Snap-to-road** для треков (выравнивание под дороги OSM).
|
||||
- **Учёт сложности (drag-level) внутри трека**: фильтр только по типу
|
||||
активности; сложность — отдельная задача (требует анализа геометрии и
|
||||
скорости).
|
||||
|
||||
## 4. Метрики успеха
|
||||
## 4. Источники (с оценкой реализуемости в MVP)
|
||||
|
||||
| Метрика | Критерий |
|
||||
|---------|----------|
|
||||
| URL-импорт | Прямая ссылка на GPX до 50 МБ загружается за ≤ 5 сек на средней сети |
|
||||
| OSM-поиск bbox | Запрос видимой области возвращает результат за ≤ 3 сек (с кэшем — мгновенно) |
|
||||
| Точность | OSM-трек после импорта визуально совпадает с тем же треком из osm.org |
|
||||
| Кэш | Повторный запрос той же области/URL в течение 24 ч — без обращения к внешнему API |
|
||||
| UX | Все ошибки (CORS, 404, лимит, формат) — внятные toast-уведомления, не падение |
|
||||
| Совместимость с ET-006 | Локальные и удалённые треки в одном списке, поведение идентично |
|
||||
| Сохранение при смене стиля | Импортированные треки переживают переключение тёмной темы и слоёв рельефа |
|
||||
Анализ каждого источника из business request с честной оценкой
|
||||
доступности и юридических условий:
|
||||
|
||||
## 5. Риски
|
||||
| # | Источник | Тип доступа | MVP | Комментарий |
|
||||
| - | ------------------------- | ------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | OSM Public GPS Traces | Документированный API | **да** | `/api/0.6/trackpoints?bbox=…&page=…`. Лицензия ODbL, атрибуция OSM. Объём для ЦФО оценочно ≈ 50–100K точек, тыс. треков. |
|
||||
| 2 | EnduroRussia.ru | Web (HTML + GPX-ссылки) | **да** | По регионам, есть прямые GPX-ссылки. Лицензия и условия скрейпинга — фиксируются в ADR `06-adr/source-licensing.md` до начала разработки. |
|
||||
| 3 | Тропинки.ру / ttrails.ru | Web (GPX/KML) | **да** | Эндуро-категория, GPX доступен без авторизации. Условия скрейпинга — то же ADR. |
|
||||
| 4 | Offmaps.ru | Web | пилот | Требует ревью формата выдачи и лицензии. Подключаем в пилот-режим если ADR разрешает. |
|
||||
| 5 | Nakarte.me | Public layers + JSON | пилот | Агрегатор: содержит ссылки на треки внешних источников. Может быть «бесплатным» путём к Wikiloc/Strava-treki косвенно. Требует ревью лицензии. |
|
||||
| 6 | Wikiloc | API (премиум) | нет | Бесплатный публичный API не отдаёт GPX. Без премиума — невозможно. **Откладываем.** |
|
||||
| 7 | Komoot | API (партнёрский) | нет | Публичный API ограничен, нет публичной выдачи GPX по bbox. **Откладываем.** |
|
||||
| 8 | Strava Metro | API (исследовательский) | нет | Heatmap, не отдельные треки → не соответствует бизнес-требованию. **Out of scope.** |
|
||||
|
||||
| Риск | Вероятность | Влияние | Митигация |
|
||||
|------|-------------|---------|-----------|
|
||||
| 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: показать сообщение, не блокировать другие функции |
|
||||
**MVP-минимум: 3 источника живут в продакшне** — обязательно OSM
|
||||
(гарантированно доступен), плюс минимум 2 из (2)–(5) по результатам
|
||||
ADR-ревью лицензий.
|
||||
|
||||
## 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.
|
||||
Перед началом разработки каждого источника (2)–(5) — **обязательный
|
||||
ADR** `docs/work-items/ET-008/06-adr/<source>-licensing.md`:
|
||||
|
||||
1. Что говорит ToS источника о скрейпинге / массовой загрузке GPX.
|
||||
2. Что говорит robots.txt.
|
||||
3. На каких условиях разрешена публикация чужих треков
|
||||
(имя/анонимизация/атрибуция).
|
||||
4. Rate-limit, который мы будем соблюдать (default: 1 req / 5 sec, с
|
||||
корректным `User-Agent: enduro-trails/<v> (+contact)`).
|
||||
5. Список метаданных, которые **нельзя** сохранять/публиковать (личные
|
||||
адреса, имена при отсутствии явного согласия).
|
||||
|
||||
Источник без явного зелёного света в ADR — **не включается** в pipeline.
|
||||
|
||||
## 5. Метрики успеха
|
||||
|
||||
| Метрика | Критерий MVP |
|
||||
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| Покрытие региона | ≥ 5000 уникальных треков для ЦФО + Чувашии после первого полного прогона pipeline |
|
||||
| Источники в продакшне | ≥ 3 источника, отдающих данные в БД |
|
||||
| Дедупликация | < 5% дублей (один реальный трек — одна запись). Метрика: руками отсэмплировать 100 треков, посчитать дубли. |
|
||||
| Производительность отдачи bbox | `GET /api/gps-tracks?bbox=…` ≤ 300 мс p95 на z ≥ 10 (≤ 500 треков в видимой области) |
|
||||
| Производительность отдачи тайлов | `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` ≤ 200 мс p95 на z = 8–11 |
|
||||
| Производительность отрисовки | При включённом слое pan/zoom без видимых фризов на десктопе и мобильных с 4 ГБ RAM |
|
||||
| Расширяемость на регион | Добавить новый регион (bbox + название + список источников) — ≤ 30 строк YAML-конфига, без правки кода |
|
||||
| Скорость UI-фильтров | Переключение фильтра по активности/источнику меняет видимую выборку за ≤ 200 мс (фильтрация на клиенте) |
|
||||
| Сохранение слоя при `setStyle()` | Слой не теряется при переключении тёмной темы / спутника / hillshade — восстанавливается через `rebuildMapOverlays()` |
|
||||
| Pipeline стабильность | Падение парсера одного источника не валит остальных; лог + алерт в `/api/gps-tracks/health` |
|
||||
| Атрибуция | На карте видна атрибуция каждого активного источника; в popup трека — ссылка на оригинал |
|
||||
|
||||
## 6. Риски
|
||||
|
||||
| Риск | Вероятность | Влияние | Митигация |
|
||||
| ----------------------------------------------------------------------------------- | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Источник меняет HTML → парсер ломается | Высокая | Среднее | Каждый источник в отдельном модуле, изолированная ошибка. Pipeline пишет статус по источнику в health-эндпоинт. Алерт при 2 неудачных прогонах подряд. |
|
||||
| ToS источника запрещает скрейпинг | Средняя | Высокое | Обязательный ADR с фиксацией лицензии до подключения источника. Источник без явного разрешения — не включается. |
|
||||
| Дубли треков из разных источников (один и тот же трек выкладывают на 2 платформах) | Высокая | Среднее | Spatial+temporal hash: bbox округлённый до 0.01° + длина ± 5% + дата ± 1 день → одна запись. Алгоритм в TRZ §6. |
|
||||
| Перегрузка карты на низких зумах (10K+ треков в видимой области) | Высокая | Высокое | На клиенте: на z < 10 — отдача через MVT-тайлы с упрощением геометрии (как `simplify_coords` для `trails`). На z ≥ 10 — JSON с лимитом 500 треков. |
|
||||
| Размер БД растёт неконтролируемо (миллионы треков при расширении на РФ) | Низкая | Среднее | Отдельная `gps_tracks.sqlite`. Ротация: треки старше N лет (по конфигу, default 5) удаляются. Метрика размера БД в health. |
|
||||
| Скрейпер банится по IP | Средняя | Среднее | Rate-limit + backoff + `User-Agent` с контактом. Сбор по cron 1–2 раза в неделю, не чаще. Per-source конфигурируемый delay. |
|
||||
| Персональные данные в треках (точки «дом», имена) | Низкая | Высокое | Не сохраняем waypoint без явного публичного флага. Не сохраняем `author` если ToS требует анонимизации. Список запрещённых полей — в `08-data-requirements.md`. |
|
||||
| Лицензия источника обязывает менять/удалять данные по требованию автора | Средняя | Среднее | Сохраняем `external_id` и `external_url` — можем удалить точечно по запросу. Pipeline уважает «удалённое на источнике» → удалять и у нас. |
|
||||
| Pipeline ест слишком много трафика mva154 | Средняя | Низкое | Per-source лимит на прогон (например, max 1000 новых треков за прогон). Метрики в health. |
|
||||
| Отдача больших MVT тайлов медленная | Средняя | Среднее | Серверный кэш тайлов (LRU 1024 записи, как уже сделано для `trails`). Упрощение геометрии по зуму. |
|
||||
|
||||
## 7. Зависимости
|
||||
|
||||
### Backend
|
||||
|
||||
- Новый пакет `src/api/gps_tracks/` с подмодулями:
|
||||
- `models.py` — Pydantic + SQL schema
|
||||
- `sources/<source>.py` — модули per-source (OSM, EnduroRussia, ttrails, …)
|
||||
- `dedup.py` — алгоритм дедупликации
|
||||
- `db.py` — обвязка SQLite + Spatialite
|
||||
- `endpoint.py` — FastAPI routes
|
||||
- `mvt.py` — генерация MVT-тайлов
|
||||
- Зависимости Python: `httpx` (есть), `lxml` или `defusedxml` (новая —
|
||||
для безопасного парсинга XML на сервере), `shapely` (есть).
|
||||
|
||||
### Pipeline
|
||||
|
||||
- Скрипт `scripts/gps_collect.py` — точка входа.
|
||||
- Конфиг `config/gps_sources.yaml` — список источников и параметры.
|
||||
- Конфиг `config/gps_regions.yaml` — список регионов (bbox + список
|
||||
активных источников per-region).
|
||||
- Cron на mva154: `0 3 * * 1,4 /usr/local/bin/python
|
||||
/opt/enduro-trails/scripts/gps_collect.py` (Mon + Thu, 03:00 UTC).
|
||||
- Логи: `/var/log/enduro-trails/gps-collect.log`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- Новый модуль `src/web/gps_tracks.js` — слой, фильтры, popup, по
|
||||
аналогии с `gpx.js`.
|
||||
- Расширение `index.html`:
|
||||
- Чекбокс «Публичные треки» и кнопка «Фильтры» в `#terrain-popup`.
|
||||
- Bottom sheet `#sheet-gps-filters` с фильтрами по активности и
|
||||
источнику.
|
||||
- Расширение `style.json` / `style-dark.json`: layer + halo-layer для
|
||||
спутника (по аналогии с `trails-track-halo-satellite` из ET-007).
|
||||
- Интеграция с `rebuildMapOverlays()` в `app.js`.
|
||||
|
||||
### Инфра
|
||||
|
||||
- Файловая: `data/gps_tracks.sqlite` на mva154, права чтения для FastAPI,
|
||||
права записи только для pipeline. Бэкап в общий backup-стек проекта.
|
||||
- Сетевая: исходящие HTTPS к источникам с mva154 (уже разрешено).
|
||||
|
||||
### Документация
|
||||
|
||||
- `06-adr/source-licensing.md` — лицензии всех источников.
|
||||
- `06-adr/dedup-algorithm.md` — обоснование выбора алгоритма
|
||||
дедупликации.
|
||||
- `06-adr/storage-schema.md` — обоснование отдельной БД vs единой.
|
||||
- `07-infra-requirements.md` — cron, бэкапы, ротация, мониторинг.
|
||||
- `08-data-requirements.md` — схема БД, поля, ограничения, политика
|
||||
персональных данных.
|
||||
- `10-tech-risks.md` — расширенный риск-реестр (расширяет §6 BRD).
|
||||
|
||||
### Связи с другими work items
|
||||
|
||||
- **ET-006** — модель `window.gpxTracks` живёт параллельно. ET-008 не
|
||||
трогает её, использует свою модель `window.gpsTracksLayer`.
|
||||
- **ET-007** — спутниковая подложка. ET-008 добавляет halo-слой для
|
||||
публичных треков в режиме «Спутник» по тому же паттерну.
|
||||
- **PH-3 Smart Route** — публичные треки в будущем могут стать входом
|
||||
для построения «реально-езженого» маршрута. Не в scope ET-008.
|
||||
- **PH-9 PWA** — слой публичных треков должен корректно работать в
|
||||
офлайне (через cached MVT). Учитывается в TRZ, но реализация офлайна
|
||||
— задача PH-9.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,274 +2,442 @@
|
||||
type: acceptance-criteria
|
||||
work_item_id: ET-008
|
||||
title: "AC: GPS-треки с публичных платформ на карте"
|
||||
version: 1
|
||||
version: 2
|
||||
status: draft
|
||||
created_at: 2026-06-01
|
||||
updated_at: 2026-06-01
|
||||
changelog:
|
||||
- "v2 (2026-06-01): полная переработка под BRD/TRZ v2 — критерии серверной агрегации, дедупликации, MVT-тайлов, фильтров активности/источника, popup, halo-на-спутнике. Предыдущая v1 описывала URL-импорт + OSM live-поиск."
|
||||
authors:
|
||||
- "agent:analyst"
|
||||
---
|
||||
|
||||
# Acceptance Criteria — ET-008: GPS-треки с публичных платформ на карте
|
||||
|
||||
## AC-01: Секция «Источники» в `#sheet-gpx`
|
||||
## AC-01: Конфигурация источников и регионов
|
||||
|
||||
```gherkin
|
||||
Feature: Переключатель источников треков
|
||||
Feature: Расширяемая конфигурация
|
||||
|
||||
Scenario: Открытие GPX-панели
|
||||
Given пользователь нажимает кнопку GPX в нижнем тулбаре
|
||||
Then открывается панель #sheet-gpx
|
||||
And в верхней части видна секция «Источники» с тремя кнопками: «Из файла», «По ссылке», «Найти рядом»
|
||||
And по умолчанию активна кнопка «Из файла»
|
||||
Scenario: Включение нового источника
|
||||
Given config/gps_sources.yaml содержит источник с enabled=false
|
||||
When оператор меняет на enabled=true и перезапускает pipeline
|
||||
Then источник участвует в следующем прогоне
|
||||
And в /api/gps-tracks/health он появляется в tracks_by_source
|
||||
|
||||
Scenario: Переключение на «По ссылке»
|
||||
Given панель #sheet-gpx открыта
|
||||
When пользователь нажимает кнопку «По ссылке»
|
||||
Then кнопка «По ссылке» становится активной
|
||||
And отображается поле ввода URL и кнопка «Загрузить»
|
||||
And контент других вкладок скрыт
|
||||
Scenario: Добавление нового региона
|
||||
Given оператор добавляет в config/gps_regions.yaml новую запись с bbox
|
||||
And запись не превышает 30 строк YAML
|
||||
When оператор запускает pipeline без аргументов
|
||||
Then новый регион обрабатывается всеми указанными в нём источниками
|
||||
And никаких правок Python-кода не требуется
|
||||
|
||||
Scenario: Переключение на «Найти рядом»
|
||||
Given панель #sheet-gpx открыта
|
||||
When пользователь нажимает кнопку «Найти рядом»
|
||||
Then отображается кнопка «Найти треки в этой области карты»
|
||||
Scenario: Отключение источника
|
||||
Given источник был enabled=true и собрал N треков
|
||||
When оператор меняет на enabled=false
|
||||
Then следующий прогон pipeline пропускает этот источник
|
||||
And ранее собранные треки остаются в БД и отдаются API
|
||||
And в фильтре по источнику соответствующий чекбокс не выбран по умолчанию
|
||||
```
|
||||
|
||||
## AC-02: Импорт по URL — успешный сценарий
|
||||
## AC-02: Pipeline сбора
|
||||
|
||||
```gherkin
|
||||
Feature: Загрузка GPX по прямой ссылке
|
||||
Feature: Pipeline gps_collect.py
|
||||
|
||||
Scenario: Валидная публичная ссылка
|
||||
Given активна вкладка «По ссылке»
|
||||
When пользователь вставляет https://example.com/test-track.gpx (валидный, 1 МБ)
|
||||
And нажимает «Загрузить»
|
||||
Then показывается индикатор загрузки
|
||||
And через ≤ 5 сек трек появляется на карте
|
||||
And карта выполняет fit bounds
|
||||
And трек добавляется в список #gpx-list
|
||||
And в карточке трека отображается «🔗 example.com»
|
||||
Scenario: Полный прогон по умолчанию
|
||||
Given config содержит регион ЦФО+Чувашия и 3 source enabled
|
||||
When оператор запускает scripts/gps_collect.py
|
||||
Then pipeline проходит по всем регионам и всем enabled-источникам
|
||||
And для каждой пары (region, source) пишется запись в pipeline_runs
|
||||
And exit code == 0 если хотя бы один трек собран по каждому источнику
|
||||
|
||||
Scenario: Загрузка по Enter
|
||||
Given активна вкладка «По ссылке»
|
||||
When пользователь вставляет URL и нажимает Enter
|
||||
Then загрузка начинается без клика по кнопке
|
||||
Scenario: Прогон одного источника
|
||||
When оператор запускает scripts/gps_collect.py --source osm
|
||||
Then обрабатывается только OSM
|
||||
And остальные source пропускаются
|
||||
|
||||
Scenario: Падение одного источника не валит остальные
|
||||
Given OSM возвращает 503 на весь прогон
|
||||
When pipeline запущен
|
||||
Then OSM-источник помечается status='error' в pipeline_runs
|
||||
And другие источники продолжают работу
|
||||
And exit code сигнализирует ошибку (1) если запрошен strict-mode, иначе 0
|
||||
|
||||
Scenario: Dry-run
|
||||
When оператор запускает с --dry-run
|
||||
Then никаких INSERT в БД не делается
|
||||
And pipeline_runs тоже не пишется
|
||||
And в stdout выводится план: N треков было бы собрано
|
||||
|
||||
Scenario: Уважение rate-limit
|
||||
Given у источника rate_limit_sec=5
|
||||
When pipeline делает 10 последовательных запросов к этому источнику
|
||||
Then суммарное время ≥ 9 * 5 = 45 сек (между запросами)
|
||||
```
|
||||
|
||||
## AC-03: Импорт по URL — ошибки
|
||||
## AC-03: Дедупликация
|
||||
|
||||
```gherkin
|
||||
Feature: Обработка ошибок URL-импорта
|
||||
Feature: Дедупликация треков
|
||||
|
||||
Scenario: Невалидный URL (схема)
|
||||
Given активна вкладка «По ссылке»
|
||||
When пользователь вставляет ftp://example.com/file.gpx
|
||||
Then показывается toast «Невалидная ссылка»
|
||||
And запрос на бэкенд не отправляется
|
||||
Scenario: Один трек найден в двух источниках
|
||||
Given OSM и EnduroRussia отдали один и тот же трек
|
||||
(один автор выложил на обоих)
|
||||
And bbox и длина совпадают в пределах допуска
|
||||
And даты совпадают
|
||||
When pipeline обрабатывает обе записи
|
||||
Then в БД одна запись tracks
|
||||
And sources_json содержит обоих
|
||||
And external_urls_json содержит обе ссылки
|
||||
|
||||
Scenario: Приватный IP
|
||||
Given пользователь вставляет http://192.168.1.1/file.gpx
|
||||
Then бэкенд возвращает 400
|
||||
And показывается toast «Эта ссылка недоступна»
|
||||
Scenario: Похожие треки разных дат — НЕ дубли
|
||||
Given два трека с одинаковым bbox и длиной
|
||||
And даты отличаются на > 1 день
|
||||
Then записи разные, дедуп НЕ срабатывает
|
||||
|
||||
Scenario: Несуществующий файл
|
||||
Given URL ведёт на 404
|
||||
Then показывается toast «Файл не найден по этой ссылке»
|
||||
Scenario: Треки без даты от разных источников
|
||||
Given оба трека без created_at
|
||||
And bbox и длина совпадают
|
||||
Then дедуп срабатывает (по умолчанию консервативный merge)
|
||||
And это поведение задокументировано в ADR-002
|
||||
|
||||
Scenario: Файл больше 50 МБ
|
||||
Given URL ведёт на GPX > 50 МБ
|
||||
Then показывается toast «Файл слишком большой (макс. 50 МБ)»
|
||||
|
||||
Scenario: Не GPX (HTML по ссылке)
|
||||
Given URL отдаёт HTML-страницу
|
||||
Then показывается toast «По этой ссылке не GPX-файл»
|
||||
|
||||
Scenario: Внешний сервер не отвечает
|
||||
Given внешний сервер таймаутит
|
||||
Then показывается toast «Сервер не отвечает, попробуйте позже»
|
||||
Scenario: Метрика < 5% дубликатов
|
||||
Given в БД собрано ≥ 5000 треков
|
||||
When QA-инженер выбирает 100 случайных треков и руками проверяет дубли
|
||||
Then не более 5 треков (5%) являются дублями
|
||||
```
|
||||
|
||||
## AC-04: Поиск OSM-треков
|
||||
## AC-04: Endpoint /api/gps-tracks (GeoJSON)
|
||||
|
||||
```gherkin
|
||||
Feature: Поиск публичных треков OSM в видимой области
|
||||
Feature: GeoJSON endpoint
|
||||
|
||||
Scenario: Успешный поиск с результатами
|
||||
Given активна вкладка «Найти рядом»
|
||||
And карта показывает область с публичными треками
|
||||
When пользователь нажимает «Найти треки в этой области карты»
|
||||
Then показывается индикатор загрузки
|
||||
And через ≤ 3 сек появляется список найденных треков
|
||||
And каждая карточка содержит: иконку OSM, описание (page N), длину в км, кнопку «Показать», ссылку «↗»
|
||||
Scenario: Запрос с малым bbox
|
||||
Given в БД 1000 треков, из них 50 в bbox=[37.5,55.6,37.7,55.8]
|
||||
When клиент шлёт GET /api/gps-tracks?bbox=37.5,55.6,37.7,55.8
|
||||
Then ответ 200, FeatureCollection с 50 features
|
||||
And total_in_bbox=50, returned=50, truncated=false
|
||||
And time ≤ 300 мс p95
|
||||
|
||||
Scenario: Пустая область
|
||||
Given карта показывает область без публичных треков
|
||||
When пользователь нажимает «Найти треки»
|
||||
Then отображается inline-сообщение «В этой области нет публичных GPS-треков»
|
||||
Scenario: Bbox с обрезкой по limit
|
||||
Given в bbox 1500 треков
|
||||
When клиент шлёт GET .../api/gps-tracks?bbox=...&limit=500
|
||||
Then returned=500, total_in_bbox=1500, truncated=true
|
||||
|
||||
Scenario: Слишком большая область
|
||||
Given карта показывает область с bbox > 0.25 deg²
|
||||
When пользователь нажимает «Найти треки»
|
||||
Then показывается toast «Слишком большая область, увеличьте zoom»
|
||||
And запрос на бэкенд не отправляется (или возвращается 400)
|
||||
Scenario: Фильтр по активности
|
||||
Given в bbox 100 треков, 20 enduro, 30 moto, 50 hike
|
||||
When клиент шлёт ?activity=enduro,moto
|
||||
Then returned=50
|
||||
|
||||
Scenario: Пагинация
|
||||
Given поиск вернул has_more=true
|
||||
Then в конце списка отображается кнопка «Показать ещё»
|
||||
When пользователь нажимает «Показать ещё»
|
||||
Then дозагружаются результаты следующей страницы
|
||||
And они добавляются в конец списка
|
||||
Scenario: Фильтр по источнику
|
||||
Given в bbox 100 треков: 60 OSM, 30 EnduroRussia, 10 ttrails
|
||||
When клиент шлёт ?source=osm
|
||||
Then returned=60
|
||||
|
||||
Scenario: Невалидный bbox
|
||||
When клиент шлёт bbox=foo
|
||||
Then ответ 400
|
||||
|
||||
Scenario: bbox вне диапазона координат
|
||||
When клиент шлёт bbox=200,100,250,150
|
||||
Then ответ 400
|
||||
|
||||
Scenario: Поля feature.properties
|
||||
Then каждая feature содержит: name, activity_type, user, created_at,
|
||||
length_km, sources (array), external_urls (array)
|
||||
```
|
||||
|
||||
## AC-05: Импорт OSM-трека
|
||||
## AC-05: Endpoint /api/gps-tracks/tiles MVT
|
||||
|
||||
```gherkin
|
||||
Feature: Импорт выбранного OSM-трека на карту
|
||||
Feature: MVT tiles
|
||||
|
||||
Scenario: Импорт по кнопке «Показать»
|
||||
Given найдено 3 OSM-трека в списке
|
||||
When пользователь нажимает «Показать» у первого трека
|
||||
Then показывается индикатор загрузки
|
||||
And через ≤ 5 сек трек появляется на карте
|
||||
And карта выполняет fit bounds
|
||||
And трек добавляется в #gpx-list
|
||||
And в карточке трека отображается «🌍 OSM #...» (кликабельная ссылка)
|
||||
And карточка в #gpx-nearby-results получает индикатор «✓ Загружен»
|
||||
Scenario: Отдача тайла на z=10
|
||||
Given в БД есть треки в видимой области
|
||||
When клиент шлёт GET /api/gps-tracks/tiles/10/623/325.mvt
|
||||
Then ответ 200, Content-Type: application/x-protobuf
|
||||
And тело содержит layer gps_tracks с LineString features
|
||||
|
||||
Scenario: Повторный импорт того же трека
|
||||
Given OSM-трек уже импортирован
|
||||
When пользователь нажимает «Показать» у этой же карточки в найденных
|
||||
Then показывается toast «Уже загружен»
|
||||
And новый трек НЕ добавляется
|
||||
Scenario: Тайл из кэша
|
||||
Given тайл уже запрашивали
|
||||
When повторный запрос того же z/x/y
|
||||
Then header X-Cache: HIT
|
||||
And время ≤ 20 мс p95
|
||||
|
||||
Scenario: Внешняя ссылка на osm.org
|
||||
Given в карточке найденного трека есть кнопка «↗»
|
||||
When пользователь нажимает «↗»
|
||||
Then открывается новая вкладка с страницей трека на openstreetmap.org
|
||||
Scenario: Упрощение геометрии на низких зумах
|
||||
Given исходный трек 1000 точек на z=7
|
||||
When MVT генерируется
|
||||
Then feature имеет упрощённую геометрию (≤ 100 точек после Douglas-Peucker)
|
||||
|
||||
Scenario: Properties фичи в MVT
|
||||
Then feature.properties содержит: id, activity, source, sources,
|
||||
length_km, name, ext_url
|
||||
```
|
||||
|
||||
## AC-06: Отображение источника в карточке трека
|
||||
## AC-06: Endpoint health
|
||||
|
||||
```gherkin
|
||||
Feature: Источник трека виден пользователю
|
||||
Feature: Health endpoint
|
||||
|
||||
Scenario: Локальный файл (ET-006 совместимость)
|
||||
Given загружен GPX из локального файла
|
||||
Then в карточке трека под именем файла отображается «📁 локальный файл»
|
||||
Scenario: Полный отчёт
|
||||
When клиент шлёт GET /api/gps-tracks/health
|
||||
Then ответ 200 JSON содержит:
|
||||
| db_path |
|
||||
| db_size_mb |
|
||||
| tracks_total |
|
||||
| tracks_by_source | (объект source_id → int)
|
||||
| tracks_by_activity | (объект activity → int)
|
||||
| last_pipeline_run | (объект с started/finished/sources_ok/sources_error)
|
||||
| tile_cache_size |
|
||||
|
||||
Scenario: Загружен по URL
|
||||
Given загружен GPX по ссылке https://github.com/user/repo/track.gpx
|
||||
Then в карточке трека отображается «🔗 github.com»
|
||||
|
||||
Scenario: Загружен из OSM
|
||||
Given загружен OSM-трек page 0
|
||||
Then в карточке трека отображается ссылка «🌍 OSM #..» которая ведёт на osm.org
|
||||
Scenario: Health без БД
|
||||
Given БД отсутствует на диске
|
||||
When клиент шлёт GET /api/gps-tracks/health
|
||||
Then ответ содержит tracks_total=0 и предупреждение о БД (или 503)
|
||||
```
|
||||
|
||||
## AC-07: Кэширование на бэкенде
|
||||
## AC-07: Чекбокс «Публичные треки» в попапе
|
||||
|
||||
```gherkin
|
||||
Feature: Серверный кэш ответов внешних API
|
||||
Feature: Включение слоя из попапа
|
||||
|
||||
Scenario: Повторный запрос URL из кэша
|
||||
Given URL запрашивался менее 24 часов назад
|
||||
When клиент делает повторный GET /api/gpx/fetch?url=...
|
||||
Then ответ возвращается с заголовком X-Cache: HIT
|
||||
And время ответа ≤ 50 мс
|
||||
And внешний запрос НЕ выполняется
|
||||
Scenario: Чекбокс присутствует
|
||||
Given пользователь нажимает #terrain-toggle
|
||||
Then в попапе #terrain-popup видна строка «Публичные треки» с чекбоксом
|
||||
|
||||
Scenario: Cache miss
|
||||
Given URL запрашивается впервые
|
||||
Then выполняется внешний запрос
|
||||
And ответ возвращается с X-Cache: MISS
|
||||
And следующий запрос того же URL — HIT
|
||||
Scenario: Включение слоя
|
||||
When пользователь ставит галку «Публичные треки»
|
||||
Then на карте появляются линии треков
|
||||
And localStorage['gps-tracks-enabled'] = 'true'
|
||||
And рядом с чекбоксом появляется ссылка «Фильтры…»
|
||||
|
||||
Scenario: Повторный bbox-поиск из кэша
|
||||
Given bbox запрашивался менее 24 часов назад
|
||||
When клиент делает повторный GET /api/gpx/osm/traces?bbox=...
|
||||
Then ответ из кэша
|
||||
And внешний запрос к OSM API НЕ выполняется
|
||||
Scenario: Выключение слоя
|
||||
When пользователь снимает галку
|
||||
Then линии исчезают с карты
|
||||
And localStorage = 'false'
|
||||
And ссылка «Фильтры…» скрывается
|
||||
|
||||
Scenario: Подсказка о минимальном zoom
|
||||
Given текущий zoom < 8
|
||||
And чекбокс включён
|
||||
Then рядом с чекбоксом видна подсказка «Зум 8+»
|
||||
And линии на карте не видны (без ошибок)
|
||||
```
|
||||
|
||||
## AC-08: Безопасность
|
||||
## AC-08: Фильтры по активности и источнику
|
||||
|
||||
```gherkin
|
||||
Feature: SSRF protection
|
||||
Feature: Sheet фильтров
|
||||
|
||||
Scenario: Прямой запрос к loopback
|
||||
When клиент шлёт GET /api/gpx/fetch?url=http://127.0.0.1/data
|
||||
Then бэкенд возвращает 400
|
||||
And никакого запроса к 127.0.0.1 не делается
|
||||
Scenario: Открытие sheet
|
||||
Given слой включён
|
||||
When пользователь нажимает «Фильтры…»
|
||||
Then открывается #sheet-gps-filters
|
||||
And видны секции «Тип активности», «Источник», «Цвет линий»
|
||||
And по умолчанию выбраны все активности и все источники
|
||||
|
||||
Scenario: Запрос к приватной подсети
|
||||
When клиент шлёт URL ведущий на 10.0.0.1, 192.168.x.x, 172.16.x.x
|
||||
Then бэкенд возвращает 400
|
||||
Scenario: Фильтрация по активности
|
||||
Given в видимой области карты 743 трека, 200 enduro, 50 moto, …
|
||||
When пользователь снимает все галки кроме «Эндуро» и «Мото»
|
||||
Then на карте отображаются только enduro и moto треки
|
||||
And gps-stat-shown отражает новое число
|
||||
And фильтрация мгновенная (≤ 200 мс), без сетевого запроса
|
||||
|
||||
Scenario: Редирект на приватный IP
|
||||
Given внешний URL отдаёт 302 redirect на http://127.0.0.1/...
|
||||
When клиент шлёт GET /api/gpx/fetch?url=<external>
|
||||
Then редирект проверяется повторно и блокируется
|
||||
And бэкенд возвращает 400
|
||||
Scenario: Фильтрация по источнику
|
||||
Given включено 3 источника
|
||||
When пользователь снимает «OSM»
|
||||
Then OSM-треки скрываются на карте
|
||||
|
||||
Scenario: Запрещённая схема
|
||||
When клиент шлёт URL с file:// или gopher://
|
||||
Then бэкенд возвращает 400
|
||||
Scenario: Переключение режима цвета
|
||||
Given color-mode = 'source'
|
||||
When пользователь выбирает «По активности»
|
||||
Then цвета линий перерисовываются по палитре активности
|
||||
And localStorage сохраняет 'gps-tracks-color-mode' = 'activity'
|
||||
|
||||
Scenario: Размер ответа превышает лимит
|
||||
Given внешний сервер начинает стримить файл > 50 МБ
|
||||
Then бэкенд прерывает соединение
|
||||
And возвращает 413
|
||||
Scenario: Сохранение фильтров между сессиями
|
||||
Given пользователь настроил фильтры (только enduro, только OSM)
|
||||
When пользователь перезагружает страницу
|
||||
Then sheet-фильтров восстанавливает те же чекбоксы
|
||||
And слой отображает только enduro+OSM треки
|
||||
```
|
||||
|
||||
## AC-09: Совместимость с ET-006
|
||||
## AC-09: Popup при клике на трек
|
||||
|
||||
```gherkin
|
||||
Feature: Локальные и удалённые треки в одной модели
|
||||
Feature: Popup трека
|
||||
|
||||
Scenario: Смешанный список
|
||||
Given загружен 1 локальный файл, 1 по URL, 1 из OSM
|
||||
Then в #gpx-list отображаются 3 карточки
|
||||
And каждая имеет уникальный цвет из палитры
|
||||
And каждая имеет свой индикатор источника
|
||||
And любую можно активировать, удалить, увидеть профиль высот
|
||||
Scenario: Клик по линии трека
|
||||
Given на карте отображается слой публичных треков
|
||||
When пользователь кликает на линию трека
|
||||
Then открывается popup с полями: name, activity (иконка+текст),
|
||||
length_km, points_count, created_at, user, sources (со ссылками)
|
||||
|
||||
Scenario: Сохранение при смене темы
|
||||
Given на карте 3 трека разных источников
|
||||
When пользователь переключает тёмную тему
|
||||
Then все 3 трека остаются на карте
|
||||
And источники в карточках сохраняются
|
||||
And статистика и профиль активного трека сохраняются
|
||||
Scenario: Трек из двух источников
|
||||
Given трек имеет sources=['osm', 'enduro_russia']
|
||||
Then popup показывает обе ссылки
|
||||
|
||||
Scenario: Сохранение при переключении слоёв рельефа
|
||||
Given на карте 3 трека разных источников
|
||||
Scenario: Трек без user/name
|
||||
Then popup показывает «Без названия» и не показывает строку «Автор»
|
||||
|
||||
Scenario: Клик по фону карты
|
||||
Given открыт popup
|
||||
When пользователь кликает на пустое место карты
|
||||
Then popup закрывается
|
||||
```
|
||||
|
||||
## AC-10: Z-order и совместимость с другими слоями
|
||||
|
||||
```gherkin
|
||||
Feature: Z-order
|
||||
|
||||
Scenario: Слой выше trails, ниже маршрута OSRM
|
||||
Given на карте: OSM tiles + trails + публичные треки + маршрут OSRM
|
||||
Then визуально маршрут OSRM перекрывает публичные треки
|
||||
And публичные треки перекрывают trails из vector tiles
|
||||
And базовая карта (OSM) — самый нижний
|
||||
|
||||
Scenario: Совместимость с ET-006 (личные GPX)
|
||||
Given пользователь загрузил свой GPX-файл (ET-006)
|
||||
And слой публичных треков включён
|
||||
Then оба видны параллельно
|
||||
And личный трек визуально выше публичных
|
||||
```
|
||||
|
||||
## AC-11: Совместимость со спутниковой подложкой (ET-007)
|
||||
|
||||
```gherkin
|
||||
Feature: Halo на спутнике
|
||||
|
||||
Scenario: Включение спутника
|
||||
Given слой публичных треков включён
|
||||
When пользователь переключает подложку на «Спутник»
|
||||
Then линии треков видны на спутнике
|
||||
And появляется белая обводка (halo) для контраста
|
||||
|
||||
Scenario: Возврат на схему
|
||||
When пользователь возвращается на «Схема»
|
||||
Then halo скрывается
|
||||
And линии отображаются обычными цветами
|
||||
|
||||
Scenario: Halo учитывает чекбокс
|
||||
Given спутник активен
|
||||
When пользователь выключает чекбокс «Публичные треки»
|
||||
Then и линии, и halo скрываются
|
||||
```
|
||||
|
||||
## AC-12: Сохранение при смене стиля карты
|
||||
|
||||
```gherkin
|
||||
Feature: Переживание setStyle()
|
||||
|
||||
Scenario: Переключение тёмной темы
|
||||
Given слой включён, фильтры настроены
|
||||
When пользователь переключает тёмную тему (вызывает map.setStyle())
|
||||
Then слой публичных треков восстанавливается
|
||||
And линии видны с теми же цветами по тому же color-mode
|
||||
And фильтры активности/источника сохранены
|
||||
|
||||
Scenario: Переключение спутник→схема
|
||||
Given слой включён, активен спутник
|
||||
When пользователь переключается на схему
|
||||
Then слой остаётся видим, halo выключается
|
||||
|
||||
Scenario: Включение hillshade
|
||||
Given слой включён
|
||||
When пользователь включает hillshade
|
||||
Then все 3 трека видны поверх hillshade
|
||||
Then публичные треки остаются видны (поверх hillshade)
|
||||
```
|
||||
|
||||
## AC-10: Метрики кэша в `/api/health`
|
||||
## AC-13: Производительность
|
||||
|
||||
```gherkin
|
||||
Feature: Наблюдаемость кэшей
|
||||
Feature: SLA отклика
|
||||
|
||||
Scenario: Размер кэшей в health-эндпоинте
|
||||
When клиент шлёт GET /api/health
|
||||
Then ответ содержит поля gpx_fetch_cache_size и gpx_osm_cache_size
|
||||
And значения — целые числа ≥ 0
|
||||
Scenario: GeoJSON p95
|
||||
When 100 запросов GET /api/gps-tracks?bbox=… с ≤ 500 треков в bbox
|
||||
Then p95 ≤ 300 мс
|
||||
|
||||
Scenario: MVT cold
|
||||
When запрос MVT-тайла без кэша
|
||||
Then p95 ≤ 200 мс
|
||||
|
||||
Scenario: MVT hot
|
||||
When повторный запрос того же тайла
|
||||
Then ≤ 20 мс, X-Cache: HIT
|
||||
|
||||
Scenario: Pan/zoom без фризов
|
||||
Given слой включён с 500 треками в видимой области
|
||||
When пользователь делает 10 быстрых pan-операций
|
||||
Then нет видимых фризов (FPS ≥ 30 на десктопе)
|
||||
```
|
||||
|
||||
## AC-11: Производительность
|
||||
## AC-14: Защита от шторма запросов
|
||||
|
||||
```gherkin
|
||||
Feature: Лимиты времени отклика
|
||||
Feature: Debounce и AbortController
|
||||
|
||||
Scenario: OSM bbox запрос с кэш-хитом
|
||||
Given bbox в кэше
|
||||
Then GET /api/gpx/osm/traces возвращается за ≤ 50 мс (p95)
|
||||
Scenario: Быстрый pan не плодит запросов
|
||||
Given слой включён на z ≥ 12
|
||||
When пользователь делает 5 быстрых pan-операций за 1 секунду
|
||||
Then выполняется не более 2 запросов /api/gps-tracks (debounce 500ms)
|
||||
And предыдущие запросы отменены AbortController
|
||||
|
||||
Scenario: URL-импорт малого файла (1 МБ)
|
||||
Then GET /api/gpx/fetch для 1 МБ файла завершается за ≤ 2 сек
|
||||
|
||||
Scenario: OSM bbox запрос без кэша
|
||||
Then GET /api/gpx/osm/traces без кэша возвращается за ≤ 3 сек (p95)
|
||||
Scenario: На z < 8 запросов нет
|
||||
Given пользователь на z=5
|
||||
When пользователь панит карту
|
||||
Then запросов /api/gps-tracks?bbox=… не выполняется
|
||||
```
|
||||
|
||||
## AC-15: Атрибуция
|
||||
|
||||
```gherkin
|
||||
Feature: Атрибуция источников
|
||||
|
||||
Scenario: На карте видна атрибуция
|
||||
Given слой включён, включены OSM и EnduroRussia
|
||||
Then в правом нижнем углу карты отображается строка
|
||||
«© OpenStreetMap contributors (ODbL) | EnduroRussia.ru»
|
||||
|
||||
Scenario: Popup содержит ссылку на оригинал
|
||||
Given пользователь открыл popup трека
|
||||
Then в нём видна ссылка «↗» на источник (или несколько)
|
||||
When пользователь кликает на ссылку
|
||||
Then открывается новая вкладка с оригиналом
|
||||
```
|
||||
|
||||
## AC-16: Безопасность и юридические гарантии
|
||||
|
||||
```gherkin
|
||||
Feature: Юридический минимум
|
||||
|
||||
Scenario: Источник без ADR не активируется
|
||||
Given оператор пытается включить новый source в gps_sources.yaml
|
||||
But ADR licensing-review отсутствует
|
||||
Then pipeline-tests падают (CI блокирует merge)
|
||||
|
||||
Scenario: Pipeline не сохраняет запрещённые поля
|
||||
Given source-ADR требует не сохранять `user`
|
||||
When pipeline получает трек с user='Vasya'
|
||||
Then в БД user=NULL для этой записи
|
||||
|
||||
Scenario: Удаление по запросу автора
|
||||
Given автор оригинала пометил трек удалённым на источнике
|
||||
When следующий прогон pipeline обнаруживает это
|
||||
Then запись в нашей БД помечается как удалённая или удаляется
|
||||
```
|
||||
|
||||
## AC-17: Расширяемость на новые регионы
|
||||
|
||||
```gherkin
|
||||
Feature: Добавление региона ≤ 30 строк YAML
|
||||
|
||||
Scenario: Добавление «Северный Кавказ»
|
||||
Given существующий config/gps_regions.yaml
|
||||
When разработчик добавляет YAML-блок региона: id, name, bbox, enabled,
|
||||
sources (≤ 30 строк суммарно)
|
||||
And запускает pipeline
|
||||
Then регион обрабатывается всеми указанными источниками
|
||||
And никаких правок Python-файлов не требуется
|
||||
And в /api/gps-tracks/health новый регион виден в last_pipeline_run.regions
|
||||
```
|
||||
|
||||
@@ -2,423 +2,564 @@
|
||||
type: test-plan
|
||||
work_item_id: ET-008
|
||||
title: "Test Plan: GPS-треки с публичных платформ на карте"
|
||||
version: 1
|
||||
version: 2
|
||||
status: draft
|
||||
created_at: 2026-06-01
|
||||
updated_at: 2026-06-01
|
||||
changelog:
|
||||
- "v2 (2026-06-01): полная переработка под BRD/TRZ/AC v2 — серверная агрегация, дедупликация, MVT, фильтры активности/источника. Предыдущая v1 описывала URL-импорт + OSM live-поиск."
|
||||
authors:
|
||||
- "agent:analyst"
|
||||
|
||||
test_suites:
|
||||
|
||||
- name: unit-gpx-proxy-validation
|
||||
- name: unit-config-loader
|
||||
type: unit
|
||||
description: "SSRF-валидация URL в gpx_proxy.is_safe_url()"
|
||||
description: "Загрузка и валидация YAML-конфигов sources/regions"
|
||||
cases:
|
||||
- id: U-01
|
||||
name: "Принимает валидный публичный HTTPS URL"
|
||||
input: "https://example.com/track.gpx (резолвится в публичный IP)"
|
||||
expected: "is_safe_url() возвращает True"
|
||||
name: "Валидный gps_sources.yaml парсится"
|
||||
input: "Корректный YAML с 3 источниками"
|
||||
expected: "Возвращает список объектов Source с обязательными полями"
|
||||
|
||||
- id: U-02
|
||||
name: "Отклоняет схему ftp://"
|
||||
input: "ftp://example.com/track.gpx"
|
||||
expected: "is_safe_url() возвращает False"
|
||||
name: "Источник без license_adr — ошибка"
|
||||
input: "YAML с enabled=true, но без license_adr"
|
||||
expected: "ConfigError: 'enabled source requires license_adr'"
|
||||
|
||||
- id: U-03
|
||||
name: "Отклоняет схему file://"
|
||||
input: "file:///etc/passwd"
|
||||
expected: "is_safe_url() возвращает False"
|
||||
name: "Регион с unknown source — ошибка"
|
||||
input: "regions.sources содержит ID, которого нет в sources.yaml"
|
||||
expected: "ConfigError: 'unknown source id'"
|
||||
|
||||
- id: U-04
|
||||
name: "Отклоняет loopback IP"
|
||||
input: "http://127.0.0.1/x.gpx"
|
||||
expected: "is_safe_url() возвращает False"
|
||||
name: "Bbox региона валидируется"
|
||||
input: "bbox=[200, 100, 250, 150]"
|
||||
expected: "ConfigError: 'bbox out of valid range'"
|
||||
|
||||
- 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)"
|
||||
name: "Disabled source игнорируется в pipeline"
|
||||
input: "Регион ссылается на disabled source"
|
||||
expected: "Pipeline пропускает этот source, warning в логе"
|
||||
|
||||
- name: unit-dedup
|
||||
type: unit
|
||||
description: "compute_dedup_key и merge-логика"
|
||||
cases:
|
||||
- id: U-10
|
||||
name: "Отклоняет хост, который не резолвится"
|
||||
input: "http://nonexistent-host-xyz-12345.invalid/x.gpx"
|
||||
expected: "is_safe_url() возвращает False"
|
||||
name: "Два трека с одинаковым bbox+length+date → один ключ"
|
||||
input: "geom1, geom2 с близкими bounds, length_m differ < 5%, dates same day"
|
||||
expected: "compute_dedup_key(g1) == compute_dedup_key(g2)"
|
||||
|
||||
- id: U-11
|
||||
name: "Разные даты → разные ключи"
|
||||
input: "Те же bbox+length, daty отличаются на 2 дня"
|
||||
expected: "compute_dedup_key различаются"
|
||||
|
||||
- id: U-12
|
||||
name: "Bbox-округление до 0.01°"
|
||||
input: "geom1.bounds=(37.6173, 55.7558, …), geom2.bounds=(37.6171, 55.7559, …)"
|
||||
expected: "Один ключ (округление до 2 знаков)"
|
||||
|
||||
- id: U-13
|
||||
name: "Merge: union sources"
|
||||
input: "track в БД с sources=['osm'], новый с source='enduro_russia', тот же dedup_key"
|
||||
expected: "Запись в БД обновлена: sources=['osm','enduro_russia']"
|
||||
|
||||
- id: U-14
|
||||
name: "Merge: union external_urls"
|
||||
input: "track в БД с external_urls=[...A], новый с [...B], тот же dedup_key"
|
||||
expected: "В БД external_urls=[...A,...B] без дубликатов"
|
||||
|
||||
- id: U-15
|
||||
name: "Merge: приоритет metadata по порядку sources.yaml"
|
||||
input: "OSM (priority 1) собрал name='X', EnduroRussia (priority 2) собрал name='Y' с тем же dedup_key"
|
||||
expected: "В БД name='X' (приоритет первого source)"
|
||||
|
||||
- name: unit-activity-mapping
|
||||
type: unit
|
||||
description: "Маппинг категорий источников в ACTIVITY_TYPES"
|
||||
cases:
|
||||
- id: U-20
|
||||
name: "OSM tag 'enduro' → 'enduro'"
|
||||
input: "['enduro', 'motorcycle']"
|
||||
expected: "'enduro'"
|
||||
|
||||
- id: U-21
|
||||
name: "OSM tag 'mtb' → 'bicycle'"
|
||||
input: "['mtb']"
|
||||
expected: "'bicycle'"
|
||||
|
||||
- id: U-22
|
||||
name: "Unknown tag → 'other'"
|
||||
input: "['xyz']"
|
||||
expected: "'other'"
|
||||
|
||||
- id: U-23
|
||||
name: "Пустой список тэгов → 'other'"
|
||||
input: "[]"
|
||||
expected: "'other'"
|
||||
|
||||
- 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"
|
||||
description: "Валидация bbox в /api/gps-tracks"
|
||||
cases:
|
||||
- id: U-30
|
||||
name: "TTL истёк → cache miss"
|
||||
input: "Положить запись с TTL 1 сек, ждать 2 сек, запросить"
|
||||
expected: "Возвращает None (или вызывает loader)"
|
||||
name: "Валидный bbox"
|
||||
input: "bbox=37.0,55.0,38.0,56.0"
|
||||
expected: "validate_bbox() = True"
|
||||
|
||||
- id: U-31
|
||||
name: "LRU вытеснение при переполнении"
|
||||
input: "Заполнить кэш max=4 записями, добавить 5-ю"
|
||||
expected: "Первая (LRU) запись вытеснена"
|
||||
name: "bbox out-of-range"
|
||||
input: "bbox=200,100,250,150"
|
||||
expected: "validate_bbox() = False"
|
||||
|
||||
- id: U-32
|
||||
name: "Округление bbox-ключа до 4 знаков"
|
||||
input: "bbox=[37.6172999, 55.7558001, ...] и bbox=[37.6173, 55.7558, ...]"
|
||||
expected: "Один и тот же кэш-ключ → cache hit"
|
||||
name: "Перевёрнутый bbox"
|
||||
input: "bbox=38,55,37,56 (west > east)"
|
||||
expected: "validate_bbox() = False"
|
||||
|
||||
- id: U-33
|
||||
name: "URL > 5 МБ не кэшируется"
|
||||
input: "Положить запись размером 6 МБ"
|
||||
expected: "Запись не попадает в кэш (cache.get → None)"
|
||||
name: "Невалидный формат"
|
||||
input: "bbox=foo"
|
||||
expected: "validate_bbox() = False"
|
||||
|
||||
- name: unit-osm-parser
|
||||
type: unit
|
||||
description: "Парсинг OSM trackpoints GPX → JSON"
|
||||
description: "Парсер OSM trackpoints"
|
||||
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}]}"
|
||||
name: "Группировка trkpt по gpx_id"
|
||||
input: "GPX 1.0 с trkpt разных gpx_id"
|
||||
expected: "Возвращает по треку на каждый gpx_id"
|
||||
|
||||
- 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)"
|
||||
name: "Анонимные точки (без gpx_id) — пропуск"
|
||||
input: "GPX с точками без gpx_id"
|
||||
expected: "Эти точки не попадают в результат"
|
||||
|
||||
- id: U-42
|
||||
name: "Пустой GPX (нет trkpt)"
|
||||
input: "GPX без точек"
|
||||
expected: "JSON: {tracks: [], total_points: 0}"
|
||||
name: "Bbox-разбиение региона"
|
||||
input: "region.bbox=(37, 55, 39, 57), cell_size=0.25"
|
||||
expected: "len(cells) = 8 * 8 = 64"
|
||||
|
||||
- id: U-43
|
||||
name: "Защита от XXE (defusedxml)"
|
||||
input: "GPX с DOCTYPE и внешней entity"
|
||||
expected: "Парсер не выполняет загрузку внешней entity (или бросает ошибку)"
|
||||
name: "Расчёт length_m через Haversine"
|
||||
input: "trkpt: [37.6,55.7], [37.7,55.8], [37.8,55.9]"
|
||||
expected: "length_m ≈ 28300 (±500)"
|
||||
|
||||
- name: unit-web-gpx-source
|
||||
- id: U-44
|
||||
name: "Защита от XXE"
|
||||
input: "GPX с DOCTYPE и внешней entity"
|
||||
expected: "defusedxml блокирует, парсер не выполняет загрузку"
|
||||
|
||||
- id: U-45
|
||||
name: "Тэги из GPX → activity_type"
|
||||
input: "<tag>enduro</tag><tag>motorcycle</tag>"
|
||||
expected: "activity_type='enduro'"
|
||||
|
||||
- name: unit-mvt-generation
|
||||
type: unit
|
||||
description: "Расширение модели window.gpxTracks полем source"
|
||||
description: "Генерация MVT-тайлов для gps_tracks"
|
||||
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'}"
|
||||
name: "Тайл z=10 с 50 треками"
|
||||
input: "tile_to_bbox(10, x, y), 50 треков в bbox"
|
||||
expected: "Валидный MVT с layer gps_tracks, 50 features"
|
||||
|
||||
- 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:'...'}"
|
||||
name: "Упрощение геометрии на z=7"
|
||||
input: "Трек 1000 точек, z=7"
|
||||
expected: "После simplify_coords ≤ 100 точек"
|
||||
|
||||
- id: U-52
|
||||
name: "Обратная совместимость: трек без source читается как 'file'"
|
||||
input: "window.gpxTracks[0] без поля source"
|
||||
expected: "renderSourceRow() возвращает '📁 локальный файл'"
|
||||
name: "Min-length фильтр на z ≤ 7"
|
||||
input: "Треки с length_m=500 и 5000 на z=7"
|
||||
expected: "Только трек ≥ 2000м попадает в тайл (min_length для z≤7)"
|
||||
|
||||
- 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: "Properties в feature"
|
||||
input: "Track в БД"
|
||||
expected: "feature.properties содержит id, activity, source, sources,
|
||||
length_km, name, ext_url"
|
||||
|
||||
- name: integration-gpx-fetch
|
||||
- id: U-54
|
||||
name: "Пустой тайл"
|
||||
input: "Bbox без треков"
|
||||
expected: "build_mvt() возвращает b'' (или валидный пустой MVT)"
|
||||
|
||||
- name: unit-color-palette
|
||||
type: unit
|
||||
description: "Цветовая палитра по источнику и активности"
|
||||
cases:
|
||||
- id: U-60
|
||||
name: "Color by source: OSM = #3cb44b"
|
||||
input: "feature.source='osm'"
|
||||
expected: "Match-expression возвращает '#3cb44b'"
|
||||
|
||||
- id: U-61
|
||||
name: "Color by activity: enduro = #e6194b"
|
||||
input: "feature.activity='enduro'"
|
||||
expected: "'#e6194b'"
|
||||
|
||||
- id: U-62
|
||||
name: "Unknown source → fallback"
|
||||
input: "feature.source='unknown'"
|
||||
expected: "'#808080' (или fallback из палитры)"
|
||||
|
||||
- name: integration-pipeline
|
||||
type: integration
|
||||
description: "GET /api/gpx/fetch — прокси с реальным HTTP"
|
||||
description: "Pipeline gps_collect.py end-to-end с mock-источниками"
|
||||
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"
|
||||
name: "Полный прогон с 1 mock-источником"
|
||||
input: "Mock OSM API → 100 треков; пустая БД"
|
||||
expected: "После прогона в БД 100 tracks, pipeline_runs.status='ok',
|
||||
tracks_new=100, tracks_updated=0"
|
||||
|
||||
- id: I-02
|
||||
name: "Повторный запрос — cache hit"
|
||||
input: "GET тот же URL"
|
||||
expected: "200, X-Cache: HIT, время ≤ 50 мс"
|
||||
name: "Повторный прогон того же источника — все треки updated"
|
||||
input: "Тот же mock + та же БД с предыдущей записью"
|
||||
expected: "tracks_new=0, tracks_updated=100"
|
||||
|
||||
- id: I-03
|
||||
name: "Отклонение приватного IP"
|
||||
input: "GET /api/gpx/fetch?url=http://127.0.0.1/x.gpx"
|
||||
expected: "400, JSON {error: ...}"
|
||||
name: "Прогон двух источников с пересечением"
|
||||
input: "OSM mock = 100 треков, EnduroRussia mock = 50, из них 20 — те же по dedup_key"
|
||||
expected: "В БД 130 уникальных записей (100 + 50 - 20). 20 пересекающихся имеют sources=['osm','enduro_russia']"
|
||||
|
||||
- id: I-04
|
||||
name: "Отклонение редиректа на приватный IP"
|
||||
input: "Внешний URL → 302 на http://127.0.0.1/x.gpx"
|
||||
expected: "400, JSON {error: ...}"
|
||||
name: "Падение одного источника"
|
||||
input: "OSM mock OK, EnduroRussia mock возвращает 503"
|
||||
expected: "OSM треки в БД, EnduroRussia status='error' в pipeline_runs,
|
||||
но pipeline exit=0 (не strict-mode)"
|
||||
|
||||
- id: I-05
|
||||
name: "Внешний 404"
|
||||
input: "URL ведёт на несуществующий путь"
|
||||
expected: "404, JSON {error: ...}"
|
||||
name: "Dry-run"
|
||||
input: "Любой источник + флаг --dry-run"
|
||||
expected: "БД не меняется, pipeline_runs не пишется,
|
||||
stdout содержит план"
|
||||
|
||||
- id: I-06
|
||||
name: "Лимит размера 50 МБ"
|
||||
input: "Mock-сервер стримит 60 МБ"
|
||||
expected: "413, соединение прервано до конца"
|
||||
name: "Rate-limit соблюдается"
|
||||
input: "Mock source с rate_limit_sec=2, 5 запросов"
|
||||
expected: "Суммарное время ≥ 8 сек (4 интервала × 2 сек)"
|
||||
|
||||
- id: I-07
|
||||
name: "Таймаут"
|
||||
input: "Mock-сервер ничего не отвечает"
|
||||
expected: "504 после 15 сек"
|
||||
name: "Backoff на 429"
|
||||
input: "Mock source первый раз 429, второй раз 200"
|
||||
expected: "Pipeline делает retry после exponential backoff,
|
||||
трек собран"
|
||||
|
||||
- id: I-08
|
||||
name: "URL > 5 МБ не попадает в кэш"
|
||||
input: "Запросить URL с ответом 6 МБ дважды"
|
||||
expected: "Второй запрос: X-Cache: MISS, внешний запрос повторно выполнен"
|
||||
|
||||
- name: integration-osm-traces
|
||||
- name: integration-endpoint-geojson
|
||||
type: integration
|
||||
description: "GET /api/gpx/osm/traces — OSM API клиент"
|
||||
description: "/api/gps-tracks GeoJSON"
|
||||
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"
|
||||
name: "Малый bbox с фильтрами"
|
||||
input: "GET /api/gps-tracks?bbox=...&activity=enduro&source=osm"
|
||||
expected: "200, FeatureCollection только enduro+OSM треков"
|
||||
|
||||
- id: I-21
|
||||
name: "Bbox > 0.25 deg² → 400"
|
||||
input: "bbox=37,55,38,56"
|
||||
expected: "400, error 'bbox too large'"
|
||||
name: "Truncation"
|
||||
input: "В bbox 1500 треков, limit=500"
|
||||
expected: "returned=500, total_in_bbox=1500, truncated=true"
|
||||
|
||||
- id: I-22
|
||||
name: "OSM API недоступен → 502"
|
||||
input: "OSM mock возвращает 500"
|
||||
expected: "502, JSON error"
|
||||
name: "Невалидный bbox → 400"
|
||||
input: "bbox=foo"
|
||||
expected: "400, JSON error"
|
||||
|
||||
- id: I-23
|
||||
name: "Cache hit на повторный bbox"
|
||||
input: "Тот же bbox дважды"
|
||||
expected: "Второй запрос: внешний запрос НЕ выполнен, ответ из кэша"
|
||||
name: "Bbox в океане → пустой результат"
|
||||
input: "bbox=0,0,1,1"
|
||||
expected: "200, features=[], total=0"
|
||||
|
||||
- id: I-24
|
||||
name: "Пустой bbox → пустой список"
|
||||
input: "bbox в океане"
|
||||
expected: "200, tracks=[], has_more=false"
|
||||
name: "CORS headers"
|
||||
input: "Origin: https://example.com"
|
||||
expected: "Response содержит Access-Control-Allow-Origin: *"
|
||||
|
||||
- id: I-25
|
||||
name: "Пагинация"
|
||||
input: "page=0 возвращает has_more=true, page=1 возвращает следующие"
|
||||
expected: "Корректное смещение, оба запроса валидны"
|
||||
name: "Производительность"
|
||||
input: "100 запросов на bbox с 500 треков"
|
||||
expected: "p95 ≤ 300 мс"
|
||||
|
||||
- name: integration-health-metrics
|
||||
- name: integration-endpoint-mvt
|
||||
type: integration
|
||||
description: "Метрики кэшей в /api/health"
|
||||
description: "/api/gps-tracks/tiles/{z}/{x}/{y}.mvt"
|
||||
cases:
|
||||
- id: I-30
|
||||
name: "Health возвращает размеры кэшей"
|
||||
input: "GET /api/health"
|
||||
expected: "JSON содержит gpx_fetch_cache_size, gpx_osm_cache_size (числа ≥ 0)"
|
||||
name: "Тайл MVT отдаётся"
|
||||
input: "GET /api/gps-tracks/tiles/10/623/325.mvt"
|
||||
expected: "200, Content-Type: application/x-protobuf,
|
||||
X-Cache: MISS"
|
||||
|
||||
- id: I-31
|
||||
name: "Счётчики растут после запросов"
|
||||
input: "После N успешных fetch и M osm_traces запросов"
|
||||
expected: "Размеры кэшей отражают добавленные записи"
|
||||
name: "Cache hit"
|
||||
input: "Повторный запрос того же тайла"
|
||||
expected: "X-Cache: HIT, ≤ 20 мс"
|
||||
|
||||
- name: e2e-url-import
|
||||
- id: I-32
|
||||
name: "Невалидные z/x/y"
|
||||
input: "z=25 / x вне диапазона"
|
||||
expected: "400"
|
||||
|
||||
- id: I-33
|
||||
name: "Очистка кэша"
|
||||
input: "POST /api/gps-tracks/cache/clear, повторный запрос тайла"
|
||||
expected: "X-Cache: MISS"
|
||||
|
||||
- name: integration-endpoint-health
|
||||
type: integration
|
||||
description: "/api/gps-tracks/health"
|
||||
cases:
|
||||
- id: I-40
|
||||
name: "Полный отчёт"
|
||||
input: "GET /api/gps-tracks/health"
|
||||
expected: "200, JSON со всеми полями (см. REQ-F-12)"
|
||||
|
||||
- id: I-41
|
||||
name: "БД отсутствует"
|
||||
input: "Удалить data/gps_tracks.sqlite, GET /api/gps-tracks/health"
|
||||
expected: "503 или 200 с tracks_total=0 и warning"
|
||||
|
||||
- id: I-42
|
||||
name: "Счётчики корректны"
|
||||
input: "БД с 100 OSM + 50 EnduroRussia"
|
||||
expected: "tracks_by_source: {osm: 100, enduro_russia: 50}"
|
||||
|
||||
- name: integration-web-layer
|
||||
type: integration
|
||||
description: "Клиентский слой публичных треков"
|
||||
cases:
|
||||
- id: I-50
|
||||
name: "Включение/выключение слоя"
|
||||
input: "Симуляция click на #public-tracks-cb"
|
||||
expected: "map.getSource('gps-tracks-tiles') существует,
|
||||
layer 'gps-tracks-layer' visibility=visible"
|
||||
|
||||
- id: I-51
|
||||
name: "Фильтр по активности через setFilter"
|
||||
input: "filters.activities = ['enduro']"
|
||||
expected: "map.getFilter('gps-tracks-layer') содержит ['in', ['get','activity'], ['literal',['enduro']]]"
|
||||
|
||||
- id: I-52
|
||||
name: "Переключение color-mode"
|
||||
input: "Переключить с source на activity"
|
||||
expected: "Layer paint['line-color'] переустановлен на activity-палитру"
|
||||
|
||||
- id: I-53
|
||||
name: "GeoJSON-загрузка при z ≥ 12"
|
||||
input: "map.zoom=14, moveend"
|
||||
expected: "Через 500мс debounce — fetch /api/gps-tracks?bbox=…"
|
||||
|
||||
- id: I-54
|
||||
name: "AbortController при быстром pan"
|
||||
input: "Два moveend подряд за 100мс"
|
||||
expected: "Первый fetch отменён, выполняется только второй"
|
||||
|
||||
- id: I-55
|
||||
name: "Halo на спутнике"
|
||||
input: "applyBaseLayer('satellite'), public-tracks включен"
|
||||
expected: "layer 'gps-tracks-halo-satellite' visibility=visible"
|
||||
|
||||
- id: I-56
|
||||
name: "Halo выключен на схеме"
|
||||
input: "applyBaseLayer('schematic')"
|
||||
expected: "halo visibility=none"
|
||||
|
||||
- id: I-57
|
||||
name: "Сохранение слоя при setStyle"
|
||||
input: "Переключение тёмной темы (switchMapStyle)"
|
||||
expected: "rebuildMapOverlays() → restorePublicTracksState() →
|
||||
слой пересоздан, фильтры применены"
|
||||
|
||||
- name: e2e-pipeline
|
||||
type: e2e
|
||||
description: "Импорт GPX по ссылке — полный сценарий"
|
||||
description: "Полный pipeline на тестовых mock-источниках"
|
||||
cases:
|
||||
- id: E-01
|
||||
name: "URL-импорт валидного трека"
|
||||
name: "Сбор → API → визуализация"
|
||||
steps:
|
||||
- "Открыть приложение"
|
||||
- "Нажать кнопку GPX в нижнем тулбаре"
|
||||
- "Переключиться на вкладку «По ссылке»"
|
||||
- "Вставить URL валидного GPX (тестовый mock)"
|
||||
- "Нажать «Загрузить»"
|
||||
- "Убедиться: индикатор показан, через ≤ 5 сек трек на карте"
|
||||
- "Убедиться: в #gpx-list появилась карточка с источником «🔗 host»"
|
||||
- "Кликнуть на трек → отображается статистика и профиль высот"
|
||||
- "Очистить test-БД"
|
||||
- "Запустить pipeline с mock OSM + mock EnduroRussia"
|
||||
- "Проверить: tracks_total > 0 в /api/gps-tracks/health"
|
||||
- "Открыть веб-интерфейс"
|
||||
- "Включить чекбокс «Публичные треки»"
|
||||
- "Убедиться: на карте видны линии треков"
|
||||
- "Кликнуть по треку → popup с метаданными"
|
||||
|
||||
- id: E-02
|
||||
name: "URL-импорт по Enter"
|
||||
name: "Дедупликация — два прогона"
|
||||
steps:
|
||||
- "Активировать «По ссылке»"
|
||||
- "Вставить URL, нажать Enter"
|
||||
- "Убедиться: трек загружен (как при клике)"
|
||||
- "Запустить pipeline (mock-источники отдают 100 треков)"
|
||||
- "Запомнить tracks_total"
|
||||
- "Запустить pipeline повторно (mock отдаёт те же 100)"
|
||||
- "Убедиться: tracks_total не изменился"
|
||||
- "Убедиться: pipeline_runs.tracks_updated=100"
|
||||
|
||||
- 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
|
||||
- name: e2e-ui-filters
|
||||
type: e2e
|
||||
description: "Поиск и импорт OSM треков"
|
||||
description: "UI-фильтры по активности и источнику"
|
||||
cases:
|
||||
- id: E-10
|
||||
name: "Поиск треков в области и импорт"
|
||||
name: "Открытие фильтров и переключение"
|
||||
steps:
|
||||
- "Открыть приложение, отзумиться к области Москвы (zoom 12)"
|
||||
- "Открыть #sheet-gpx, активировать «Найти рядом»"
|
||||
- "Нажать «Найти треки в этой области карты»"
|
||||
- "Убедиться: индикатор, потом список карточек"
|
||||
- "Нажать «Показать» у первой карточки"
|
||||
- "Убедиться: трек появился на карте, fit bounds"
|
||||
- "Убедиться: карточка в найденных получила «✓ Загружен»"
|
||||
- "Убедиться: в #gpx-list появилась карточка с «🌍 OSM #...»"
|
||||
- "Включить чекбокс «Публичные треки»"
|
||||
- "Нажать «Фильтры…» → открывается #sheet-gps-filters"
|
||||
- "Снять все галки активности кроме «Эндуро»"
|
||||
- "Убедиться: на карте видны только enduro-треки"
|
||||
- "Снять «OSM» в источниках"
|
||||
- "Убедиться: OSM enduro-треки скрылись"
|
||||
|
||||
- id: E-11
|
||||
name: "Слишком большая область"
|
||||
name: "Переключение color-mode"
|
||||
steps:
|
||||
- "Отзумиться на всю Россию"
|
||||
- "Активировать «Найти рядом»"
|
||||
- "Нажать «Найти»"
|
||||
- "Убедиться: toast «Слишком большая область, увеличьте zoom»"
|
||||
- "Включить слой"
|
||||
- "Открыть фильтры"
|
||||
- "Выбрать «По активности»"
|
||||
- "Убедиться: цвета линий перерисованы (например, enduro = красный)"
|
||||
- "Перезагрузить страницу"
|
||||
- "Убедиться: color-mode='activity' сохранён"
|
||||
|
||||
- id: E-12
|
||||
name: "Пустая область"
|
||||
name: "Persistence фильтров"
|
||||
steps:
|
||||
- "Перейти к области без треков (океан)"
|
||||
- "Активировать «Найти рядом»"
|
||||
- "Нажать «Найти»"
|
||||
- "Убедиться: сообщение «В этой области нет публичных GPS-треков»"
|
||||
- "Настроить фильтры (только moto, только EnduroRussia)"
|
||||
- "Перезагрузить страницу"
|
||||
- "Открыть фильтры"
|
||||
- "Убедиться: чекбоксы соответствуют настройкам"
|
||||
|
||||
- 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
|
||||
- name: e2e-popup
|
||||
type: e2e
|
||||
description: "Совместимость трёх источников в одной сессии"
|
||||
description: "Popup трека"
|
||||
cases:
|
||||
- id: E-20
|
||||
name: "3 трека разных источников"
|
||||
name: "Popup полный набор полей"
|
||||
steps:
|
||||
- "Загрузить 1 локальный файл"
|
||||
- "Загрузить 1 по URL"
|
||||
- "Загрузить 1 из OSM"
|
||||
- "Убедиться: 3 карточки в #gpx-list, разные цвета, разные источники"
|
||||
- "Удалить URL-трек"
|
||||
- "Убедиться: 2 трека на карте, корректные источники"
|
||||
- "Включить слой"
|
||||
- "Кликнуть на трек на карте"
|
||||
- "Убедиться: popup содержит name, activity-иконку, км, дату, user, sources"
|
||||
- "Кликнуть по ссылке источника"
|
||||
- "Убедиться: открыта новая вкладка"
|
||||
|
||||
- id: E-21
|
||||
name: "Сохранение при смене темы"
|
||||
name: "Popup для трека без user"
|
||||
steps:
|
||||
- "Загрузить 3 трека разных источников"
|
||||
- "Переключить тёмную тему"
|
||||
- "Убедиться: все 3 трека на карте"
|
||||
- "Убедиться: источники в карточках сохранены"
|
||||
- "Найти трек без user"
|
||||
- "Кликнуть → popup без строки «Автор»"
|
||||
|
||||
- id: E-22
|
||||
name: "Сохранение при включении hillshade"
|
||||
steps:
|
||||
- "Загрузить 3 трека"
|
||||
- "Включить hillshade"
|
||||
- "Убедиться: все 3 трека видны поверх hillshade"
|
||||
|
||||
- name: e2e-cache
|
||||
- name: e2e-compat
|
||||
type: e2e
|
||||
description: "Поведение кэша через API"
|
||||
description: "Совместимость с другими функциями"
|
||||
cases:
|
||||
- id: E-30
|
||||
name: "Кэш URL-fetch снижает время"
|
||||
name: "Слой + спутник + halo"
|
||||
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"
|
||||
name: "Слой + тёмная тема"
|
||||
steps:
|
||||
- "Сделать N запросов /api/gpx/fetch"
|
||||
- "GET /api/health"
|
||||
- "Убедиться: gpx_fetch_cache_size == N (или min(N, лимит))"
|
||||
- "Включить слой"
|
||||
- "Переключить тёмную тему"
|
||||
- "Убедиться: треки остаются на карте"
|
||||
- "Убедиться: фильтры сохранены"
|
||||
|
||||
- id: E-32
|
||||
name: "Слой + личный GPX (ET-006)"
|
||||
steps:
|
||||
- "Включить слой"
|
||||
- "Загрузить личный GPX"
|
||||
- "Убедиться: оба видны"
|
||||
- "Убедиться: личный трек выше публичных по z-order"
|
||||
|
||||
- id: E-33
|
||||
name: "Слой + маршрут OSRM"
|
||||
steps:
|
||||
- "Включить слой"
|
||||
- "Построить маршрут OSRM"
|
||||
- "Убедиться: маршрут OSRM визуально выше публичных треков"
|
||||
|
||||
- id: E-34
|
||||
name: "Слой + hillshade"
|
||||
steps:
|
||||
- "Включить слой"
|
||||
- "Включить hillshade"
|
||||
- "Убедиться: оба видны"
|
||||
|
||||
- name: e2e-low-zoom-protection
|
||||
type: e2e
|
||||
description: "Защита от шторма запросов на low-zoom"
|
||||
cases:
|
||||
- id: E-40
|
||||
name: "Слой скрыт на z<8"
|
||||
steps:
|
||||
- "Включить слой"
|
||||
- "Отзумиться до z=5"
|
||||
- "Убедиться: линии не отображаются"
|
||||
- "Убедиться: появилась подсказка «Зум 8+» у чекбокса"
|
||||
|
||||
- id: E-41
|
||||
name: "Pan на z 14 не штормит запросы"
|
||||
steps:
|
||||
- "Включить слой, z=14"
|
||||
- "Быстро панить карту (5 раз за 1 сек)"
|
||||
- "Проверить network log: не более 2 запросов /api/gps-tracks"
|
||||
|
||||
- name: load-pipeline
|
||||
type: load
|
||||
description: "Нагрузочные сценарии pipeline и API"
|
||||
cases:
|
||||
- id: L-01
|
||||
name: "Полный прогон pipeline на ЦФО+Чувашию (mock)"
|
||||
input: "Mock OSM с реальным объёмом ≈ 50K треков"
|
||||
expected: "Прогон завершается за ≤ 6 часов (cron-окно)"
|
||||
|
||||
- id: L-02
|
||||
name: "API под нагрузкой"
|
||||
input: "10 параллельных клиентов делают по 100 запросов /api/gps-tracks"
|
||||
expected: "p95 ≤ 500 мс, нет ошибок"
|
||||
|
||||
- id: L-03
|
||||
name: "MVT-тайлы под нагрузкой"
|
||||
input: "100 параллельных запросов разных тайлов"
|
||||
expected: "p95 cold ≤ 300 мс, hit-rate кэша > 80% на повторах"
|
||||
|
||||
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"
|
||||
fixtures_dir: "tests/fixtures/gps-tracks/"
|
||||
fixtures:
|
||||
- name: "osm-trackpoints-bbox-moscow.gpx"
|
||||
description: "Реальный ответ OSM API на bbox центра Москвы"
|
||||
- name: "osm-trackpoints-multipage.json"
|
||||
description: "Серия ответов OSM с has_more=true на нескольких страницах"
|
||||
- name: "enduro-russia-mock-listing.html"
|
||||
description: "Главная страница региона на EnduroRussia (mock)"
|
||||
- name: "enduro-russia-mock-track.gpx"
|
||||
description: "GPX-файл, отдаваемый EnduroRussia mock"
|
||||
- name: "ttrails-mock-track.gpx"
|
||||
description: "GPX от ttrails mock"
|
||||
- name: "xxe-payload.gpx"
|
||||
description: "GPX с DOCTYPE и внешней entity (для проверки defusedxml)"
|
||||
- name: "dedup-pair-osm-enduro.json"
|
||||
description: "Пара треков (одна и та же поездка из двух источников) для проверки dedup"
|
||||
- name: "gps_tracks_seed.sql"
|
||||
description: "SQL-сид: 1000 синтетических треков для интеграционных тестов"
|
||||
|
||||
test_environment:
|
||||
mock_servers:
|
||||
- "Mock HTTP-сервер для /api/gpx/fetch тестов (отдаёт фиксированные ответы)"
|
||||
- "Mock OSM API для /api/gpx/osm/traces тестов"
|
||||
fixtures_dir: "tests/fixtures/gpx-public/"
|
||||
- "Mock OSM API (отвечает на /api/0.6/trackpoints и /api/0.6/gpx/<id>)"
|
||||
- "Mock EnduroRussia.ru (HTML-страницы + GPX-файлы)"
|
||||
- "Mock ttrails.ru"
|
||||
cron_simulation:
|
||||
- "В тестах cron заменяется на pytest fixture, вызывающий run() напрямую"
|
||||
db_isolation:
|
||||
- "Каждый тест использует in-memory или временный sqlite-файл в pytest tmp_path"
|
||||
network:
|
||||
- "Все исходящие HTTP в unit/integration — через httpx_mock или respx (без реальной сети)"
|
||||
notes:
|
||||
- "OSM API в e2e тестах должен мокироваться, чтобы не зависеть от внешней доступности"
|
||||
- "Для нагрузочных тестов кэша использовать pytest-benchmark"
|
||||
- "L-01 (полный прогон pipeline) запускается отдельно, не в обычном CI"
|
||||
- "E2E UI-тесты — Playwright; URL test-среды https://openclaw.mva154.duckdns.org/enduro/ (см. 04b-ui-test-cases.md)"
|
||||
- "Для load-тестов использовать pytest-benchmark + locust"
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
type: ui-test-cases
|
||||
work_item_id: ET-008
|
||||
title: "UI Test Cases: GPS-треки с публичных платформ"
|
||||
version: 1
|
||||
version: 2
|
||||
status: draft
|
||||
created_at: 2026-06-01
|
||||
updated_at: 2026-06-01
|
||||
changelog:
|
||||
- "v2 (2026-06-01): полная переработка под BRD/TRZ v2 — чекбокс «Публичные треки» в попапе, sheet фильтров, halo на спутнике, popup трека. Предыдущая v1 описывала вкладки источников в #sheet-gpx (URL/OSM)."
|
||||
authors:
|
||||
- "agent:analyst"
|
||||
---
|
||||
@@ -14,299 +16,33 @@ authors:
|
||||
|
||||
Базовый URL: `https://openclaw.mva154.duckdns.org/enduro/`
|
||||
|
||||
Все тесты проверяют появление и поведение секции «Источники» в
|
||||
`#sheet-gpx`, импорта по URL и поиска OSM-треков. Внешние сетевые
|
||||
запросы в test-окружении мокаются (см. test-plan).
|
||||
Все тесты проверяют появление и поведение нового слоя «Публичные
|
||||
треки»: чекбокса в `#terrain-popup`, sheet фильтров, отрисовки линий,
|
||||
popup и совместимости со спутниковой подложкой / тёмной темой.
|
||||
|
||||
Селекторы (новые, добавляются 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`.
|
||||
- `#public-tracks-cb` — чекбокс «Публичные треки» в `#terrain-popup`
|
||||
- `#public-tracks-zoom-hint` — подсказка «Зум 8+»
|
||||
- `#public-tracks-filters-btn` — ссылка «Фильтры…»
|
||||
- `#sheet-gps-filters` — bottom sheet фильтров
|
||||
- `#gps-activity-grid` — секция чекбоксов активности
|
||||
- `#gps-source-grid` — секция чекбоксов источников
|
||||
- `#gps-color-by-source`, `#gps-color-by-activity` — переключатель color-mode
|
||||
- `#gps-stat-total`, `#gps-stat-shown` — счётчики в sheet
|
||||
- `.gps-track-popup` — MapLibre Popup с метаданными трека (имя класса
|
||||
можно задать через `setHTML` и контейнер)
|
||||
|
||||
Существующие селекторы: `#terrain-toggle`, `#terrain-popup`,
|
||||
`#btn-theme`, `#base-btn-satellite`, `#base-btn-schematic`,
|
||||
`#terrain-hillshade-cb`, `#tb-gpx`, `#map`.
|
||||
|
||||
Предусловие: тестовая среда содержит pre-collected dataset публичных
|
||||
треков (или mock-backend подменяет `/api/gps-tracks*` фикстурами).
|
||||
|
||||
---
|
||||
|
||||
### 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 — Совместимость со спутниковой подложкой
|
||||
### TC-UI-01 — Чекбокс «Публичные треки» виден в попапе
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
@@ -316,22 +52,381 @@ authors:
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#base-btn-satellite"
|
||||
6. wait: 5000
|
||||
5. screenshot: "01-popup-with-public-tracks-checkbox"
|
||||
6. check-visual: "В открытом попапе #terrain-popup между секциями «Тропы» и «POI» (после соответствующего разделителя `<hr>`) видна строка «Публичные треки» с чекбоксом #public-tracks-cb. По умолчанию чекбокс снят, ссылка «Фильтры…» не видна."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-02 — Включение слоя «Публичные треки»
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. screenshot: "02-public-tracks-enabled"
|
||||
8. check-visual: "Чекбокс установлен. На карте поверх существующих trail-линий и POI видны цветные линии публичных треков (отдельные линии, не heatmap). Рядом с чекбоксом появилась ссылка «Фильтры…»."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-03 — Подсказка «Зум 8+» на низком зуме
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/?z=5
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 1500
|
||||
7. screenshot: "03-public-tracks-zoom-hint"
|
||||
8. check-visual: "Чекбокс включён, но на карте линии публичных треков не видны. Рядом с чекбоксом (или под ним) отображается подсказка «Зум 8+» (стилем как существующая подсказка «Зум 10+» у hillshade)."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-04 — Открытие sheet фильтров
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 2000
|
||||
7. click: "#public-tracks-filters-btn"
|
||||
8. wait: 800
|
||||
9. screenshot: "04-gps-filters-sheet-open"
|
||||
10. check-visual: "Открылся bottom sheet #sheet-gps-filters с заголовком «Фильтры публичных треков». Видны секции: «ТИП АКТИВНОСТИ» (7 чекбоксов: эндуро, мото, off-road, велосипед, пешком, лыжи, другое), «ИСТОЧНИК» (≥ 3 чекбокса), «ЦВЕТ ЛИНИЙ» (segmented control «По источнику» / «По активности»). По умолчанию все чекбоксы установлены, color-mode='По источнику' активен."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-05 — Фильтрация по активности (клиентская, мгновенная)
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#public-tracks-filters-btn"
|
||||
8. wait: 800
|
||||
9. screenshot: "05a-filters-all-on"
|
||||
10. check-visual: "В sheet видны все 7 чекбоксов активности — установлены. На карте видно много линий разных типов."
|
||||
11. click: "#gps-activity-grid input[value='bicycle']"
|
||||
12. wait: 300
|
||||
13. click: "#gps-activity-grid input[value='hike']"
|
||||
14. wait: 300
|
||||
15. click: "#gps-activity-grid input[value='ski']"
|
||||
16. wait: 300
|
||||
17. click: "#gps-activity-grid input[value='other']"
|
||||
18. wait: 500
|
||||
19. screenshot: "05b-filters-only-moto-types"
|
||||
20. check-visual: "Выключены чекбоксы «Велосипед», «Пешком», «Лыжи», «Другое». На карте линий стало заметно меньше (только enduro/moto/offroad). Счётчик «Видны (фильтр)» в нижней части sheet уменьшился."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-06 — Фильтрация по источнику
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#public-tracks-filters-btn"
|
||||
8. wait: 800
|
||||
9. click: "#gps-source-grid input[value='osm']"
|
||||
10. wait: 500
|
||||
11. screenshot: "06-source-osm-disabled"
|
||||
12. check-visual: "Чекбокс «OSM» снят. На карте все линии цвета OSM (зелёного — при color-by-source) скрыты. Счётчик «Видны» уменьшился."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-07 — Переключение color-mode
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#public-tracks-filters-btn"
|
||||
8. wait: 800
|
||||
9. screenshot: "07a-color-by-source"
|
||||
10. check-visual: "Активна кнопка «По источнику». Линии на карте окрашены по источникам (например, зелёный = OSM, красный = EnduroRussia)."
|
||||
11. click: "#gps-color-by-activity"
|
||||
12. wait: 600
|
||||
13. screenshot: "07b-color-by-activity"
|
||||
14. check-visual: "Активна кнопка «По активности». Линии перекрашены: например, красные = enduro, оранжевые = moto. Кнопка «По источнику» больше не подсвечена."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-08 — Popup при клике на трек
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#map"
|
||||
8. wait: 1500
|
||||
9. screenshot: "08-track-popup"
|
||||
10. check-visual: "При клике на линию трека (предполагается, что под центром карты есть трек) открылся MapLibre Popup. В нём видны: иконка активности (🏍 / 🚴 / …) + текстовая метка, длина в км, дата (если есть), автор (если есть), список источников со ссылками '↗'. Popup имеет крестик закрытия."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-09 — Halo на спутниковой подложке
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#base-btn-satellite"
|
||||
8. wait: 5000
|
||||
9. screenshot: "09-public-tracks-on-satellite"
|
||||
10. check-visual: "Карта показывает спутниковые снимки. Линии публичных треков видны поверх спутника, у каждой линии есть белая (или светлая) обводка-halo для контраста на тёмном фоне. Цвета линий по-прежнему отличаются по источнику/активности."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-10 — Возврат на схему — halo пропадает
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#base-btn-satellite"
|
||||
8. wait: 5000
|
||||
9. click: "#base-btn-schematic"
|
||||
10. wait: 3000
|
||||
11. screenshot: "10-back-to-schematic-no-halo"
|
||||
12. check-visual: "Карта вернулась на схему OSM. Линии публичных треков видны без halo (обычная толщина и цвет). На фоне светлой схемы — без обводки."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-11 — Сохранение слоя при переключении тёмной темы
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#btn-theme"
|
||||
8. wait: 3000
|
||||
9. screenshot: "11-public-tracks-after-theme-switch"
|
||||
10. check-visual: "После переключения темы (например, на тёмную) линии публичных треков остались на карте. Цвета сохранены. На тёмной теме линии хорошо различимы."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-12 — Сохранение слоя при включении hillshade
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#terrain-hillshade-cb"
|
||||
8. wait: 3000
|
||||
9. screenshot: "12-public-tracks-over-hillshade"
|
||||
10. check-visual: "Включён hillshade (тени рельефа). Линии публичных треков остаются видны поверх теней рельефа. Контраст сохраняется."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-13 — Совместимость с маршрутом OSRM (z-order)
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#tb-route"
|
||||
8. wait: 1000
|
||||
9. click: "#map"
|
||||
10. wait: 1500
|
||||
11. scroll: 100
|
||||
12. click: "#map"
|
||||
13. wait: 5000
|
||||
14. screenshot: "13-public-tracks-and-osrm-route"
|
||||
15. check-visual: "Видны и линии публичных треков, и линия маршрута OSRM (синяя/оранжевая). Маршрут OSRM визуально лежит поверх публичных треков (выше по z-order). Обе системы линий читаемы."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-14 — Sheet фильтров на мобильном
|
||||
|
||||
- тип: ui
|
||||
- viewport: mobile
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#public-tracks-filters-btn"
|
||||
8. wait: 800
|
||||
9. screenshot: "14-gps-filters-mobile"
|
||||
10. check-visual: "На мобильном viewport sheet #sheet-gps-filters занимает всю ширину. Все 7 чекбоксов активности видны (например, 2-3 колонки grid). Чекбоксы источников видны. Segmented control color-mode помещается. Все элементы нажимаемы, не перекрываются."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-15 — Включение слоя на мобильном
|
||||
|
||||
- тип: ui
|
||||
- viewport: mobile
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. screenshot: "15-public-tracks-mobile"
|
||||
8. check-visual: "На мобильном устройстве после включения чекбокса линии публичных треков видны на карте. Попап слоёв и тулбар не перекрывают карту целиком — слой просматривается."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-16 — Persistence: слой включён после перезагрузки
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
8. wait: 6000
|
||||
9. screenshot: "16-public-tracks-after-reload"
|
||||
10. check-visual: "После перезагрузки страницы карта сразу показывает линии публичных треков (слой автоматически восстановлен из localStorage). Открытие попапа слоёв должно показать чекбокс установленным."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-17 — Persistence: фильтры сохраняются после перезагрузки
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#public-tracks-filters-btn"
|
||||
8. wait: 800
|
||||
9. click: "#gps-activity-grid input[value='bicycle']"
|
||||
10. wait: 300
|
||||
11. click: "#gps-activity-grid input[value='hike']"
|
||||
12. wait: 300
|
||||
13. click: "#gps-color-by-activity"
|
||||
14. wait: 500
|
||||
15. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
16. wait: 6000
|
||||
17. click: "#terrain-toggle"
|
||||
18. wait: 500
|
||||
19. click: "#public-tracks-filters-btn"
|
||||
20. wait: 800
|
||||
21. screenshot: "17-filters-after-reload"
|
||||
22. check-visual: "Чекбоксы «Велосипед» и «Пешком» по-прежнему сняты. Color-mode = «По активности» (соответствующая кнопка подсвечена). Линии на карте окрашены по активности."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-18 — Атрибуция источников
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. screenshot: "18-attribution-public-tracks"
|
||||
8. check-visual: "В правом нижнем углу карты (в стандартной MapLibre-панели атрибуции) видны строки с атрибуцией источников публичных треков: например, «© OpenStreetMap contributors (ODbL)» и «EnduroRussia.ru» (либо иконка info, при клике на которую разворачивается полный текст)."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-19 — Совместимость с личным GPX (ET-006)
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
|
||||
шаги:
|
||||
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
|
||||
2. wait: 5000
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
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 не конфликтует со спутником визуально."
|
||||
9. screenshot: "19-public-tracks-with-gpx-sheet"
|
||||
10. check-visual: "Открыт sheet #sheet-gpx (для личных треков из ET-006). Слой публичных треков на карте остаётся видимым. Sheet и слой не конфликтуют визуально. Список личных треков в sheet — пустой (если ничего не загружено)."
|
||||
|
||||
---
|
||||
|
||||
### TC-UI-16 — Сохранение треков при переключении тёмной темы
|
||||
### TC-UI-20 — Выключение слоя — линии исчезают
|
||||
|
||||
- тип: ui
|
||||
- viewport: desktop
|
||||
@@ -339,57 +434,11 @@ authors:
|
||||
шаги:
|
||||
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), визуально отличима от основной кнопки «Показать»."
|
||||
3. click: "#terrain-toggle"
|
||||
4. wait: 500
|
||||
5. click: "#public-tracks-cb"
|
||||
6. wait: 3000
|
||||
7. click: "#public-tracks-cb"
|
||||
8. wait: 1500
|
||||
9. screenshot: "20-public-tracks-disabled"
|
||||
10. check-visual: "Чекбокс снят. Все линии публичных треков исчезли с карты. Ссылка «Фильтры…» рядом с чекбоксом скрылась. Базовые слои (схема, trails, POI) остались видимыми и без изменений."
|
||||
|
||||
169
docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md
Normal file
169
docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-008
|
||||
adr_id: ADR-005
|
||||
title: "ADR-005: Хранение публичных GPS-треков — отдельная БД data/gps_tracks.sqlite, SQLite+Spatialite, общая схема для всех источников, sources как JSON-массив"
|
||||
status: accepted
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels:
|
||||
- "arch:major-change"
|
||||
---
|
||||
|
||||
# ADR-005 — Схема хранения публичных GPS-треков
|
||||
|
||||
## Статус
|
||||
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
ET-008 вводит новый класс данных в проект — **публичные GPS-треки**, агрегированные офлайн-pipeline'ом из ≥ 3 внешних источников по региону MVP (ЦФО + Чувашия). По BRD §3 целевой объём — ≥ 5000 треков, по BRD §6 предел — несколько ГБ на регион при дальнейшем расширении. По BRD §1 модель данных не пересекается с существующими сущностями:
|
||||
|
||||
- vector-tile слой `trails` (`data/centralfederal.sqlite`) — OSM-дороги/тропы, отдельный формат, отдельный pipeline (osm2pgsql-like);
|
||||
- личные GPX-треки (ET-006) — живут только в памяти браузера (`window.gpxTracks`), на сервере не хранятся;
|
||||
- POI и маршруты (PH-1/2) — другие сущности `centralfederal.sqlite`.
|
||||
|
||||
Архитектурно нужно решить:
|
||||
|
||||
1. **Где хранить** — в существующей `centralfederal.sqlite` или отдельным файлом.
|
||||
2. **Как организовать схему** — одна таблица на все источники или партиционирование по источнику.
|
||||
3. **Как хранить мульти-источник** (трек найден в N платформах после дедупа) — нормализованная таблица `track_sources` или JSON-массив в основной таблице.
|
||||
4. **Какие индексы** дают приемлемый p95 ≤ 300 мс на bbox-запрос с фильтрами.
|
||||
5. **Совместимость с MVT-generation pipeline'ом**, уже существующим в `src/api/main.py` для `/api/tiles/{z}/{x}/{y}.mvt`.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
### Вариант D (Database) — где хранить
|
||||
|
||||
- **D-A — отдельный файл `data/gps_tracks.sqlite`** (выбран, совпадает с BRD §7 и TRZ §8 ADR-001-recommendation).
|
||||
Плюсы:
|
||||
- Pipeline пишет в свою БД — нет блокировок write на `centralfederal.sqlite`, который активно читается API под нагрузкой раздачи MVT.
|
||||
- Независимый цикл бэкапа (см. `07-infra-requirements.md` §4): `gps_tracks.sqlite` бэкапится ежедневно, `centralfederal.sqlite` — после редкой ребилд-сессии OSM-данных.
|
||||
- Независимая ротация: ретеншн 5 лет (REQ-NF-03) применяется только к одной БД; `centralfederal.sqlite` пересобирается из OSM по своему графику.
|
||||
- Изоляция риска при ошибке pipeline — нельзя случайно повредить OSM-данные.
|
||||
- В будущем (BRD §6 риск роста до миллионов треков) переход на PostGIS затрагивает один файл, а не корневую БД.
|
||||
Минусы:
|
||||
- Второй коннект из FastAPI (мелкая сложность, ~10 строк в `main.py`).
|
||||
- При совместных запросах «дороги OSM × публичные треки рядом» (PH-3 Smart Route) — кросс-БД JOIN неэффективен. Принято: на горизонте MVP таких запросов нет; в PH-3 решается отдельным ADR (вариант: `ATTACH DATABASE` или денормализация в материализованную таблицу).
|
||||
|
||||
- **D-B — в существующую `centralfederal.sqlite`, отдельные таблицы `gps_tracks_*`**. Отклонён:
|
||||
- Pipeline writer и MVT reader конкурируют за один файл; SQLite WAL смягчает, но не устраняет.
|
||||
- Backup-цикл становится зависимым: невозможно ребилдить OSM-данные не «остановив» pipeline.
|
||||
- Сценарий «удалить весь gps-датасет и пересобрать» (R-3 ниже) требует `DROP TABLE` в большой production-БД; в отдельном файле — `rm data/gps_tracks.sqlite && python scripts/gps_collect.py`.
|
||||
|
||||
- **D-C — PostGIS**. Отклонён:
|
||||
- BRD §1 «SQLite по умолчанию, PostgreSQL когда нужно». ≥ 5000 треков для ЦФО легко влезают в SQLite (оценочно ≤ 500 МБ при средней геометрии 1240 точек × 16 байт). Spatialite даёт BLOB+R-tree, чего хватает для всех запросов TRZ.
|
||||
- Введение PostgreSQL — новый класс инфры (контейнер + бэкап + миграции через alembic). Это `arch:major-change` уровня всего проекта; ET-008 такого не требует.
|
||||
|
||||
### Вариант T (Table layout) — одна или несколько таблиц
|
||||
|
||||
- **T-A — единая таблица `tracks`** (выбран). Поля per-источник денормализованы в JSON-колонки. Все источники приводятся к общему контракту в `models.py::Track` (TRZ §7).
|
||||
Плюсы:
|
||||
- Самый простой bbox-запрос: один SELECT с одним bbox-фильтром.
|
||||
- Дедупликация на уровне БД через UNIQUE-индекс по `dedup_key` (TRZ REQ-F-08).
|
||||
- MVT-генерация на низком зуме — одно сканирование R-tree → одна `LineString → MVT` петля.
|
||||
- **T-B — таблица на источник + view `tracks_all UNION ALL ...`**. Отклонён:
|
||||
- Дедупликация между источниками превращается в кросс-таблицу процедуру.
|
||||
- Изменение списка источников требует DDL-миграции, что блокирует «расширяемость на новый регион ≤ 30 строк YAML без правки кода» (BRD-метрика).
|
||||
|
||||
### Вариант S (Sources field) — как хранить N источников у одного трека
|
||||
|
||||
- **S-A — JSON-массив в колонках `sources_json`, `external_urls_json`** (выбран, совпадает с TRZ REQ-F-09).
|
||||
Плюсы:
|
||||
- Запись/чтение трека — атомарная операция.
|
||||
- При мерже дубликата `UPDATE sources_json = json_array_union(...)` через Python-сторону (без JSON1-функций SQLite, чтобы не зависеть от SQLite-версии).
|
||||
- Фильтр API «source=osm,ttrails» работает через bbox-prefetch + Python-постфильтр (≤ 500 треков на bbox — это O(500) проверка `'osm' in sources`, ничтожно).
|
||||
Минусы:
|
||||
- Невозможно индексировать массив без JSON1; нет нативного `WHERE 'osm' = ANY(sources)`. Принято: на BRD-объёме это не узкое место.
|
||||
- **S-B — нормализованная таблица `track_sources(track_id, source_id, ext_url)`**. Отклонён:
|
||||
- JOIN на каждый bbox-запрос (1 → N запись на трек) +30–60% к p95.
|
||||
- Усложняет API: GeoJSON-формирование требует aggregate-функции (`group_concat`) → лишний SQL.
|
||||
- Не даёт значимого выигрыша на BRD-объёме (≤ 5–10 источников на трек после дедупа в худшем случае).
|
||||
|
||||
### Вариант I (Indexes) — как ускорить bbox-фильтр
|
||||
|
||||
- **I-A — Spatialite R-tree через виртуальную таблицу `idx_tracks_geom` + обычный B-tree на `activity_type`** (выбран).
|
||||
- R-tree даёт O(log n) на bbox-prefetch.
|
||||
- `idx_tracks_activity` ускоряет fallback-фильтр.
|
||||
- `created_at` — обычный B-tree для GC и для health-отчёта.
|
||||
- **I-B — четыре B-tree-индекса на `min_lon`, `max_lon`, `min_lat`, `max_lat`** (вариант из TRZ REQ-F-09). Отклонён:
|
||||
- SQLite-оптимизатор не комбинирует 4 индекса в bbox-плане; в лучшем случае использует один (по `min_lon`), что даёт линейный полу-скан.
|
||||
- R-tree через Spatialite — стандартный паттерн для spatial-запросов; уже используется в `centralfederal.sqlite` (`idx_features_geom`).
|
||||
|
||||
### Вариант W (WAL) — режим записи
|
||||
|
||||
- **W-A — WAL-mode постоянно** (выбран). При запуске pipeline `PRAGMA journal_mode=WAL`. Даёт читателям (FastAPI) видеть консистентный снэпшот пока pipeline пишет.
|
||||
- **W-B — DELETE-mode + блокировка читателей на время прогона**. Отклонён: означает простой `/api/gps-tracks` на 1–6 часов в неделю.
|
||||
|
||||
## Решение
|
||||
|
||||
Принимается комбинация: **D-A + T-A + S-A + I-A + W-A**.
|
||||
|
||||
1. **Отдельная БД `data/gps_tracks.sqlite`** (Spatialite-extension загружается при коннекте). Путь в окружении — `GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite` (см. `07-infra-requirements.md` §5).
|
||||
|
||||
2. **Единая таблица `tracks`** со схемой, зафиксированной в `08-data-requirements.md` §3. Уточнения относительно TRZ REQ-F-09:
|
||||
- `points_count` и `length_m` — посчитанные на pipeline (НФТ Endpoint p95 ≤ 300 мс не оставляет бюджета считать длину на лету).
|
||||
- `min_lon/max_lon/min_lat/max_lat` сохраняются денормализованно вместе с R-tree (избыточно, но ускоряет MVT-генерацию: можно отбросить трек до `wkb_to_coords()` если bbox целиком вне тайла).
|
||||
- `tags_json`, `description` — допускается NULL (не все источники их отдают).
|
||||
- `user` (имя автора) сохраняется **только если** ADR licensing соответствующего источника явно разрешает (см. ADR-009/010/011). Иначе — NULL.
|
||||
|
||||
3. **`sources_json` и `external_urls_json` — JSON-массивы** строк, длина ≤ 8 элементов (дополнительные источники после дедупа). Порядок — стабильный (по `gps_sources.yaml`), что фиксирует «первый» источник для MVT-фичи `properties.source` (используется для цветовой палитры по умолчанию, REQ-F-16).
|
||||
|
||||
4. **Индексация:**
|
||||
- Spatialite R-tree `idx_tracks_geom` через `CreateSpatialIndex('tracks', 'geom')`.
|
||||
- B-tree `idx_tracks_activity(activity_type)`.
|
||||
- B-tree `idx_tracks_created(created_at)` для GC и health.
|
||||
- UNIQUE `idx_tracks_dedup(dedup_key)` — критичен для ON CONFLICT логики dedup (ADR-006).
|
||||
- Дополнительный bbox-индекс из TRZ REQ-F-09 (`min_lon, max_lon, min_lat, max_lat`) **не создаётся** — R-tree его покрывает; B-tree на 4 колонки даст overhead на INSERT без выгоды на SELECT.
|
||||
|
||||
5. **WAL-mode** включается в `db.py::open_connection()` через `PRAGMA journal_mode=WAL` при первом запуске; повторно команда no-op. Pipeline пишет в WAL, читатели видят последний checkpoint. После каждого `(region, source)` pipeline вызывает `PRAGMA wal_checkpoint(PASSIVE)` для контроля размера WAL-файла.
|
||||
|
||||
6. **Размер БД** оценивается ≤ 2 ГБ для ЦФО+Чувашии при ≥ 5000 треков (REQ-NF-03). Метрика `db_size_mb` — в `/api/gps-tracks/health` (REQ-F-12), порог-алерт > 2 ГБ — в `10-tech-risks.md` R-4.
|
||||
|
||||
7. **Pipeline-история** — таблица `pipeline_runs` (TRZ REQ-F-09) в той же БД. Используется только для health-эндпоинта и оператора. Не индексируется по region/source — её объём ≤ 10⁴ строк за годы.
|
||||
|
||||
8. **Совместимость с MVT-pipeline в `main.py`.** Утилитарные функции `tile_to_bbox`, `wkb_to_coords`, `simplify_coords` уже существуют в `src/api/main.py` для слоя `trails`. ET-008 **не рефакторит** их (out of scope, риск регрессии слоя `trails`). Вместо этого:
|
||||
- В `src/api/gps_tracks/mvt.py` функции `_tile_to_bbox` / `_wkb_to_coords` дублируются с TODO-комментарием и ссылкой на тех-долг (`10-tech-risks.md` R-7).
|
||||
- Если в будущей фазе появится третий MVT-источник (BRD §1 «Видеть реальные дороги/тропы»), перед ним вводится shared-модуль `src/api/tiles_util.py` отдельным work item.
|
||||
|
||||
9. **Cross-DB запросы (PH-3)** — out of scope. Принципиальный путь, если понадобится в Smart Route: `ATTACH DATABASE 'data/gps_tracks.sqlite' AS gps` в коннекте main-API. Это решение откладывается до конкретной задачи PH-3.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- Pipeline пишет, не блокируя API-чтения OSM-данных.
|
||||
- Бэкап и ротация независимы — оператор управляет каждой БД отдельно.
|
||||
- Расширение списка источников (BRD F-04) или регионов (BRD F-12) не требует DDL — только обновление YAML.
|
||||
- При ошибке pipeline (повреждение БД) — `rm data/gps_tracks.sqlite && python scripts/gps_collect.py` восстанавливает за один прогон (≤ 6 часов, REQ-NF-02). Это закрывает риск «pipeline испортил продакшен-данные».
|
||||
- Spatialite R-tree обеспечивает p95 ≤ 300 мс на bbox-запросах без необходимости PostgreSQL.
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- Денормализация `sources_json`/`external_urls_json` не позволяет нативного `WHERE 'osm' = ANY(sources)`. Фильтр source — постфильтр на Python после bbox-prefetch (приемлемо: BRD §6 показывает ≤ 500 треков на bbox).
|
||||
- Дублирование `tile_to_bbox` / `wkb_to_coords` между `main.py` и `gps_tracks/mvt.py` — технический долг (`10-tech-risks.md` R-7). При следующем добавлении MVT-источника обязательно вынести в shared util.
|
||||
- Cross-DB запросы между OSM-данными и GPS-треками невозможны без `ATTACH DATABASE`. На горизонте MVP таких запросов нет, но это блокер для будущей фичи «маршрут предпочитает реально-езженые дороги» (PH-3).
|
||||
- Дублирование bbox-полей (`min_lon`/`max_lon`/`min_lat`/`max_lat`) в строке трека + R-tree-индексе — избыточные ~32 байта на трек; на 5000 треков ничтожно, осознанный compromise ради быстрого «бросить трек до парсинга WKB».
|
||||
|
||||
### Технический долг
|
||||
|
||||
- Если объём вырастает > 2 ГБ (расширение на всю РФ), перевод на PostGIS. Контракт API `/api/gps-tracks/*` стабилен; меняется только `db.py`. Backend-код, фронтенд, миграции — без изменений.
|
||||
- Возможный future-rewrite на shared `src/api/tiles_util.py` (см. §8 решения).
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Major change.** Введение **новой БД** на сервере явно перечислено в правилах для агентов (CLAUDE.md, эскалация: «новый сервис, новая БД → arch:major-change»). Лейбл `arch:major-change` выставлен. Обязательный архитектурный approve — да.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-008/01-brd.md` §7 «БД»
|
||||
- `docs/work-items/ET-008/02-trz.md` REQ-F-09 «Схема БД»
|
||||
- `docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md`
|
||||
- `docs/work-items/ET-008/07-infra-requirements.md` §4 «Хранилища данных»
|
||||
- `docs/work-items/ET-008/08-data-requirements.md` §3 «Серверные данные»
|
||||
- `docs/work-items/ET-008/10-tech-risks.md` R-3, R-4, R-7
|
||||
149
docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md
Normal file
149
docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-008
|
||||
adr_id: ADR-006
|
||||
title: "ADR-006: Дедупликация публичных GPS-треков — bbox+length+date bucket-hash, мерж sources при коллизии, без геометрических метрик"
|
||||
status: accepted
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels: []
|
||||
---
|
||||
|
||||
# ADR-006 — Алгоритм дедупликации публичных GPS-треков
|
||||
|
||||
## Статус
|
||||
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
Один и тот же реальный трек может быть выложен автором на несколько платформ (BRD §6 риск №3): тот же маршрут пользователь публикует в EnduroRussia.ru и в Wikiloc, дублирует в OSM Public GPS Traces при разборе и т.п. Цель ET-008 (BRD §1) — «одна запись на реальный трек, с union'ом источников и ссылок». Метрика — BRD §5: ≤ 5% дублей при ручной проверке 100 случайных треков.
|
||||
|
||||
Архитектурно нужно выбрать:
|
||||
|
||||
1. **Какой признак считать «тот же трек».** Координаты на платформах округлены / прорежены / иногда обработаны (сглаживание); полное совпадение точек — редкое.
|
||||
2. **Сложность алгоритма.** На 5000 треков допустим O(n²); на 50 000+ при расширении на РФ — нет. Нужно либо O(n log n), либо хэш O(n).
|
||||
3. **Поведение при отсутствии метаданных.** У OSM-треков нет «активности», у скрейпленых страниц иногда нет даты — что делать.
|
||||
4. **Что фиксируется при коллизии** — кто из источников «выиграл» в полях `name`/`user`/`activity_type`.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
### Вариант A — Bucket-hash по bbox + length + date (выбран; совпадает с TRZ REQ-F-08)
|
||||
|
||||
```python
|
||||
def compute_dedup_key(geom: LineString, meta: dict) -> str:
|
||||
w, s, e, n = geom.bounds
|
||||
bbox_round = (round(w, 2), round(s, 2), round(e, 2), round(n, 2)) # ≈ 1.1 км
|
||||
length_bucket = round(meta["length_m"] / 1000) * 1000 # 1 км
|
||||
date_bucket = (meta.get("created_at") or "")[:10] # YYYY-MM-DD
|
||||
return f"{bbox_round}|{length_bucket}|{date_bucket}"
|
||||
```
|
||||
|
||||
- Сложность: **O(1)** на трек, **O(n)** на пайплайн. Идеально для INSERT с `UNIQUE(dedup_key)` ON CONFLICT.
|
||||
- Точность: для треков с известной датой — высокая (BBox-проекция отлично различает соседние «утренний эндуро в Калужской» vs «вечерний в Подмосковье»; на одной дате одинаковая длина в одном bbox — это почти всегда тот же трек).
|
||||
- Ложные коллизии: треки без даты в одном bbox с похожей длиной — будут смерджены. По BRD §6 это явный риск (пользователь может потерять «свой» вариант трека). Митигация — `08-data-requirements.md` §6 и AC-03 «Треки без даты от разных источников».
|
||||
- Ложные не-коллизии: один и тот же трек у двух источников с расхождением даты на 1+ день (один источник датирует загрузку, другой — запись GPS) — не смердживается. На практике источники сохраняют дату GPS из самого файла; расхождение редкое.
|
||||
|
||||
### Вариант B — Frechet/Hausdorff-расстояние между LineString (отклонён)
|
||||
|
||||
- Сложность: O(n²) на регион при наивной реализации; даже с R-tree-префильтром по bbox остаётся O(n × k), где k — кандидаты в 1-км окне.
|
||||
- Реалистичный pipeline-overhead: для 5000 треков с медианой 1240 точек — ~30 минут вычислений на регион. Это съедает половину cron-окна (6 ч).
|
||||
- Преимущества — устойчивость к шумам в координатах; недостатки — высокая стоимость, и при ≥ 50 000 треков становится непригодным.
|
||||
|
||||
### Вариант C — Хэш resampled-points (отклонён)
|
||||
|
||||
```python
|
||||
sampled = resample(geom, every_n_meters=100)
|
||||
key = sha256(",".join(f"{lat:.4f},{lon:.4f}" for lat, lon in sampled))
|
||||
```
|
||||
|
||||
- Сложность: O(n) на трек, O(n) на пайплайн. Хорошо.
|
||||
- Точность: хуже A — на платформах с разным сглаживанием те же 100-метровые точки могут отличаться в 4-м знаке после запятой → хэши не совпадают. То есть метод нестабилен между источниками.
|
||||
- Можно округлять до 3 знаков (≈ 100 м), но тогда два соседних трека по той же лесной просеке дают одинаковый хэш — снова коллизии.
|
||||
|
||||
### Вариант D — Гибрид: bucket-hash как первичный фильтр + Frechet как тай-брейкер (отклонён)
|
||||
|
||||
- Соблазнительно: A для скорости, B на коллизиях.
|
||||
- Сложность реализации высокая: при коллизии bucket-hash нужно подтянуть из БД полную геометрию обоих треков, посчитать Frechet, принять решение. Это блокирующий round-trip в SQLite на каждый коллидирующий INSERT.
|
||||
- На MVP это over-engineering. Если метрика BRD §5 «≤ 5%» не выполнится — заводится отдельный work item «улучшение dedup».
|
||||
|
||||
## Решение
|
||||
|
||||
**Принимается Вариант A — bucket-hash O(1)**, в точности по формуле TRZ REQ-F-08, с уточнениями:
|
||||
|
||||
1. **Гранулярность `bbox_round`** — 2 знака после запятой (≈ 1.1 км). Не 1 знак (≈ 11 км — слишком грубо, ложные коллизии для коротких треков в одном городе) и не 3 знака (≈ 110 м — слишком точно, не сходится между источниками с разным сглаживанием).
|
||||
|
||||
2. **Гранулярность `length_bucket`** — 1 км. На треках длиной 5–50 км это 2–20% разброс, что покрывает межисточниковую разницу подсчёта (округление координат → разные интегралы длины). На очень коротких треках (< 1 км) `length_bucket = 0` для всех таких треков — что даст переслияние «всех коротких в одном km²-bbox в одной дате»; вероятность такого совпадения от двух разных авторов исчезающе мала.
|
||||
|
||||
3. **Гранулярность `date_bucket`** — день (YYYY-MM-DD). Не «час» (источники часто хранят только дату), не «месяц» (слишком грубо — есть популярные маршруты, которые ездят сотнями раз).
|
||||
|
||||
4. **Отсутствие `created_at`** — `date_bucket = ""` для обоих треков → они считаются одним ключом. Это сознательный consenrvative-merge:
|
||||
- Источники, не отдающие дату, обычно отдают её отдельно (OSM публикует timestamp загрузки; ttrails — дату публикации; EnduroRussia — дату поездки). После анализа лог-сэмплов BRD §5 ожидаем, что > 95% треков имеют дату.
|
||||
- Без даты — мы и не отличим «два разных трека с одинаковой геометрией» от «один и тот же выложенный дважды». Merge — меньшее зло, чем дубль; при ошибке достаточно дополнительно показать оба `external_urls` в popup (REQ-F-18).
|
||||
- Документировано в AC-03 «Треки без даты — дедуп срабатывает».
|
||||
|
||||
5. **Поведение при коллизии — мерж, а не replace:**
|
||||
- `sources_json` ← union существующих + нового `[source_id]`.
|
||||
- `external_urls_json` ← union существующих + нового `[external_url]`.
|
||||
- `name`, `description`, `user`, `tags`, `activity_type` — берутся **по приоритету источника в `gps_sources.yaml`** (порядок объявления = приоритет). Если у нового источника приоритет выше — поля перезаписываются; иначе сохраняются старые. Это даёт стабильный детерминированный результат независимо от порядка обхода в pipeline.
|
||||
- `length_m`, `points_count`, `geom` — берутся от **первого** источника (того, кто первым создал запись). Не пересчитываются при мерже. Это снижает риск «джиттера» геометрии трека от прогона к прогону.
|
||||
- `updated_at` — обновляется на текущее время прогона.
|
||||
|
||||
6. **Реализация в коде** — SQL-уровень:
|
||||
|
||||
```sql
|
||||
INSERT INTO tracks (dedup_key, name, ..., sources_json, external_urls_json, ...)
|
||||
VALUES (?, ?, ..., ?, ?, ...)
|
||||
ON CONFLICT(dedup_key) DO UPDATE SET
|
||||
sources_json = (SELECT json_union(sources_json, excluded.sources_json)),
|
||||
external_urls_json = (SELECT json_union(external_urls_json, excluded.external_urls_json)),
|
||||
name = CASE WHEN excluded._priority > _priority THEN excluded.name ELSE name END,
|
||||
...
|
||||
updated_at = excluded.updated_at;
|
||||
```
|
||||
|
||||
Поскольку SQLite без JSON1 не имеет `json_union`, мерж массивов реализуется на Python в `db.py::upsert_track()` (read-merge-write в одной транзакции). Производительность достаточная: O(1) на трек, < 5 мс на upsert.
|
||||
|
||||
7. **Валидация метрики BRD §5 «< 5% дублей»** — отдельный скрипт `scripts/dedup_audit.py` (отсэмплировать 100 треков, вывести в JSON для ручной проверки). Этот скрипт — артефакт фазы тестирования (`04-test-plan.yaml`), не runtime.
|
||||
|
||||
8. **План отступления.** Если метрика < 5% не выполнится на реальном датасете:
|
||||
- Сузить `length_bucket` до 500 м.
|
||||
- Добавить `activity_type` в ключ (но тогда сломается «OSM без активности vs EnduroRussia с активностью=enduro» — merge не сработает; нужно явно маппить пропуски в общий слот).
|
||||
- В крайнем случае — гибрид A+B (Вариант D выше).
|
||||
Эти эволюции — отдельный ADR, не блокируют ET-008 MVP.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- O(1) per track, O(n) per pipeline — никакого квадратичного blow-up.
|
||||
- Реализуется одним SQL ON CONFLICT + Python-мерж массивов; < 100 строк кода.
|
||||
- Детерминированный результат при перезапуске pipeline (порядок источников фиксирован конфигом).
|
||||
- Соответствует BRD-метрике «< 5%» на ожидаемом датасете (валидируется QA в фазе теста).
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- **Ложные коллизии для треков без даты.** Принято осознанно (см. §4 решения).
|
||||
- **Ложные коллизии для одного маршрута, проехавшего в разные дни** двумя разными людьми с похожей длиной — это **не баг, а ограничение**: один и тот же популярный 30-км маршрут, проехавший двумя гонщиками в один день, будет смерджен в одну запись. Бизнес-смысл сохраняется (пользователь увидит «по этой тропе ездят»), но статистика «сколько раз проехали» — потеряна. Это out of scope MVP; в BRD §5 «плотность треков» — отдельная фича.
|
||||
- **Length-bucket не работает на круговых треках** с малой длиной по прямой — но bbox-проекция эти случаи всё равно различает по координатам.
|
||||
- **При наследовании MVP-кода на регионы с миллионом треков** ложные коллизии могут вырасти. Митигация — `10-tech-risks.md` R-2; метрика отслеживается на каждом прогоне в `pipeline_runs.errors_json`.
|
||||
|
||||
### Технический долг
|
||||
|
||||
- Если QA-метрика провалится — план отступления §8 решения.
|
||||
- Возможный future-rewrite на Вариант D (hybrid) — задокументирован, но не выполняется в MVP.
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Minor change.** Алгоритм — внутренний contract pipeline'а, не виден ни наружу API, ни во фронтенде. Любая будущая правка `compute_dedup_key()` требует полного re-collect (отбросить БД и пересобрать), но это операционная процедура; затрагивает только `data/gps_tracks.sqlite`. `arch:major-change` не требуется.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-008/02-trz.md` §6.1 «compute_dedup_key»
|
||||
- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-03
|
||||
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` §3 (sources_json)
|
||||
- `docs/work-items/ET-008/08-data-requirements.md` §3.2 (dedup_key)
|
||||
- `docs/work-items/ET-008/10-tech-risks.md` R-2 (ложные коллизии)
|
||||
233
docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md
Normal file
233
docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-008
|
||||
adr_id: ADR-007
|
||||
title: "ADR-007: Pipeline сбора GPS-треков — отдельный docker-compose service с profiles:[batch], запускаемый host cron'ом mva154; per-source изоляция; без message queue"
|
||||
status: accepted
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels:
|
||||
- "arch:major-change"
|
||||
---
|
||||
|
||||
# ADR-007 — Архитектура pipeline'а сбора GPS-треков
|
||||
|
||||
## Статус
|
||||
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
ET-008 вводит первый в проекте **офлайн-pipeline** — периодический сбор GPS-треков с внешних публичных платформ (BRD §3 F-01, BRD §7 «Pipeline»). Требования:
|
||||
|
||||
- Запускается 1–2 раза в неделю по cron (BRD §3 Out of scope «Real-time»).
|
||||
- ≤ 6 часов на полный прогон ЦФО+Чувашию (REQ-NF-02).
|
||||
- Падение одного источника **не валит** остальные (AC-02 «scenario 3»).
|
||||
- Pipeline не блокирует и не деградирует production API `/api/*` во время прогона.
|
||||
- Pipeline пишет в `data/gps_tracks.sqlite` (ADR-005), читатели API видят консистентный снэпшот (WAL).
|
||||
- Не использовать message queue (BRD § «Запрещено»: «Добавлять message queue без явной необходимости»).
|
||||
- Минимум зависимостей (BRD § «Принципы»: «Минимум зависимостей»).
|
||||
|
||||
Архитектурно нужно решить:
|
||||
|
||||
1. **Где исполнять pipeline** — внутри FastAPI-контейнера (background task), отдельный контейнер, или host-Python.
|
||||
2. **Чем запускать** — host cron, in-process scheduler (APScheduler/Celery beat), systemd-timer.
|
||||
3. **Как изолировать ошибки источника** — отдельные процессы, asyncio с try/except, отдельные контейнеры.
|
||||
4. **Где жить конфигам и логам.**
|
||||
5. **Стратегия retry / backoff / rate-limit** (отдельный субкомпонент или встроено в per-source модули).
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
### Вариант X (eXecution) — где исполнять
|
||||
|
||||
- **X-A — отдельный docker-compose service `gps-collector`** в том же `docker-compose.yml`, использующий тот же image что и `app`, с `profiles: [batch]` чтобы не стартовать вместе с API. Запуск — `docker compose --profile batch run --rm gps-collector`. (Выбран.)
|
||||
Плюсы:
|
||||
- Никакого нового образа, никаких новых зависимостей в самом API-контейнере. Из контейнера API исключены HTTP-скрейперы — пользователи не имеют шансов вызвать парсер через SSRF.
|
||||
- Изоляция CPU/RAM: процесс pipeline не делит память с API; OOM в pipeline не убивает API.
|
||||
- Использует ту же кодовую базу (`COPY src/api/`, `COPY scripts/` в Dockerfile); deploy один.
|
||||
- Точка расширения: при росте до многоконтейнерной сборки (PostGIS в будущем) — pipeline уже отдельный сервис.
|
||||
Минусы:
|
||||
- Лёгкое усложнение `docker-compose.yml` (+1 service-блок ≈ 15 строк).
|
||||
- Host cron должен знать команду `docker compose --profile batch run`.
|
||||
|
||||
- **X-B — background task внутри FastAPI** (APScheduler в lifespan). Отклонён:
|
||||
- Pipeline жрёт CPU/память на API-контейнере → деградация запросов во время прогона.
|
||||
- Сложно остановить отдельно от API.
|
||||
- При перезапуске API теряется состояние прогона (если пайплайн не идемпотентный).
|
||||
- Запрещено BRD «Добавлять X без явной необходимости» — это де-факто in-process scheduler.
|
||||
|
||||
- **X-C — host-Python venv + системный cron** (вне Docker). Отклонён:
|
||||
- Нарушает BRD «Всё в Docker».
|
||||
- Дублирование зависимостей: один venv в Docker, второй на хосте.
|
||||
- Усложняет CI/CD: pipeline не покрывается тем же `make build`.
|
||||
|
||||
- **X-D — Celery worker + Redis** (queue-based). Отклонён прямо BRD «Запрещено: Добавлять message queue». Не нужен — задача одна, без распараллеливания.
|
||||
|
||||
### Вариант S (Scheduling) — чем запускать
|
||||
|
||||
- **S-A — host cron на mva154** (выбран). Запись в `/etc/cron.d/enduro-gps`:
|
||||
```cron
|
||||
# GPS tracks pipeline — ET-008
|
||||
0 3 * * 1,4 root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector >> /var/log/enduro-trails/gps-collect.log 2>&1
|
||||
```
|
||||
Плюсы:
|
||||
- Часть базовой ОС, не требует доп. установок.
|
||||
- Лог в файл — оператор может `tail -f`.
|
||||
- Если прогон завис — `kill <pid>` штатно убивает контейнер; следующий cron-тик запустит заново.
|
||||
- **S-B — systemd timer** на хосте. Отклонён: даёт более тонкий контроль (зависимости, рестарты), но это инфра-апгрейд за гранью BRD «минимум зависимостей»; cron достаточно.
|
||||
- **S-C — in-container scheduler** (APScheduler). Отклонён (см. X-B).
|
||||
- **S-D — Gitea Actions self-hosted scheduled workflow**. Отклонён: CI/CD контейнер не должен делать write в production-данные.
|
||||
|
||||
### Вариант I (Isolation) — изоляция ошибок per-source
|
||||
|
||||
- **I-A — try/except на уровне источника в asyncio-loop** (выбран). Один процесс python, для каждого `(region, source)` отдельный `try/except`; на падении пишется в `pipeline_runs.errors_json`, цикл идёт дальше к следующему источнику.
|
||||
- **I-B — отдельный процесс per-source** (subprocess + JSON pipe). Отклонён: усложнение без существенной выгоды; OOM одного source при умеренных лимитах не валит весь python-процесс.
|
||||
- **I-C — отдельный контейнер per-source**. Отклонён: гросс over-engineering для 3 источников.
|
||||
|
||||
### Вариант R (Rate-limit) — где живёт rate-limit-логика
|
||||
|
||||
- **R-A — в per-source модуле** через `asyncio.sleep(rate_limit_sec)` после каждого HTTP (выбран; совпадает с TRZ §1 REQ-F-03). Простой, явный, контролируется конфигом `gps_sources.yaml`.
|
||||
- **R-B — глобальный rate-limiter** (semaphore на all-sources). Отклонён: rate-limit per-source, у каждого источника свой ToS-лимит. Глобальный лимитер только усложнит.
|
||||
- **R-C — внешний прокси с rate-limit** (HAProxy / nginx-limit-req). Отклонён: новая инфра-зависимость.
|
||||
|
||||
### Вариант C (Config) — где конфиг
|
||||
|
||||
- **C-A — YAML в репозитории** `config/gps_sources.yaml`, `config/gps_regions.yaml` (выбран; совпадает с TRZ REQ-F-01/02). Источник истины — git; ревью изменений идёт стандартным PR-флоу.
|
||||
- **C-B — в БД, редактирование через админ-UI**. Отклонён: над-инжиниринг для MVP; добавляет attack surface.
|
||||
- **C-C — в env-переменных docker-compose**. Отклонён: не масштабируется на 3+ источников.
|
||||
|
||||
## Решение
|
||||
|
||||
Принимается комбинация: **X-A + S-A + I-A + R-A + C-A**.
|
||||
|
||||
1. **Pipeline — отдельный docker-compose service `gps-collector`** в `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gps-collector:
|
||||
build: .
|
||||
profiles: ["batch"]
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./config:/app/config:ro
|
||||
- /var/log/enduro-trails:/var/log/enduro-trails
|
||||
environment:
|
||||
- GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite
|
||||
- GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml
|
||||
- GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml
|
||||
- HTTPX_LOG_LEVEL=INFO
|
||||
command: ["python", "-m", "scripts.gps_collect"]
|
||||
restart: "no"
|
||||
```
|
||||
|
||||
- `profiles: ["batch"]` — service **не стартует** при штатном `docker compose up -d` (важно: API uptime не зависит от pipeline).
|
||||
- Запускается командой `docker compose --profile batch run --rm gps-collector` (запись — `host cron`).
|
||||
- Использует **тот же image**, что и `app` — сборка одна, пакет тот же.
|
||||
- Конфиги примонтированы read-only — `gps-collector` их не пишет.
|
||||
- `/var/log/enduro-trails` шарится с хостом; stdout/stderr ловит cron в `gps-collect.log`, а pipeline пишет structured JSON-лог в `/var/log/enduro-trails/pipeline-<run_id>.jsonl`.
|
||||
|
||||
2. **Cron на mva154** — `/etc/cron.d/enduro-gps`:
|
||||
```
|
||||
0 3 * * 1,4 root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector >> /var/log/enduro-trails/gps-collect.log 2>&1
|
||||
```
|
||||
- Mon + Thu 03:00 UTC (BRD §7 «Cron на mva154»).
|
||||
- Логи ротируются стандартным `logrotate` (см. `07-infra-requirements.md` §10).
|
||||
- Простого «flock» против overlapping runs **не нужно**: cron-окно 3-дневное, реальная длина прогона ≤ 6 ч.
|
||||
|
||||
3. **GC-прогон** — отдельная команда `docker compose --profile batch run --rm gps-collector python -m scripts.gps_collect --gc`. Запускается раз в месяц host cron'ом отдельной строкой `0 4 1 * * root ...`. Удаляет треки с `updated_at < NOW() - 5 years` (REQ-NF-03).
|
||||
|
||||
4. **Per-source модули в `src/api/gps_tracks/sources/`** реализуют **абстрактный контракт** `base.py::SourceParser`:
|
||||
|
||||
```python
|
||||
class SourceParser:
|
||||
MAPPING: dict[str, str] # source-category → ACTIVITY_TYPE
|
||||
async def collect(self, bbox: BBox, ctx: PipelineContext) -> AsyncIterator[Track]: ...
|
||||
```
|
||||
|
||||
Главная петля `scripts/gps_collect.py::run_pipeline()`:
|
||||
|
||||
```python
|
||||
for region in regions_enabled:
|
||||
for source_id in region.sources:
|
||||
parser = load_parser(source_id)
|
||||
run = pipeline_runs.start(region.id, source_id)
|
||||
try:
|
||||
async for track in parser.collect(region.bbox, ctx):
|
||||
db.upsert_track(track) # ADR-006 dedup-логика
|
||||
run.tracks_new_or_updated += 1
|
||||
except Exception as e:
|
||||
run.status = "error"
|
||||
run.errors_json = serialize_exc(e)
|
||||
logger.exception("source %s failed", source_id)
|
||||
finally:
|
||||
run.finalize()
|
||||
```
|
||||
|
||||
- Падение `parser.collect()` локализовано в один `try/except` — следующий источник стартует без рестарта процесса.
|
||||
- `parser.collect()` — асинхронный генератор; pipeline pulls треки по одному, не накапливает в памяти больше одного.
|
||||
|
||||
5. **Per-source rate-limit и backoff** реализованы в `base.py::SourceParser._http_get()` через `asyncio.sleep(rate_limit_sec)` после каждого запроса и `tenacity`-стиль retry с exponential backoff (TRZ §6.3). `User-Agent` берётся из `gps_sources.yaml` per-source.
|
||||
|
||||
6. **Лицензионные guard'ы.** Перед `load_parser(source_id)` pipeline **проверяет**: `config/gps_sources.yaml::sources[id].license_adr` указывает на файл `docs/work-items/ET-008/06-adr/ADR-NNN-<source>-licensing.md` со статусом `accepted`. Если файл не найден или статус не `accepted` → exception → source пропускается; запись `pipeline_runs.status = "skipped_license"`. Это превращает BRD §4 «Юридический минимум» в **runtime-enforced** правило, не «обещание разработчика». См. `10-tech-risks.md` R-9.
|
||||
|
||||
7. **Cache-invalidation тайлов после прогона.** В конце успешного прогона pipeline делает HTTP-запрос:
|
||||
`POST http://app:5556/api/gps-tracks/cache/clear`
|
||||
(внутренняя сеть docker-compose). API сбрасывает LRU-кэш MVT-тайлов. Если API недоступен — лог-предупреждение, не ошибка прогона (REQ-NF-04).
|
||||
|
||||
8. **Health-эндпоинт `/api/gps-tracks/health`** (REQ-F-12) **читает** последнюю запись `pipeline_runs` из БД (не имеет прямой связи с процессом pipeline; уже остановленный pipeline продолжает быть «виден» через свою историю в БД).
|
||||
|
||||
9. **WAL и concurrent reads.** Pipeline пишет в БД в WAL-mode (ADR-005 §5). FastAPI читает ту же БД, видит последний checkpoint. Pipeline вызывает `PRAGMA wal_checkpoint(PASSIVE)` после каждого `(region, source)` чтобы WAL-файл не разрастался.
|
||||
|
||||
10. **C4 / архитектурная диаграмма.** В `docs/architecture/README.md` добавляется раздел «GPS Tracks Pipeline»: новый компонент `gps-collector` (внутри docker-compose, не стартует штатно), новые внешние зависимости (OSM API + 2 source-сайта), новая БД `gps_tracks.sqlite`. Mermaid C4-диаграммы в проекте отсутствуют; следуем прецеденту ADR-004 §8 — текстовое описание.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- Pipeline и API изолированы по контейнерам, по процессам, по CPU/RAM. Pipeline не может уронить API.
|
||||
- Расширение списка источников = добавить файл `src/api/gps_tracks/sources/<name>.py` + запись в `gps_sources.yaml` + ADR-licensing. Никакого кода pipeline не правится (BRD-метрика «расширяемость без правки Python-кода» выполняется).
|
||||
- Расширение списка регионов = одна запись в `gps_regions.yaml` ≤ 30 строк (BRD-метрика выполняется).
|
||||
- Сбой одного парсера не останавливает остальные (AC-02 выполняется через try/except на per-source уровне).
|
||||
- `profiles: ["batch"]` гарантирует, что pipeline никогда не стартует автоматически с `docker compose up` — нулевая вероятность случайного «pipeline скачивает на проде» во время рестарта API.
|
||||
- Простой деплой: тот же `make build` собирает образ; новый сервис сразу доступен.
|
||||
- Лицензионные guard'ы (§6 решения) делают BRD §4 «Юридический минимум» **enforceable**, не на честное слово разработчика.
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- Pipeline зависит от установленного на mva154 `docker compose` (v2 plugin). Это **уже выполняется** — на mva154 docker compose v2 используется для штатного деплоя.
|
||||
- Логи живут на хосте (`/var/log/enduro-trails/`) — не в Docker. Это сознательно: ротация через `logrotate`, доступ через ssh, не требует доп. log-агрегатора.
|
||||
- При смене image (новой версии Python / новой системной зависимости) нужно `docker compose --profile batch build gps-collector` — но `--profile batch` теперь должен быть в команде, что легко забыть. Митигация: smoke-проверка в deploy-runbook (`07-infra-requirements.md` §7).
|
||||
- Pipeline не имеет UI/админки — оператор работает через ssh + cron logs. На MVP это приемлемо; админ-UI — отдельная задача после PH-3 при необходимости.
|
||||
|
||||
### Технический долг
|
||||
|
||||
- Если в будущем понадобится распараллелить источники для скорости — заменить `for source_id ... await parser.collect()` на `asyncio.gather([parser.collect(...) for source_id ...])`. Контракт `SourceParser.collect()` уже асинхронный — изменение локально.
|
||||
- Если понадобится централизованная очередь / распределённый pipeline — заменить cron+single-container на Celery/Redis. Контракт `pipeline_runs` в БД останется; меняется только запуск.
|
||||
- Если на масштабе РФ понадобится дробить регион на параллельные шарды — расширение `gps_regions.yaml` поддерживает это (subregions); меняется только runner.
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Major change.** Pipeline вводит:
|
||||
- Первый scheduled-job на mva154 для проекта (cron-запись).
|
||||
- Первый outbound-скрейпинг (правовой режим, rate-limit-обязательства перед третьими сторонами).
|
||||
- Новый docker-compose service.
|
||||
- Новую БД (через ADR-005, отдельно).
|
||||
|
||||
Каждый из этих пунктов сам по себе **не** требует `arch:major-change` (по правилам CLAUDE.md новый сервис / новая БД — да). Лейбл `arch:major-change` выставлен. Обязательный архитектурный approve — да.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-008/01-brd.md` §7 «Pipeline», §3 F-01..F-03, F-12, F-17
|
||||
- `docs/work-items/ET-008/02-trz.md` §1 REQ-F-01..REQ-F-03, REQ-F-07, REQ-F-12, §6.2, §6.3
|
||||
- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-01, AC-02
|
||||
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-006-dedup-algorithm.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md`
|
||||
- `docs/work-items/ET-008/07-infra-requirements.md`
|
||||
- `docs/work-items/ET-008/10-tech-risks.md` R-1, R-5, R-6, R-9
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-008
|
||||
adr_id: ADR-008
|
||||
title: "ADR-008: Двухрежимная отдача публичных треков — MVT-тайлы на z ≤ 11, GeoJSON по bbox на z ≥ 12; клиентское переключение по zoom; общий cache-invalidation"
|
||||
status: accepted
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels: []
|
||||
---
|
||||
|
||||
# ADR-008 — Стратегия отдачи треков клиенту: MVT vs GeoJSON
|
||||
|
||||
## Статус
|
||||
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
Слой публичных треков (BRD §3 F-05..F-09) должен:
|
||||
|
||||
- Показываться на широком диапазоне zoom — от z=8 (вся область региона видна сразу) до z=16+ (один трек крупно).
|
||||
- Поддерживать **клик с popup** на трек (REQ-F-18) — то есть feature должна быть «настоящей», а не растровой.
|
||||
- Поддерживать **клиентскую фильтрацию** по активности и источнику без сетевого запроса (REQ-F-14, AC-08).
|
||||
- Уложиться в p95 ≤ 300 мс для GeoJSON-ответа (BRD-метрика).
|
||||
- Не штормить сервер запросами при быстром pan (AC-14).
|
||||
|
||||
На низком zoom (z=8) в видимую область могут попасть тысячи треков. Отдавать их одним GeoJSON-ответом неприемлемо: payload в 10–100 МБ → сетевой p95 проседает; парсинг GeoJSON блокирует main thread браузера; MapLibre перерисовывает каждое pan-move.
|
||||
|
||||
На высоком zoom (z ≥ 12) в видимую область попадают десятки треков, и пользователь ждёт interactive popup + точную геометрию.
|
||||
|
||||
Архитектурно нужно выбрать стратегию отдачи и переключения между режимами.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
### Вариант M (Mode) — единый режим отдачи
|
||||
|
||||
- **M-A — только GeoJSON для всех zoom**. Отклонён:
|
||||
- На z=8 payload неприемлем (см. контекст).
|
||||
- Не использует существующий MVT-кэш-паттерн `main.py` для слоя `trails` — теряем уже отлаженный механизм для аналогичной задачи.
|
||||
- **M-B — только MVT для всех zoom**. Отклонён:
|
||||
- MVT не даёт удобного `popup` с богатыми метаданными: `properties` MVT-тайла ограничены (плюс через MapLibre `queryRenderedFeatures` доступ есть, но фильтр feature-level через `setFilter` требует чтобы все нужные поля сидели в MVT-фиче — а у нас `sources` массив, который в MVT нативно не представляется).
|
||||
- Клиентская фильтрация по `source` через `setFilter` работает только на одной колонке source (REQ-F-16 «первый source»); для multi-source filtering на MVT-фиче без множественной колонки — компромисс.
|
||||
- **M-C — гибрид: MVT на z ≤ 11, GeoJSON на z ≥ 12** (выбран, совпадает с TRZ REQ-F-11 финальной формулировкой).
|
||||
- На z ≤ 11 — MVT, серверный LRU-кэш, ограниченное упрощение геометрии. Клиент видит «общий ландшафт» — где много треков, плотность, какие источники доминируют.
|
||||
- На z ≥ 12 — GeoJSON по bbox, полные точные координаты, полные `sources_json`/`external_urls_json` для popup.
|
||||
- Cutoff z=12 — реалистичный порог: 1 тайл z=11 ≈ 19 × 12 км (на широте 55°), z=12 ≈ 10 × 6 км. В bbox z=12 типично попадает ≤ 500 треков → GeoJSON ≤ 2 МБ → влезает в SLA 300 мс.
|
||||
|
||||
### Вариант T (Tile generation) — как генерировать MVT
|
||||
|
||||
- **T-A — реальное время по запросу + LRU-кэш** (выбран; совпадает с архитектурой текущего слоя `trails` в `main.py`):
|
||||
- На запрос `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt`:
|
||||
1. Проверить LRU-кэш (1024 записи).
|
||||
2. На промахе — выполнить SELECT из `tracks` по bbox тайла, упростить геометрии по `simplify_coords(coords, z)`, отдать через `mapbox-vector-tile`.
|
||||
3. Записать результат в LRU.
|
||||
- Cache-invalidation — `POST /api/gps-tracks/cache/clear` после успешного pipeline-прогона (ADR-007 §7).
|
||||
- Cold-cache p95 ≤ 200 мс (REQ-NF-02). Hot-cache ≤ 20 мс.
|
||||
- **T-B — pre-generated tile cache** (после pipeline сразу генерируется весь z=8..z=11 grid на диск). Отклонён:
|
||||
- 4ˡ tiles на каждом zoom — z=8 = 16 tiles, z=9 = 64, z=10 = 256, z=11 = 1024 → ≈ 1.4k тайлов. Несложно, но: при росте региона до РФ — десятки тысяч; диск растёт без необходимости.
|
||||
- Cold-cache при первой загрузке после прогона всё равно нужен (LRU прогревается естественно).
|
||||
- Усложняет cache-invalidation: нужно удалять файлы вместо `_tile_cache.clear()`.
|
||||
- **T-C — внешний tile server** (tilelive/tilemaker/Tegola). Отклонён: новый сервис, новая инфра-зависимость; mapbox-vector-tile в Python уже умеет всё, что нужно.
|
||||
|
||||
### Вариант G (GeoJSON limit) — как обрезать GeoJSON
|
||||
|
||||
- **G-A — фиксированный limit=500, truncated=true в payload** (выбран; совпадает с TRZ REQ-F-10).
|
||||
- На z ≥ 12 типично ≤ 500 треков в bbox → truncated:false.
|
||||
- На редких плотных bbox (10+ треков/км²) сервер возвращает первые 500 (LIMIT в SQL), `truncated:true`, клиент показывает в UI «показано 500 из 743, увеличьте zoom».
|
||||
- Простая семантика, нет surprise для разработчика API.
|
||||
- **G-B — server-side pagination cursor**. Отклонён: над-инжиниринг; для visualisation-слоя пагинация не интуитивна; пользователю удобнее zoom, а не next-page.
|
||||
- **G-C — server-side clustering для overflow**. Отклонён: track — это LineString, кластеризация по линейным сущностям нетривиальна; out of scope.
|
||||
|
||||
### Вариант F (Filter location) — где фильтровать по activity/source
|
||||
|
||||
- **F-A — серверный фильтр в SQL** (по `activity_type`) + Python-постфильтр (по `sources_json`); итоговое FeatureCollection уже отфильтровано (выбран для GeoJSON, совпадает с TRZ REQ-F-10).
|
||||
- Сервер сразу возвращает только нужное → меньше трафика.
|
||||
- Но: смена фильтра в UI → новый запрос. Это ОК для GeoJSON (z ≥ 12, < 500 треков) — REQ-NF-06 «≤ 200 мс» выполнимо при cache miss.
|
||||
- **F-B — клиентский фильтр через `setFilter`** на уже загруженной выборке (выбран **дополнительно**, для MVT-режима).
|
||||
- На z ≤ 11 — MVT уже содержит всё; смена фильтра — мгновенный `setFilter` без сетевого запроса. AC-08 «фильтрация мгновенная (≤ 200 мс)».
|
||||
- На z ≥ 12 — клиентский setFilter работает поверх загруженного GeoJSON; для повторного fetch при следующем `moveend` уже учитываются новые фильтры.
|
||||
|
||||
### Вариант D (Debounce) — защита от шторма запросов
|
||||
|
||||
- **D-A — клиентский debounce 500 мс + AbortController** (выбран; совпадает с TRZ §6.4):
|
||||
- На `moveend` карта запускает 500-мс таймер; новые `moveend` сбрасывают его.
|
||||
- Старые in-flight запросы отменяются `AbortController.abort()`.
|
||||
- Server-side rate-limit не нужен — фронтенд сам себя ограничивает.
|
||||
- **D-B — server-side rate-limit middleware**. Отклонён: усложняет API, не нужно при D-A.
|
||||
|
||||
### Вариант H (Halo on satellite) — гибридный слой через MVT/GeoJSON
|
||||
|
||||
- **H-A — две `'source'`-привязки в MapLibre**: одна на `gps-tracks-tiles` (vector source MVT), вторая на `gps-tracks-geo` (GeoJSON source). Один и тот же слой `gps-tracks-layer` нельзя привязать к двум sources одновременно. Поэтому **два параллельных слоя**: `gps-tracks-layer-mvt` (visible на z ≤ 11) и `gps-tracks-layer-geo` (visible на z ≥ 12). Переключение через `setLayoutProperty('visibility')` по `zoomend`. (Выбран — единственный нормально работающий способ.)
|
||||
- **H-B — переключать `setData` на одном слое**. Отклонён: GeoJSON-source и vector-source — разные типы в MapLibre; нельзя «переключить» source у layer'а без `removeLayer` + `addLayer`.
|
||||
|
||||
## Решение
|
||||
|
||||
Принимается комбинация: **M-C + T-A + G-A + F-A + F-B + D-A + H-A**.
|
||||
|
||||
1. **Двухрежимная отдача:**
|
||||
- `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` — векторные тайлы, **только для клиента**, который добавил vector-source `gps-tracks-tiles`. Клиент использует на z ≤ 11.
|
||||
- `GET /api/gps-tracks?bbox=&activity=&source=&limit=` — GeoJSON FeatureCollection, для z ≥ 12.
|
||||
|
||||
2. **Cutoff z=12** — выбран как баланс между «MVT даёт обзор + кэш» и «GeoJSON даёт полный popup-data». Cutoff фиксирован в клиенте константой `GPS_TRACKS_ZOOM_CUTOFF = 12`.
|
||||
|
||||
3. **MVT-слой клиента:**
|
||||
- Source: `vector` type, `tiles: ['/api/gps-tracks/tiles/{z}/{x}/{y}.mvt']`, `minzoom: 8`, `maxzoom: 11`. На z < 8 слой полностью скрыт (TRZ REQ-F-20).
|
||||
- Layer: `gps-tracks-layer-mvt`, `source-layer: 'gps_tracks'`, paint по REQ-F-17.
|
||||
- Properties фичи: `id, activity, source (первый), sources (comma-separated), length_km, name, ext_url` (TRZ §4.3). `sources` как comma-string, потому что MVT не поддерживает массивы.
|
||||
|
||||
4. **GeoJSON-слой клиента:**
|
||||
- Source: `geojson`, `data: { type: 'FeatureCollection', features: [] }` (пустой при инициализации).
|
||||
- Layer: `gps-tracks-layer-geo`, `source: 'gps-tracks-geo'`, paint по REQ-F-17.
|
||||
- На `moveend` (debounced 500 мс через AbortController) — `fetch('/api/gps-tracks?bbox=...&activity=...&source=...&limit=500')` → `getSource().setData(json)`.
|
||||
|
||||
5. **Переключение по zoom:**
|
||||
- `zoomend` listener: `if (z < 12) hide(geo); show(mvt); else show(geo); hide(mvt);`.
|
||||
- `visibility` управляется `setLayoutProperty`.
|
||||
- Кратко: оба source и layer всегда **существуют** при включённом чекбоксе; меняется только видимость.
|
||||
- На z < 8 — оба невидимы (REQ-F-20); статус-баннер «Зум 8+».
|
||||
|
||||
6. **Серверный MVT-кэш:**
|
||||
- LRU-словарь в памяти процесса FastAPI, ёмкость **1024** записи (как для слоя `trails`).
|
||||
- Ключ — `(z, x, y)`. Значение — байты `.mvt`.
|
||||
- На промахе SELECT идёт через R-tree (Spatialite `idx_tracks_geom`) с bbox тайла + 5% padding.
|
||||
- Упрощение геометрии — `simplify_coords(coords, z)` (Douglas-Peucker tolerance зависит от zoom).
|
||||
- LIMIT тайла — как у `trails` (3000/8000/15000 на z ≤ 7/9/11).
|
||||
|
||||
7. **Cache-invalidation:**
|
||||
- `POST /api/gps-tracks/cache/clear` — единственный POST в этом семействе эндпоинтов, авторизуется по сетевому пути (только из docker-compose internal network; через `/enduro/` proxy не маршрутизируется — см. `07-infra-requirements.md` §3).
|
||||
- Pipeline вызывает его при успешном завершении (ADR-007 §7).
|
||||
|
||||
8. **Сервер GeoJSON (`GET /api/gps-tracks`):**
|
||||
- SQL: `SELECT * FROM tracks WHERE ROWID IN (SELECT pkid FROM idx_tracks_geom WHERE ... bbox ...) [AND activity_type IN (...)] ORDER BY length_m DESC LIMIT N` — длинные треки первыми (полезнее для overview).
|
||||
- `source` фильтр — постфильтр на Python после получения < 500 строк (`'osm' in json.loads(sources_json)`).
|
||||
- Total — отдельный `COUNT(*)` запрос с теми же WHERE-условиями (без LIMIT) для `total_in_bbox`.
|
||||
- Response — GeoJSON по REQ-F-10 со всеми properties.
|
||||
- p95 ≤ 300 мс — выполнимо на bbox с ≤ 500 треков (запросы R-tree + N парсингов WKB по 1.5 КБ).
|
||||
|
||||
9. **Atomic state в клиенте** через объект `window.gpsTracksLayer` (TRZ §4.4). Поля state на 100% derived из (`localStorage` + `map.getZoom()` + последний GeoJSON-ответ); восстановление в `rebuildMapOverlays() → restorePublicTracksState()` (REQ-F-19).
|
||||
|
||||
10. **Halo на спутнике (REQ-F-15, ET-007 §7.2 паттерн):**
|
||||
- Для **обоих** клиентских слоёв (MVT и GeoJSON) — свои halo:
|
||||
- `gps-tracks-halo-mvt-satellite` — halo поверх `gps-tracks-tiles`.
|
||||
- `gps-tracks-halo-geo-satellite` — halo поверх `gps-tracks-geo`.
|
||||
- Видимость halo управляется хелпером `applyGpsHaloVisibility()` по правилу: halo видим ⇔ `(public-tracks ON) AND (zoom band matches) AND (base === 'satellite')`.
|
||||
- Hook добавляется в `applyBaseLayer()` (ET-007) — по тому же паттерну, что halo для trails (ADR-004 §9).
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- Соответствует SLA: MVT cold p95 ≤ 200 мс, GeoJSON p95 ≤ 300 мс при разумном bbox.
|
||||
- Низкий зум — обзор; высокий зум — полный popup. Пользователь получает оптимум на каждом масштабе.
|
||||
- Кэш-стратегия идентична существующему слою `trails` — оператор уже знаком; единый паттерн.
|
||||
- AbortController + debounce защищают от шторма запросов независимо от того, насколько быстро юзер pan'ит карту.
|
||||
- Cache-invalidation после прогона — пользователь видит свежие данные при следующем pan/zoom.
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- **Два source / два layer на один логический слой** — небольшое усложнение клиентского кода (sync visibility, sync filter). Кодовое разбиение — в `src/web/gps_tracks.js`; внутренняя сложность не «протекает» наружу.
|
||||
- **Жёсткий cutoff z=12.** На границе (z=11.5) пользователь может видеть мигание: MVT-тайлы упрощены до 1км, GeoJSON покажет точные кривые. Сглаживание — `transition` на opacity (UI-микро-улучшение, не блокер).
|
||||
- **`source` в MVT — только первый из dedup-list.** Цвет по источнику (REQ-F-16) показывает «первый по приоритету»; реальное мульти-источникство видно только в popup на z ≥ 12. Принято: «дедупный мульти-источникный» трек редок (< 10% по оценке BRD §5); цвет по «первому источнику» интуитивен.
|
||||
- **Серверный кэш сбрасывается ТОЛЬКО pipeline'ом.** Если оператор вручную `UPDATE tracks` — кэш не инвалидируется. Митигация — оператор знает про эндпоинт; в runbook (`07-infra-requirements.md` §8). На практике вручную в БД лазать не предполагается.
|
||||
|
||||
### Технический долг
|
||||
|
||||
- `tile_to_bbox` / `wkb_to_coords` / `simplify_coords` дублируются между `main.py` и `gps_tracks/mvt.py` (см. ADR-005 §8). При появлении третьего MVT-источника — вынести в shared util.
|
||||
- Если в будущем понадобится фильтр по multiple `source` непосредственно в MVT (для multi-color по источникам трека) — необходимо переработать схему MVT properties (массив через JSON-string или через несколько колонок). Не блокер MVP.
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Minor change.** Стратегия отдачи — внутренний контракт клиент↔API, всё в пределах FastAPI и фронтенда. Новых сервисов, БД, очередей не вводит. `arch:major-change` не требуется.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-008/02-trz.md` §1 REQ-F-10, REQ-F-11, REQ-F-13, REQ-F-17, REQ-F-20, §6.4
|
||||
- `docs/work-items/ET-008/03-acceptance-criteria.md` AC-04, AC-05, AC-13, AC-14
|
||||
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` §8 (общий tile-utility)
|
||||
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §7 (cache-clear hook)
|
||||
- `docs/work-items/ET-008/07-infra-requirements.md` §3 (network)
|
||||
- `docs/work-items/ET-008/10-tech-risks.md` R-7, R-8
|
||||
- `docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md` §5, §9 (halo-паттерн)
|
||||
146
docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md
Normal file
146
docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-008
|
||||
adr_id: ADR-009
|
||||
title: "ADR-009: Источник OSM Public GPS Traces — лицензия ODbL, документированный API, акцептовано для MVP"
|
||||
status: accepted
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels:
|
||||
- "ET-008:source-licensing"
|
||||
---
|
||||
|
||||
# ADR-009 — OSM Public GPS Traces: licensing review
|
||||
|
||||
## Статус
|
||||
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
BRD §4 ET-008 требует обязательный ADR licensing-review для каждого внешнего источника **до** активации его в pipeline. Без `status: accepted` в этом ADR — pipeline отказывается загружать source-parser (см. ADR-007 §6).
|
||||
|
||||
Источник: **OpenStreetMap Public GPS Traces**.
|
||||
|
||||
- Endpoint: `GET https://api.openstreetmap.org/api/0.6/trackpoints?bbox=W,S,E,N&page=P`.
|
||||
- Endpoint метаданных: `GET https://api.openstreetmap.org/api/0.6/gpx/{id}`.
|
||||
- Документирован: <https://wiki.openstreetmap.org/wiki/API_v0.6#GPS_traces>.
|
||||
- Лицензия данных: **ODbL 1.0** — Open Database License (<https://opendatacommons.org/licenses/odbl/1-0/>).
|
||||
- Атрибуция: «© OpenStreetMap contributors (ODbL)».
|
||||
|
||||
## Чеклист по BRD §4
|
||||
|
||||
### 1. ToS источника по поводу скрейпинга / массовой загрузки GPX
|
||||
|
||||
OSM API имеет **документированный публичный contract**. Использование `bbox + page` пагинации — штатный сценарий, не «скрейпинг» (это публичный API).
|
||||
|
||||
Operational limit, опубликованный OSM:
|
||||
- bbox area ≤ 0.25 deg² на запрос (жёсткий серверный лимит).
|
||||
- Public usage policy (<https://operations.osmfoundation.org/policies/api/>): «Heavy use must be at least 1 sec between requests, no faster». Рекомендация — `1 req/sec`, что и зафиксировано в `gps_sources.yaml::osm.rate_limit_sec = 1`.
|
||||
- При злоупотреблении OSM Operations Team вправе временно блокировать IP. Митигация в `10-tech-risks.md` R-5.
|
||||
|
||||
**Вывод:** массовая выгрузка по bbox разрешена при соблюдении rate-limit.
|
||||
|
||||
### 2. robots.txt
|
||||
|
||||
`https://api.openstreetmap.org/robots.txt`:
|
||||
```
|
||||
User-agent: *
|
||||
Disallow:
|
||||
```
|
||||
|
||||
Все эндпоинты API доступны без ограничений robots.
|
||||
|
||||
### 3. Условия публикации чужих треков
|
||||
|
||||
ODbL даёт «свободу копировать, изменять, использовать и предоставлять третьим лицам» при условии:
|
||||
- **Attribution.** Атрибуция OSM contributors с указанием ODbL.
|
||||
- **Share-alike.** Производное произведение должно распространяться на условиях, совместимых с ODbL.
|
||||
- **Keep open.** Если производное произведение публикуется, source-data не должна закрываться.
|
||||
|
||||
Применительно к ET-008:
|
||||
- Атрибуция OSM выводится MapLibre автоматически при наличии source с правильным `attribution` (уже работает для базового слоя «Схема»).
|
||||
- В `gps_sources.yaml::osm.attribution = "© OpenStreetMap contributors (ODbL)"` дополнительно выставляется на ВСЕ агрегированные данные.
|
||||
- В popup трека (REQ-F-18) выводится ссылка на оригинал `https://www.openstreetmap.org/user/{user}/traces/{id}`.
|
||||
- Share-alike относится к опубликованной нами производной БД. `data/gps_tracks.sqlite` **не публикуется наружу** — отдаётся только через FastAPI как агрегированный сервисный слой. Это попадает под «Produced Work» определение ODbL и атрибуция здесь обязательна, share-alike — нет.
|
||||
|
||||
**Имя автора** (`user`) — публичное поле OSM-трека (видно на странице трека); сохранение `user` не нарушает ToS, при этом — см. §5 ниже.
|
||||
|
||||
### 4. Rate-limit
|
||||
|
||||
- Конфигурация `gps_sources.yaml::osm.rate_limit_sec = 1` (1 запрос в секунду).
|
||||
- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` — соответствует требованию OSM API «provide a clear user agent with contact information».
|
||||
- Backoff на 429/503 — экспоненциальный 2^n до 3 попыток (TRZ §6.3).
|
||||
- Per-source максимальное число запросов на прогон — не ограничено явно; ЦФО+Чувашия ≈ 700 cells × 5 pages × 1 сек ≈ 1 час реального времени (REQ-NF-02). Это < 6-часового cron-окна и существенно меньше «heavy use» порога OSM.
|
||||
|
||||
### 5. Метаданные, запрещённые к сохранению
|
||||
|
||||
ODbL не накладывает ограничений на сохранение публично доступных полей. Однако:
|
||||
|
||||
- **`user` (имя автора)** — публикуется OSM на странице трека; сохранение разрешено. **Решение ET-008: сохраняем**, потому что это даёт пользователю credit в popup; это семантика самой OSM.
|
||||
- **`description`, `tags`** — публичные, сохраняем.
|
||||
- **GPS-точки** — публичные (трек загружен автором как public; private/trackable треки не отдаются в `trackpoints` API). Сохраняем как геометрию.
|
||||
- **`email`, `display_name` отдельно от `user`** — OSM API таких полей в `gpx`-эндпоинте не отдаёт; сохранять нечего.
|
||||
|
||||
### 6. Удаление по требованию автора
|
||||
|
||||
Если автор удалит трек на OSM (PUT visibility=private или DELETE):
|
||||
- Следующий полный прогон pipeline по тому же bbox не найдёт этот `gpx_id` → запись в нашей БД останется (stale).
|
||||
- Митигация: per-source GC-проход (отдельная команда `gps_collect.py --gc-stale`) сравнивает наши `external_id` со списком актуальных id OSM и удаляет stale.
|
||||
- На MVP **только реактивно**: при ручном запросе автора через issue tracker оператор может удалить запись по `external_id = "osm-<gpx_id>"`. Автоматический GC-проход — отдельный work item.
|
||||
|
||||
### 7. Полученное юридическое заключение
|
||||
|
||||
OSM Public GPS Traces — **самый изученный** open-data источник; используется тысячами open-source проектов (OsmAnd, JOSM, Strava Routes, и т.д.) для аналогичных целей. ODbL — стандартизованная лицензия фондом Open Knowledge. Внешнего юридического review не требуется для MVP.
|
||||
|
||||
## Решение
|
||||
|
||||
**Источник OSM Public GPS Traces включается в pipeline как `enabled: true` в `gps_sources.yaml`** со следующими параметрами:
|
||||
|
||||
```yaml
|
||||
- id: osm
|
||||
name: "OSM Public GPS Traces"
|
||||
enabled: true
|
||||
license_adr: "docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md"
|
||||
base_url: "https://api.openstreetmap.org/api/0.6"
|
||||
rate_limit_sec: 1
|
||||
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
|
||||
attribution: "© OpenStreetMap contributors (ODbL)"
|
||||
parser_module: "src.api.gps_tracks.sources.osm"
|
||||
save_user_field: true # ADR-009 §5 разрешает
|
||||
external_url_template: "https://www.openstreetmap.org/user/{user}/traces/{external_id_numeric}"
|
||||
```
|
||||
|
||||
Атрибуция автоматически выводится MapLibre в правом нижнем углу карты при включённом source (REQ-NF-06).
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- Самый стабильный источник: документированный API, ODbL — общепринятая open-data лицензия, нет коммерческих условий, нет API-ключей.
|
||||
- BRD-метрика «≥ 3 источника, отдающих данные» закрывается через OSM + 2 других после ADR-010/011.
|
||||
- OSM-треки — единственный гарантированно доступный источник; даже если ADR-010/011 будут отклонены, OSM в одиночку покрывает BRD-минимум.
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- OSM-treki не имеют `activity_type` — у нас по умолчанию `other`. Уточнение возможно через `tags` (если автор пометил «moto/enduro/mtb»). Mapping в `osm.py::MAPPING` (TRZ REQ-F-07). Часть треков останется `other` — это ожидаемо.
|
||||
- IP-сервера mva154 будет «известен» OSM как scraper. Это допустимо при честном User-Agent + соблюдении rate-limit.
|
||||
- Stale-tracks (удалённые автором, оставшиеся у нас) — GC задача для post-MVP.
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Minor change.** Источник со стандартной open-лицензией, без скрейпинга HTML, без коммерческих условий. `arch:major-change` не требуется на уровне отдельного licensing-ADR (общая major-классификация — на ADR-005 и ADR-007).
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-008/01-brd.md` §4 «Источники», «Юридический минимум»
|
||||
- `docs/work-items/ET-008/02-trz.md` REQ-F-04
|
||||
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (licensing guard)
|
||||
- `docs/work-items/ET-008/08-data-requirements.md` §5 (персональные данные)
|
||||
- `docs/work-items/ET-008/10-tech-risks.md` R-5 (rate-limit), R-9 (licensing enforcement)
|
||||
- <https://wiki.openstreetmap.org/wiki/API_v0.6#GPS_traces>
|
||||
- <https://operations.osmfoundation.org/policies/api/>
|
||||
- <https://opendatacommons.org/licenses/odbl/1-0/>
|
||||
142
docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md
Normal file
142
docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-008
|
||||
adr_id: ADR-010
|
||||
title: "ADR-010: Источник EnduroRussia.ru — БЛОКИРОВАН до завершения ToS/robots-ревью; pipeline пропускает source до перехода status=accepted"
|
||||
status: proposed
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels:
|
||||
- "ET-008:source-licensing"
|
||||
- "blocking"
|
||||
---
|
||||
|
||||
# ADR-010 — EnduroRussia.ru: licensing review (БЛОКИРУЮЩИЙ)
|
||||
|
||||
## Статус
|
||||
|
||||
**Proposed** — заблокирован до полного review.
|
||||
|
||||
> Pipeline (`scripts/gps_collect.py`) при загрузке `enduro_russia` parser **проверяет** этот ADR. Пока `status: proposed` — source пропускается с записью `pipeline_runs.status = "skipped_license"`. См. ADR-007 §6 — licensing guard.
|
||||
|
||||
## Контекст
|
||||
|
||||
BRD §4 ET-008 требует обязательный ADR licensing-review для каждого внешнего источника **до** активации в pipeline. Источник `EnduroRussia.ru` упомянут BRD как один из приоритетных (категория «эндуро-treki по регионам»), но в отличие от OSM (ADR-009) не имеет документированного публичного API, а отдаёт треки через HTML + прямые GPX-ссылки на страницах.
|
||||
|
||||
Этот ADR — **шаблон для completion**. До тех пор пока не выполнен полный чеклист ниже (включая получение явных ответов от платформы при их недоступности из robots/ToS), source находится в состоянии `proposed` и pipeline его пропускает.
|
||||
|
||||
## Чеклист по BRD §4 (открытые вопросы)
|
||||
|
||||
### 1. ToS источника по поводу скрейпинга / массовой загрузки
|
||||
|
||||
**ОТКРЫТО.** Необходимо:
|
||||
|
||||
- Извлечь актуальную версию пользовательского соглашения с `enduro-russia.ru/agreement` или аналогичной страницы.
|
||||
- Найти/получить ответ на вопросы:
|
||||
- Разрешён ли автоматизированный сбор страниц?
|
||||
- Разрешено ли массовое скачивание GPX-файлов, опубликованных пользователями платформы?
|
||||
- Допускается ли передача / републикация GPX третьим лицам (т.е. отдача через наш API)?
|
||||
- При отсутствии явного разрешения — отправить запрос администратору платформы по контактам (`info@enduro-russia.ru` или эквивалент) с описанием цели использования; **получить письменное подтверждение** (email или его архив).
|
||||
|
||||
**Принимаемый статус:**
|
||||
- Если ToS явно разрешает или администратор подтверждает → §7 решения переключается на `accepted`.
|
||||
- Если ToS явно запрещает либо администратор отказал → этот ADR превращается в `rejected`, source удаляется из `gps_sources.yaml` (или остаётся `enabled: false`).
|
||||
- При неоднозначности — `deferred`; source не включается в MVP, повторное review через 6 месяцев.
|
||||
|
||||
### 2. robots.txt
|
||||
|
||||
**ОТКРЫТО.** Прочитать `https://enduro-russia.ru/robots.txt` и зафиксировать выписку в этот раздел при completion.
|
||||
|
||||
Принимаемое правило:
|
||||
- `Disallow: /treki/` или `Disallow: /` → source отклоняется автоматически.
|
||||
- `Crawl-delay: N` — `rate_limit_sec` в конфиге выставляется не меньше N.
|
||||
- Отсутствие robots.txt — трактуется как «нет явного запрета» (но не «явное разрешение» — см. §1).
|
||||
|
||||
### 3. Условия публикации чужих треков
|
||||
|
||||
**ОТКРЫТО.** Установить:
|
||||
- Какая лицензия применяется к user-generated content на платформе.
|
||||
- Указано ли в ToS, что платформа предоставляет автору право выкладывать на других площадках.
|
||||
- Содержат ли GPX-метаданные явный copyright notice/CC-лицензию автора.
|
||||
|
||||
Если лицензия не CC-by или совместимая → сохраняем **только** геометрию и обезличенные поля; полей `user`, `name` автора, `description` — **не сохраняем** (`save_user_field: false`, `save_description: false`).
|
||||
|
||||
### 4. Rate-limit
|
||||
|
||||
Предварительная установка (до получения данных §1–§2):
|
||||
|
||||
- `rate_limit_sec: 5` (5 сек между запросами; консервативно).
|
||||
- Per-source максимум на прогон — 1000 новых треков (BRD §6 риск трафика).
|
||||
- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` с контактным URL.
|
||||
- Backoff на 429/503: exponential 2^n, 3 попытки.
|
||||
- При 4 неудачных прогонах подряд — алерт в health-эндпоинт (TRZ REQ-F-12); оператор приостанавливает source вручную (`enabled: false`).
|
||||
|
||||
### 5. Метаданные, запрещённые к сохранению
|
||||
|
||||
**Default до §3 review** — сохраняем только:
|
||||
- `external_id` (id записи на платформе).
|
||||
- `external_url` (ссылка на страницу трека на платформе).
|
||||
- `geom` (геометрия).
|
||||
- `length_m`, `points_count` (производные).
|
||||
- `activity_type` (категория с самой платформы → ACTIVITY_TYPES через `MAPPING`).
|
||||
- `created_at` (дата трека, если публично доступна).
|
||||
|
||||
Не сохраняем без явного зелёного света §3:
|
||||
- `user` (имя автора).
|
||||
- `name` трека.
|
||||
- `description`.
|
||||
- Любые координаты waypoint, отдельные от основной геометрии (точки «домой»/«стоянка»).
|
||||
|
||||
### 6. Удаление по требованию автора
|
||||
|
||||
- Сохраняем `external_url` и `external_id` — это гарантирует точечное удаление по запросу.
|
||||
- При полном пере-сборе pipeline записи, не найденные на источнике, помечаются как stale → удаляются GC-проходом.
|
||||
- Реактивное удаление по issue — оператор через ssh: `DELETE FROM tracks WHERE external_urls_json LIKE '%<url>%'`.
|
||||
|
||||
### 7. Решение licensing
|
||||
|
||||
**Текущее: proposed (БЛОКИРОВАН).** Pipeline source `enduro_russia` находится в `gps_sources.yaml` как `enabled: false` (или отсутствует) пока этот ADR не переключён в `accepted`.
|
||||
|
||||
**Critical path для разблокировки:**
|
||||
1. Аналитик/PO завершает §1–§3 (получение/архивирование ответа от платформы).
|
||||
2. Архитектор обновляет этот ADR: §1/§2/§3 заполнены, status → `accepted`, добавляются принятые параметры.
|
||||
3. В `gps_sources.yaml` source переключается на `enabled: true`.
|
||||
4. Следующий cron-прогон pipeline начинает собирать треки.
|
||||
|
||||
Без завершения шага 1 source **не включается** в MVP. Это соответствует BRD §4 «Источник без явного зелёного света в ADR — не включается».
|
||||
|
||||
## Решение (до review)
|
||||
|
||||
Source `enduro_russia` в `gps_sources.yaml` присутствует с `enabled: false`. Parser-модуль `src/api/gps_tracks/sources/enduro_russia.py` **разработан и протестирован** (TRZ REQ-F-05), но pipeline до accepted-status не загружает его.
|
||||
|
||||
Это даёт два полезных эффекта:
|
||||
- Код парсера живёт в репозитории — review/security audit возможны до активации.
|
||||
- Активация — однострочное изменение конфига после ADR-апрува, не требует деплоя кода.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- Юридическое условие BRD §4 выполняется автоматически: source не работает до явного разрешения.
|
||||
- Тех-долг minimal: парсер уже написан и покрыт тестами с фикстурами; активация = один YAML-флаг.
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- BRD-метрика «≥ 3 источника в продакшне» **не закрыта**, пока этот ADR не accepted. На MVP — закроется через OSM (ADR-009) + ttrails (ADR-011) при условии что любой из этих двух или этот один достигнет accepted.
|
||||
- Затягивание review = source не виден пользователю. Это сознательный compromise: лучше задержать фичу, чем нарушить ToS.
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Minor change** на уровне ADR; **blocking** на уровне MVP-метрики «≥ 3 источника».
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-008/01-brd.md` §4 «Юридический минимум»
|
||||
- `docs/work-items/ET-008/02-trz.md` REQ-F-05
|
||||
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (runtime-guard)
|
||||
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` (паттерн ADR licensing для сравнения)
|
||||
- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md`
|
||||
- `docs/work-items/ET-008/10-tech-risks.md` R-9
|
||||
91
docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md
Normal file
91
docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-008
|
||||
adr_id: ADR-011
|
||||
title: "ADR-011: Источник Тропинки.ру / ttrails.ru — БЛОКИРОВАН до завершения ToS/robots-ревью; pipeline пропускает source до перехода status=accepted"
|
||||
status: proposed
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels:
|
||||
- "ET-008:source-licensing"
|
||||
- "blocking"
|
||||
---
|
||||
|
||||
# ADR-011 — ttrails.ru (Тропинки.ру): licensing review (БЛОКИРУЮЩИЙ)
|
||||
|
||||
## Статус
|
||||
|
||||
**Proposed** — заблокирован до полного review.
|
||||
|
||||
> Pipeline (`scripts/gps_collect.py`) при загрузке `ttrails` parser **проверяет** этот ADR. Пока `status: proposed` — source пропускается с записью `pipeline_runs.status = "skipped_license"`. См. ADR-007 §6 — licensing guard.
|
||||
|
||||
## Контекст
|
||||
|
||||
Источник `ttrails.ru` (Тропинки.ру, эндуро-категория) — публичная платформа с GPX-загрузками без авторизации (BRD §4 #3). Структурно повторяет случай EnduroRussia.ru (ADR-010): не имеет документированного API, доступ через HTML-страницы + ссылки на GPX-файлы.
|
||||
|
||||
Принципы и чеклист — те же, что в ADR-010. Здесь — только специфика ttrails.
|
||||
|
||||
## Чеклист по BRD §4
|
||||
|
||||
### 1. ToS источника по поводу скрейпинга / массовой загрузки
|
||||
|
||||
**ОТКРЫТО.** Аналогично ADR-010 §1:
|
||||
- Найти и архивировать ToS платформы (`ttrails.ru/about`, `/agreement` или эквивалент).
|
||||
- При отсутствии разрешения — связаться с администратором, получить письменный ответ.
|
||||
|
||||
### 2. robots.txt
|
||||
|
||||
**ОТКРЫТО.** Прочитать `https://ttrails.ru/robots.txt`, зафиксировать выписку.
|
||||
|
||||
### 3. Условия публикации чужих треков
|
||||
|
||||
**ОТКРЫТО.** Установить лицензию user-generated content. Default — пока не подтверждено иное:
|
||||
- Сохраняем только обезличенные поля (геометрия, length, points_count, activity_type, created_at если публично доступна).
|
||||
- Не сохраняем `user`, `name`, `description`.
|
||||
|
||||
### 4. Rate-limit
|
||||
|
||||
Предварительная установка:
|
||||
- `rate_limit_sec: 5` (консервативно).
|
||||
- Per-source максимум на прогон — 1000 треков.
|
||||
- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)`.
|
||||
- Backoff на 429/503 — exponential 2^n, 3 попытки.
|
||||
|
||||
### 5. Метаданные, запрещённые к сохранению
|
||||
|
||||
Default — как ADR-010 §5. Пересмотр после §3 review.
|
||||
|
||||
### 6. Удаление по требованию автора
|
||||
|
||||
- `external_url` + `external_id` сохраняются → точечное удаление по запросу автора.
|
||||
- Stale-GC — отдельный work item.
|
||||
|
||||
### 7. Решение licensing
|
||||
|
||||
**Текущее: proposed (БЛОКИРОВАН).** Source `ttrails` в `gps_sources.yaml` остаётся `enabled: false` или отсутствует.
|
||||
|
||||
**Critical path для разблокировки:** см. ADR-010 §7.
|
||||
|
||||
## Решение (до review)
|
||||
|
||||
Source `ttrails` в `gps_sources.yaml` присутствует с `enabled: false`. Parser-модуль `src/api/gps_tracks/sources/ttrails.py` разрабатывается и тестируется (TRZ REQ-F-06), но не активен.
|
||||
|
||||
## Последствия
|
||||
|
||||
См. ADR-010 §«Последствия». Идентичная логика.
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Minor change** на уровне ADR; **blocking** на уровне MVP-метрики «≥ 3 источника» (вместе с ADR-010).
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-008/01-brd.md` §4 «Юридический минимум»
|
||||
- `docs/work-items/ET-008/02-trz.md` REQ-F-06
|
||||
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (licensing guard)
|
||||
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` (паттерн ADR licensing accepted)
|
||||
- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` (одно-к-одному паттерн)
|
||||
- `docs/work-items/ET-008/10-tech-risks.md` R-9
|
||||
323
docs/work-items/ET-008/07-infra-requirements.md
Normal file
323
docs/work-items/ET-008/07-infra-requirements.md
Normal file
@@ -0,0 +1,323 @@
|
||||
---
|
||||
type: infra-requirements
|
||||
work_item_id: ET-008
|
||||
title: "Инфраструктурные требования — ET-008: GPS-треки с публичных платформ"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
---
|
||||
|
||||
# Инфраструктурные требования — ET-008
|
||||
|
||||
## 1. Резюме
|
||||
|
||||
В отличие от ET-007 (только-фронтенд), ET-008 — **серверная фича со scheduled-pipeline**. Изменения охватывают:
|
||||
|
||||
- Новый docker-compose service `gps-collector` (тот же образ, что `app`, с `profiles: [batch]`).
|
||||
- Новый файл БД на mva154: `data/gps_tracks.sqlite` (≤ 2 ГБ).
|
||||
- Новая cron-запись на хосте mva154.
|
||||
- Новый каталог логов `/var/log/enduro-trails/`.
|
||||
- Новые Python-зависимости в общем образе: `defusedxml`, `pyyaml`.
|
||||
- Новые исходящие HTTPS-вызовы из контейнера `gps-collector` к 1–3 внешним источникам.
|
||||
|
||||
Все изменения помещаются в существующий docker-compose стек без введения новых контейнеров API/нового reverse-proxy/новой БД-движка. Эскалация: `arch:major-change` (см. ADR-005, ADR-007).
|
||||
|
||||
## 2. Контейнеры и сервисы
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Новый сервис `app` (FastAPI) | Не вводится; существующий API расширяется новыми routes `/api/gps-tracks/*` через регистрацию роутера из `src/api/gps_tracks/endpoint.py` |
|
||||
| Новый сервис `gps-collector` | **Да.** docker-compose service, `profiles: ["batch"]`, тот же `build: .`, command `python -m scripts.gps_collect`, `restart: "no"`. Не стартует штатно при `docker compose up -d`. Активируется только запуском `docker compose --profile batch run --rm gps-collector` |
|
||||
| Изменение `Dockerfile` | `COPY scripts/ ./scripts/`, `COPY config/ ./config/`. Текущий Dockerfile (`COPY src/api/ src/api/`, `COPY src/web/ src/web/`) не содержит `scripts/` и `config/` — нужно добавить две `COPY`-строки |
|
||||
| Новый блок в `docker-compose.yml` | ≈ 15 строк (см. ADR-007 §1) |
|
||||
| Изменения OSRM, nginx | Нет |
|
||||
| Перезапуск API после деплоя | Нужен (новые routes регистрируются при старте FastAPI) — стандартный `docker compose up -d --no-deps app` |
|
||||
| Простой API | ≤ 5 секунд (рестарт контейнера API). Pipeline-сервис independent — его запуск/остановка не аффектит API |
|
||||
|
||||
### 2.1 Зависимости между сервисами
|
||||
|
||||
- `gps-collector` **не** имеет `depends_on: [app]`. Он работает с БД-файлом напрямую через примонтированный volume `/app/data`.
|
||||
- В конце прогона pipeline дёргает HTTP `POST http://app:5556/api/gps-tracks/cache/clear` (внутренняя docker-сеть). Если `app` недоступен — pipeline пишет WARNING в лог, успех прогона не отменяется (ADR-007 §7).
|
||||
- Сетевое имя `app` доступно потому что оба сервиса в одной default-сети docker-compose.
|
||||
|
||||
### 2.2 Конфликт с production API во время прогона
|
||||
|
||||
- Pipeline пишет в `data/gps_tracks.sqlite` в WAL-mode (ADR-005 §5). API читает ту же БД — видит снэпшот checkpoint'а; конкуренция не блокирует читателей.
|
||||
- CPU/RAM: pipeline ограничен через docker-cgroup limits (см. §9 ниже). Параллельный API не деградирует.
|
||||
|
||||
## 3. Сеть
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Новые серверные порты на mva154 | Нет |
|
||||
| Изменения reverse proxy (`/enduro/` в nginx) | **Минимальные.** Новые routes `/api/gps-tracks/*` уже попадают под существующий `location /api/` proxy_pass. Дополнительных правил не нужно |
|
||||
| Внутренние DNS / docker-сеть | Стандартная default-сеть docker-compose. Service-name `app` резолвится в адрес API-контейнера; используется pipeline для cache-clear |
|
||||
| **Endpoint `POST /api/gps-tracks/cache/clear`** | **Ограничен docker-internal**: блок `RealIPFromTrustedProxy` в nginx (proxy mva154) **не пропускает** `POST` на этот endpoint извне. Деталь: в nginx-конфиге `location = /api/gps-tracks/cache/clear { allow 172.0.0.0/8; deny all; }` — допуск только из docker-сетей |
|
||||
| Новые исходящие HTTPS-вызовы из mva154 | **Да.** Из контейнера `gps-collector`:<br>• `api.openstreetmap.org` (ADR-009) — всегда;<br>• `enduro-russia.ru` (ADR-010) — пока accepted;<br>• `ttrails.ru` (ADR-011) — пока accepted |
|
||||
| Firewall mva154 | Исходящие HTTPS уже разрешены (BRD §7); правил не добавляется |
|
||||
| Внешние входящие | Только существующий `/enduro/` через nginx — без изменений |
|
||||
|
||||
### 3.1 Ограничение cache-clear
|
||||
|
||||
Cache-clear endpoint **должен быть закрыт от внешнего интернета** (он сбрасывает производительный кэш, потенциальный DoS-вектор). Реализация:
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/openclaw — добавляется в существующий server { } для /enduro/
|
||||
location = /enduro/api/gps-tracks/cache/clear {
|
||||
allow 172.16.0.0/12; # docker default networks
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
proxy_pass http://app:5556/api/gps-tracks/cache/clear;
|
||||
}
|
||||
```
|
||||
|
||||
Pipeline дёргает endpoint напрямую через docker-сеть (`http://app:5556/...`), не через nginx → реальный путь обходит правило allow/deny и работает. Snippet выше защищает только публичный путь через `/enduro/`.
|
||||
|
||||
## 4. Хранилища данных
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Новая БД | `data/gps_tracks.sqlite` (SQLite + Spatialite extension) |
|
||||
| Расположение на хосте | `/home/slin/enduro-trails/data/gps_tracks.sqlite` (`./data` в `docker-compose.yml`) |
|
||||
| Расположение в контейнерах | `/app/data/gps_tracks.sqlite` |
|
||||
| Создание | Pipeline создаёт при первом запуске; миграция `migrations/gps_tracks_001_init.sql` применяется автоматически (см. §4.2) |
|
||||
| Размер | Ожидаемо ≤ 500 МБ для ЦФО+Чувашии при 5000 треков; верхний предел операционный — **2 ГБ** (REQ-NF-03). Алерт > 2 ГБ — см. `10-tech-risks.md` R-4 |
|
||||
| Spatialite-extension | Уже доступен в python-образе через `pysqlite3-binary`? Нет: текущий образ использует stdlib `sqlite3`. Нужно установить системный пакет `libsqlite3-mod-spatialite` (см. §4.3) |
|
||||
| Изменения схемы существующей `centralfederal.sqlite` | Нет |
|
||||
| Миграции существующих таблиц | Нет |
|
||||
|
||||
### 4.1 Зачем отдельная БД
|
||||
|
||||
См. ADR-005 §«Решение D-A». Изоляция backup-цикла, ротации, риска повреждения, write-конкуренции.
|
||||
|
||||
### 4.2 Миграция
|
||||
|
||||
`migrations/gps_tracks_001_init.sql` — IDempotent CREATE TABLE IF NOT EXISTS + R-tree creation. Применяется автоматически из `src/api/gps_tracks/db.py::ensure_schema()` при первом коннекте (ленивая инициализация). Никакого `alembic` или внешнего раннера миграций.
|
||||
|
||||
### 4.3 Установка Spatialite в Docker-образе
|
||||
|
||||
Изменение `Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
# ET-008: Spatialite extension для slot.api.gps_tracks.db
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsqlite3-mod-spatialite \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY src/api/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY src/api/ ./src/api/
|
||||
COPY src/web/ ./src/web/
|
||||
COPY scripts/ ./scripts/ # ET-008
|
||||
COPY config/ ./config/ # ET-008
|
||||
ENV STATIC_DIR=/app/src/web
|
||||
ENV PORT=5556
|
||||
EXPOSE 5556
|
||||
CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "5556"]
|
||||
```
|
||||
|
||||
Образ увеличится на ≈ 30 МБ (модуль Spatialite). На размер production-нагрузки не влияет.
|
||||
|
||||
### 4.4 Backup
|
||||
|
||||
- **Ежедневный snapshot** через cron на mva154:
|
||||
```cron
|
||||
0 5 * * * root sqlite3 /home/slin/enduro-trails/data/gps_tracks.sqlite ".backup /home/slin/enduro-trails/backups/gps_tracks-$(date +\%F).sqlite"
|
||||
```
|
||||
- Retention 14 дней — отдельный `find ... -mtime +14 -delete`.
|
||||
- Pipeline-running во время backup допустим: `.backup` в sqlite3 — атомарный, использует WAL.
|
||||
- Восстановление: остановить `gps-collector` запуски, `cp` snapshot в `data/gps_tracks.sqlite`, перезапустить API (cache-clear автоматически).
|
||||
|
||||
### 4.5 Клиентское хранилище
|
||||
|
||||
| Ключ localStorage | Значение | Default |
|
||||
|---|---|---|
|
||||
| `gps-tracks-enabled` | `"true"` \| `"false"` | `"false"` |
|
||||
| `gps-tracks-activities` | JSON-array | все ACTIVITY_TYPES |
|
||||
| `gps-tracks-sources` | JSON-array | все enabled source IDs |
|
||||
| `gps-tracks-color-mode` | `"source"` \| `"activity"` | `"source"` |
|
||||
|
||||
Суммарный объём ≤ 256 байт. Конвенция имён согласуется с существующими (`enduro-theme-mode`, `terrain-*`, `trails-*`, `map-base-layer`).
|
||||
|
||||
Подробности — `08-data-requirements.md` §4.
|
||||
|
||||
## 5. Конфигурация и секреты
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Новые env-переменные API-контейнера | `GPS_TRACKS_DB_PATH=/app/data/gps_tracks.sqlite` |
|
||||
| Новые env-переменные gps-collector | `GPS_TRACKS_DB_PATH`, `GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml`, `GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml` |
|
||||
| Новые секреты / API-ключи | **Нет** — все источники без авторизации (см. ADR-009, ADR-010, ADR-011 — outside source без ключа; платные API явно out of scope BRD §3) |
|
||||
| Новые конфиг-файлы в репозитории | `config/gps_sources.yaml`, `config/gps_regions.yaml` — оба под git-контролем |
|
||||
| Изменения reverse-proxy / nginx | Только cache-clear защита (§3.1) |
|
||||
| Изменения OSRM | Нет |
|
||||
|
||||
## 6. Зависимости
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Python-пакеты (`src/api/requirements.txt`) — добавить | `defusedxml==0.7.1` (безопасный XML-парсинг GPX), `pyyaml==6.0.1` (конфиги pipeline) |
|
||||
| Python-пакеты — НЕ добавлять | `lxml` (упомянут в BRD §7 как опция; для GPX-парсинга достаточно `defusedxml.ElementTree`; экономит ≈ 8 МБ образа). `tenacity` — реализуем backoff inline (≈ 30 строк, TRZ §6.3) чтобы не вводить ещё один пакет |
|
||||
| Системные библиотеки в Dockerfile | `libsqlite3-mod-spatialite` (см. §4.3) |
|
||||
| Версия Python | 3.12, без изменений |
|
||||
| Новые third-party runtime-зависимости (внешние сервисы) | • `api.openstreetmap.org` — OSM API (ADR-009)<br>• `enduro-russia.ru` — после ADR-010 accepted<br>• `ttrails.ru` — после ADR-011 accepted |
|
||||
| Альтернативные источники / fail-over | Не закладывается; каждый source изолирован (ADR-007 §I-A); падение одного не валит других |
|
||||
|
||||
## 7. Сборка и деплой
|
||||
|
||||
- **Pipeline CI:** существующий Gitea Actions (`make lint` + `make test` + `make build`). Новые backend-tests (`tests/api/test_gps_tracks_*.py`) добавляются в существующий pytest. Новые frontend-tests — в существующий ESLint и JS-test pipeline.
|
||||
- **Артефакт:** Docker-образ. После ET-008 один образ запускается **двумя сервисами** (`app` и `gps-collector` через `profiles`). Это стандартный паттерн docker-compose.
|
||||
- **Деплой шаг-за-шагом:**
|
||||
1. `git pull origin main` на mva154.
|
||||
2. `docker compose build` (пересобирает образ с `libsqlite3-mod-spatialite`).
|
||||
3. `docker compose up -d --no-deps app` (перезапускает только API; `gps-collector` profile-disabled).
|
||||
4. Установить cron-запись (см. §8).
|
||||
5. Первый ручной запуск pipeline в dry-run:
|
||||
`docker compose --profile batch run --rm gps-collector python -m scripts.gps_collect --dry-run`
|
||||
6. Проверить `/api/gps-tracks/health` — БД создана, пуста.
|
||||
7. Запустить production-сбор:
|
||||
`docker compose --profile batch run --rm gps-collector` (≤ 6 часов).
|
||||
8. Smoke: открыть `/enduro/`, включить чекбокс «Публичные треки», убедиться что слой виден.
|
||||
|
||||
- **Время простоя API:** ≤ 5 секунд на шаге 3.
|
||||
- **Время простоя pipeline:** не применимо — pipeline не daemon.
|
||||
|
||||
### 7.1 Cron-запись
|
||||
|
||||
`/etc/cron.d/enduro-gps` (root-owned, 0644):
|
||||
```cron
|
||||
# ET-008: GPS Tracks Pipeline
|
||||
# Mon + Thu 03:00 UTC — full collection
|
||||
0 3 * * 1,4 root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector >> /var/log/enduro-trails/gps-collect.log 2>&1
|
||||
|
||||
# 1-е число каждого месяца 04:00 UTC — GC stale tracks
|
||||
0 4 1 * * root cd /opt/enduro-trails && /usr/bin/docker compose --profile batch run --rm gps-collector python -m scripts.gps_collect --gc >> /var/log/enduro-trails/gps-gc.log 2>&1
|
||||
```
|
||||
|
||||
Никаких отдельных `flock` / `lockfile` — cron-окно (3 дня) > длительности прогона (≤ 6 ч).
|
||||
|
||||
### 7.2 Rollback
|
||||
|
||||
| Откат | Действие | Время |
|
||||
|---|---|---|
|
||||
| Откат кода (revert + redeploy) | `git revert <commit> && docker compose up -d --build app` | ≈ 2 мин |
|
||||
| Откат БД (повреждение / неверная схема) | Остановить `gps-collector` cron, `cp backups/gps_tracks-<date>.sqlite data/gps_tracks.sqlite`, рестарт API | ≈ 1 мин |
|
||||
| Полный отказ от фичи (kill switch) | Закомментировать cron-строки, удалить `gps-tracks-cb` checkbox в UI через `display:none` | ≈ 1 мин |
|
||||
| Откат от pipeline без отката API | Закомментировать cron-строки — API продолжает отдавать собранное | мгновенно |
|
||||
|
||||
Скрипт `scripts/disable_gps_pipeline.sh` (TODO в `04-test-plan.yaml`) автоматизирует «kill switch».
|
||||
|
||||
## 8. Cron / scheduled jobs
|
||||
|
||||
См. §7.1.
|
||||
|
||||
**Мониторинг cron:**
|
||||
- При сбое cron-job отправляется email на адрес администратора через стандартный `cron MAILTO=` (mva154 уже настроен). Опционально — алерт в Telegram, но это outside scope (если в проекте уже есть алерт-канал — используется он).
|
||||
- `/api/gps-tracks/health` отдаёт `last_pipeline_run.sources_error` — оператор видит при ручной проверке/мониторинге.
|
||||
|
||||
## 9. Ресурсы (CPU / RAM / диск)
|
||||
|
||||
### 9.1 API-контейнер
|
||||
|
||||
- **CPU:** +5% от текущего baseline за счёт MVT-генерации нового слоя. На существующем mva154 (по BRD §1 одиночный сервер) — не критично.
|
||||
- **RAM:** +50 МБ baseline (новые модули) + до 64 МБ LRU-кэш MVT-тайлов (1024 × ~64 КБ). Итого +120 МБ. Текущий API использует ≈ 200 МБ; после ET-008 — ≈ 320 МБ.
|
||||
- **Network egress:** +0 (внутри сервера; клиент скачивает с того же mva154).
|
||||
|
||||
### 9.2 gps-collector контейнер (во время прогона)
|
||||
|
||||
- **CPU:** ограничен docker-compose cgroup `cpus: "1.0"` (один логический CPU) — pipeline не вытесняет API.
|
||||
- **RAM:** ограничен `mem_limit: 512m`. На практике pipeline + asyncio + httpx + shapely + спарс одного парсера ≤ 200 МБ; запас 2.5×.
|
||||
- **Network egress (mva154 → external):** для OSM ≈ 100 МБ за прогон (≤ 5000 треков × ≤ 20 КБ), для скрейпинга — порядок 10–100 МБ. Полная стоимость cron-прогона ≈ 200 МБ / неделю — пренебрежимо.
|
||||
- **Network ingress:** не применимо.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml фрагмент
|
||||
services:
|
||||
gps-collector:
|
||||
# ...
|
||||
cpus: "1.0"
|
||||
mem_limit: 512m
|
||||
pids_limit: 256
|
||||
```
|
||||
|
||||
### 9.3 Диск
|
||||
|
||||
- `data/gps_tracks.sqlite` — ≤ 2 ГБ.
|
||||
- Лог-файлы `/var/log/enduro-trails/*.log` — ротация через logrotate, default 14 дней × ≤ 50 МБ = ≤ 700 МБ.
|
||||
- Backup-снапшоты — ≤ 14 × 2 ГБ = ≤ 28 ГБ (с retention; см. §4.4).
|
||||
- Сумма: + ≈ 30 ГБ на текущий disk-budget mva154.
|
||||
|
||||
## 10. Наблюдаемость
|
||||
|
||||
| Артефакт | Источник | Использование |
|
||||
|---|---|---|
|
||||
| `GET /api/gps-tracks/health` | API (читает `pipeline_runs` из БД) | Оператор проверяет вручную или через monitoring |
|
||||
| `/var/log/enduro-trails/gps-collect.log` | Cron stdout/stderr | Лог cron-выполнений: успех/код возврата/исключения |
|
||||
| `/var/log/enduro-trails/pipeline-<run_id>.jsonl` | Pipeline structured log | Per-run JSON-lines: source, region, статус, tracks_new |
|
||||
| `pipeline_runs` в БД | Pipeline-side | Историческая трассировка для health-эндпоинта |
|
||||
| Docker `docker compose logs app` | API stdout | Запросы `/api/gps-tracks/*`, ошибки SQL |
|
||||
|
||||
### 10.1 Алерты
|
||||
|
||||
- **Cron MAILTO** при ненулевом exit code прогона — стандартный механизм.
|
||||
- **2 неудачных прогона подряд для одного source** — `pipeline_runs` собирает; алерт **не автоматический** (out of MVP), оператор увидит при ручной проверке `/health` или в weekly review. Алерт-канал — отдельный work item.
|
||||
- **db_size_mb > 2 ГБ** — health отдаёт значение; внешний мониторинг (если есть) пинает.
|
||||
- **Ошибка лицензионного guard'а** (`status: "skipped_license"`) — оператор видит в `pipeline_runs`; не алерт-кейс, нормальное поведение до accepted-ADR.
|
||||
|
||||
### 10.2 Logrotate
|
||||
|
||||
```
|
||||
# /etc/logrotate.d/enduro-gps
|
||||
/var/log/enduro-trails/*.log {
|
||||
daily
|
||||
rotate 14
|
||||
compress
|
||||
missingok
|
||||
notifempty
|
||||
}
|
||||
/var/log/enduro-trails/pipeline-*.jsonl {
|
||||
weekly
|
||||
rotate 8
|
||||
compress
|
||||
missingok
|
||||
notifempty
|
||||
}
|
||||
```
|
||||
|
||||
## 11. Безопасность
|
||||
|
||||
- **Парсинг XML на сервере (GPX)** — через `defusedxml.ElementTree` (защита XXE / billion laughs). `lxml` не используется.
|
||||
- **Endpoint `POST /api/gps-tracks/cache/clear`** — ограничен docker-internal сетью на уровне nginx (§3.1). Pipeline ↔ API остаются связаны через docker-сеть.
|
||||
- **Скрейпинг — только outgoing** с mva154. Никаких open ports.
|
||||
- **Атаки на pipeline через подделанные GPX** (источник вернул malformed XML, exploding XML) — митигируется `defusedxml` и timeout `httpx.get(timeout=30)`. Per-track exception isolated в pipeline-loop.
|
||||
- **CSP-заголовок** — в проекте отсутствует (см. ET-007 §3.2). ET-008 ничего не меняет.
|
||||
|
||||
## 12. Влияние на C4 / архитектурную документацию
|
||||
|
||||
Изменения состава компонентов:
|
||||
|
||||
- **Новый компонент** в стеке mva154: docker-compose service `gps-collector` (batch).
|
||||
- **Новая БД** `data/gps_tracks.sqlite`.
|
||||
- **Новые внешние зависимости рантайма**: 1–3 платформы (OSM всегда + 0/1/2 после ADR-010/011).
|
||||
- **Новые scheduled-jobs**: 2 cron-записи.
|
||||
|
||||
`docs/architecture/README.md` обновляется новым разделом «GPS Tracks Pipeline (ET-008)» с описанием компонента, БД, внешних зависимостей и расписания.
|
||||
|
||||
`docs/architecture/adr/README.md` пополняется записями ADR-005..ADR-011.
|
||||
|
||||
C4 mmd-диаграмм в проекте нет — текстовое описание (по прецеденту ADR-004 §8).
|
||||
|
||||
## 13. Вывод
|
||||
|
||||
ET-008 — **major-change** на инфра-уровне:
|
||||
- Новый docker-compose service.
|
||||
- Новый файл БД.
|
||||
- Первые scheduled jobs (cron) на mva154.
|
||||
- Новые исходящие сетевые соединения с обязательными licensing-ADR.
|
||||
|
||||
Все элементы — расширение существующего стека (не новый stack). Реверсная процедура и rollback — однострочные операции.
|
||||
|
||||
Эскалация: лейбл `arch:major-change` выставлен на ADR-005 и ADR-007. Архитектурный approve обязателен перед merge.
|
||||
382
docs/work-items/ET-008/08-data-requirements.md
Normal file
382
docs/work-items/ET-008/08-data-requirements.md
Normal file
@@ -0,0 +1,382 @@
|
||||
---
|
||||
type: data-requirements
|
||||
work_item_id: ET-008
|
||||
title: "Требования к данным — ET-008: GPS-треки с публичных платформ"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
---
|
||||
|
||||
# Требования к данным — ET-008
|
||||
|
||||
## 1. Резюме
|
||||
|
||||
ET-008 вводит:
|
||||
|
||||
- **Новую серверную БД** `data/gps_tracks.sqlite` (Spatialite) с двумя таблицами: `tracks`, `pipeline_runs`.
|
||||
- **Контракт публичного API GeoJSON** и **MVT layer schema** (см. TRZ §4.2, §4.3 — здесь финализируется).
|
||||
- **Внешние входные данные** — GPS-треки с 1–3 публичных платформ.
|
||||
- **Клиентское хранилище** (`localStorage`) — 4 новых ключа состояния UI.
|
||||
- **Персональные данные**: возможно `user` (имя автора публичного трека) для OSM (ADR-009 разрешает); для других источников — пока заблокировано (ADR-010, ADR-011).
|
||||
|
||||
## 2. Архитектурные границы данных
|
||||
|
||||
| Слой данных | Тип | Расположение | Владелец | Lifecycle |
|
||||
|---|---|---|---|---|
|
||||
| OSM-vector (`trails`, `centralfederal.sqlite`) | существующий | `/app/data/centralfederal.sqlite` | ET-001..006 | пересборка из OSM ad-hoc |
|
||||
| Личные GPX треки (ET-006) | существующий | браузер (memory only) | ET-006 | сессия |
|
||||
| **Публичные GPS треки** | **новый** | `/app/data/gps_tracks.sqlite` | **ET-008** | rebuild при необходимости + ежемесячный GC |
|
||||
| OSRM-граф | существующий | `/app/data/enduro.osrm.*` | PH-2 | пересборка после OSM-обновления |
|
||||
| User UI state | существующий + расширение | `localStorage` браузера | каждый work item | до явной очистки |
|
||||
|
||||
Между новой БД и существующей `centralfederal.sqlite` **нет cross-DB запросов** на горизонте MVP (см. ADR-005 §9).
|
||||
|
||||
## 3. Серверные данные — `gps_tracks.sqlite`
|
||||
|
||||
### 3.1 Таблица `tracks`
|
||||
|
||||
```sql
|
||||
CREATE TABLE tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dedup_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
activity_type TEXT NOT NULL, -- ACTIVITY_TYPES (см. §3.4)
|
||||
user TEXT, -- ADR-009 разрешает; null для ADR-010/011 до accepted
|
||||
created_at TEXT, -- ISO date YYYY-MM-DD; nullable
|
||||
length_m REAL NOT NULL,
|
||||
points_count INTEGER NOT NULL,
|
||||
min_lon REAL NOT NULL,
|
||||
min_lat REAL NOT NULL,
|
||||
max_lon REAL NOT NULL,
|
||||
max_lat REAL NOT NULL,
|
||||
geom BLOB NOT NULL, -- WKB LineString (Spatialite)
|
||||
sources_json TEXT NOT NULL, -- JSON-array ["osm", "enduro_russia"]
|
||||
external_urls_json TEXT NOT NULL, -- JSON-array URLs
|
||||
tags_json TEXT, -- JSON-array string tags
|
||||
inserted_at TEXT NOT NULL, -- ISO datetime
|
||||
updated_at TEXT NOT NULL -- ISO datetime
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_tracks_dedup ON tracks(dedup_key);
|
||||
CREATE INDEX idx_tracks_activity ON tracks(activity_type);
|
||||
CREATE INDEX idx_tracks_created ON tracks(created_at);
|
||||
|
||||
-- Spatialite R-tree
|
||||
SELECT CreateSpatialIndex('tracks', 'geom');
|
||||
```
|
||||
|
||||
Поля `min_lon`/`max_lon`/`min_lat`/`max_lat` денормализованы из `geom` для **раннего отбрасывания** треков в MVT-генерации без парсинга WKB (ADR-005 §2).
|
||||
|
||||
### 3.2 `dedup_key`
|
||||
|
||||
Алгоритм — ADR-006. Формат строки:
|
||||
```
|
||||
((w, s, e, n), length_bucket, "YYYY-MM-DD")
|
||||
```
|
||||
где координаты округлены до 2 знаков после запятой, `length_bucket` = `round(length_m / 1000) * 1000`. UNIQUE индекс обеспечивает ON CONFLICT логику.
|
||||
|
||||
### 3.3 `sources_json` и `external_urls_json`
|
||||
|
||||
JSON-массивы строк. Длина ≤ 8 элементов (источников после дедупа). Порядок — стабильный по приоритету в `gps_sources.yaml`. Первый элемент `sources_json` — «первичный» источник; его id попадает в `properties.source` MVT-фичи для цветовой палитры по умолчанию (REQ-F-16).
|
||||
|
||||
Пример:
|
||||
```json
|
||||
sources_json = ["osm", "enduro_russia"]
|
||||
external_urls_json = ["https://www.openstreetmap.org/user/Vasya/traces/12345",
|
||||
"https://enduro-russia.ru/treki/678"]
|
||||
```
|
||||
|
||||
Запись фиксирует **тот же индекс** = тот же источник: `external_urls_json[i]` — это URL `sources_json[i]`.
|
||||
|
||||
### 3.4 ACTIVITY_TYPES
|
||||
|
||||
Закрытый enum (TRZ REQ-F-07):
|
||||
|
||||
| code | label-ru |
|
||||
|---|---|
|
||||
| `enduro` | Эндуро |
|
||||
| `moto` | Мото |
|
||||
| `offroad` | Off-road |
|
||||
| `bicycle` | Велосипед |
|
||||
| `hike` | Пешком |
|
||||
| `ski` | Лыжи |
|
||||
| `other` | Другое |
|
||||
|
||||
`MAPPING` per source — константа в `<source>.py`. Категории источника, не найденные в MAPPING → `other`. На MVP `MAPPING` для OSM фиксирован: парсим OSM-tags (`tag: enduro` → `enduro`, `tag: motorbike` → `moto`, `tag: mtb`/`tag: bike` → `bicycle`, etc.). Точная таблица — в коде, ревью при ADR-апруве.
|
||||
|
||||
### 3.5 Таблица `pipeline_runs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE pipeline_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
region_id TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- ok | partial | error | skipped_license
|
||||
tracks_new INTEGER DEFAULT 0,
|
||||
tracks_updated INTEGER DEFAULT 0,
|
||||
errors_json TEXT -- JSON object {error_type: count}
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pipeline_started ON pipeline_runs(started_at);
|
||||
```
|
||||
|
||||
История прогонов. Read-only для API; пишет только pipeline. Используется `/api/gps-tracks/health`.
|
||||
|
||||
### 3.6 Размер БД
|
||||
|
||||
| Объём | Оценка |
|
||||
|---|---|
|
||||
| Среднее число точек на трек | 1240 (по BRD §3 F-13 popup; реалистично) |
|
||||
| Геометрия WKB на трек | ≈ 16 байт/точка × 1240 = 20 КБ |
|
||||
| Метаданные на трек | ≈ 1 КБ |
|
||||
| Итого на трек | ≈ 21 КБ |
|
||||
| 5000 треков MVP | ≈ 105 МБ |
|
||||
| 50 000 треков (через год при расширении) | ≈ 1.05 ГБ |
|
||||
| Лимит REQ-NF-03 | 2 ГБ |
|
||||
|
||||
Запас 2× от MVP-объёма до операционного лимита. При превышении — миграция на PostGIS (отдельный work item, тех-долг в ADR-005).
|
||||
|
||||
### 3.7 Ротация и GC
|
||||
|
||||
- Команда `python -m scripts.gps_collect --gc` (ADR-007 §3) — удаляет треки `WHERE updated_at < NOW() - 5 years`.
|
||||
- Параметр `5 years` зашит в `config/gps_sources.yaml::retention_years` (default 5; per-source override возможен).
|
||||
- Cron — 1-е число каждого месяца 04:00 UTC.
|
||||
- Stale-cleanup (трек удалён на источнике) — отдельный GC-режим `--gc-stale`; на MVP не входит (см. ADR-009 §6).
|
||||
|
||||
### 3.8 Backup
|
||||
|
||||
См. `07-infra-requirements.md` §4.4. Ежедневный `.backup`, retention 14 дней.
|
||||
|
||||
## 4. Клиентское хранилище
|
||||
|
||||
| Ключ | Значение | Default | Расход |
|
||||
|---|---|---|---|
|
||||
| `gps-tracks-enabled` | `"true"` \| `"false"` | `"false"` | ≤ 5 байт |
|
||||
| `gps-tracks-activities` | JSON-array из ACTIVITY_TYPES | все 7 значений | ≤ 70 байт |
|
||||
| `gps-tracks-sources` | JSON-array source IDs | все enabled на момент первого открытия | ≤ 80 байт |
|
||||
| `gps-tracks-color-mode` | `"source"` \| `"activity"` | `"source"` | ≤ 8 байт |
|
||||
| **Итого на браузер** | | | ≤ 256 байт |
|
||||
|
||||
- **Чтение**: `restorePublicTracksState()` в `rebuildMapOverlays()` (REQ-F-19); инициализация при старте приложения.
|
||||
- **Запись**: каждое изменение checkbox / segmented control в `#sheet-gps-filters`.
|
||||
- **Миграция со старых значений**: не требуется (ключи новые).
|
||||
- **Невалидные значения**: ignore + restore defaults; не вызывают исключение.
|
||||
|
||||
### 4.1 Конвенция имён
|
||||
|
||||
Префиксация — `gps-tracks-*`. Согласуется с существующими (`terrain-*`, `trails-*`, `map-base-layer`).
|
||||
|
||||
### 4.2 Не-персистентное состояние в памяти браузера
|
||||
|
||||
```js
|
||||
window.gpsTracksLayer = {
|
||||
enabled: false,
|
||||
filters: {
|
||||
activities: [...ACTIVITY_TYPES],
|
||||
sources: [...enabledSourceIds],
|
||||
colorMode: 'source'
|
||||
},
|
||||
sourceId: 'gps-tracks-tiles', // vector source for MVT mode
|
||||
sourceGeoId: 'gps-tracks-geo', // geojson source for GeoJSON mode
|
||||
layerMvtId: 'gps-tracks-layer-mvt',
|
||||
layerGeoId: 'gps-tracks-layer-geo',
|
||||
haloMvtId: 'gps-tracks-halo-mvt-satellite',
|
||||
haloGeoId: 'gps-tracks-halo-geo-satellite',
|
||||
geojsonAbortController: null,
|
||||
geojsonReqDebounceTimer: null,
|
||||
stats: { total: 0, shown: 0 },
|
||||
activeMode: 'mvt' | 'geo' | 'hidden' // derived from zoom
|
||||
};
|
||||
```
|
||||
|
||||
Конкретное содержимое и переходы — TRZ §4.4 + ADR-008.
|
||||
|
||||
## 5. Внешние входные данные
|
||||
|
||||
### 5.1 OSM Public GPS Traces (ADR-009)
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Endpoint | `GET https://api.openstreetmap.org/api/0.6/trackpoints?bbox=...&page=...` |
|
||||
| Metadata | `GET https://api.openstreetmap.org/api/0.6/gpx/{id}` |
|
||||
| Формат | XML (GPX 1.1) — `<trkpt>` + `<wpt>` + meta |
|
||||
| Лицензия | ODbL 1.0 |
|
||||
| Атрибуция | `© OpenStreetMap contributors (ODbL)` |
|
||||
| Rate-limit | 1 req/sec (per OSM policy) |
|
||||
| Объём для ЦФО+Чувашии (оценка) | ≈ 50 000–100 000 точек, ≈ 1 000–5 000 треков |
|
||||
| User-Agent | `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` |
|
||||
|
||||
### 5.2 EnduroRussia.ru (ADR-010 — БЛОКИРОВАН)
|
||||
|
||||
До accepted-status — pipeline пропускает.
|
||||
|
||||
### 5.3 ttrails.ru (ADR-011 — БЛОКИРОВАН)
|
||||
|
||||
До accepted-status — pipeline пропускает.
|
||||
|
||||
## 6. Контракт публичного API
|
||||
|
||||
### 6.1 `GET /api/gps-tracks`
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Параметр | Тип | Обязательность | Default | Валидация |
|
||||
|---|---|---|---|---|
|
||||
| `bbox` | 4 float comma-separated | required | — | -180 ≤ lon ≤ 180, -85 ≤ lat ≤ 85, west < east, south < north, площадь ≤ 10 deg² |
|
||||
| `activity` | comma-string из ACTIVITY_TYPES | optional | all | каждое значение — известный enum |
|
||||
| `source` | comma-string source IDs | optional | all enabled | значения сверяются с `gps_sources.yaml` |
|
||||
| `limit` | int | optional | 500 | 1 ≤ limit ≤ 2000 |
|
||||
|
||||
**Response 200 (`Content-Type: application/json`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": 12345,
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[lon, lat], ...]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Утренний эндуро",
|
||||
"activity_type": "enduro",
|
||||
"user": "Vasya",
|
||||
"created_at": "2024-05-12",
|
||||
"length_km": 47.3,
|
||||
"points_count": 1240,
|
||||
"sources": ["osm", "enduro_russia"],
|
||||
"external_urls": ["https://...", "https://..."],
|
||||
"tags": ["forest", "river"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"total_in_bbox": 743,
|
||||
"returned": 500,
|
||||
"truncated": true
|
||||
}
|
||||
```
|
||||
|
||||
**Error responses:**
|
||||
|
||||
| Code | Условие |
|
||||
|---|---|
|
||||
| 400 | невалидный bbox / activity / source / limit |
|
||||
| 503 | БД отсутствует или Spatialite не загрузился |
|
||||
|
||||
### 6.2 `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt`
|
||||
|
||||
**Path params:** `z` 0..18, `x`/`y` валидны для z.
|
||||
|
||||
**Response:**
|
||||
- 200 `Content-Type: application/x-protobuf`, тело — `mapbox-vector-tile`-encoded MVT.
|
||||
- 200 + пустое тело — если в тайле нет треков.
|
||||
- 304 — стандартная HTTP cache на ETag (опционально, MVP — не реализуется).
|
||||
- Header `X-Cache: HIT | MISS` — для observability.
|
||||
|
||||
**Layer schema:**
|
||||
|
||||
| Layer | Geometry | Properties |
|
||||
|---|---|---|
|
||||
| `gps_tracks` | LineString | `id (int)`, `activity (string)`, `source (string, первый)`, `sources (string, comma-separated)`, `length_km (float)`, `name (string)`, `ext_url (string, первый)` |
|
||||
|
||||
Properties — упрощены под MVT-ограничения (нет массивов).
|
||||
|
||||
### 6.3 `GET /api/gps-tracks/health`
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"db_path": "/app/data/gps_tracks.sqlite",
|
||||
"db_size_mb": 124.5,
|
||||
"tracks_total": 8421,
|
||||
"tracks_by_source": {"osm": 5234, "enduro_russia": 2102, "ttrails": 1085},
|
||||
"tracks_by_activity": {"enduro": 2104, "moto": 850, "offroad": 305, "bicycle": 3201, "hike": 1810, "other": 151},
|
||||
"last_pipeline_run": {
|
||||
"started_at": "2026-05-30T03:00:00Z",
|
||||
"finished_at": "2026-05-30T05:14:00Z",
|
||||
"regions": ["tsfo_plus_chuvashia"],
|
||||
"sources_ok": ["osm"],
|
||||
"sources_error": [{"source": "ttrails", "error": "HTTP 503"}],
|
||||
"sources_skipped_license": ["enduro_russia"]
|
||||
},
|
||||
"tile_cache_size": 412,
|
||||
"tile_cache_max": 1024
|
||||
}
|
||||
```
|
||||
|
||||
**Response 503:** если БД отсутствует или Spatialite не доступен.
|
||||
|
||||
### 6.4 `POST /api/gps-tracks/cache/clear`
|
||||
|
||||
**Auth:** ограничен docker-internal сетью (`07-infra-requirements.md` §3.1).
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{"cleared": 412}
|
||||
```
|
||||
|
||||
Запрос идемпотентен, вызывается только pipeline'ом в конце прогона.
|
||||
|
||||
## 7. Персональные данные (PII)
|
||||
|
||||
| Канал | PII | Условия |
|
||||
|---|---|---|
|
||||
| `tracks.user` (имя автора) | да, **публичное** имя | сохраняется **только** если ADR соответствующего источника явно разрешает (`save_user_field: true` в `gps_sources.yaml`). По ADR-009 OSM — разрешено. ADR-010, ADR-011 — пока запрещено |
|
||||
| `tracks.geom` (координаты трека) | низкий риск; **публично выложенные** автором | сохраняются всегда |
|
||||
| `tracks.created_at` | дата проезда | публичная; сохраняется всегда |
|
||||
| `tracks.description`, `tracks.tags` | возможные следы PII в свободном тексте | сохраняются только при `save_description: true` в конфиге источника |
|
||||
| Запросы к `api.openstreetmap.org` (исходящие с mva154) | IP **сервера mva154**, не клиента | да, mva154-IP становится известен OSM (стандартное поведение для скрейпера) |
|
||||
| Запросы к `enduro-russia.ru`, `ttrails.ru` | то же | пока ADR не accepted — не происходит |
|
||||
| `localStorage['gps-tracks-*']` | UI-настройки | нет PII |
|
||||
|
||||
### 7.1 Право на удаление
|
||||
|
||||
- Запись `external_urls_json` сохраняет ссылку на оригинал — оператор может удалить конкретную запись по запросу автора (`DELETE FROM tracks WHERE external_urls_json LIKE '%<url>%'`).
|
||||
- Pipeline уважает «удалённое на источнике» при `--gc-stale` (post-MVP).
|
||||
|
||||
### 7.2 GDPR / РФ ФЗ-152
|
||||
|
||||
- ET-008 обрабатывает **только публично опубликованные** автором данные.
|
||||
- Имя автора (`user`) — публичное на платформе источника (по ADR-009, ADR-010 для OSM/EnduroRussia это публикуется на странице трека).
|
||||
- Контактные данные (email, телефон) — **не сохраняются ни при каких условиях**; платформы их не отдают в публичных GPX-эндпоинтах.
|
||||
- Локация «дом»/«работа» как отдельная точка интереса — не сохраняется (waypoints без public-флага в OSM не отдаются; для скрейпленых источников — `save_waypoints: false`).
|
||||
- DPO-ответственность minimal — нет сервиса регистрации/учёта пользователей; это публичный read-only слой.
|
||||
|
||||
## 8. Атрибуция
|
||||
|
||||
Обязательное требование BRD §5 «Атрибуция» и AC-15:
|
||||
|
||||
- **На карте**: MapLibre автоматически отображает `attribution` из source-spec в правом нижнем углу. Каждый source (`gps-tracks-tiles`, `gps-tracks-geo`) указывает `attribution: "© OSM contributors (ODbL) | EnduroRussia.ru | ttrails.ru"` — динамически сформированную клиентом из `/api/gps-tracks/health.tracks_by_source` (только активные источники).
|
||||
- **В popup трека**: ссылки на оригинал по `external_urls_json` (REQ-F-18).
|
||||
- **В docs/architecture/README.md**: новый раздел «GPS Tracks Pipeline» содержит таблицу источников и их атрибуций.
|
||||
|
||||
## 9. Backup и retention
|
||||
|
||||
| Объект | Backup | Retention |
|
||||
|---|---|---|
|
||||
| `data/gps_tracks.sqlite` | Ежедневный `.backup` через cron на mva154 | 14 дней |
|
||||
| `pipeline_runs` (внутри той же БД) | через backup БД | вечно (растёт медленно, ≤ 10⁴ строк/год) |
|
||||
| `tracks` старше 5 лет | удаляются при `--gc` | retention configurable в `gps_sources.yaml` |
|
||||
| `/var/log/enduro-trails/*.log` | через logrotate | 14 дней |
|
||||
| Pipeline JSON-lines logs | через logrotate | 8 недель |
|
||||
|
||||
## 10. Контракты, которые **нельзя ломать**
|
||||
|
||||
1. `dedup_key` формула (ADR-006 §6) — менять можно только при полном rebuild БД.
|
||||
2. `ACTIVITY_TYPES` enum — добавление новых значений требует UI-обновления (новый цвет, новая локализация); удаление — миграция существующих треков.
|
||||
3. GeoJSON response shape (§6.1) — public API, ломающие изменения через v2-endpoint.
|
||||
4. MVT layer name `gps_tracks` и properties (§6.2) — клиент завязан; ломающие — через новый layer-name.
|
||||
5. `localStorage` keys (§4) — менять имя ключа требует миграцию (`gps-tracks-enabled-v2`).
|
||||
|
||||
## 11. Вывод
|
||||
|
||||
Серверная модель данных полностью локализована в `data/gps_tracks.sqlite`. Контракты API и MVT-схема финализированы. Клиентское хранилище — 256 байт UI-state. Персональные данные минимизированы по дизайну: только публичные поля от accepted-источников; default-deny для не-accepted.
|
||||
209
docs/work-items/ET-008/10-tech-risks.md
Normal file
209
docs/work-items/ET-008/10-tech-risks.md
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
type: tech-risks
|
||||
work_item_id: ET-008
|
||||
title: "Технические риски — ET-008: GPS-треки с публичных платформ"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
---
|
||||
|
||||
# Технические риски — ET-008
|
||||
|
||||
Технические риски этапа разработки и эксплуатации. Бизнес-риски — в BRD §6 (пересечение есть, здесь акцент на технические митигации). Шкала: вероятность (Н/С/В) × влияние (Н/С/В).
|
||||
|
||||
## R-1 — Парсер источника ломается при изменении HTML
|
||||
|
||||
- **Описание:** ADR-010/011 источники (`enduro_russia`, `ttrails`) скрейпят HTML-страницы. Платформа может в любой момент изменить разметку (новый шаблон, JS-rendering) → парсер перестаёт извлекать треки.
|
||||
- **Вероятность / Влияние:** В / С.
|
||||
- **Митигация:**
|
||||
- Каждый source в отдельном модуле (`src/api/gps_tracks/sources/<name>.py`); падение одного не валит других (ADR-007 §I-A).
|
||||
- Pipeline пишет `status=error` в `pipeline_runs`; оператор видит через `/api/gps-tracks/health`.
|
||||
- Параметризированные тесты с фикстурами HTML-снапшота — при первом упавшем прогоне разработчик обновляет фикстуру и парсер за 1 итерацию.
|
||||
- При двух неудачных прогонах подряд — алерт (`07-infra-requirements.md` §10.1). На MVP — ручная проверка.
|
||||
- Конфиг `gps_sources.yaml::enabled: false` — мгновенное отключение источника без deploy.
|
||||
|
||||
## R-2 — Ложные коллизии дедупа
|
||||
|
||||
- **Описание:** ADR-006 алгоритм `bbox+length+date bucket` детерминированно мерджит треки с похожими параметрами. На треках без `created_at` (от источников без даты) — гарантированный merge всех таких треков в одном bbox/length. На дата-датасете — возможны коллизии для популярных маршрутов (двое разных гонщиков проехали тот же 30-км круг в один день).
|
||||
- **Вероятность / Влияние:** С / С.
|
||||
- **Митигация:**
|
||||
- BRD §5 фиксирует допустимую метрику «< 5% дублей»; QA-скрипт `scripts/dedup_audit.py` проверяет на выборке 100 треков (`04-test-plan.yaml`).
|
||||
- При провале метрики — план отступления ADR-006 §8 (сузить length-bucket, добавить activity в ключ).
|
||||
- Если меняется формула dedup_key — полный rebuild БД (`rm + python -m scripts.gps_collect`); регенерация ≤ 6 часов.
|
||||
- Документация в `08-data-requirements.md` §3.2 для оператора.
|
||||
|
||||
## R-3 — Pipeline повреждает БД
|
||||
|
||||
- **Описание:** Бaг в Python-коде upsert (ADR-006 §6) при ON CONFLICT может оставить БД в несогласованном состоянии (битый JSON в `sources_json`, частично записанная transaction). SQLite + WAL обычно atomic per-statement, но composite upsert может рассогласоваться.
|
||||
- **Вероятность / Влияние:** Н / В.
|
||||
- **Митигация:**
|
||||
- Все upsert операции — внутри SQLite `BEGIN IMMEDIATE / COMMIT` (atomic transaction).
|
||||
- Ежедневный backup `data/gps_tracks.sqlite` (`07-infra-requirements.md` §4.4).
|
||||
- При повреждении: `cp backups/gps_tracks-<date>.sqlite data/gps_tracks.sqlite` + cache-clear API. RTO ≈ 1–2 минуты.
|
||||
- Полный rebuild: `rm gps_tracks.sqlite && docker compose --profile batch run --rm gps-collector` — ≤ 6 часов.
|
||||
- Изоляция в отдельной БД (ADR-005 D-A) гарантирует, что повреждение не затронет `centralfederal.sqlite` (OSM-данные).
|
||||
|
||||
## R-4 — Размер БД превышает 2 ГБ
|
||||
|
||||
- **Описание:** REQ-NF-03 предел `data/gps_tracks.sqlite` — 2 ГБ. На MVP-объёме (5000 треков ≈ 105 МБ) запас 20×. Но при расширении на РФ или при отсутствии работающего GC размер может вырасти линейно.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- Health-эндпоинт отдаёт `db_size_mb` — оператор видит.
|
||||
- Месячный GC `--gc` удаляет треки старше 5 лет (`07-infra-requirements.md` §7.1).
|
||||
- При устойчивом росте > 2 ГБ — миграция на PostGIS (отдельный work item; контракт API стабилен, см. ADR-005 §«Технический долг»).
|
||||
- Алерт `db_size_mb > 2000` — пока ручная проверка (post-MVP — автоматический).
|
||||
|
||||
## R-5 — IP mva154 банится источником
|
||||
|
||||
- **Описание:** Скрейпер с фиксированного IP может попасть в чёрный список платформы (особенно при ошибках rate-limit). Pipeline начинает возвращать 429/403 на все запросы → source не пополняется.
|
||||
- **Вероятность / Влияние:** С / С.
|
||||
- **Митигация:**
|
||||
- Rate-limit в `gps_sources.yaml` per-source (1 сек для OSM, 5 сек для скрейп-источников).
|
||||
- Корректный User-Agent с контактом — платформа может связаться, прежде чем банить.
|
||||
- Backoff на 429 (`TRZ §6.3`) — exponential до 3 попыток.
|
||||
- `pipeline_runs.errors_json` фиксирует HTTP-коды → оператор видит.
|
||||
- При бане — приостановить source (`enabled: false`), связаться с платформой, при необходимости отключить полностью.
|
||||
- **Прокси через сторонний IP** — не закладывается (нарушает дух прозрачности).
|
||||
|
||||
## R-6 — Pipeline жрёт ресурсы и деградирует API во время прогона
|
||||
|
||||
- **Описание:** На время прогона `gps-collector` контейнер активен, скачивает GPX, парсит, пишет в БД. Если ресурсы не ограничены — `httpx` + `shapely` могут уйти в GC-storm; SQLite write lock конкурирует с API readers.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- Docker `cpus: "1.0"`, `mem_limit: 512m` (`07-infra-requirements.md` §9.2). Pipeline не вытесняет API даже на одно-CPU-сервере.
|
||||
- WAL-mode позволяет API читать БД во время записи pipeline'а (ADR-005 W-A).
|
||||
- Cron в 03:00 UTC = 06:00 MSK — низкий traffic.
|
||||
- Async-генератор `parser.collect()` — pipeline pulls треки по одному, не накапливает в памяти больше одного (ADR-007 §4).
|
||||
|
||||
## R-7 — Дублирование tile-утилит между `main.py` и `gps_tracks/mvt.py`
|
||||
|
||||
- **Описание:** ADR-005 §8 принимает дублирование `tile_to_bbox` / `wkb_to_coords` / `simplify_coords` (≈ 100 строк) ради избежания риска регрессии существующего слоя `trails`. Любая правка формулы упрощения требует синхронной правки в двух местах.
|
||||
- **Вероятность / Влияние:** С / Н.
|
||||
- **Митигация:**
|
||||
- Комментарий в обоих файлах `# ET-008/ADR-005-§8: дубль из main.py; при добавлении третьего MVT-источника — вынести в src/api/tiles_util.py`.
|
||||
- Code review-чеклист: при правке `simplify_coords` в одном файле — проверить второй.
|
||||
- При появлении третьего MVT-источника — обязательный рефакторинг (отдельный work item).
|
||||
|
||||
## R-8 — GeoJSON-эндпоинт превышает SLA на плотных bbox
|
||||
|
||||
- **Описание:** REQ-NF-02 предел 300 мс p95 на bbox с ≤ 500 треков. На реальной географии возможны bbox в плотных регионах (например, Подмосковье на z=12) где `total_in_bbox > 2000`. SQL даже с R-tree может проигрывать при ORDER BY + post-filter source.
|
||||
- **Вероятность / Влияние:** С / Н.
|
||||
- **Митигация:**
|
||||
- Cutoff `limit=500` обрезает результат на уровне SQL.
|
||||
- Cutoff zoom 12 — на z=11 уходим в MVT-кэш, нагрузки на GeoJSON-endpoint нет.
|
||||
- R-tree обеспечивает O(log n) bbox-prefetch.
|
||||
- Дополнительный индекс по `length_m DESC` для ORDER BY (длинные треки приоритетнее) — фиксируется в коде; SQLite сделает sort быстро на 500 строках.
|
||||
- Если SLA не выполняется — server-side кэширование GeoJSON-ответов по `(bbox_quantized, activity, source)` (post-MVP).
|
||||
|
||||
## R-9 — Лицензионный ADR не enforced
|
||||
|
||||
- **Описание:** ADR-007 §6 требует, чтобы pipeline отказывался загружать source-parser без `accepted`-ADR. Если разработчик обходит проверку (например, забывает добавить `license_adr:` поле в `gps_sources.yaml`) — pipeline пойдёт скрейпить без юридического подтверждения. BRD §4 явно требует «зелёного света».
|
||||
- **Вероятность / Влияние:** Н / В.
|
||||
- **Митигация:**
|
||||
- Pydantic-валидация `gps_sources.yaml` — поле `license_adr` обязательное, отсутствие → exception при старте pipeline.
|
||||
- Дополнительная проверка в runtime: `license_adr` должен указывать на существующий файл; YAML frontmatter `status: accepted`. Иначе source skip с `status: skipped_license`.
|
||||
- Code review-чеклист в `12-review.md`: при добавлении source в `gps_sources.yaml` обязательна ссылка на accepted-ADR.
|
||||
- QA-кейс: `tests/api/test_gps_tracks_licensing_guard.py` — поднимает pipeline с `proposed`-ADR, проверяет что source пропускается.
|
||||
|
||||
## R-10 — Cache-clear endpoint доступен извне
|
||||
|
||||
- **Описание:** `POST /api/gps-tracks/cache/clear` сбрасывает LRU. Если эндпоинт доступен через `/enduro/` — атакующий может вызывать его в цикле, обнуляя кэш и заставляя сервер постоянно перегенерировать тайлы (DoS).
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- `07-infra-requirements.md` §3.1: nginx-правило `location = /enduro/api/gps-tracks/cache/clear { allow 172.16/12; deny all; }`.
|
||||
- Pipeline ↔ API дёргает endpoint напрямую через docker-сеть, минуя nginx → работает.
|
||||
- При появлении CSP-заголовка — `connect-src 'self'` блокирует внешние POST'ы из браузера (но это уже есть).
|
||||
|
||||
## R-11 — Pipeline зависает (вечная проблема скрейперов)
|
||||
|
||||
- **Описание:** Парсер одного источника попадает в бесконечный pagination loop или висит на медленном HTTP. Cron-job не завершается, следующий cron-тик попадает на ту же задачу.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- `httpx.AsyncClient(timeout=30)` — таймаут на каждый запрос.
|
||||
- Per-source максимум треков на прогон (`max_tracks_per_run` в `gps_sources.yaml`, default 5000) — стопорит pagination loop.
|
||||
- Cron-окно (3 дня между прогонами) > потенциального hang-окна; overlapping runs — два docker container'а, ресурсы изолированы; следующий cron не блокируется первым.
|
||||
- Опционально: `timeout 21600 docker compose ...` в cron — kill после 6 часов (REQ-NF-02). На MVP — не обязательно, но рекомендовано.
|
||||
|
||||
## R-12 — Несогласованность UI/style при `setStyle()`
|
||||
|
||||
- **Описание:** При переключении тёмной темы / спутника `map.setStyle()` сбрасывает все runtime-добавленные source/layer. `rebuildMapOverlays()` пересоздаёт; если порядок вызовов нарушен — слой публичных треков может оказаться поверх маршрута или ниже спутника.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- `restorePublicTracksState()` вызывается в `rebuildMapOverlays()` после `restoreTrailsState()`, до `restorePoiState()` и маршрута/GPX (TRZ REQ-F-19).
|
||||
- AC-12 «Переживание setStyle()» проверяет: чекбокс работает после смены темы.
|
||||
- Идемпотентные `if (!map.getSource(id)) map.addSource(...)` — паттерн из ADR-004 R-6.
|
||||
|
||||
## R-13 — Конфликт с ET-006 (личные GPX)
|
||||
|
||||
- **Описание:** ET-006 хранит личные GPX треки в `window.gpxTracks` и отображает как `gpx-layer-*`. Если ET-008 случайно использует тот же layer-id или event-handler — взаимная коллизия.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- Префикс `gps-tracks-*` для всех новых id (source, layer, halo) — конфликт исключён.
|
||||
- `window.gpsTracksLayer` ≠ `window.gpxTracks` (TRZ §4.4).
|
||||
- Z-order: `gps-tracks-layer-*` < `gpx-layer-*` (личные приоритетнее, как уточняется в TRZ §7.1).
|
||||
- AC-10 «Совместимость с ET-006» проверяет совместное отображение.
|
||||
|
||||
## R-14 — Конфликт с ET-007 (спутник + halo)
|
||||
|
||||
- **Описание:** ET-007 уже реализовал паттерн halo для trails на спутнике через `applyTrailHaloVisibility()` (ADR-004 §9). ET-008 добавляет два новых halo (`gps-tracks-halo-mvt-satellite`, `gps-tracks-halo-geo-satellite`) и расширяет `applyBaseLayer()`.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- Новые halo-слои добавляются в оба `style.json` / `style-dark.json` с `visibility: none` — по тому же паттерну ET-007.
|
||||
- `applyBaseLayer()` (ET-007) расширяется одним блоком (см. TRZ §7.2):
|
||||
```js
|
||||
const gpsHaloOn = (currentBase === 'satellite' && layerState.publicTracks);
|
||||
setLayoutProperty('gps-tracks-halo-mvt-satellite', 'visibility', gpsHaloOn && activeMode === 'mvt' ? 'visible' : 'none');
|
||||
setLayoutProperty('gps-tracks-halo-geo-satellite', 'visibility', gpsHaloOn && activeMode === 'geo' ? 'visible' : 'none');
|
||||
```
|
||||
- AC-11 «Halo на спутнике» проверяет.
|
||||
|
||||
## R-15 — Pipeline не находит зависимости (defusedxml, pyyaml)
|
||||
|
||||
- **Описание:** При смене образа без полного rebuild — `gps-collector` стартует с старым `requirements.txt` → ImportError.
|
||||
- **Вероятность / Влияние:** Н / Н.
|
||||
- **Митигация:**
|
||||
- Deploy-runbook (§7) явно требует `docker compose build` перед запуском нового pipeline.
|
||||
- CI-job собирает образ при каждом push → новые зависимости видны на CI, а не в production.
|
||||
|
||||
## R-16 — Атрибуция теряется при включении/выключении источников
|
||||
|
||||
- **Описание:** BRD-метрика «атрибуция каждого активного источника видна». При динамическом изменении набора enabled-источников (например, оператор временно выключил `ttrails` в `gps_sources.yaml`) клиент может продолжать показывать атрибуцию, потому что в БД уже есть треки с `sources_json` содержащим `ttrails`.
|
||||
- **Вероятность / Влияние:** Н / Н.
|
||||
- **Митигация:**
|
||||
- Атрибуция формируется на клиенте из `/api/gps-tracks/health.tracks_by_source` (только source с tracks_count > 0). Если в БД остались `ttrails` записи — атрибуция корректно отображает.
|
||||
- Если source удалён + треки удалены — `tracks_by_source` его не содержит → атрибуция корректно скрывается.
|
||||
- AC-15 проверяет.
|
||||
|
||||
## Сводная таблица
|
||||
|
||||
| ID | Риск | Вер. | Влияние | Класс | Статус |
|
||||
|---|---|---|---|---|---|
|
||||
| R-1 | Парсер ломается при смене HTML | В | С | Высокий | принят + per-source изоляция + алерт |
|
||||
| R-2 | Ложные коллизии dedup | С | С | Средний | принят + метрика BRD + план отступления |
|
||||
| R-3 | Pipeline повреждает БД | Н | В | Средний | atomic tx + ежедневный backup + rebuild за 6 ч |
|
||||
| R-4 | Размер БД > 2 ГБ | Н | С | Низкий | GC + health + миграция на PostGIS |
|
||||
| R-5 | IP mva154 банится | С | С | Средний | rate-limit + UA + backoff + отключение источника |
|
||||
| R-6 | Pipeline деградирует API | Н | С | Низкий | cgroup limits + WAL + ночное окно |
|
||||
| R-7 | Дублирование tile-утилит | С | Н | Низкий | принят + комментарии в коде + review-чеклист |
|
||||
| R-8 | GeoJSON SLA на плотных bbox | С | Н | Низкий | limit + zoom-cutoff + R-tree |
|
||||
| R-9 | Licensing-ADR не enforced | Н | В | Высокий | runtime-guard + Pydantic-валидация + тест |
|
||||
| R-10 | Cache-clear доступен извне | Н | С | Низкий | nginx allow/deny |
|
||||
| R-11 | Pipeline зависает | Н | С | Низкий | httpx timeout + max_tracks_per_run + (опц.) timeout cron |
|
||||
| R-12 | UI несогласован после setStyle | Н | С | Низкий | паттерн ADR-004 + AC-12 |
|
||||
| R-13 | Конфликт с ET-006 (GPX) | Н | С | Низкий | префикс + параллельные модели + AC-10 |
|
||||
| R-14 | Конфликт с ET-007 (halo) | Н | С | Низкий | новые halo по тому же паттерну + AC-11 |
|
||||
| R-15 | Зависимости pipeline | Н | Н | Низкий | CI-build + runbook |
|
||||
| R-16 | Атрибуция теряется | Н | Н | Низкий | health-derived rendering |
|
||||
|
||||
**Высокие классы:**
|
||||
- R-1 — операционный, ожидаемый для скрейп-источников; митигация — per-source изоляция и быстрое отключение через конфиг.
|
||||
- R-9 — критический для legal compliance; митигация многослойная (Pydantic + runtime check + тест).
|
||||
|
||||
**Блокирующих рисков нет.** R-1 и R-9 требуют внимания разработки и code review, но не блокируют merge.
|
||||
|
||||
## Эскалация
|
||||
|
||||
- **arch:major-change** — выставлен на ADR-005 (новая БД) и ADR-007 (новый сервис + cron). Требует архитектурного approve перед merge.
|
||||
- **back-to:analysis** — не требуется. ТЗ полное, BRD ясный, ADR-010 и ADR-011 явно блокирующие до закрытия licensing review (это операционный pre-requisite, не дефект анализа).
|
||||
542
docs/work-items/ET-008/12-review.md
Normal file
542
docs/work-items/ET-008/12-review.md
Normal file
@@ -0,0 +1,542 @@
|
||||
---
|
||||
type: code-review
|
||||
work_item_id: ET-008
|
||||
title: "Review: GPS-треки с публичных платформ на карте"
|
||||
version: 2
|
||||
status: REQUEST_CHANGES
|
||||
created_at: 2026-06-01
|
||||
updated_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:reviewer"
|
||||
reviewed_branch: feature/ET-008-gps
|
||||
base_branch: main
|
||||
reviewed_commits:
|
||||
- 0060003 "feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ"
|
||||
- 3734b98 "feat(ET-008): GPS tracks pipeline, API, frontend layer"
|
||||
verdict: REQUEST_CHANGES
|
||||
findings_summary:
|
||||
P0: 1
|
||||
P1: 4
|
||||
P2: 3
|
||||
P3: 4
|
||||
changelog:
|
||||
- "v1 (2026-06-01): REQUEST_CHANGES — на ветке отсутствовал код."
|
||||
- "v2 (2026-06-01): код появился (~7700 LOC). Проведено code-review против ТЗ v2, AC v2 и ADR-005..011."
|
||||
---
|
||||
|
||||
# Code Review — ET-008 (v2)
|
||||
|
||||
## Verdict: **REQUEST_CHANGES**
|
||||
|
||||
На ветке появилась реализация (commits `0060003`, `3734b98`): backend
|
||||
пакет `src/api/gps_tracks/`, pipeline `scripts/gps_collect.py`, фронт
|
||||
`src/web/gps_tracks.js`, миграция, YAML-конфиги, docker-compose сервис
|
||||
`gps-collector`, тесты, fixtures. Архитектурно сборка следует ADR-005…008
|
||||
и REQ-F-01…F-03. Однако обнаружено **одно блокирующее (P0) расхождение**
|
||||
с ТЗ, ломающее основной сценарий просмотра на детальном zoom, и
|
||||
несколько P1-несоответствий контракту API.
|
||||
|
||||
## Что проверено
|
||||
|
||||
1. ✅ `docs/work-items/ET-008/02-trz.md` v2 (draft) — REQ-F-01…F-20, REQ-NF-01…NF-07.
|
||||
2. ✅ `docs/work-items/ET-008/03-acceptance-criteria.md` v2 (draft) — AC-01…AC-17.
|
||||
3. ✅ `docs/work-items/ET-008/06-adr/` — ADR-005…011 (5 accepted, 2 proposed/blocking).
|
||||
4. ✅ `CLAUDE.md` — конвенции, фазы, правила для агентов.
|
||||
5. ✅ Git diff `main...feature/ET-008-gps` — 53 файла, +7705/-1218.
|
||||
6. ✅ Прочитан исходник backend (config.py, db.py, dedup.py, endpoint.py,
|
||||
mvt.py, models.py, sources/{base,osm,enduro_russia,ttrails}.py).
|
||||
7. ✅ Прочитан исходник frontend (gps_tracks.js целиком, изменения в
|
||||
app.js, app.css, index.html).
|
||||
8. ✅ Прочитана миграция, scripts/gps_collect.py, оба YAML, requirements.txt,
|
||||
docker-compose.yml.
|
||||
9. ✅ Прочитаны 4 тест-файла (dedup, endpoint, mvt, sources/osm) и fixtures.
|
||||
|
||||
## Findings
|
||||
|
||||
### F-01 [P0]: GeoJSON-слой полностью скрывается из-за рассогласования имён properties
|
||||
|
||||
**Severity:** P0 (blocker — нарушено REQ-F-13/F-14, AC-04, AC-08; основной
|
||||
визуальный сценарий слоя на z ≥ 12 не работает).
|
||||
|
||||
**Где:**
|
||||
- `src/api/gps_tracks/endpoint.py`, `_row_to_geojson_feature()` (стр. 51–84)
|
||||
- `src/web/gps_tracks.js`, `applyGpsFilter()` (стр. 246–267) и
|
||||
`_buildColorExpression()` (стр. 71–88)
|
||||
|
||||
**Что обнаружено:**
|
||||
|
||||
GeoJSON endpoint отдаёт в `properties` поля `activity_type` и `sources`
|
||||
(массив):
|
||||
|
||||
```python
|
||||
"properties": {
|
||||
...
|
||||
"activity_type": row["activity_type"],
|
||||
...
|
||||
"sources": sources, # list
|
||||
"external_urls": ext_urls,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
MVT-эндпойнт (правильно по ТЗ §4.3) отдаёт `activity` (скаляр) и `source`
|
||||
(скаляр первой sources):
|
||||
|
||||
```python
|
||||
props = {
|
||||
...
|
||||
"activity": row["activity_type"] or "other",
|
||||
"source": first_source,
|
||||
"sources": sources_str, # comma-string
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Клиентский фильтр в `applyGpsFilter()` использует **только** имена из
|
||||
MVT-схемы:
|
||||
|
||||
```js
|
||||
const filter = ['all',
|
||||
['in', ['get', 'activity'], ['literal', activities]],
|
||||
['in', ['get', 'source'], ['literal', sources]]
|
||||
];
|
||||
map.setFilter(window.gpsTracksLayer.layerGeoId, filter);
|
||||
```
|
||||
|
||||
Для feature из GeoJSON-source-а `get('activity')` и `get('source')`
|
||||
возвращают `null` → `['in', null, ['literal', […]]]` = `false` → **все
|
||||
features фильтруются из показа**. То же касается `line-color`:
|
||||
`_buildColorExpression('source')` matches по `['get', 'source']` →
|
||||
GeoJSON-features попадают в fallback `'#808080'`.
|
||||
|
||||
**Воспроизведение:**
|
||||
1. Включить чекбокс «Публичные треки».
|
||||
2. Увеличить zoom до 12+. (`_syncGpsLayersVisibility` делает
|
||||
`gps-tracks-layer-geo` видимым и скрывает MVT-слой.)
|
||||
3. На карте — ни одного публичного трека, хотя `/api/gps-tracks?bbox=…`
|
||||
отдаёт >0 features (`returned > 0`, `total_in_bbox > 0`). Toast
|
||||
«Показаны N треков из M…» возможен, но карта пустая.
|
||||
|
||||
**Ссылка на правило:** Reviewer severity «не реализовано требование ТЗ» → P0.
|
||||
Затронуты:
|
||||
- REQ-F-14 (фильтры мгновенно действуют через setFilter).
|
||||
- REQ-F-17 (стили `gps-tracks-layer` с paint `match`).
|
||||
- AC-04 Scenario «Поля feature.properties» — `feature.properties` обязан
|
||||
содержать `length_km`, см. также F-02 ниже.
|
||||
- AC-08 «Фильтрация по активности»: «на карте отображаются только enduro
|
||||
и moto треки» — невозможно, т.к. на z ≥ 12 ВСЕ треки отфильтрованы.
|
||||
|
||||
**Что починить:**
|
||||
Унифицировать contract. Один из двух вариантов:
|
||||
- (рекомендуется) В `_row_to_geojson_feature` добавить дублирующие
|
||||
поля `activity` (= `activity_type`) и `source` (= `sources[0]`) —
|
||||
не ломая существующих потребителей. Параллельно проверить попап:
|
||||
`_renderTrackPopupHtml` уже читает `props.activity_type || props.activity`
|
||||
— менять не нужно.
|
||||
- Либо переписать `applyGpsFilter`/`_buildColorExpression` так, чтобы они
|
||||
ветвились по `['has', 'activity']` vs `['get', 'activity_type']` + для
|
||||
source использовать `['in', 'osm', ['get', 'sources']]` через
|
||||
index-of / contains (MapLibre expressions поддерживают `in` с массивом
|
||||
правой части).
|
||||
|
||||
Тест, который должен поймать это (отсутствует):
|
||||
`tests/web/gps_tracks.test.js` — feature `{activity_type:'enduro'}` после
|
||||
`applyGpsFilter` остаётся видимой. Добавить в test-plan.
|
||||
|
||||
---
|
||||
|
||||
### F-02 [P1]: GeoJSON `feature.properties` отдаёт `length_m`, ТЗ требует `length_km`
|
||||
|
||||
**Severity:** P1 (must-fix — нарушение контракта API, описанного в
|
||||
REQ-F-10 и AC-04; UI-попап показывает «—»).
|
||||
|
||||
**Где:** `src/api/gps_tracks/endpoint.py`, `_row_to_geojson_feature()`
|
||||
(стр. 51–84).
|
||||
|
||||
**ТЗ REQ-F-10** (ст. 280–305):
|
||||
```json
|
||||
"properties": {
|
||||
"name": "...",
|
||||
"activity_type": "...",
|
||||
"user": "...",
|
||||
"created_at": "...",
|
||||
"length_km": 47.3,
|
||||
"sources": [...],
|
||||
"external_urls": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**AC-04 Scenario «Поля feature.properties»:**
|
||||
> Then каждая feature содержит: name, activity_type, user, created_at,
|
||||
> length_km, sources (array), external_urls (array)
|
||||
|
||||
**Имеется в коде:**
|
||||
```python
|
||||
"length_m": row["length_m"],
|
||||
# и нет ни одного "length_km"
|
||||
```
|
||||
|
||||
**Последствие:** В `_renderTrackPopupHtml` (`gps_tracks.js` стр. 325):
|
||||
```js
|
||||
const lengthKm = typeof props.length_km === 'number' ? props.length_km.toFixed(1) : '—';
|
||||
```
|
||||
для GeoJSON-feature (z ≥ 12) `props.length_km` всегда `undefined` → в
|
||||
попапе постоянно «📏 — км». MVT-features (z 8…11) показывают правильно,
|
||||
т.к. MVT-builder уже считает `length_km` (`mvt.py` стр. 148).
|
||||
|
||||
**Что починить:** добавить в properties `length_km` (как минимум).
|
||||
Поле `length_m` оставить, если используется. Аналогично уточнить
|
||||
`points_count` и `created_at` для popup (см. также F-04).
|
||||
|
||||
---
|
||||
|
||||
### F-03 [P1]: REQ-F-04 не реализован полностью — все OSM-треки сохраняются как `activity_type='other'`
|
||||
|
||||
**Severity:** P1 (must-fix — функциональный пробел; пользователь не
|
||||
сможет осмысленно использовать фильтр по активности, который явно
|
||||
закреплён в AC-08).
|
||||
|
||||
**Где:** `src/api/gps_tracks/sources/osm.py`, `_parse_gpx_trackpoints()`
|
||||
(стр. 154–284).
|
||||
|
||||
**ТЗ REQ-F-04** (ст. 119–144):
|
||||
> Для треков с gpx_id — дополнительный запрос
|
||||
> `GET /api/0.6/gpx/<id>` для метаданных (name, description, tags, user,
|
||||
> timestamp). Этот запрос делаем отложенно в batch: накопить 100 id →
|
||||
> запросить. Тип активности — из tags GPX-трека (`Tag: enduro/moto/mtb/hike/...`).
|
||||
|
||||
**Реализовано:**
|
||||
- batch-запрос метаданных НЕ сделан;
|
||||
- `name`, `description`, `tags`, `user` всегда `None`/`[]`;
|
||||
- `activity_type` явно зашит как `"other"`:
|
||||
```python
|
||||
track = TrackInsert(..., activity_type="other", ...)
|
||||
```
|
||||
- константа `OsmParser.MAPPING` (стр. 25–39) объявлена, но `map_activity`
|
||||
не вызывается — мёртвый код.
|
||||
|
||||
**Последствие:** в БД ВСЕ треки от OSM (единственного включённого
|
||||
источника) попадают как `activity_type='other'`. Фильтр по активности
|
||||
теряет смысл — пользователь видит только «Другое». AC-08 Scenario
|
||||
«Фильтрация по активности» не может пройти на реальных данных.
|
||||
|
||||
**Что починить:** реализовать batch-fetch на `/api/0.6/gpx/<id>` по
|
||||
накоплению ID, как описано в ТЗ. Использовать `self.map_activity(...)`
|
||||
для tags. Если решено отложить — оформить ADR/коммент с явной отметкой
|
||||
«частичная реализация REQ-F-04, follow-up tracked в …» и понизить
|
||||
ожидания AC-08 в ТЗ (но это работа аналитика, не разработчика).
|
||||
|
||||
---
|
||||
|
||||
### F-04 [P1]: Health endpoint не соответствует REQ-F-12 / AC-06
|
||||
|
||||
**Severity:** P1 (must-fix — нарушение контракта; AC-06 явно перечисляет
|
||||
обязательные поля, которых нет).
|
||||
|
||||
**Где:** `src/api/gps_tracks/endpoint.py`, `gps_health()` (стр. 196–232).
|
||||
|
||||
**ТЗ REQ-F-12 / AC-06** требуют поля:
|
||||
| Поле | Тип |
|
||||
| ------------------ | ------ |
|
||||
| `db_path` | str |
|
||||
| `db_size_mb` | float |
|
||||
| `tracks_total` | int |
|
||||
| `tracks_by_source` | dict |
|
||||
| `tracks_by_activity` | dict |
|
||||
| `last_pipeline_run` | object с полями started/finished/regions/sources_ok/sources_error |
|
||||
| `tile_cache_size` | int |
|
||||
|
||||
**Имеется:**
|
||||
```python
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_path": db_path,
|
||||
"total_tracks": total_tracks, # должно быть tracks_total
|
||||
"by_activity": by_activity, # должно быть tracks_by_activity
|
||||
"recent_pipeline_runs": recent_runs, # должно быть last_pipeline_run (объект)
|
||||
}
|
||||
```
|
||||
|
||||
Отсутствуют: `db_size_mb`, `tracks_by_source`, `tile_cache_size`.
|
||||
Переименованы: `tracks_total → total_tracks`, `tracks_by_activity →
|
||||
by_activity`, `last_pipeline_run → recent_pipeline_runs` (массив, не
|
||||
объект).
|
||||
|
||||
Также `recent_pipeline_runs` отдаёт «10 последних запусков», а ТЗ
|
||||
требует ОДИН последний (агрегированно). Это влияет на UI/админский
|
||||
view.
|
||||
|
||||
**Что починить:** привести JSON-схему к контракту. Минимум — добавить
|
||||
`tracks_by_source` (вычислить по `sources_json` агрегацией в Python или
|
||||
JSON_EACH в SQL), `db_size_mb` (через `os.path.getsize`), `tile_cache_size`
|
||||
(через `len(_gps_tile_cache)` из `mvt.py`), `last_pipeline_run` объект
|
||||
(берём первую строку из `pipeline_runs ORDER BY started_at DESC`,
|
||||
агрегируем `sources_ok`/`sources_error` по последнему region).
|
||||
|
||||
Tests `test_i40_health_endpoint` сейчас закреплены на текущей неправильной
|
||||
схеме — их тоже придётся обновить.
|
||||
|
||||
---
|
||||
|
||||
### F-05 [P1]: Z-order ET-006 не зафиксирован — `gps-tracks-layer` может оказаться выше `gpx-layer-*`
|
||||
|
||||
**Severity:** P1 (must-fix — нарушение REQ-F-17 paint requirements §7.1
|
||||
и AC-10 Scenario «личный трек визуально выше публичных»).
|
||||
|
||||
**Где:** `src/web/gps_tracks.js`, `_findGpsInsertPosition()` (стр. 191–196).
|
||||
|
||||
**ТЗ §7.1:**
|
||||
> На карте оба видны параллельно; z-order:
|
||||
> `gps-tracks-layer` < `gpx-layer-*` (личные треки выше).
|
||||
|
||||
**AC-10 Scenario «Совместимость с ET-006»:**
|
||||
> Then оба видны параллельно
|
||||
> And личный трек визуально выше публичных
|
||||
|
||||
**Имеется:**
|
||||
```js
|
||||
function _findGpsInsertPosition(map) {
|
||||
const style = map.getStyle && map.getStyle();
|
||||
if (!style || !style.layers) return undefined;
|
||||
const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-'));
|
||||
return routeLayer ? routeLayer.id : undefined;
|
||||
}
|
||||
```
|
||||
|
||||
Функция ищет только `route-*`. Если `gpx-layer-*` уже добавлен в стиль
|
||||
(ET-006), но route-line ещё нет, gps-tracks-layer добавится **в конец**
|
||||
(`before = undefined` → addLayer без beforeId → поверх всего стиля),
|
||||
**в том числе поверх gpx-layer**. Это нарушает обязательное правило
|
||||
из §7.1 / AC-10.
|
||||
|
||||
**Что починить:**
|
||||
```js
|
||||
function _findGpsInsertPosition(map) {
|
||||
const style = map.getStyle && map.getStyle();
|
||||
if (!style || !style.layers) return undefined;
|
||||
// Приоритет beforeId: gpx-layer-* (ET-006), затем route-* (если нет gpx).
|
||||
const gpxLayer = style.layers.find(l => l.id.startsWith('gpx-layer'));
|
||||
if (gpxLayer) return gpxLayer.id;
|
||||
const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-'));
|
||||
return routeLayer ? routeLayer.id : undefined;
|
||||
}
|
||||
```
|
||||
|
||||
Соответствующий unit-тест добавить в `tests/web/gps_tracks.test.js`
|
||||
(в test-plan он есть как WEB-INTEG).
|
||||
|
||||
---
|
||||
|
||||
### F-06 [P2]: Валидация bbox по площади отсутствует (REQ-NF-01)
|
||||
|
||||
**Severity:** P2 (should-fix).
|
||||
|
||||
**Где:** `src/api/gps_tracks/endpoint.py`, `_parse_bbox()` (стр. 17–48).
|
||||
|
||||
**ТЗ REQ-NF-01:**
|
||||
> Bbox-параметр валидируется (диапазон координат, площадь).
|
||||
|
||||
**Имеется:** валидация диапазона `[-180,180]/[-90,90]`, проверка
|
||||
`west<east`/`south<north`. Площадь не проверяется.
|
||||
|
||||
**Что починить:** добавить max площадь bbox (например, 10° × 10° = 100°²)
|
||||
для предотвращения «штормового» сканирования БД.
|
||||
|
||||
---
|
||||
|
||||
### F-07 [P2]: Дефолт `gps-tracks-sources` в localStorage включает отключённые источники
|
||||
|
||||
**Severity:** P2.
|
||||
|
||||
**Где:** `src/web/gps_tracks.js`, `window.gpsTracksLayer.filters.sources`
|
||||
(стр. 55).
|
||||
|
||||
```js
|
||||
sources: ['osm', 'enduro_russia', 'ttrails'],
|
||||
```
|
||||
|
||||
При том, что `config/gps_sources.yaml` явно держит `enduro_russia` и
|
||||
`ttrails` в `enabled: false` (соответствует ADR-010/011 status=proposed).
|
||||
ТЗ REQ-F-15 говорит дефолт = «все enabled». Не блокирует, но создаёт
|
||||
галки в UI для источников, по которым в БД ничего нет.
|
||||
|
||||
**Что починить:** инициализировать sources списком из
|
||||
`/api/gps-tracks/health.tracks_by_source.keys()` при отсутствии
|
||||
localStorage-ключа. Это естественно подтянется после F-04.
|
||||
|
||||
---
|
||||
|
||||
### F-08 [P2]: «LRU-кэш» в `mvt.py` — на самом деле FIFO
|
||||
|
||||
**Severity:** P2.
|
||||
|
||||
**Где:** `src/api/gps_tracks/mvt.py`, `_gps_tile_cache` (стр. 12–24).
|
||||
|
||||
```python
|
||||
def set_gps_cached_tile(z, x, y, data):
|
||||
if len(_gps_tile_cache) >= _GPS_TILE_CACHE_MAX:
|
||||
_gps_tile_cache.pop(next(iter(_gps_tile_cache))) # FIFO, не LRU
|
||||
_gps_tile_cache[(z, x, y)] = data
|
||||
```
|
||||
|
||||
ТЗ REQ-NF-04: «LRU-кэш в памяти процесса FastAPI: 1024 записи». Текущая
|
||||
реализация — FIFO. Для тайлов это особо ухудшает: часто запрашиваемый
|
||||
тайл, попавший в кэш первым, будет вытеснен раньше, чем редкий тайл,
|
||||
попавший позже.
|
||||
|
||||
**Что починить:** `from functools import lru_cache` нельзя из-за
|
||||
mutable invalidation; использовать `collections.OrderedDict` с
|
||||
`move_to_end` при чтении, либо `cachetools.LRUCache`. Совместимая идея —
|
||||
сделать `get_gps_cached_tile`:
|
||||
```python
|
||||
def get_gps_cached_tile(z, x, y):
|
||||
key = (z, x, y)
|
||||
if key in _gps_tile_cache:
|
||||
_gps_tile_cache.move_to_end(key) # OrderedDict
|
||||
return _gps_tile_cache[key]
|
||||
return None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### F-09 [P3]: Мёртвое поле конфигурации `save_user_field`
|
||||
|
||||
**Severity:** P3 (nice-to-have).
|
||||
|
||||
**Где:** `config/gps_sources.yaml` — поле задано (`save_user_field:
|
||||
true|false`); ни один модуль его не читает. AC-16 Scenario «Pipeline не
|
||||
сохраняет запрещённые поля» рассчитывает, что это поле уважается.
|
||||
|
||||
**Что починить:** в `OsmParser`/upsert обрабатывать `save_user_field=false`
|
||||
→ `user=None`. Сейчас OSM всё равно ставит `user=None` (см. F-03), но
|
||||
поле должно работать как контракт для будущих источников.
|
||||
|
||||
---
|
||||
|
||||
### F-10 [P3]: Лишний импорт `pytest_asyncio` в `tests/api/test_gps_tracks_endpoint.py`
|
||||
|
||||
**Severity:** P3.
|
||||
|
||||
`import pytest_asyncio` есть, но `@pytest_asyncio.fixture` нигде не
|
||||
используется (только `@pytest.mark.asyncio`). Не блокирует, но в чистом
|
||||
коде убирается.
|
||||
|
||||
---
|
||||
|
||||
### F-11 [P3]: `MockRow(dict)` в `tests/api/test_gps_tracks_mvt.py`
|
||||
|
||||
**Severity:** P3.
|
||||
|
||||
Тесты используют `class MockRow(dict)` как замену `sqlite3.Row`. Работает
|
||||
для текущего кода, но `sqlite3.Row` не поддерживает `__contains__` так
|
||||
же, как dict. Если в `mvt.py` появится `if "x" in row:`, тесты
|
||||
разойдутся с продом. Безопаснее использовать `sqlite3.Row` напрямую через
|
||||
`open_db + INSERT + fetchone()`.
|
||||
|
||||
---
|
||||
|
||||
### F-12 [P3]: Лишняя проверка `"source_priority" in existing.keys()` в `db.py`
|
||||
|
||||
**Severity:** P3.
|
||||
|
||||
`src/api/gps_tracks/db.py` стр. 116:
|
||||
```python
|
||||
existing_priority = existing["source_priority"] if "source_priority" in existing.keys() else 999
|
||||
```
|
||||
Колонка `source_priority` объявлена в миграции (`migrations/gps_tracks_001_init.sql`
|
||||
ст. 22) с `NOT NULL DEFAULT 999`. Проверка избыточна (read-protection
|
||||
осталась от какой-то ранней итерации). Лучше убрать — иначе создаётся
|
||||
впечатление, что колонка опциональна.
|
||||
|
||||
---
|
||||
|
||||
## Соответствие ADR
|
||||
|
||||
| ADR | Status | Соблюдение в коде | Замечания |
|
||||
|-----|--------|-------------------|-----------|
|
||||
| ADR-005 storage-schema | accepted | ✅ соблюдено | таблица `tracks` (+`source_priority`), индексы и `pipeline_runs` совпадают |
|
||||
| ADR-006 dedup-algorithm | accepted | ✅ соблюдено | `compute_dedup_key` 1-в-1; покрыт unit-тестами U-10…U-14 |
|
||||
| ADR-007 pipeline-architecture | accepted | ✅ соблюдено | сервис `gps-collector` с `profiles:["batch"]`, license-guard в `_check_license_adr` |
|
||||
| ADR-008 tile-vs-geojson | accepted | ⚠️ частично | переключение по zoom есть; но contract фич GeoJSON vs MVT расходится (см. F-01) |
|
||||
| ADR-009 osm-licensing | accepted | ✅ соблюдено | attribution в source, рабочий парсер |
|
||||
| ADR-010 enduro-russia (proposed) | proposed | ✅ соблюдено | parser — заглушка, `enabled: false`, license-guard сработает |
|
||||
| ADR-011 ttrails (proposed) | proposed | ✅ соблюдено | то же |
|
||||
|
||||
License-guard в `scripts/gps_collect.py` `_check_license_adr()` корректно
|
||||
читает YAML front-matter ADR. Покрытия unit-тестом нет — рекомендую
|
||||
добавить (`test_pipeline_skips_unaccepted_source`).
|
||||
|
||||
## Тесты — оценка
|
||||
|
||||
| Тест-файл | Покрытие test-plan | Качество |
|
||||
|-----------|-------------------|----------|
|
||||
| `test_gps_tracks_dedup.py` | U-10…U-14 | ✅ хорошо |
|
||||
| `test_gps_tracks_mvt.py` | U-50…U-52 | ✅ адекватно (см. F-11) |
|
||||
| `test_gps_tracks_endpoint.py` | I-20…I-23, I-30…I-31, I-40 | ⚠️ AC-06 не покрыт корректно — тест зафиксирован на неверной схеме (F-04) |
|
||||
| `test_gps_tracks_sources_osm.py` | U-42…U-44 | ✅ defusedxml проверен фикстурой xxe-payload.gpx |
|
||||
|
||||
**Не покрыто тестами:**
|
||||
- `tests/web/gps_tracks.test.js` — заявлен в ТЗ §5 ст. 787 и
|
||||
`04-test-plan.yaml`, ОТСУТСТВУЕТ. Был бы первой защитой от F-01.
|
||||
- pipeline (`scripts/gps_collect.py`) — нет ни одного теста на
|
||||
`_check_license_adr`, `_collect_source_for_region`, dry-run, exit-code.
|
||||
AC-02 Scenarios «Падение одного источника не валит остальные» и
|
||||
«Dry-run» по факту не верифицированы.
|
||||
- bbox area-валидация — отсутствует и в коде (F-06), и в тестах.
|
||||
|
||||
## Замечания, не доходящие до P-finding
|
||||
|
||||
- `_simplify_coords` в `mvt.py` дублирует `simplify_coords` из `src/api/main.py`.
|
||||
Не критично, но напрашивается общая утилита.
|
||||
- `src/api/main.py` ст. 17–20: `GPS_TRACKS_DB_PATH` вычисляется **до**
|
||||
импорта shapely/fastapi, в строгом смысле это «нечистый» импорт-time
|
||||
side-effect. Не блок.
|
||||
- В `endpoint.py` `init_db` вызывается на каждый запрос (`_get_conn`).
|
||||
Это означает, что `executescript` выполняется на каждый запрос. SQL
|
||||
использует `IF NOT EXISTS`, так что функционально ок, но это лишний
|
||||
I/O. Рекомендую инициализировать БД один раз при `create_gps_router`.
|
||||
- `src/api/main.py` ст. 1255: `app.include_router(gps_router)` хорошо
|
||||
встроено перед `StaticFiles` mount — порядок правильный.
|
||||
|
||||
## Воспроизведение P0 для разработчика
|
||||
|
||||
```bash
|
||||
# 1. Запустить с тестовой БД (см. test_i20_geojson_basic):
|
||||
pytest tests/api/test_gps_tracks_endpoint.py::test_i20_geojson_basic -q
|
||||
|
||||
# 2. Вытащить feature.properties — наблюдать "activity_type", "sources" (list),
|
||||
# отсутствие "length_km".
|
||||
|
||||
# 3. Открыть DevTools в браузере на dev-стенде, проверить:
|
||||
window._map.queryRenderedFeatures({layers:['gps-tracks-layer-geo']})
|
||||
# → пустой массив на z >= 12, потому что setFilter с ['get','activity'] всё скрыл.
|
||||
|
||||
# 4. Временный обход для отладки:
|
||||
window._map.setFilter('gps-tracks-layer-geo', null)
|
||||
# → треки появятся, но серые (line-color fallback) — что подтверждает F-01.
|
||||
```
|
||||
|
||||
## Рекомендация для CI
|
||||
|
||||
- `pytest tests/api/` сейчас зелёный, потому что:
|
||||
- `test_i40_health_endpoint` фиксирует текущую неправильную схему
|
||||
(F-04) → нужно поправить вместе с фиксом;
|
||||
- frontend-тесты отсутствуют (F-01 не отлавливается).
|
||||
- `make lint` ожидаемо проходит.
|
||||
- Перед закрытием задачи CI должен прогонять и frontend-тесты
|
||||
(`tests/web/gps_tracks.test.js`), которые на данный момент не написаны.
|
||||
- Не закрывать ET-008 в Plane как Done.
|
||||
|
||||
## Итог
|
||||
|
||||
**REQUEST_CHANGES.** Минимальный объём правок:
|
||||
1. F-01 (P0) — унифицировать имена properties между MVT и GeoJSON, +
|
||||
web-тест на `applyGpsFilter`.
|
||||
2. F-02 (P1) — добавить `length_km` в GeoJSON.
|
||||
3. F-03 (P1) — реализовать batch-fetch `/api/0.6/gpx/<id>` и mapping
|
||||
`activity_type` (или явная декларация частичной реализации REQ-F-04).
|
||||
4. F-04 (P1) — выровнять `gps_health()` под REQ-F-12 / AC-06.
|
||||
5. F-05 (P1) — корректный `beforeId` в `_findGpsInsertPosition`.
|
||||
|
||||
После исправлений P0/P1 — повторить ревью; P2/P3 могут пройти отдельным
|
||||
PR follow-up.
|
||||
209
docs/work-items/ET-008/13-test-report.md
Normal file
209
docs/work-items/ET-008/13-test-report.md
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ET-008
|
||||
title: "Test Report: GPS-треки с публичных платформ на карте"
|
||||
version: 3
|
||||
status: pass
|
||||
created_at: 2026-06-01
|
||||
updated_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:tester"
|
||||
tested_branch: feature/ET-008-gps
|
||||
tested_commits:
|
||||
- 1ffa178 "fix(gps-tracks): aggregate last_pipeline_run in health endpoint (REQ-F-12)"
|
||||
- ba356ae "fix(gps-tracks): rename health fields and fix layer insert priority (F-04, F-05)"
|
||||
- edbe9a3 "fix(gps-tracks): normalise GeoJSON props, add health fields, OSM meta fetch, z-order fix"
|
||||
- 3734b98 "feat(ET-008): GPS tracks pipeline, API, frontend layer"
|
||||
verdict: stage:ready-to-deploy
|
||||
---
|
||||
|
||||
# Test Report — ET-008: GPS-треки с публичных платформ на карте (v3)
|
||||
|
||||
## Вердикт: **stage:ready-to-deploy**
|
||||
|
||||
Коммит `1ffa178` закрывает последний P1-дефект (F-04 — структура
|
||||
`last_pipeline_run`). Все 141 pytest и 22 JS unit-теста зелёные. Все
|
||||
P0/P1 находки из code-review v2 устранены. E2E и UI-тесты пропущены по
|
||||
инфраструктурным причинам (бэкенд ET-008 не задеплоен на тест-стенд,
|
||||
UI-раннер `/home/slin/tools/ui-test/run_tests.js` недоступен) — это не
|
||||
дефект кода; рекомендуется выполнить E2E-прогон сразу после деплоя.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1 — Проверка окружения
|
||||
|
||||
| Endpoint | Статус | Детали |
|
||||
|---|---|---|
|
||||
| `GET /enduro/api/health` | ✅ 200 OK | `{"status":"ok","db_exists":true}` |
|
||||
| `GET /enduro/api/gps-tracks/health` | ❌ 404 | ET-008 не задеплоен на стенд |
|
||||
| `GET /enduro/api/gps-tracks?bbox=…` | ❌ 404 | ET-008 не задеплоен на стенд |
|
||||
| `GET /enduro/api/gps-tracks/tiles/…mvt` | ❌ 404 | ET-008 не задеплоен на стенд |
|
||||
| Фронтенд (HTML) | ✅ | `#public-tracks-cb`, `#sheet-gps-filters`, `gps_tracks.js` в разметке |
|
||||
|
||||
Бэкенд-роуты `/api/gps-tracks/*` возвращают 404 — статика ET-008
|
||||
задеплоена, сервис не поднят. E2E и UI тесты выполнить невозможно до
|
||||
деплоя.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2 — Функциональные тесты (`python -m pytest tests/ -v`)
|
||||
|
||||
```
|
||||
cd /repos/enduro-trails/src/api
|
||||
python -m pytest ../../tests/ -v --tb=short
|
||||
```
|
||||
|
||||
**Результат: 141 passed, 0 failed, 7 warnings**
|
||||
|
||||
| Сюита | Тестов | Результат |
|
||||
|---|---|---|
|
||||
| `tests/api/test_gps_tracks_dedup.py` | 8 | ✅ PASS |
|
||||
| `tests/api/test_gps_tracks_endpoint.py` | 15 | ✅ PASS |
|
||||
| `tests/api/test_gps_tracks_mvt.py` | 9 | ✅ PASS |
|
||||
| `tests/api/test_gps_tracks_sources_osm.py` | 21 | ✅ PASS |
|
||||
| `tests/integration/test_routing_barriers.py` | 7 | ✅ PASS |
|
||||
| `tests/unit/test_base_layer.py` | 22 | ✅ PASS |
|
||||
| `tests/unit/test_gpx_upload.py` | 21 | ✅ PASS |
|
||||
| `tests/unit/test_health.py` | 1 | ✅ PASS |
|
||||
| `tests/unit/test_poi_toggle.py` | 10 | ✅ PASS |
|
||||
| `tests/unit/test_unit_toggle.py` | 18 | ✅ PASS |
|
||||
| `tests/web/test_gps_tracks.py` | 9 | ✅ PASS |
|
||||
|
||||
Предупреждения (7 шт.) — `DeprecationWarning` в `mapbox_vector_tile.encode`
|
||||
(внешняя библиотека, некритично).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3 — E2E тесты (Playwright)
|
||||
|
||||
**SKIP** — бэкенд ET-008 не задеплоен на тест-стенд; Playwright-сценарии
|
||||
E-01, E-02 (pipeline smoke) и E-10…E-12 (UI-фильтры) выполнить
|
||||
невозможно. Рекомендуется запустить после `make deploy-test`.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4 — JS Unit-тесты (`node --test`)
|
||||
|
||||
```
|
||||
node --test tests/web/gps_tracks.test.js
|
||||
```
|
||||
|
||||
**Результат: 22 passed, 0 failed**
|
||||
|
||||
| Группа | Тестов | Результат |
|
||||
|---|---|---|
|
||||
| F-05: `_findGpsInsertPosition` — приоритет слоёв | 9 | ✅ PASS |
|
||||
| Filters: начальное состояние `window.gpsTracksLayer` | 5 | ✅ PASS |
|
||||
| Colors: палитра источников, активностей, fallback | 8 | ✅ PASS |
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5 — UI / Visual тесты (TC-UI-01…TC-UI-20)
|
||||
|
||||
**SKIP** — `/home/slin/tools/ui-test/run_tests.js` недоступен; бэкенд
|
||||
ET-008 не отвечает на тест-стенде. Скриншоты TC-UI-01…TC-UI-20 не
|
||||
сделаны.
|
||||
|
||||
---
|
||||
|
||||
## Верификация фиксов из `12-review.md`
|
||||
|
||||
### Итоговая таблица
|
||||
|
||||
| Finding | Severity | v2 | v3 | Вердикт |
|
||||
|---|---|---|---|---|
|
||||
| F-01: GeoJSON props несовместимы с MVT | P0 | PASS | PASS | ✅ PASS |
|
||||
| F-02: `length_m` вместо `length_km` в GeoJSON | P1 | PASS | PASS | ✅ PASS |
|
||||
| F-03: OSM batch-fetch и activity_type не реализованы | P1 | PASS | PASS | ✅ PASS |
|
||||
| F-04: Health endpoint несовместим с REQ-F-12 | P1 | ⚠️ WARN | PASS | ✅ PASS |
|
||||
| F-05: Z-order vs `gpx-layer-*` | P1 | PASS | PASS | ✅ PASS |
|
||||
| `tests/web/gps_tracks.test.js` отсутствует | — | PASS | PASS | ✅ PASS |
|
||||
| F-06: Нет валидации площади bbox | P2 | follow-up | follow-up | ⏭ follow-up |
|
||||
| F-07: Дефолт sources включает disabled | P2 | follow-up | follow-up | ⏭ follow-up |
|
||||
| F-08: LRU-кэш — на самом деле FIFO | P2 | follow-up | follow-up | ⏭ follow-up |
|
||||
| F-09…F-12: P3-находки | P3 | follow-up | follow-up | ⏭ follow-up |
|
||||
|
||||
---
|
||||
|
||||
### F-04 [P1] → ✅ **PASS** (v2: ⚠️ WARN)
|
||||
|
||||
Коммит `1ffa178` реализует агрегацию строк `pipeline_runs` по
|
||||
`MAX(started_at)` в полный контракт REQ-F-12:
|
||||
|
||||
```python
|
||||
# endpoint.py, gps_health()
|
||||
cur.execute("""
|
||||
SELECT started_at, finished_at, region_id, source_id,
|
||||
status, tracks_new, errors_json
|
||||
FROM pipeline_runs
|
||||
WHERE started_at = (SELECT MAX(started_at) FROM pipeline_runs)
|
||||
ORDER BY region_id, source_id
|
||||
""")
|
||||
# → агрегация в:
|
||||
{
|
||||
"started_at": "...", "finished_at": "...",
|
||||
"regions": ["tsfo_plus_chuvashia"],
|
||||
"sources_ok": ["osm", "enduro_russia"],
|
||||
"sources_error": [{"source": "ttrails", ...}],
|
||||
"tracks_added": 100
|
||||
}
|
||||
```
|
||||
|
||||
Тест `test_i40_health_endpoint` (обновлён) проверяет:
|
||||
- наличие всех 6 обязательных полей (`started_at`, `finished_at`,
|
||||
`regions`, `sources_ok`, `sources_error`, `tracks_added`);
|
||||
- типы (`list`, `int`);
|
||||
- отсутствие сырых полей БД (`region_id`, `source_id`);
|
||||
- конкретные агрегированные значения из фикстуры (2 региона,
|
||||
2 ok-источника).
|
||||
|
||||
`test_i40_health_empty_db` подтверждает: при пустой БД — `last_pipeline_run: null`.
|
||||
|
||||
---
|
||||
|
||||
### Детали: что проверяют ключевые тесты ET-008
|
||||
|
||||
| Тест-ID | Связанный AC / REQ | Что проверяется |
|
||||
|---|---|---|
|
||||
| `test_f01_f02_geojson_normalised_properties` | AC-04, REQ-F-10 | GeoJSON `activity`, `source`, `length_km`, `activity_type` |
|
||||
| `test_i20_filter_by_activity` | AC-04 | фильтр `?activity=enduro` возвращает только enduro |
|
||||
| `test_i20_filter_by_source` | AC-04 | фильтр `?source=osm` возвращает только OSM |
|
||||
| `test_i21_truncation` | AC-04 | `truncated=true`, `returned=500`, `total_in_bbox=1500` |
|
||||
| `test_i22_invalid_bbox_returns_400` (7 param) | AC-04 | 400 на невалидные bbox |
|
||||
| `test_i30_mvt_tile_returns` | AC-05 | `200 application/x-protobuf`, layer `gps_tracks` |
|
||||
| `test_i31_cache_hit` | AC-05, REQ-NF-04 | `X-Cache: HIT` на повторный запрос |
|
||||
| `test_i40_health_endpoint` | AC-06, REQ-F-12 | все поля health, агрегированный `last_pipeline_run` |
|
||||
| `test_u13_merge_sources_on_upsert` | AC-03, REQ-F-08 | дедупликация: union sources |
|
||||
| `test_u44_xxe_protection` | REQ-NF-01 | defusedxml блокирует XXE |
|
||||
| `test_u45_meta_response_with_known_tag` | REQ-F-04, REQ-F-07 | OSM tag → `activity_type` |
|
||||
| `test_gps_tracks_find_insert_position_priority_gpx_first` | AC-10, §7.1 | gpx-layer-* > route-* |
|
||||
|
||||
---
|
||||
|
||||
## Открытые P2/P3 — follow-up (не меняют вердикт)
|
||||
|
||||
| Finding | Severity | Описание | Рекомендация |
|
||||
|---|---|---|---|
|
||||
| F-06 | P2 | Нет проверки площади bbox | Добавить max area ≤ 100°² в `_parse_bbox()` |
|
||||
| F-07 | P2 | Дефолт sources содержит disabled-источники | Инициализировать из `/api/gps-tracks/health.tracks_by_source` |
|
||||
| F-08 | P2 | LRU-кэш — на самом деле FIFO | `OrderedDict` с `move_to_end` при чтении |
|
||||
| F-09 | P3 | `save_user_field` в YAML не читается кодом | Обработать в upsert |
|
||||
| F-10 | P3 | Лишний `import pytest_asyncio` | Убрать |
|
||||
| F-11 | P3 | `MockRow(dict)` вместо `sqlite3.Row` | Рефактор тестов |
|
||||
| F-12 | P3 | Лишняя проверка `"source_priority" in existing.keys()` | Упростить |
|
||||
|
||||
---
|
||||
|
||||
## Рекомендации для деплоя
|
||||
|
||||
После выполнения `make deploy-test` или `docker compose up -d` на тест-стенде
|
||||
с веткой `feature/ET-008-gps`:
|
||||
|
||||
1. **Smoke API:**
|
||||
```bash
|
||||
curl https://openclaw.mva154.duckdns.org/enduro/api/gps-tracks/health
|
||||
curl "https://openclaw.mva154.duckdns.org/enduro/api/gps-tracks?bbox=37.0,55.0,38.0,56.0&limit=5"
|
||||
```
|
||||
2. **E2E Playwright:** E-01 (pipeline smoke), E-02 (dedup), E-10…E-12 (filters).
|
||||
3. **UI тесты:** TC-UI-01…TC-UI-20 через `run_tests.js` (при наличии раннера).
|
||||
4. **P2 follow-up** можно закрыть отдельным PR после приёмки основного.
|
||||
52
docs/work-items/ET-008/14-deploy-log.md
Normal file
52
docs/work-items/ET-008/14-deploy-log.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Deploy Log — ET-008
|
||||
|
||||
- **Version:** v0.0.1
|
||||
- **Date:** 2026-06-01 14:32 UTC
|
||||
- **PR:** #12
|
||||
- **Branch:** feature/ET-008-gps
|
||||
- **Environment:** test (https://openclaw.mva154.duckdns.org/enduro/)
|
||||
- **Merge commit:** 04d9d3e
|
||||
- **Healthcheck:** PASS (HTTP 200, attempt 1/12)
|
||||
- **Smoke:** PARTIAL PASS
|
||||
- **Status:** SUCCESS (frontend deployed; backend service pending)
|
||||
|
||||
## Smoke results
|
||||
|
||||
| Check | Result | Notes |
|
||||
|---|---|---|
|
||||
| `GET /enduro/` | ✅ 200 | index.html |
|
||||
| `GET /enduro/app.js` | ✅ 200 | core frontend |
|
||||
| `GET /enduro/app.css` | ✅ 200 | styles |
|
||||
| `GET /enduro/gps_tracks.js` | ✅ 200 | **новый модуль ET-008** |
|
||||
| `GET /enduro/units.js` | ✅ 200 | |
|
||||
| `GET /enduro/gpx.js` | ✅ 200 | |
|
||||
| `GET /enduro/api/health` | ✅ 200 | `{"status":"ok","db_exists":true}` |
|
||||
| `GET /enduro/api/gps-tracks/health` | ⚠️ 404 | backend-сервис `gps-collector` не поднят |
|
||||
|
||||
## Что задеплоено
|
||||
|
||||
- **Frontend:** `src/web/gps_tracks.js` — новый модуль GPS-треков (588 строк)
|
||||
- **Frontend:** изменения в `app.js`, `app.css`, `index.html` (чекбокс, фильтр-панель)
|
||||
- **Backend:** `src/api/gps_tracks/` — пакет API (endpoint, mvt, db, dedup, models, sources)
|
||||
- **Migration:** `migrations/gps_tracks_001_init.sql`
|
||||
- **Scripts:** `scripts/gps_collect.py` — pipeline сбора треков
|
||||
- **Config:** `config/gps_sources.yaml`, `config/gps_regions.yaml`
|
||||
- **Docker:** новый сервис `gps-collector` в `docker-compose.yml`
|
||||
- **Tests:** 141 pytest + 22 JS unit (все зелёные на ветке)
|
||||
|
||||
## Pending actions
|
||||
|
||||
1. **Backend service start:** запустить `docker compose up -d gps-collector` на хосте
|
||||
для активации `/api/gps-tracks/*` эндпойнтов.
|
||||
2. **E2E Playwright:** после старта сервиса выполнить E-01, E-02, E-10…E-12.
|
||||
3. **Initial pipeline run:** `docker compose run --rm gps-collector python scripts/gps_collect.py`
|
||||
для первичной загрузки OSM-треков.
|
||||
4. **P2 follow-up:** F-06 (bbox area validation), F-07 (default sources), F-08 (LRU cache)
|
||||
— отдельный PR.
|
||||
|
||||
## Test report reference
|
||||
|
||||
`docs/work-items/ET-008/13-test-report.md` v3 — verdict: `stage:ready-to-deploy`
|
||||
- 141 pytest PASS
|
||||
- 22 JS unit PASS
|
||||
- All P0/P1 findings resolved (F-01…F-05)
|
||||
40
migrations/gps_tracks_001_init.sql
Normal file
40
migrations/gps_tracks_001_init.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dedup_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
activity_type TEXT,
|
||||
user TEXT,
|
||||
created_at TEXT,
|
||||
length_m REAL NOT NULL,
|
||||
points_count INTEGER NOT NULL,
|
||||
min_lon REAL NOT NULL,
|
||||
min_lat REAL NOT NULL,
|
||||
max_lon REAL NOT NULL,
|
||||
max_lat REAL NOT NULL,
|
||||
geom BLOB NOT NULL,
|
||||
sources_json TEXT NOT NULL,
|
||||
external_urls_json TEXT NOT NULL,
|
||||
tags_json TEXT,
|
||||
inserted_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source_priority INTEGER NOT NULL DEFAULT 999
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tracks_dedup ON tracks(dedup_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_activity ON tracks(activity_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_created ON tracks(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_bbox ON tracks(min_lon, max_lon, min_lat, max_lat);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pipeline_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
region_id TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
tracks_new INTEGER DEFAULT 0,
|
||||
tracks_updated INTEGER DEFAULT 0,
|
||||
errors_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pipeline_started ON pipeline_runs(started_at);
|
||||
366
scripts/gps_collect.py
Normal file
366
scripts/gps_collect.py
Normal file
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI pipeline для сбора GPS-треков из публичных источников (ET-008).
|
||||
|
||||
Usage:
|
||||
python scripts/gps_collect.py [--region <id>] [--source <id>] [--dry-run] [--gc]
|
||||
|
||||
Exit code: 0 (success) or 1 (any error/skip)
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Добавляем корень проекта в PYTHONPATH
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from src.api.gps_tracks.config import load_regions_config, load_sources_config
|
||||
from src.api.gps_tracks.db import init_db, open_db, upsert_track
|
||||
from src.api.gps_tracks.dedup import compute_dedup_key
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
logger = logging.getLogger("gps_collect")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _check_license_adr(adr_path: str, project_root: str) -> str:
|
||||
"""Читает ADR файл и возвращает статус ('accepted', 'proposed', ...).
|
||||
|
||||
Returns:
|
||||
str статус или 'unknown' если файл не найден/не парсится
|
||||
"""
|
||||
full_path = os.path.join(project_root, adr_path)
|
||||
if not os.path.exists(full_path):
|
||||
logger.warning("ADR file not found: %s", full_path)
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Ищем YAML front-matter или поле status
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
front_matter = yaml.safe_load(parts[1])
|
||||
if isinstance(front_matter, dict) and "status" in front_matter:
|
||||
return str(front_matter["status"]).lower()
|
||||
|
||||
# Fallback: ищем строку "status: <value>"
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip().lower()
|
||||
if stripped.startswith("status:"):
|
||||
value = stripped.split(":", 1)[1].strip()
|
||||
return value
|
||||
|
||||
return "unknown"
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to parse ADR %s: %s", adr_path, exc)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _record_pipeline_run(
|
||||
conn,
|
||||
region_id: str,
|
||||
source_id: str,
|
||||
started_at: str,
|
||||
finished_at: str,
|
||||
status: str,
|
||||
tracks_new: int = 0,
|
||||
tracks_updated: int = 0,
|
||||
errors: list = None,
|
||||
) -> None:
|
||||
"""Записывает результат запуска pipeline в БД."""
|
||||
errors_json = json.dumps(errors) if errors else None
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO pipeline_runs
|
||||
(started_at, finished_at, region_id, source_id, status,
|
||||
tracks_new, tracks_updated, errors_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
started_at,
|
||||
finished_at,
|
||||
region_id,
|
||||
source_id,
|
||||
status,
|
||||
tracks_new,
|
||||
tracks_updated,
|
||||
errors_json,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def _collect_source_for_region(
|
||||
region: dict,
|
||||
source_cfg: dict,
|
||||
conn,
|
||||
dry_run: bool,
|
||||
) -> dict:
|
||||
"""Запускает сбор треков для одного (region, source).
|
||||
|
||||
Returns:
|
||||
dict с ключами: status, tracks_new, tracks_updated, errors
|
||||
"""
|
||||
source_id = source_cfg["id"]
|
||||
region_id = region["id"]
|
||||
bbox = tuple(region["bbox"]) # (west, south, east, north)
|
||||
|
||||
parser_module_path = source_cfg.get("parser_module", "")
|
||||
if not parser_module_path:
|
||||
return {"status": "error", "tracks_new": 0, "tracks_updated": 0, "errors": ["No parser_module"]}
|
||||
|
||||
try:
|
||||
module = importlib.import_module(parser_module_path)
|
||||
# Конвенция: класс называется <CamelCase>Parser
|
||||
class_name = source_id.replace("_", " ").title().replace(" ", "") + "Parser"
|
||||
parser_class = getattr(module, class_name, None)
|
||||
if parser_class is None:
|
||||
# Fallback: первый класс с суффиксом Parser
|
||||
for name in dir(module):
|
||||
if name.endswith("Parser") and name != "SourceParser":
|
||||
parser_class = getattr(module, name)
|
||||
break
|
||||
|
||||
if parser_class is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"tracks_new": 0,
|
||||
"tracks_updated": 0,
|
||||
"errors": [f"Parser class not found in {parser_module_path}"],
|
||||
}
|
||||
|
||||
parser = parser_class(source_cfg)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"tracks_new": 0,
|
||||
"tracks_updated": 0,
|
||||
"errors": [f"Failed to load parser: {exc}"],
|
||||
}
|
||||
|
||||
tracks_new = 0
|
||||
tracks_updated = 0
|
||||
errors = []
|
||||
source_priority = source_cfg.get("source_priority", 50)
|
||||
|
||||
try:
|
||||
async for track in parser.collect(bbox, {"dry_run": dry_run, "conn": conn}):
|
||||
if dry_run:
|
||||
logger.info("[dry-run] Would upsert track from %s: %s", source_id, track.external_id)
|
||||
tracks_new += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
dedup_key = compute_dedup_key(
|
||||
(track.min_lon, track.min_lat, track.max_lon, track.max_lat),
|
||||
{"length_m": track.length_m, "created_at": track.created_at},
|
||||
)
|
||||
result = upsert_track(conn, track, dedup_key, source_priority)
|
||||
if result == "inserted":
|
||||
tracks_new += 1
|
||||
else:
|
||||
tracks_updated += 1
|
||||
except Exception as exc:
|
||||
errors.append(f"upsert error for {track.external_id}: {exc}")
|
||||
logger.error("Upsert error: %s", exc)
|
||||
except NotImplementedError as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"tracks_new": 0,
|
||||
"tracks_updated": 0,
|
||||
"errors": [str(exc)],
|
||||
}
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
logger.error("Collect error for %s/%s: %s", region_id, source_id, exc)
|
||||
return {
|
||||
"status": "error",
|
||||
"tracks_new": tracks_new,
|
||||
"tracks_updated": tracks_updated,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
status = "ok" if not errors else "partial"
|
||||
return {
|
||||
"status": status,
|
||||
"tracks_new": tracks_new,
|
||||
"tracks_updated": tracks_updated,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
"""Главная функция pipeline сбора GPS-треков."""
|
||||
parser = argparse.ArgumentParser(description="GPS tracks collection pipeline")
|
||||
parser.add_argument("--region", help="Region ID to process (all if not set)")
|
||||
parser.add_argument("--source", help="Source ID to process (all if not set)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Simulate without writing to DB")
|
||||
parser.add_argument("--gc", action="store_true", help="Run garbage collection after each region")
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
sources_config_path = os.environ.get(
|
||||
"GPS_SOURCES_CONFIG",
|
||||
os.path.join(project_root, "config/gps_sources.yaml"),
|
||||
)
|
||||
regions_config_path = os.environ.get(
|
||||
"GPS_REGIONS_CONFIG",
|
||||
os.path.join(project_root, "config/gps_regions.yaml"),
|
||||
)
|
||||
db_path = os.environ.get(
|
||||
"GPS_TRACKS_DB_PATH",
|
||||
os.path.join(project_root, "data/gps_tracks.sqlite"),
|
||||
)
|
||||
|
||||
# Загружаем конфигурации
|
||||
try:
|
||||
sources = load_sources_config(sources_config_path)
|
||||
regions = load_regions_config(regions_config_path)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to load config: %s", exc)
|
||||
return 1
|
||||
|
||||
# Фильтруем по параметрам CLI
|
||||
if args.region:
|
||||
regions = [r for r in regions if r["id"] == args.region]
|
||||
if not regions:
|
||||
logger.error("Region '%s' not found", args.region)
|
||||
return 1
|
||||
|
||||
if args.source:
|
||||
sources = [s for s in sources if s["id"] == args.source]
|
||||
if not sources:
|
||||
logger.error("Source '%s' not found", args.source)
|
||||
return 1
|
||||
|
||||
# Открываем БД
|
||||
try:
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to open DB: %s", exc)
|
||||
return 1
|
||||
|
||||
# Строим индекс источников по id
|
||||
sources_by_id = {s["id"]: s for s in sources}
|
||||
|
||||
has_error = False
|
||||
|
||||
for region in regions:
|
||||
if not region.get("enabled", True):
|
||||
logger.info("Skipping disabled region: %s", region["id"])
|
||||
continue
|
||||
|
||||
region_sources = region.get("sources", [])
|
||||
|
||||
for source_id in region_sources:
|
||||
if source_id not in sources_by_id:
|
||||
logger.warning("Source '%s' not found in sources config", source_id)
|
||||
continue
|
||||
|
||||
source_cfg = sources_by_id[source_id]
|
||||
|
||||
# Фильтр по --source
|
||||
if args.source and source_cfg["id"] != args.source:
|
||||
continue
|
||||
|
||||
if not source_cfg.get("enabled", False):
|
||||
logger.info("Skipping disabled source: %s", source_id)
|
||||
started_at = _now_iso()
|
||||
_record_pipeline_run(
|
||||
conn,
|
||||
region["id"],
|
||||
source_id,
|
||||
started_at,
|
||||
_now_iso(),
|
||||
"skipped_disabled",
|
||||
)
|
||||
continue
|
||||
|
||||
# Проверяем лицензию
|
||||
license_adr = source_cfg.get("license_adr", "")
|
||||
started_at = _now_iso()
|
||||
|
||||
if license_adr:
|
||||
license_status = _check_license_adr(license_adr, project_root)
|
||||
if license_status != "accepted":
|
||||
logger.warning(
|
||||
"Skipping %s/%s: license ADR status is '%s' (need 'accepted')",
|
||||
region["id"],
|
||||
source_id,
|
||||
license_status,
|
||||
)
|
||||
_record_pipeline_run(
|
||||
conn,
|
||||
region["id"],
|
||||
source_id,
|
||||
started_at,
|
||||
_now_iso(),
|
||||
"skipped_license",
|
||||
)
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Collecting %s for region %s (bbox=%s)",
|
||||
source_id,
|
||||
region["id"],
|
||||
region["bbox"],
|
||||
)
|
||||
|
||||
result = await _collect_source_for_region(region, source_cfg, conn, args.dry_run)
|
||||
|
||||
finished_at = _now_iso()
|
||||
_record_pipeline_run(
|
||||
conn,
|
||||
region["id"],
|
||||
source_id,
|
||||
started_at,
|
||||
finished_at,
|
||||
result["status"],
|
||||
result["tracks_new"],
|
||||
result["tracks_updated"],
|
||||
result["errors"] or None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Done %s/%s: status=%s new=%d updated=%d errors=%d",
|
||||
region["id"],
|
||||
source_id,
|
||||
result["status"],
|
||||
result["tracks_new"],
|
||||
result["tracks_updated"],
|
||||
len(result["errors"]),
|
||||
)
|
||||
|
||||
if result["status"] in ("error",):
|
||||
has_error = True
|
||||
|
||||
if args.gc:
|
||||
import gc
|
||||
gc.collect()
|
||||
logger.info("GC collected after region %s", region["id"])
|
||||
|
||||
conn.close()
|
||||
return 1 if has_error else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
0
src/api/gps_tracks/__init__.py
Normal file
0
src/api/gps_tracks/__init__.py
Normal file
89
src/api/gps_tracks/config.py
Normal file
89
src/api/gps_tracks/config.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Загрузка и валидация конфигурации GPS-источников и регионов (ET-008)."""
|
||||
import yaml
|
||||
|
||||
|
||||
def load_sources_config(path: str) -> list:
|
||||
"""Загружает конфигурацию источников GPS-треков.
|
||||
|
||||
Args:
|
||||
path: путь к YAML-файлу конфигурации источников
|
||||
|
||||
Returns:
|
||||
list[dict] — список источников
|
||||
|
||||
Raises:
|
||||
ValueError: при ошибках валидации
|
||||
FileNotFoundError: если файл не найден
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
sources = data.get("sources", [])
|
||||
if not isinstance(sources, list):
|
||||
raise ValueError("sources must be a list")
|
||||
|
||||
for src in sources:
|
||||
if not src.get("id"):
|
||||
raise ValueError(f"Source missing 'id': {src}")
|
||||
if not src.get("base_url"):
|
||||
raise ValueError(f"Source '{src['id']}' missing 'base_url'")
|
||||
|
||||
# Enabled source must have license_adr
|
||||
if src.get("enabled", False):
|
||||
if not src.get("license_adr"):
|
||||
raise ValueError(
|
||||
f"Enabled source '{src['id']}' must have 'license_adr'"
|
||||
)
|
||||
|
||||
return sources
|
||||
|
||||
|
||||
def load_regions_config(path: str) -> list:
|
||||
"""Загружает конфигурацию регионов для сбора GPS-треков.
|
||||
|
||||
Args:
|
||||
path: путь к YAML-файлу конфигурации регионов
|
||||
|
||||
Returns:
|
||||
list[dict] — список регионов
|
||||
|
||||
Raises:
|
||||
ValueError: при ошибках валидации
|
||||
FileNotFoundError: если файл не найден
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
regions = data.get("regions", [])
|
||||
if not isinstance(regions, list):
|
||||
raise ValueError("regions must be a list")
|
||||
|
||||
for reg in regions:
|
||||
if not reg.get("id"):
|
||||
raise ValueError(f"Region missing 'id': {reg}")
|
||||
|
||||
bbox = reg.get("bbox")
|
||||
if not bbox or len(bbox) != 4:
|
||||
raise ValueError(f"Region '{reg['id']}' must have bbox with 4 values")
|
||||
|
||||
west, south, east, north = bbox
|
||||
|
||||
# Валидация диапазонов координат
|
||||
if not (-180 <= west <= 180):
|
||||
raise ValueError(f"Region '{reg['id']}' bbox west={west} out of range")
|
||||
if not (-180 <= east <= 180):
|
||||
raise ValueError(f"Region '{reg['id']}' bbox east={east} out of range")
|
||||
if not (-90 <= south <= 90):
|
||||
raise ValueError(f"Region '{reg['id']}' bbox south={south} out of range")
|
||||
if not (-90 <= north <= 90):
|
||||
raise ValueError(f"Region '{reg['id']}' bbox north={north} out of range")
|
||||
if west >= east:
|
||||
raise ValueError(
|
||||
f"Region '{reg['id']}' bbox: west must be < east"
|
||||
)
|
||||
if south >= north:
|
||||
raise ValueError(
|
||||
f"Region '{reg['id']}' bbox: south must be < north"
|
||||
)
|
||||
|
||||
return regions
|
||||
232
src/api/gps_tracks/db.py
Normal file
232
src/api/gps_tracks/db.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Функции работы с БД для GPS-треков (ET-008)."""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from src.api.gps_tracks.models import TrackInsert
|
||||
|
||||
|
||||
_MIGRATION_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "../../../migrations/gps_tracks_001_init.sql"
|
||||
)
|
||||
|
||||
|
||||
def open_db(db_path: str) -> sqlite3.Connection:
|
||||
"""Открывает соединение с SQLite БД."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(conn: sqlite3.Connection) -> None:
|
||||
"""Применяет миграцию SQL для создания схемы."""
|
||||
migration_path = os.path.abspath(_MIGRATION_PATH)
|
||||
with open(migration_path, "r", encoding="utf-8") as f:
|
||||
sql = f.read()
|
||||
# Выполняем каждый statement отдельно (executescript не поддерживает параметры,
|
||||
# но зато не требует явного commit)
|
||||
conn.executescript(sql)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def upsert_track(
|
||||
conn: sqlite3.Connection,
|
||||
track: TrackInsert,
|
||||
dedup_key: str,
|
||||
source_priority: int,
|
||||
) -> str:
|
||||
"""Вставляет или обновляет трек в БД.
|
||||
|
||||
При коллизии dedup_key:
|
||||
- UNION sources (без дублей)
|
||||
- UNION external_urls (без дублей)
|
||||
- Метаданные обновляются если новый source_priority < существующего
|
||||
|
||||
Returns:
|
||||
"inserted" или "updated"
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
now = _now_iso()
|
||||
|
||||
# Проверяем существующую запись
|
||||
cur.execute(
|
||||
"SELECT id, sources_json, external_urls_json, name, description, activity_type, "
|
||||
"user, created_at, source_priority FROM tracks WHERE dedup_key = ?",
|
||||
(dedup_key,),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing is None:
|
||||
# INSERT новой записи
|
||||
sources = [track.source_id]
|
||||
ext_urls = [track.external_url] if track.external_url else []
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tracks (
|
||||
dedup_key, name, description, activity_type, user, created_at,
|
||||
length_m, points_count, min_lon, min_lat, max_lon, max_lat,
|
||||
geom, sources_json, external_urls_json, tags_json,
|
||||
inserted_at, updated_at, source_priority
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
dedup_key,
|
||||
track.name,
|
||||
track.description,
|
||||
track.activity_type,
|
||||
track.user,
|
||||
track.created_at,
|
||||
track.length_m,
|
||||
track.points_count,
|
||||
track.min_lon,
|
||||
track.min_lat,
|
||||
track.max_lon,
|
||||
track.max_lat,
|
||||
track.geom_wkb,
|
||||
json.dumps(sources),
|
||||
json.dumps(ext_urls),
|
||||
json.dumps(track.tags) if track.tags else json.dumps([]),
|
||||
now,
|
||||
now,
|
||||
source_priority,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return "inserted"
|
||||
else:
|
||||
# UPDATE: мержим sources и external_urls
|
||||
existing_sources = json.loads(existing["sources_json"] or "[]")
|
||||
existing_urls = json.loads(existing["external_urls_json"] or "[]")
|
||||
|
||||
# Union без дублей, сохраняя порядок
|
||||
merged_sources = list(dict.fromkeys(existing_sources + [track.source_id]))
|
||||
new_urls = [track.external_url] if track.external_url else []
|
||||
merged_urls = list(dict.fromkeys(existing_urls + new_urls))
|
||||
|
||||
# Получаем текущий source_priority (может отсутствовать в старых записях)
|
||||
existing_priority = existing["source_priority"] if "source_priority" in existing.keys() else 999
|
||||
|
||||
# Обновляем метаданные только если новый источник имеет более высокий приоритет
|
||||
if source_priority < existing_priority:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE tracks SET
|
||||
name = ?,
|
||||
description = ?,
|
||||
activity_type = ?,
|
||||
user = ?,
|
||||
created_at = ?,
|
||||
sources_json = ?,
|
||||
external_urls_json = ?,
|
||||
updated_at = ?,
|
||||
source_priority = ?
|
||||
WHERE dedup_key = ?
|
||||
""",
|
||||
(
|
||||
track.name,
|
||||
track.description,
|
||||
track.activity_type,
|
||||
track.user,
|
||||
track.created_at,
|
||||
json.dumps(merged_sources),
|
||||
json.dumps(merged_urls),
|
||||
now,
|
||||
source_priority,
|
||||
dedup_key,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Только обновляем sources/urls и updated_at
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE tracks SET
|
||||
sources_json = ?,
|
||||
external_urls_json = ?,
|
||||
updated_at = ?
|
||||
WHERE dedup_key = ?
|
||||
""",
|
||||
(
|
||||
json.dumps(merged_sources),
|
||||
json.dumps(merged_urls),
|
||||
now,
|
||||
dedup_key,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return "updated"
|
||||
|
||||
|
||||
def get_tracks_in_bbox(
|
||||
conn: sqlite3.Connection,
|
||||
west: float,
|
||||
south: float,
|
||||
east: float,
|
||||
north: float,
|
||||
activities: Optional[list] = None,
|
||||
sources: Optional[list] = None,
|
||||
limit: int = 500,
|
||||
) -> tuple:
|
||||
"""Возвращает треки в указанном bbox.
|
||||
|
||||
Returns:
|
||||
(tracks: list[sqlite3.Row], total_count: int)
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Базовое условие bbox
|
||||
conditions = [
|
||||
"min_lon <= :east",
|
||||
"max_lon >= :west",
|
||||
"min_lat <= :north",
|
||||
"max_lat >= :south",
|
||||
]
|
||||
params: dict = {"west": west, "south": south, "east": east, "north": north}
|
||||
|
||||
# Фильтр по activity_type
|
||||
if activities:
|
||||
placeholders = ",".join(f":act{i}" for i in range(len(activities)))
|
||||
conditions.append(f"activity_type IN ({placeholders})")
|
||||
for i, act in enumerate(activities):
|
||||
params[f"act{i}"] = act
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
# Подсчёт общего числа (без фильтра по source, он применяется постфактум)
|
||||
count_sql = f"SELECT COUNT(*) as cnt FROM tracks WHERE {where_clause}"
|
||||
cur.execute(count_sql, params)
|
||||
total_count = cur.fetchone()["cnt"]
|
||||
|
||||
# Основной запрос
|
||||
select_sql = f"""
|
||||
SELECT id, dedup_key, name, description, activity_type, user,
|
||||
created_at, length_m, points_count,
|
||||
min_lon, min_lat, max_lon, max_lat,
|
||||
sources_json, external_urls_json, tags_json,
|
||||
inserted_at, updated_at, geom
|
||||
FROM tracks
|
||||
WHERE {where_clause}
|
||||
LIMIT :limit
|
||||
"""
|
||||
params["limit"] = limit
|
||||
cur.execute(select_sql, params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
# Постфильтрация по sources (если задан)
|
||||
if sources:
|
||||
filtered = []
|
||||
for row in rows:
|
||||
row_sources = json.loads(row["sources_json"] or "[]")
|
||||
if any(s in row_sources for s in sources):
|
||||
filtered.append(row)
|
||||
rows = filtered
|
||||
|
||||
return rows, total_count
|
||||
32
src/api/gps_tracks/dedup.py
Normal file
32
src/api/gps_tracks/dedup.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Функции дедупликации GPS-треков (ET-008)."""
|
||||
|
||||
|
||||
def compute_dedup_key(geom_bounds: tuple, metadata: dict) -> str:
|
||||
"""Вычисляет ключ дедупликации для трека.
|
||||
|
||||
Args:
|
||||
geom_bounds: (min_lon, min_lat, max_lon, max_lat)
|
||||
metadata: dict с полями length_m и created_at
|
||||
|
||||
Returns:
|
||||
Строка вида "{bbox_round}|{length_bucket}|{date_bucket}"
|
||||
"""
|
||||
min_lon, min_lat, max_lon, max_lat = geom_bounds
|
||||
|
||||
# Округление bbox до 2 знаков после запятой
|
||||
bbox_round = (
|
||||
round(min_lon, 2),
|
||||
round(min_lat, 2),
|
||||
round(max_lon, 2),
|
||||
round(max_lat, 2),
|
||||
)
|
||||
|
||||
# Длина в бакетах по 1 км
|
||||
length_m = metadata.get("length_m", 0) or 0
|
||||
length_bucket = round(length_m / 1000) * 1000
|
||||
|
||||
# Дата: первые 10 символов (YYYY-MM-DD) или пустая строка
|
||||
created_at = metadata.get("created_at") or ""
|
||||
date_bucket = created_at[:10] if created_at else ""
|
||||
|
||||
return f"{bbox_round}|{length_bucket}|{date_bucket}"
|
||||
310
src/api/gps_tracks/endpoint.py
Normal file
310
src/api/gps_tracks/endpoint.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""FastAPI router для GPS-треков (ET-008)."""
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
|
||||
from src.api.gps_tracks.db import get_tracks_in_bbox, init_db, open_db
|
||||
from src.api.gps_tracks.mvt import (
|
||||
_gps_tile_cache,
|
||||
build_gps_mvt,
|
||||
clear_gps_tile_cache,
|
||||
get_gps_cached_tile,
|
||||
set_gps_cached_tile,
|
||||
_tile_to_bbox,
|
||||
)
|
||||
|
||||
|
||||
def _parse_bbox(bbox_str: str) -> tuple:
|
||||
"""Парсит и валидирует bbox строку 'west,south,east,north'.
|
||||
|
||||
Returns:
|
||||
(west, south, east, north)
|
||||
|
||||
Raises:
|
||||
HTTPException 400 при невалидных значениях
|
||||
"""
|
||||
try:
|
||||
parts = [float(v.strip()) for v in bbox_str.split(",")]
|
||||
except (ValueError, AttributeError):
|
||||
raise HTTPException(400, "bbox must be 4 comma-separated floats")
|
||||
|
||||
if len(parts) != 4:
|
||||
raise HTTPException(400, "bbox must have exactly 4 values: west,south,east,north")
|
||||
|
||||
west, south, east, north = parts
|
||||
|
||||
if not (-180 <= west <= 180) or not (-180 <= east <= 180):
|
||||
raise HTTPException(400, "bbox longitude values must be in range -180..180")
|
||||
|
||||
if not (-90 <= south <= 90) or not (-90 <= north <= 90):
|
||||
raise HTTPException(400, "bbox latitude values must be in range -90..90")
|
||||
|
||||
if west >= east:
|
||||
raise HTTPException(400, "bbox west must be < east")
|
||||
|
||||
if south >= north:
|
||||
raise HTTPException(400, "bbox south must be < north")
|
||||
|
||||
return west, south, east, north
|
||||
|
||||
|
||||
def _row_to_geojson_feature(row) -> dict:
|
||||
"""Конвертирует sqlite3.Row в GeoJSON Feature."""
|
||||
from src.api.gps_tracks.mvt import _wkb_to_coords
|
||||
|
||||
coords = _wkb_to_coords(row["geom"])
|
||||
|
||||
sources = json.loads(row["sources_json"] or "[]")
|
||||
ext_urls = json.loads(row["external_urls_json"] or "[]")
|
||||
tags = json.loads(row["tags_json"] or "[]")
|
||||
|
||||
activity_type = row["activity_type"] or "other"
|
||||
first_source = sources[0] if sources else ""
|
||||
length_m = row["length_m"] or 0
|
||||
length_km = round(length_m / 1000, 2)
|
||||
|
||||
geometry = None
|
||||
if coords:
|
||||
geometry = {"type": "LineString", "coordinates": coords}
|
||||
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": geometry,
|
||||
"properties": {
|
||||
"id": row["id"],
|
||||
"dedup_key": row["dedup_key"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"activity_type": row["activity_type"],
|
||||
"activity": activity_type,
|
||||
"user": row["user"],
|
||||
"created_at": row["created_at"],
|
||||
"length_m": row["length_m"],
|
||||
"length_km": length_km,
|
||||
"points_count": row["points_count"],
|
||||
"sources": sources,
|
||||
"source": first_source,
|
||||
"external_urls": ext_urls,
|
||||
"tags": tags,
|
||||
"inserted_at": row["inserted_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_gps_router(db_path: str) -> APIRouter:
|
||||
"""Создаёт FastAPI router для GPS-треков.
|
||||
|
||||
Args:
|
||||
db_path: путь к SQLite БД для GPS-треков
|
||||
|
||||
Returns:
|
||||
APIRouter с prefix="/api/gps-tracks"
|
||||
"""
|
||||
router = APIRouter(prefix="/api/gps-tracks", tags=["gps-tracks"])
|
||||
|
||||
def _get_conn():
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
return conn
|
||||
|
||||
@router.get("")
|
||||
async def get_tracks(
|
||||
bbox: str = Query(..., description="west,south,east,north"),
|
||||
activity: Optional[str] = Query(None, description="Comma-separated activity types"),
|
||||
source: Optional[str] = Query(None, description="Comma-separated source ids"),
|
||||
limit: int = Query(500, ge=1, le=2000),
|
||||
):
|
||||
"""Возвращает GPS-треки в bbox как GeoJSON FeatureCollection."""
|
||||
west, south, east, north = _parse_bbox(bbox)
|
||||
|
||||
activities = [a.strip() for a in activity.split(",")] if activity else None
|
||||
sources = [s.strip() for s in source.split(",")] if source else None
|
||||
|
||||
try:
|
||||
conn = _get_conn()
|
||||
rows, total_count = get_tracks_in_bbox(
|
||||
conn, west, south, east, north,
|
||||
activities=activities,
|
||||
sources=sources,
|
||||
limit=limit,
|
||||
)
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"DB error: {exc}")
|
||||
|
||||
features = [_row_to_geojson_feature(row) for row in rows]
|
||||
returned = len(features)
|
||||
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"total_in_bbox": total_count,
|
||||
"returned": returned,
|
||||
"truncated": total_count > returned,
|
||||
}
|
||||
|
||||
@router.get("/tiles/{z}/{x}/{y}.mvt")
|
||||
async def get_gps_tile(z: int, x: int, y: int):
|
||||
"""Возвращает MVT тайл с GPS-треками."""
|
||||
if z < 0 or z > 22:
|
||||
raise HTTPException(400, "Invalid z")
|
||||
max_coord = 2 ** z
|
||||
if x < 0 or x >= max_coord or y < 0 or y >= max_coord:
|
||||
raise HTTPException(400, "Invalid x/y for zoom level")
|
||||
|
||||
# Проверяем кэш
|
||||
cached = get_gps_cached_tile(z, x, y)
|
||||
if cached is not None:
|
||||
return Response(
|
||||
content=cached,
|
||||
media_type="application/x-protobuf",
|
||||
headers={
|
||||
"Content-Encoding": "identity",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"X-Cache": "HIT",
|
||||
},
|
||||
)
|
||||
|
||||
west, south, east, north = _tile_to_bbox(z, x, y)
|
||||
|
||||
# Небольшой буфер для edge features
|
||||
buf_x = (east - west) * 0.1
|
||||
buf_y = (north - south) * 0.1
|
||||
|
||||
try:
|
||||
conn = _get_conn()
|
||||
rows, _ = get_tracks_in_bbox(
|
||||
conn,
|
||||
west - buf_x,
|
||||
south - buf_y,
|
||||
east + buf_x,
|
||||
north + buf_y,
|
||||
limit=25000,
|
||||
)
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"DB error: {exc}")
|
||||
|
||||
mvt = build_gps_mvt(rows, z, x, y)
|
||||
|
||||
if mvt:
|
||||
set_gps_cached_tile(z, x, y, mvt)
|
||||
|
||||
return Response(
|
||||
content=mvt,
|
||||
media_type="application/x-protobuf",
|
||||
headers={
|
||||
"Content-Encoding": "identity",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"X-Cache": "MISS",
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/health")
|
||||
async def gps_health():
|
||||
"""Статистика GPS-треков БД.
|
||||
|
||||
Поле last_pipeline_run агрегирует все записи pipeline_runs,
|
||||
принадлежащие последнему запуску (по максимальному started_at).
|
||||
Возвращает None если прогонов ещё не было.
|
||||
"""
|
||||
try:
|
||||
conn = _get_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT COUNT(*) as cnt FROM tracks")
|
||||
total_tracks = cur.fetchone()["cnt"]
|
||||
|
||||
cur.execute(
|
||||
"SELECT activity_type, COUNT(*) as cnt FROM tracks GROUP BY activity_type"
|
||||
)
|
||||
by_activity = {row["activity_type"] or "other": row["cnt"] for row in cur.fetchall()}
|
||||
|
||||
# REQ-F-12: агрегированный объект по всем строкам последнего прогона.
|
||||
# Все строки одного запуска pipeline имеют одинаковый started_at —
|
||||
# pipeline устанавливает его перед итерацией по (region, source).
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT started_at, finished_at, region_id, source_id,
|
||||
status, tracks_new, errors_json
|
||||
FROM pipeline_runs
|
||||
WHERE started_at = (SELECT MAX(started_at) FROM pipeline_runs)
|
||||
ORDER BY region_id, source_id
|
||||
"""
|
||||
)
|
||||
run_rows = cur.fetchall()
|
||||
|
||||
if run_rows:
|
||||
regions: list = []
|
||||
sources_ok: list = []
|
||||
sources_error: list = []
|
||||
tracks_added = 0
|
||||
finished_at_values: list = []
|
||||
|
||||
for row in run_rows:
|
||||
region = row["region_id"]
|
||||
if region not in regions:
|
||||
regions.append(region)
|
||||
|
||||
if row["status"] in ("ok", "partial"):
|
||||
sources_ok.append(row["source_id"])
|
||||
else:
|
||||
sources_error.append(row["source_id"])
|
||||
|
||||
tracks_added += row["tracks_new"] or 0
|
||||
|
||||
if row["finished_at"]:
|
||||
finished_at_values.append(row["finished_at"])
|
||||
|
||||
last_run: Optional[dict] = {
|
||||
"started_at": run_rows[0]["started_at"],
|
||||
"finished_at": max(finished_at_values) if finished_at_values else None,
|
||||
"regions": regions,
|
||||
"sources_ok": sources_ok,
|
||||
"sources_error": sources_error,
|
||||
"tracks_added": tracks_added,
|
||||
}
|
||||
else:
|
||||
last_run = None
|
||||
|
||||
cur.execute("SELECT sources_json FROM tracks")
|
||||
tracks_by_source: dict = {}
|
||||
for trow in cur.fetchall():
|
||||
try:
|
||||
src_list = json.loads(trow["sources_json"] or "[]")
|
||||
except Exception:
|
||||
src_list = []
|
||||
for src in src_list:
|
||||
tracks_by_source[src] = tracks_by_source.get(src, 0) + 1
|
||||
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"DB error: {exc}")
|
||||
|
||||
db_size_mb = 0.0
|
||||
try:
|
||||
db_size_mb = os.path.getsize(db_path) / 1024 / 1024
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_path": db_path,
|
||||
"tracks_total": total_tracks,
|
||||
"tracks_by_activity": by_activity,
|
||||
"last_pipeline_run": last_run,
|
||||
"db_size_mb": db_size_mb,
|
||||
"tracks_by_source": tracks_by_source,
|
||||
"tile_cache_size": len(_gps_tile_cache),
|
||||
}
|
||||
|
||||
@router.post("/cache/clear")
|
||||
async def clear_cache():
|
||||
"""Сбрасывает LRU-кэш GPS-тайлов."""
|
||||
clear_gps_tile_cache()
|
||||
return {"status": "ok", "cleared": True}
|
||||
|
||||
return router
|
||||
52
src/api/gps_tracks/models.py
Normal file
52
src/api/gps_tracks/models.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Pydantic-модели и константы для публичных GPS-треков (ET-008)."""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
ACTIVITY_TYPES = [
|
||||
"enduro", "moto", "offroad", "bicycle", "hike", "ski", "other"
|
||||
]
|
||||
|
||||
|
||||
class TrackRecord(BaseModel):
|
||||
"""Трек из БД, готовый к отдаче через API."""
|
||||
|
||||
id: int
|
||||
dedup_key: str
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
activity_type: Optional[str] = "other"
|
||||
user: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
length_m: float
|
||||
points_count: int
|
||||
min_lon: float
|
||||
min_lat: float
|
||||
max_lon: float
|
||||
max_lat: float
|
||||
sources: List[str]
|
||||
external_urls: List[str]
|
||||
tags: List[str]
|
||||
inserted_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class TrackInsert(BaseModel):
|
||||
"""Трек для вставки в БД (из парсера)."""
|
||||
|
||||
external_id: str
|
||||
source_id: str
|
||||
external_url: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
activity_type: str = "other"
|
||||
user: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
length_m: float
|
||||
points_count: int
|
||||
geom_wkb: bytes # WKB bytes
|
||||
min_lon: float
|
||||
min_lat: float
|
||||
max_lon: float
|
||||
max_lat: float
|
||||
tags: List[str] = []
|
||||
source_priority: int = 999
|
||||
167
src/api/gps_tracks/mvt.py
Normal file
167
src/api/gps_tracks/mvt.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""MVT-тайлы для GPS-треков (ET-008)."""
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
from typing import Optional
|
||||
|
||||
from shapely.geometry import LineString
|
||||
|
||||
|
||||
# ─── LRU-like tile cache ─────────────────────────────────────────────────────
|
||||
|
||||
_gps_tile_cache: dict = {}
|
||||
_GPS_TILE_CACHE_MAX = 1024
|
||||
|
||||
|
||||
def get_gps_cached_tile(z: int, x: int, y: int) -> Optional[bytes]:
|
||||
return _gps_tile_cache.get((z, x, y))
|
||||
|
||||
|
||||
def set_gps_cached_tile(z: int, x: int, y: int, data: bytes) -> None:
|
||||
if len(_gps_tile_cache) >= _GPS_TILE_CACHE_MAX:
|
||||
# FIFO вытеснение
|
||||
_gps_tile_cache.pop(next(iter(_gps_tile_cache)))
|
||||
_gps_tile_cache[(z, x, y)] = data
|
||||
|
||||
|
||||
def clear_gps_tile_cache() -> None:
|
||||
_gps_tile_cache.clear()
|
||||
|
||||
|
||||
# ─── Geometry helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _simplify_coords(coords: list, z: int) -> list:
|
||||
"""Упрощает геометрию трека по зуму через Douglas-Peucker."""
|
||||
if z >= 12:
|
||||
return coords
|
||||
elif z >= 10:
|
||||
tolerance = 0.0005 # ~50м
|
||||
elif z >= 8:
|
||||
tolerance = 0.002 # ~200м
|
||||
else:
|
||||
tolerance = 0.008 # ~800м на z7 и ниже
|
||||
|
||||
if len(coords) < 3:
|
||||
return coords
|
||||
|
||||
line = LineString(coords)
|
||||
simplified = line.simplify(tolerance, preserve_topology=False)
|
||||
result = list(simplified.coords)
|
||||
return result if len(result) >= 2 else coords
|
||||
|
||||
|
||||
def _wkb_to_coords(blob: bytes) -> Optional[list]:
|
||||
"""Парсит WKB LineString, возвращает [(lon, lat), ...]."""
|
||||
try:
|
||||
b = bytes(blob)
|
||||
if len(b) < 9:
|
||||
return None
|
||||
endian = "<" if b[0] == 1 else ">"
|
||||
gtype = struct.unpack_from(endian + "I", b, 1)[0]
|
||||
base_type = gtype & 0xFF
|
||||
if base_type != 2:
|
||||
return None
|
||||
offset = 5
|
||||
if gtype & 0x20000000:
|
||||
offset += 4
|
||||
npts = struct.unpack_from(endian + "I", b, offset)[0]
|
||||
offset += 4
|
||||
coords = []
|
||||
for _ in range(npts):
|
||||
lon, lat = struct.unpack_from(endian + "dd", b, offset)
|
||||
offset += 16
|
||||
coords.append((lon, lat))
|
||||
return coords if len(coords) >= 2 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _tile_to_bbox(z: int, x: int, y: int) -> tuple:
|
||||
n = 2 ** z
|
||||
west = x / n * 360.0 - 180.0
|
||||
east = (x + 1) / n * 360.0 - 180.0
|
||||
north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
|
||||
south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
|
||||
return west, south, east, north
|
||||
|
||||
|
||||
# ─── MVT builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
def build_gps_mvt(rows: list, z: int, x: int, y: int) -> bytes:
|
||||
"""Собирает MVT тайл с layer 'gps_tracks'.
|
||||
|
||||
Args:
|
||||
rows: список sqlite3.Row из таблицы tracks
|
||||
z, x, y: координаты тайла
|
||||
|
||||
Returns:
|
||||
bytes — protobuf MVT или b"" если нет фич
|
||||
"""
|
||||
import mapbox_vector_tile
|
||||
|
||||
west, south, east, north = _tile_to_bbox(z, x, y)
|
||||
|
||||
# Min-length фильтр по зуму
|
||||
if z <= 7:
|
||||
min_length_m = 2000
|
||||
limit = 3000
|
||||
elif z <= 9:
|
||||
min_length_m = 0
|
||||
limit = 8000
|
||||
elif z <= 11:
|
||||
min_length_m = 0
|
||||
limit = 15000
|
||||
else:
|
||||
min_length_m = 0
|
||||
limit = 25000
|
||||
|
||||
features = []
|
||||
for row in rows:
|
||||
length_m = row["length_m"] or 0
|
||||
|
||||
# Min-length фильтр
|
||||
if min_length_m > 0 and length_m < min_length_m:
|
||||
continue
|
||||
|
||||
if len(features) >= limit:
|
||||
break
|
||||
|
||||
coords = _wkb_to_coords(row["geom"])
|
||||
if not coords:
|
||||
continue
|
||||
|
||||
coords = _simplify_coords(coords, z)
|
||||
|
||||
try:
|
||||
sources_list = json.loads(row["sources_json"] or "[]")
|
||||
sources_str = ",".join(sources_list)
|
||||
first_source = sources_list[0] if sources_list else ""
|
||||
|
||||
ext_urls = json.loads(row["external_urls_json"] or "[]")
|
||||
ext_url = ext_urls[0] if ext_urls else ""
|
||||
|
||||
props = {
|
||||
"id": row["id"],
|
||||
"activity": row["activity_type"] or "other",
|
||||
"source": first_source,
|
||||
"sources": sources_str,
|
||||
"length_km": round(length_m / 1000, 2),
|
||||
"name": row["name"] or "",
|
||||
"ext_url": ext_url,
|
||||
}
|
||||
features.append({
|
||||
"geometry": {"type": "LineString", "coordinates": coords},
|
||||
"properties": props,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not features:
|
||||
return b""
|
||||
|
||||
return mapbox_vector_tile.encode(
|
||||
[{"name": "gps_tracks", "features": features}],
|
||||
quantize_bounds=(west, south, east, north),
|
||||
extents=4096,
|
||||
default_options={"y_coord_down": False},
|
||||
)
|
||||
0
src/api/gps_tracks/sources/__init__.py
Normal file
0
src/api/gps_tracks/sources/__init__.py
Normal file
34
src/api/gps_tracks/sources/base.py
Normal file
34
src/api/gps_tracks/sources/base.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Базовый класс для парсеров GPS-источников (ET-008)."""
|
||||
from src.api.gps_tracks.models import ACTIVITY_TYPES
|
||||
|
||||
|
||||
class SourceParser:
|
||||
"""Базовый класс для всех парсеров GPS-источников."""
|
||||
|
||||
MAPPING: dict = {} # source-category → ACTIVITY_TYPE
|
||||
|
||||
def __init__(self, source_config: dict):
|
||||
self.config = source_config
|
||||
|
||||
def map_activity(self, raw_category: str) -> str:
|
||||
"""Маппит категорию источника в ACTIVITY_TYPES enum."""
|
||||
if not raw_category:
|
||||
return "other"
|
||||
mapped = self.MAPPING.get(raw_category.lower(), "other")
|
||||
if mapped not in ACTIVITY_TYPES:
|
||||
return "other"
|
||||
return mapped
|
||||
|
||||
async def collect(self, bbox: tuple, ctx: dict):
|
||||
"""Асинхронный генератор треков. Реализуется в наследниках.
|
||||
|
||||
Args:
|
||||
bbox: (west, south, east, north)
|
||||
ctx: контекст выполнения (db conn, logger, etc.)
|
||||
|
||||
Yields:
|
||||
TrackInsert объекты
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return
|
||||
yield # make it a generator
|
||||
17
src/api/gps_tracks/sources/enduro_russia.py
Normal file
17
src/api/gps_tracks/sources/enduro_russia.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Парсер EnduroRussia.ru — заглушка (ADR-010 status=proposed)."""
|
||||
from src.api.gps_tracks.sources.base import SourceParser
|
||||
|
||||
|
||||
class EnduroRussiaParser(SourceParser):
|
||||
"""Парсер EnduroRussia.ru.
|
||||
|
||||
Заблокирован до получения лицензии. См. ADR-010.
|
||||
"""
|
||||
|
||||
MAPPING = {"enduro": "enduro", "мото": "moto"}
|
||||
|
||||
async def collect(self, bbox, ctx):
|
||||
# ADR-010: blocked, status=proposed
|
||||
raise NotImplementedError("EnduroRussia parser not yet licensed (ADR-010)")
|
||||
return
|
||||
yield # make it a generator
|
||||
413
src/api/gps_tracks/sources/osm.py
Normal file
413
src/api/gps_tracks/sources/osm.py
Normal file
@@ -0,0 +1,413 @@
|
||||
"""Парсер OSM Public GPS Traces (ET-008)."""
|
||||
import asyncio
|
||||
import math
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
import httpx
|
||||
|
||||
from src.api.gps_tracks.models import TrackInsert
|
||||
from src.api.gps_tracks.sources.base import SourceParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Пространства имён GPX
|
||||
_GPX_NS = {
|
||||
"gpx0": "http://www.topografix.com/GPX/1/0",
|
||||
"gpx1": "http://www.topografix.com/GPX/1/1",
|
||||
}
|
||||
|
||||
|
||||
class OsmParser(SourceParser):
|
||||
"""Парсер OSM Public GPS Traces API."""
|
||||
|
||||
MAPPING = {
|
||||
"enduro": "enduro",
|
||||
"moto": "moto",
|
||||
"motorcycle": "moto",
|
||||
"mtb": "bicycle",
|
||||
"bicycle": "bicycle",
|
||||
"bike": "bicycle",
|
||||
"hike": "hike",
|
||||
"hiking": "hike",
|
||||
"running": "hike",
|
||||
"ski": "ski",
|
||||
"skiing": "ski",
|
||||
"offroad": "offroad",
|
||||
"4x4": "offroad",
|
||||
}
|
||||
|
||||
async def collect(self, bbox: tuple, ctx: dict) -> AsyncGenerator[TrackInsert, None]:
|
||||
"""Собирает треки из OSM Public GPS Traces API.
|
||||
|
||||
Args:
|
||||
bbox: (west, south, east, north)
|
||||
ctx: контекст (может содержать 'dry_run', 'session')
|
||||
|
||||
Yields:
|
||||
TrackInsert объекты
|
||||
"""
|
||||
west, south, east, north = bbox
|
||||
rate_limit = self.config.get("rate_limit_sec", 1)
|
||||
base_url = self.config.get("base_url", "https://api.openstreetmap.org/api/0.6")
|
||||
user_agent = self.config.get("user_agent", "enduro-trails/1.0")
|
||||
source_id = self.config.get("id", "osm")
|
||||
ext_url_template = self.config.get("external_url_template", "")
|
||||
|
||||
headers = {"User-Agent": user_agent}
|
||||
|
||||
# Разбиваем bbox на ячейки 0.25°
|
||||
cells = split_bbox_for_osm((west, south, east, north))
|
||||
|
||||
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
|
||||
for cell_bbox in cells:
|
||||
cell_west, cell_south, cell_east, cell_north = cell_bbox
|
||||
page = 0
|
||||
while True:
|
||||
url = (
|
||||
f"{base_url}/trackpoints"
|
||||
f"?bbox={cell_west},{cell_south},{cell_east},{cell_north}"
|
||||
f"&page={page}"
|
||||
)
|
||||
try:
|
||||
resp = await _fetch_with_backoff(client, url)
|
||||
if resp is None:
|
||||
break
|
||||
if resp.status_code == 204:
|
||||
break
|
||||
if resp.status_code != 200:
|
||||
logger.warning("OSM API returned %d for %s", resp.status_code, url)
|
||||
break
|
||||
content = resp.content
|
||||
except Exception as exc:
|
||||
logger.error("Error fetching %s: %s", url, exc)
|
||||
break
|
||||
|
||||
# Парсим GPX ответ
|
||||
tracks = _parse_gpx_trackpoints(content, source_id, ext_url_template)
|
||||
|
||||
if not tracks:
|
||||
break # Пустая страница — больше треков нет
|
||||
|
||||
# Обогащаем треки метаданными из OSM API
|
||||
gpx_ids = [t.external_id for t in tracks]
|
||||
meta_map = await _batch_fetch_gpx_meta(
|
||||
client, base_url, gpx_ids, headers, rate_limit
|
||||
)
|
||||
|
||||
for track in tracks:
|
||||
meta = meta_map.get(track.external_id)
|
||||
if meta:
|
||||
updates = {}
|
||||
if meta.get("activity_type") is not None:
|
||||
updates["activity_type"] = meta["activity_type"]
|
||||
if meta.get("name") is not None:
|
||||
updates["name"] = meta["name"]
|
||||
if meta.get("description") is not None:
|
||||
updates["description"] = meta["description"]
|
||||
if meta.get("user") is not None:
|
||||
updates["user"] = meta["user"]
|
||||
if updates:
|
||||
track = track.model_copy(update=updates)
|
||||
yield track
|
||||
|
||||
page += 1
|
||||
await asyncio.sleep(rate_limit)
|
||||
|
||||
|
||||
def split_bbox_for_osm(region_bbox: tuple, cell_size: float = 0.25) -> list:
|
||||
"""Разбивает регион на ячейки cell_size градусов для OSM API.
|
||||
|
||||
OSM API требует bbox не более 0.25° x 0.25°.
|
||||
|
||||
Args:
|
||||
region_bbox: (west, south, east, north)
|
||||
cell_size: размер ячейки в градусах (по умолчанию 0.25)
|
||||
|
||||
Returns:
|
||||
list of (west, south, east, north) tuples
|
||||
"""
|
||||
west, south, east, north = region_bbox
|
||||
cells = []
|
||||
|
||||
# Перебираем ячейки с запада на восток, с юга на север
|
||||
lat = south
|
||||
while lat < north:
|
||||
cell_south = lat
|
||||
cell_north = min(lat + cell_size, north)
|
||||
lon = west
|
||||
while lon < east:
|
||||
cell_west = lon
|
||||
cell_east = min(lon + cell_size, east)
|
||||
cells.append((
|
||||
round(cell_west, 6),
|
||||
round(cell_south, 6),
|
||||
round(cell_east, 6),
|
||||
round(cell_north, 6),
|
||||
))
|
||||
lon += cell_size
|
||||
lat += cell_size
|
||||
|
||||
return cells
|
||||
|
||||
|
||||
def _haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
|
||||
"""Расстояние между двумя точками в метрах."""
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def _calc_track_length(coords: list) -> float:
|
||||
"""Считает длину трека через Haversine."""
|
||||
total = 0.0
|
||||
for i in range(len(coords) - 1):
|
||||
total += _haversine_m(coords[i][0], coords[i][1], coords[i + 1][0], coords[i + 1][1])
|
||||
return total
|
||||
|
||||
|
||||
def _parse_gpx_trackpoints(content: bytes, source_id: str, ext_url_template: str) -> list:
|
||||
"""Парсит GPX-ответ OSM API с треками.
|
||||
|
||||
Группирует trkpt по атрибуту gpx_id.
|
||||
Анонимные точки (без gpx_id) пропускаются.
|
||||
|
||||
Returns:
|
||||
list[TrackInsert]
|
||||
"""
|
||||
try:
|
||||
# defusedxml защищает от XXE
|
||||
root = ET.fromstring(content)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to parse GPX: %s", exc)
|
||||
return []
|
||||
|
||||
# Группируем точки по gpx_id
|
||||
tracks_points: dict = {}
|
||||
|
||||
# Определяем namespace
|
||||
ns = ""
|
||||
tag = root.tag
|
||||
if tag.startswith("{"):
|
||||
ns = tag.split("}")[0] + "}"
|
||||
|
||||
# Ищем trkpt напрямую и через trk/trkseg
|
||||
trkpt_elements = []
|
||||
|
||||
# Вариант 1: OSM возвращает trkpt напрямую в корне (API 0.6 trackpoints endpoint)
|
||||
for child in root:
|
||||
local = child.tag.replace(ns, "") if ns else child.tag
|
||||
if local == "trk":
|
||||
for trkseg in child:
|
||||
local2 = trkseg.tag.replace(ns, "") if ns else trkseg.tag
|
||||
if local2 == "trkseg":
|
||||
for trkpt in trkseg:
|
||||
trkpt_elements.append(trkpt)
|
||||
elif local == "trkpt":
|
||||
trkpt_elements.append(child)
|
||||
|
||||
for trkpt in trkpt_elements:
|
||||
gpx_id = trkpt.get("gpx_id") or trkpt.get("{http://www.topografix.com/GPX/1/0}gpx_id")
|
||||
if not gpx_id:
|
||||
# Анонимные точки — пропускаем
|
||||
continue
|
||||
|
||||
try:
|
||||
lat = float(trkpt.get("lat", 0))
|
||||
lon = float(trkpt.get("lon", 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if gpx_id not in tracks_points:
|
||||
tracks_points[gpx_id] = []
|
||||
|
||||
# Получаем время из дочернего элемента
|
||||
time_elem = None
|
||||
for child in trkpt:
|
||||
local = child.tag.replace(ns, "") if ns else child.tag
|
||||
if local == "time":
|
||||
time_elem = child
|
||||
break
|
||||
|
||||
time_str = time_elem.text if time_elem is not None else None
|
||||
tracks_points[gpx_id].append((lon, lat, time_str))
|
||||
|
||||
results = []
|
||||
for gpx_id, points in tracks_points.items():
|
||||
if len(points) < 2:
|
||||
continue
|
||||
|
||||
coords = [(p[0], p[1]) for p in points]
|
||||
|
||||
# Вычисляем bbox
|
||||
lons = [c[0] for c in coords]
|
||||
lats = [c[1] for c in coords]
|
||||
min_lon, max_lon = min(lons), max(lons)
|
||||
min_lat, max_lat = min(lats), max(lats)
|
||||
|
||||
# Длина трека
|
||||
length_m = _calc_track_length(coords)
|
||||
if length_m < 10: # Слишком короткий трек — пропускаем
|
||||
continue
|
||||
|
||||
# Дата из первой точки с временем
|
||||
created_at = None
|
||||
for p in points:
|
||||
if p[2]:
|
||||
created_at = p[2][:19].replace("T", "T") # ISO без миллисекунд
|
||||
break
|
||||
|
||||
# WKB из shapely
|
||||
try:
|
||||
from shapely.geometry import LineString
|
||||
from shapely import wkb
|
||||
|
||||
geom = LineString(coords)
|
||||
geom_wkb = wkb.dumps(geom)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# External URL
|
||||
ext_url = None
|
||||
if ext_url_template:
|
||||
ext_url = ext_url_template.format(
|
||||
user="",
|
||||
external_id_numeric=gpx_id,
|
||||
)
|
||||
|
||||
track = TrackInsert(
|
||||
external_id=str(gpx_id),
|
||||
source_id=source_id,
|
||||
external_url=ext_url,
|
||||
name=None,
|
||||
description=None,
|
||||
activity_type="other",
|
||||
user=None,
|
||||
created_at=created_at,
|
||||
length_m=length_m,
|
||||
points_count=len(coords),
|
||||
geom_wkb=geom_wkb,
|
||||
min_lon=min_lon,
|
||||
min_lat=min_lat,
|
||||
max_lon=max_lon,
|
||||
max_lat=max_lat,
|
||||
tags=[],
|
||||
source_priority=50,
|
||||
)
|
||||
results.append(track)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def _fetch_with_backoff(
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
max_retries: int = 3,
|
||||
) -> httpx.Response | None:
|
||||
"""Выполняет HTTP-запрос с экспоненциальным backoff."""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 429:
|
||||
wait = 2 ** attempt * 2
|
||||
logger.warning("Rate limited, waiting %ds", wait)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
return resp
|
||||
except httpx.TimeoutException:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("Timeout on attempt %d, waiting %ds", attempt + 1, wait)
|
||||
await asyncio.sleep(wait)
|
||||
except Exception as exc:
|
||||
logger.error("Request failed: %s", exc)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_gpx_meta_response(content: bytes) -> dict | None:
|
||||
"""Парсит XML-ответ OSM API /gpx/<id>.
|
||||
|
||||
Returns:
|
||||
dict с ключами activity_type, name, description, user или None при ошибке XML.
|
||||
Если gpx_file элемент отсутствует — возвращает dict со всеми None-значениями.
|
||||
"""
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to parse GPX meta XML: %s", exc)
|
||||
return None
|
||||
|
||||
gpx_file = root.find("gpx_file")
|
||||
if gpx_file is None:
|
||||
return {"activity_type": None, "name": None, "description": None, "user": None}
|
||||
|
||||
name = gpx_file.get("name")
|
||||
user = gpx_file.get("user")
|
||||
|
||||
desc_elem = gpx_file.find("description")
|
||||
description = desc_elem.text if desc_elem is not None else None
|
||||
|
||||
# Сопоставляем теги через MAPPING (берём первое совпадение)
|
||||
activity_type = None
|
||||
for tag_elem in gpx_file.findall("tag"):
|
||||
tag_text = (tag_elem.text or "").strip().lower()
|
||||
if tag_text in OsmParser.MAPPING:
|
||||
activity_type = OsmParser.MAPPING[tag_text]
|
||||
break
|
||||
|
||||
return {
|
||||
"activity_type": activity_type,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"user": user,
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_gpx_meta(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
gpx_id: str,
|
||||
headers: dict,
|
||||
) -> dict | None:
|
||||
"""Загружает метаданные одного GPX-трека через OSM API /gpx/<id>."""
|
||||
url = f"{base_url}/gpx/{gpx_id}"
|
||||
try:
|
||||
resp = await _fetch_with_backoff(client, url)
|
||||
if resp is None or resp.status_code != 200:
|
||||
return None
|
||||
return _parse_gpx_meta_response(resp.content)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch GPX meta for %s: %s", gpx_id, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _batch_fetch_gpx_meta(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
gpx_ids: list,
|
||||
headers: dict,
|
||||
rate_limit: float,
|
||||
batch_size: int = 20,
|
||||
) -> dict:
|
||||
"""Загружает метаданные GPX-треков пакетами через asyncio.gather.
|
||||
|
||||
Returns:
|
||||
dict {gpx_id: meta_dict}
|
||||
"""
|
||||
result = {}
|
||||
for i in range(0, len(gpx_ids), batch_size):
|
||||
batch = gpx_ids[i: i + batch_size]
|
||||
metas = await asyncio.gather(
|
||||
*[_fetch_gpx_meta(client, base_url, gid, headers) for gid in batch],
|
||||
return_exceptions=False,
|
||||
)
|
||||
for gid, meta in zip(batch, metas):
|
||||
if meta is not None:
|
||||
result[gid] = meta
|
||||
if i + batch_size < len(gpx_ids):
|
||||
await asyncio.sleep(rate_limit)
|
||||
return result
|
||||
17
src/api/gps_tracks/sources/ttrails.py
Normal file
17
src/api/gps_tracks/sources/ttrails.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Парсер Тропинки.ру — заглушка (ADR-011 status=proposed)."""
|
||||
from src.api.gps_tracks.sources.base import SourceParser
|
||||
|
||||
|
||||
class TtrailsParser(SourceParser):
|
||||
"""Парсер Тропинки.ру.
|
||||
|
||||
Заблокирован до получения лицензии. См. ADR-011.
|
||||
"""
|
||||
|
||||
MAPPING = {"велосипед": "bicycle", "пешком": "hike", "мото": "moto"}
|
||||
|
||||
async def collect(self, bbox, ctx):
|
||||
# ADR-011: blocked, status=proposed
|
||||
raise NotImplementedError("Ttrails parser not yet licensed (ADR-011)")
|
||||
return
|
||||
yield # make it a generator
|
||||
@@ -14,6 +14,11 @@ import sqlite3
|
||||
|
||||
import itertools
|
||||
|
||||
GPS_TRACKS_DB_PATH = os.environ.get(
|
||||
"GPS_TRACKS_DB_PATH",
|
||||
os.path.join(os.path.dirname(__file__), "../../data/gps_tracks.sqlite"),
|
||||
)
|
||||
|
||||
from shapely.geometry import LineString
|
||||
from typing import List
|
||||
|
||||
@@ -1246,6 +1251,10 @@ async def terrain_tile(layer: str, z: int, x: int, y: int):
|
||||
|
||||
# ─── Static files ─────────────────────────────────────────────────────────────
|
||||
|
||||
from src.api.gps_tracks.endpoint import create_gps_router
|
||||
gps_router = create_gps_router(GPS_TRACKS_DB_PATH)
|
||||
app.include_router(gps_router)
|
||||
|
||||
if os.path.exists(STATIC_DIR):
|
||||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||
|
||||
|
||||
@@ -3,3 +3,6 @@ uvicorn==0.29.0
|
||||
shapely==2.0.4
|
||||
mapbox-vector-tile==2.2.0
|
||||
httpx==0.27.0
|
||||
defusedxml==0.7.1
|
||||
lxml==5.2.2
|
||||
pyyaml==6.0.1
|
||||
|
||||
@@ -1227,3 +1227,76 @@ body.satellite-active #btn-basemap {
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* ─── ET-008: GPS-треки ──────────────────────────── */
|
||||
.terrain-link-btn {
|
||||
display: block;
|
||||
margin: 4px 0 0 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent, #ff8c1a);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gps-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gps-filter-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gps-filter-chip input[type=checkbox] {
|
||||
accent-color: var(--accent, #ff8c1a);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.gps-stats-row {
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Track popup */
|
||||
.track-popup {
|
||||
font-size: 13px;
|
||||
color: var(--text, #fff);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.track-popup-name {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.track-popup-row {
|
||||
margin: 3px 0;
|
||||
color: var(--text2, #ccc);
|
||||
}
|
||||
|
||||
.track-popup-sources {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.track-popup-sources a {
|
||||
color: var(--accent, #ff8c1a);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.track-popup-sources a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,10 @@ function rebuildMapOverlays() {
|
||||
restoreTerrainState();
|
||||
restoreTrailsState();
|
||||
restorePoiState();
|
||||
// ET-008: публичные GPS-треки
|
||||
if (typeof restorePublicTracksState === 'function') {
|
||||
restorePublicTracksState();
|
||||
}
|
||||
|
||||
// Re-apply recon circle if active
|
||||
if (reconMode && reconCenter) {
|
||||
@@ -3041,6 +3045,10 @@ function applyBaseLayer(base) {
|
||||
// ET-007 P1-6: halo синхронизирован с состоянием чекбоксов
|
||||
// «Грунтовки» / «Тропы», а не безусловно включён.
|
||||
_applyTrailHaloVisibility(map, 'satellite');
|
||||
// ET-008: halo публичных треков на спутнике
|
||||
if (typeof applyGpsHaloVisibility === 'function') {
|
||||
applyGpsHaloVisibility(map);
|
||||
}
|
||||
_applyPoiSatellitePaint(map, true);
|
||||
_applyBackgroundForSatellite(map, true);
|
||||
} else {
|
||||
@@ -3057,6 +3065,10 @@ function applyBaseLayer(base) {
|
||||
_setBodyClass('satellite-active', false);
|
||||
// На «Схеме» halo всегда скрыт независимо от чекбоксов.
|
||||
_applyTrailHaloVisibility(map, 'schematic');
|
||||
// ET-008: halo публичных треков выключить
|
||||
if (typeof applyGpsHaloVisibility === 'function') {
|
||||
applyGpsHaloVisibility(map);
|
||||
}
|
||||
_applyPoiSatellitePaint(map, false);
|
||||
_applyBackgroundForSatellite(map, false);
|
||||
}
|
||||
|
||||
588
src/web/gps_tracks.js
Normal file
588
src/web/gps_tracks.js
Normal file
@@ -0,0 +1,588 @@
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// gps_tracks.js — ET-008: Публичные GPS-треки
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Константы ────────────────────────────────────────────────────
|
||||
|
||||
const GPS_TRACKS_ZOOM_CUTOFF = 12; // ниже — MVT, выше — GeoJSON
|
||||
const GPS_TRACKS_MIN_ZOOM = 8; // ниже — слой скрыт
|
||||
|
||||
const GPS_SOURCE_COLORS = {
|
||||
osm: '#3cb44b',
|
||||
enduro_russia: '#e6194b',
|
||||
ttrails: '#4363d8',
|
||||
offmaps: '#f58231',
|
||||
nakarte: '#911eb4',
|
||||
};
|
||||
const GPS_FALLBACK_COLORS = ['#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8'];
|
||||
|
||||
const GPS_ACTIVITY_COLORS = {
|
||||
enduro: '#e6194b',
|
||||
moto: '#f58231',
|
||||
offroad: '#ffe119',
|
||||
bicycle: '#3cb44b',
|
||||
hike: '#4363d8',
|
||||
ski: '#42d4f4',
|
||||
other: '#808080',
|
||||
};
|
||||
|
||||
const GPS_ACTIVITY_ICONS = {
|
||||
enduro: '🏍',
|
||||
moto: '🛵',
|
||||
offroad: '🚙',
|
||||
bicycle: '🚵',
|
||||
hike: '🥾',
|
||||
ski: '⛷️',
|
||||
other: '📍',
|
||||
};
|
||||
|
||||
const GPS_ACTIVITY_LABELS = {
|
||||
enduro: 'Эндуро',
|
||||
moto: 'Мото',
|
||||
offroad: 'Off-road',
|
||||
bicycle: 'Велосипед',
|
||||
hike: 'Пешком',
|
||||
ski: 'Лыжи',
|
||||
other: 'Другое',
|
||||
};
|
||||
|
||||
// ─── Состояние ───────────────────────────────────────────────────
|
||||
|
||||
window.gpsTracksLayer = {
|
||||
enabled: false,
|
||||
filters: {
|
||||
activities: ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'],
|
||||
sources: ['osm', 'enduro_russia', 'ttrails'],
|
||||
colorMode: 'source'
|
||||
},
|
||||
sourceId: 'gps-tracks-tiles',
|
||||
sourceGeoId: 'gps-tracks-geo',
|
||||
layerId: 'gps-tracks-layer-mvt',
|
||||
layerGeoId: 'gps-tracks-layer-geo',
|
||||
layerHaloId: 'gps-tracks-halo-mvt-satellite',
|
||||
layerHaloGeoId: 'gps-tracks-halo-geo-satellite',
|
||||
geojsonAbortController: null,
|
||||
geojsonReqDebounceTimer: null,
|
||||
stats: { total: 0, shown: 0 }
|
||||
};
|
||||
|
||||
// ─── Цветовые выражения MapLibre ──────────────────────────────────
|
||||
|
||||
function _buildColorExpression(mode) {
|
||||
if (mode === 'activity') {
|
||||
const expr = ['match', ['get', 'activity']];
|
||||
for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
|
||||
expr.push(act, color);
|
||||
}
|
||||
expr.push('#808080'); // fallback
|
||||
return expr;
|
||||
} else {
|
||||
// по источнику
|
||||
const expr = ['match', ['get', 'source']];
|
||||
for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
|
||||
expr.push(src, color);
|
||||
}
|
||||
expr.push('#808080'); // fallback
|
||||
return expr;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Layer definitions ────────────────────────────────────────────
|
||||
|
||||
function _gpsLayerDef(id, source, sourceLayer) {
|
||||
const colorExpr = _buildColorExpression(window.gpsTracksLayer.filters.colorMode);
|
||||
return {
|
||||
id,
|
||||
type: 'line',
|
||||
source,
|
||||
'source-layer': sourceLayer || undefined,
|
||||
paint: {
|
||||
'line-color': colorExpr,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1.0, 12, 2.0, 16, 3.0],
|
||||
'line-opacity': 0.75,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' }
|
||||
};
|
||||
}
|
||||
|
||||
function _gpsHaloDef(id, source, sourceLayer) {
|
||||
return {
|
||||
id,
|
||||
type: 'line',
|
||||
source,
|
||||
'source-layer': sourceLayer || undefined,
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 2.5, 12, 4.0, 16, 6.0],
|
||||
'line-opacity': 0.6,
|
||||
},
|
||||
layout: { visibility: 'none' }
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Создание/удаление sources и layers ──────────────────────────
|
||||
|
||||
function _ensureGpsSources(map) {
|
||||
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||||
|
||||
if (!map.getSource(window.gpsTracksLayer.sourceId)) {
|
||||
map.addSource(window.gpsTracksLayer.sourceId, {
|
||||
type: 'vector',
|
||||
tiles: [`${window.location.origin}${basePath}/api/gps-tracks/tiles/{z}/{x}/{y}.mvt`],
|
||||
minzoom: GPS_TRACKS_MIN_ZOOM,
|
||||
maxzoom: 11,
|
||||
attribution: '© OpenStreetMap contributors (ODbL)',
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getSource(window.gpsTracksLayer.sourceGeoId)) {
|
||||
map.addSource(window.gpsTracksLayer.sourceGeoId, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureGpsLayers(map) {
|
||||
if (!map.getLayer(window.gpsTracksLayer.layerId)) {
|
||||
const def = _gpsLayerDef(
|
||||
window.gpsTracksLayer.layerId,
|
||||
window.gpsTracksLayer.sourceId,
|
||||
'gps_tracks'
|
||||
);
|
||||
// Добавить поверх trails, ниже route (если есть)
|
||||
const before = _findGpsInsertPosition(map);
|
||||
map.addLayer(def, before);
|
||||
}
|
||||
|
||||
if (!map.getLayer(window.gpsTracksLayer.layerGeoId)) {
|
||||
const def = _gpsLayerDef(
|
||||
window.gpsTracksLayer.layerGeoId,
|
||||
window.gpsTracksLayer.sourceGeoId,
|
||||
null
|
||||
);
|
||||
delete def['source-layer'];
|
||||
const before = _findGpsInsertPosition(map);
|
||||
map.addLayer(def, before);
|
||||
}
|
||||
|
||||
if (!map.getLayer(window.gpsTracksLayer.layerHaloId)) {
|
||||
const def = _gpsHaloDef(
|
||||
window.gpsTracksLayer.layerHaloId,
|
||||
window.gpsTracksLayer.sourceId,
|
||||
'gps_tracks'
|
||||
);
|
||||
const before = window.gpsTracksLayer.layerId;
|
||||
map.addLayer(def, before);
|
||||
}
|
||||
|
||||
if (!map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) {
|
||||
const def = _gpsHaloDef(
|
||||
window.gpsTracksLayer.layerHaloGeoId,
|
||||
window.gpsTracksLayer.sourceGeoId,
|
||||
null
|
||||
);
|
||||
delete def['source-layer'];
|
||||
const before = window.gpsTracksLayer.layerGeoId;
|
||||
map.addLayer(def, before);
|
||||
}
|
||||
}
|
||||
|
||||
function _findGpsInsertPosition(map) {
|
||||
/**
|
||||
* Returns the id of the first layer that GPS tracks should be inserted
|
||||
* below, using priority order:
|
||||
* 1. gpx-layer-* — ET-006 GPX file layers (highest priority)
|
||||
* 2. route-* — ET-002 routing layers
|
||||
* Returns undefined if neither is present (GPS tracks go on top).
|
||||
*/
|
||||
const style = map.getStyle && map.getStyle();
|
||||
if (!style || !style.layers) return undefined;
|
||||
|
||||
// Priority 1: gpx-layer-* (ET-006 GPX file layers)
|
||||
const gpxLayer = style.layers.find(l => l.id.startsWith('gpx-layer-'));
|
||||
if (gpxLayer) return gpxLayer.id;
|
||||
|
||||
// Priority 2: route-* (ET-002 routing layers)
|
||||
const routeLayer = style.layers.find(l => l.id.startsWith('route-'));
|
||||
if (routeLayer) return routeLayer.id;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Управление видимостью ────────────────────────────────────────
|
||||
|
||||
function _syncGpsLayersVisibility(map) {
|
||||
const enabled = window.gpsTracksLayer.enabled;
|
||||
const zoom = map.getZoom ? map.getZoom() : 0;
|
||||
const mvtVisible = enabled && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF;
|
||||
const geoVisible = enabled && zoom >= GPS_TRACKS_ZOOM_CUTOFF;
|
||||
|
||||
const setVis = (layerId, visible) => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
|
||||
}
|
||||
};
|
||||
|
||||
setVis(window.gpsTracksLayer.layerId, mvtVisible);
|
||||
setVis(window.gpsTracksLayer.layerGeoId, geoVisible);
|
||||
|
||||
// Hint «Зум 8+»
|
||||
const hint = document.getElementById('public-tracks-zoom-hint');
|
||||
if (hint) {
|
||||
hint.style.display = (enabled && zoom < GPS_TRACKS_MIN_ZOOM) ? 'inline' : 'none';
|
||||
}
|
||||
|
||||
// Halo обновляется через applyGpsHaloVisibility
|
||||
applyGpsHaloVisibility(map);
|
||||
}
|
||||
|
||||
// ─── Halo ──────────────────────────────────────────────────────────
|
||||
|
||||
function applyGpsHaloVisibility(map) {
|
||||
if (!map) return;
|
||||
const zoom = map.getZoom ? map.getZoom() : 0;
|
||||
const isSatellite = document.body.classList.contains('satellite-active');
|
||||
const enabled = window.gpsTracksLayer.enabled;
|
||||
|
||||
const mvtHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF;
|
||||
const geoHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_ZOOM_CUTOFF;
|
||||
|
||||
if (map.getLayer(window.gpsTracksLayer.layerHaloId)) {
|
||||
map.setLayoutProperty(window.gpsTracksLayer.layerHaloId, 'visibility', mvtHaloOn ? 'visible' : 'none');
|
||||
}
|
||||
if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) {
|
||||
map.setLayoutProperty(window.gpsTracksLayer.layerHaloGeoId, 'visibility', geoHaloOn ? 'visible' : 'none');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Фильтрация ───────────────────────────────────────────────────
|
||||
|
||||
function applyGpsFilter() {
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
const { activities, sources } = window.gpsTracksLayer.filters;
|
||||
const filter = ['all',
|
||||
['in', ['get', 'activity'], ['literal', activities]],
|
||||
['in', ['get', 'source'], ['literal', sources]]
|
||||
];
|
||||
if (map.getLayer(window.gpsTracksLayer.layerId)) {
|
||||
map.setFilter(window.gpsTracksLayer.layerId, filter);
|
||||
}
|
||||
if (map.getLayer(window.gpsTracksLayer.layerGeoId)) {
|
||||
map.setFilter(window.gpsTracksLayer.layerGeoId, filter);
|
||||
}
|
||||
if (map.getLayer(window.gpsTracksLayer.layerHaloId)) {
|
||||
map.setFilter(window.gpsTracksLayer.layerHaloId, filter);
|
||||
}
|
||||
if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) {
|
||||
map.setFilter(window.gpsTracksLayer.layerHaloGeoId, filter);
|
||||
}
|
||||
_updateGpsStatsUI();
|
||||
}
|
||||
|
||||
// ─── GeoJSON загрузка ─────────────────────────────────────────────
|
||||
|
||||
function onGpsMapMoveEnd() {
|
||||
const map = window._map;
|
||||
if (!map || !window.gpsTracksLayer.enabled) return;
|
||||
if (map.getZoom() < GPS_TRACKS_ZOOM_CUTOFF) return;
|
||||
|
||||
clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer);
|
||||
window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => {
|
||||
fetchAndUpdateGpsGeoJson(map.getBounds());
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function fetchAndUpdateGpsGeoJson(bounds) {
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
|
||||
if (window.gpsTracksLayer.geojsonAbortController) {
|
||||
window.gpsTracksLayer.geojsonAbortController.abort();
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
window.gpsTracksLayer.geojsonAbortController = ctrl;
|
||||
|
||||
const { activities, sources } = window.gpsTracksLayer.filters;
|
||||
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||||
const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`;
|
||||
const url = `${basePath}/api/gps-tracks?bbox=${bbox}&activity=${activities.join(',')}&source=${sources.join(',')}&limit=500`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { signal: ctrl.signal });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const json = await resp.json();
|
||||
if (map.getSource(window.gpsTracksLayer.sourceGeoId)) {
|
||||
map.getSource(window.gpsTracksLayer.sourceGeoId).setData(json);
|
||||
}
|
||||
window.gpsTracksLayer.stats = { total: json.total_in_bbox || 0, shown: json.returned || 0 };
|
||||
if (json.truncated) {
|
||||
// показываем toast один раз
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`Показаны ${json.returned} треков из ${json.total_in_bbox}. Увеличьте zoom для полной выборки`);
|
||||
}
|
||||
}
|
||||
_updateGpsStatsUI();
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') return;
|
||||
if (typeof showToast === 'function') showToast('Не удалось загрузить треки');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Popup при клике ──────────────────────────────────────────────
|
||||
|
||||
function _renderTrackPopupHtml(props) {
|
||||
const name = props.name || 'Без названия';
|
||||
const activity = props.activity_type || props.activity || 'other';
|
||||
const icon = GPS_ACTIVITY_ICONS[activity] || '📍';
|
||||
const actLabel = GPS_ACTIVITY_LABELS[activity] || activity;
|
||||
const lengthKm = typeof props.length_km === 'number' ? props.length_km.toFixed(1) : '—';
|
||||
const points = props.points_count || '—';
|
||||
const dateStr = props.created_at ? new Date(props.created_at).toLocaleDateString('ru-RU', {day:'numeric',month:'long',year:'numeric'}) : null;
|
||||
const user = props.user || null;
|
||||
|
||||
let sourcesHtml = '';
|
||||
try {
|
||||
let srcs = props.sources;
|
||||
let urls = props.external_urls;
|
||||
if (typeof srcs === 'string') srcs = srcs.split(',').filter(Boolean);
|
||||
if (typeof urls === 'string') urls = urls.split(',').filter(Boolean);
|
||||
if (Array.isArray(srcs) && srcs.length) {
|
||||
sourcesHtml = '<div class="track-popup-sources">Источники: ' +
|
||||
srcs.map((s, i) => {
|
||||
const url = Array.isArray(urls) && urls[i] ? urls[i] : null;
|
||||
const label = s;
|
||||
return url
|
||||
? `<a href="${url}" target="_blank" rel="noopener">${label} ↗</a>`
|
||||
: `<span>${label}</span>`;
|
||||
}).join(' · ') + '</div>';
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
return `
|
||||
<div class="track-popup">
|
||||
<div class="track-popup-name">${name}</div>
|
||||
<div class="track-popup-row">${icon} ${actLabel}</div>
|
||||
<div class="track-popup-row">📏 ${lengthKm} км · ${points} точек</div>
|
||||
${dateStr ? `<div class="track-popup-row">📅 ${dateStr}</div>` : ''}
|
||||
${user ? `<div class="track-popup-row">👤 ${user}</div>` : ''}
|
||||
${sourcesHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _setupGpsClickHandler(map) {
|
||||
const layerIds = [window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId];
|
||||
|
||||
layerIds.forEach(layerId => {
|
||||
map.on('click', layerId, (e) => {
|
||||
// Не открывать popup если активен другой режим
|
||||
if (window._routeMode || window._reconMode || window._scenicMode || window._rulerMode) return;
|
||||
|
||||
const feature = e.features && e.features[0];
|
||||
if (!feature) return;
|
||||
|
||||
new maplibregl.Popup({ closeOnClick: true, maxWidth: '300px' })
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(_renderTrackPopupHtml(feature.properties))
|
||||
.addTo(map);
|
||||
});
|
||||
|
||||
map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; });
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Включение/выключение слоя ────────────────────────────────────
|
||||
|
||||
function onPublicTracksCheckbox() {
|
||||
const cb = document.getElementById('public-tracks-cb');
|
||||
const filterBtn = document.getElementById('public-tracks-filters-btn');
|
||||
if (!cb) return;
|
||||
|
||||
window.gpsTracksLayer.enabled = cb.checked;
|
||||
localStorage.setItem('gps-tracks-enabled', cb.checked ? 'true' : 'false');
|
||||
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
|
||||
if (cb.checked) {
|
||||
_ensureGpsSources(map);
|
||||
_ensureGpsLayers(map);
|
||||
_setupGpsClickHandler(map);
|
||||
|
||||
// Убедиться, что moveend listener есть
|
||||
map.off('moveend', onGpsMapMoveEnd);
|
||||
map.on('moveend', onGpsMapMoveEnd);
|
||||
map.off('zoomend', onGpsZoomEnd);
|
||||
map.on('zoomend', onGpsZoomEnd);
|
||||
}
|
||||
|
||||
_syncGpsLayersVisibility(map);
|
||||
applyGpsFilter();
|
||||
|
||||
// Фильтры btn
|
||||
if (filterBtn) filterBtn.style.display = cb.checked ? 'block' : 'none';
|
||||
|
||||
// Если включили и zoom >= 12 — загрузить GeoJSON
|
||||
if (cb.checked && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) {
|
||||
fetchAndUpdateGpsGeoJson(map.getBounds());
|
||||
}
|
||||
}
|
||||
|
||||
function onGpsZoomEnd() {
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
_syncGpsLayersVisibility(map);
|
||||
// При переходе на z>=12 загрузить GeoJSON
|
||||
if (window.gpsTracksLayer.enabled && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) {
|
||||
clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer);
|
||||
window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => {
|
||||
fetchAndUpdateGpsGeoJson(map.getBounds());
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sheet фильтров ───────────────────────────────────────────────
|
||||
|
||||
function togglePublicTracksFiltersSheet() {
|
||||
const sheet = document.getElementById('sheet-gps-filters');
|
||||
if (!sheet) return;
|
||||
const isOpen = sheet.classList.contains('open');
|
||||
if (!isOpen) {
|
||||
_buildGpsFiltersUI();
|
||||
openSheet('sheet-gps-filters');
|
||||
} else {
|
||||
closeAllSheets();
|
||||
}
|
||||
}
|
||||
|
||||
function _buildGpsFiltersUI() {
|
||||
// Активности
|
||||
const actGrid = document.getElementById('gps-activity-grid');
|
||||
if (actGrid) {
|
||||
const all = ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'];
|
||||
actGrid.innerHTML = all.map(act => {
|
||||
const checked = window.gpsTracksLayer.filters.activities.includes(act);
|
||||
return `
|
||||
<label class="gps-filter-chip">
|
||||
<input type="checkbox" value="${act}" ${checked ? 'checked' : ''} onchange="onGpsActivityFilterChange()">
|
||||
<span>${GPS_ACTIVITY_ICONS[act]} ${GPS_ACTIVITY_LABELS[act]}</span>
|
||||
</label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Источники (из localStorage или дефолт)
|
||||
const srcGrid = document.getElementById('gps-source-grid');
|
||||
if (srcGrid) {
|
||||
const allSources = ['osm', 'enduro_russia', 'ttrails'];
|
||||
const sourceLabels = { osm: 'OSM', enduro_russia: 'EnduroRussia.ru', ttrails: 'Тропинки.ру' };
|
||||
srcGrid.innerHTML = allSources.map(src => {
|
||||
const checked = window.gpsTracksLayer.filters.sources.includes(src);
|
||||
return `
|
||||
<label class="gps-filter-chip">
|
||||
<input type="checkbox" value="${src}" ${checked ? 'checked' : ''} onchange="onGpsSourceFilterChange()">
|
||||
<span>${sourceLabels[src] || src}</span>
|
||||
</label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Color mode
|
||||
const colorMode = window.gpsTracksLayer.filters.colorMode;
|
||||
const btnSrc = document.getElementById('gps-color-by-source');
|
||||
const btnAct = document.getElementById('gps-color-by-activity');
|
||||
if (btnSrc) btnSrc.classList.toggle('active', colorMode === 'source');
|
||||
if (btnAct) btnAct.classList.toggle('active', colorMode === 'activity');
|
||||
|
||||
_updateGpsStatsUI();
|
||||
}
|
||||
|
||||
function onGpsActivityFilterChange() {
|
||||
const checked = [];
|
||||
document.querySelectorAll('#gps-activity-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value));
|
||||
window.gpsTracksLayer.filters.activities = checked;
|
||||
localStorage.setItem('gps-tracks-activities', JSON.stringify(checked));
|
||||
applyGpsFilter();
|
||||
}
|
||||
|
||||
function onGpsSourceFilterChange() {
|
||||
const checked = [];
|
||||
document.querySelectorAll('#gps-source-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value));
|
||||
window.gpsTracksLayer.filters.sources = checked;
|
||||
localStorage.setItem('gps-tracks-sources', JSON.stringify(checked));
|
||||
applyGpsFilter();
|
||||
}
|
||||
|
||||
function onGpsColorModeChange(mode) {
|
||||
window.gpsTracksLayer.filters.colorMode = mode;
|
||||
localStorage.setItem('gps-tracks-color-mode', mode);
|
||||
|
||||
const btnSrc = document.getElementById('gps-color-by-source');
|
||||
const btnAct = document.getElementById('gps-color-by-activity');
|
||||
if (btnSrc) btnSrc.classList.toggle('active', mode === 'source');
|
||||
if (btnAct) btnAct.classList.toggle('active', mode === 'activity');
|
||||
|
||||
// Перестроить color expression
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
const colorExpr = _buildColorExpression(mode);
|
||||
[window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId].forEach(layerId => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setPaintProperty(layerId, 'line-color', colorExpr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _updateGpsStatsUI() {
|
||||
const totalEl = document.getElementById('gps-stat-total');
|
||||
const shownEl = document.getElementById('gps-stat-shown');
|
||||
if (totalEl) totalEl.textContent = window.gpsTracksLayer.stats.total || '—';
|
||||
if (shownEl) shownEl.textContent = window.gpsTracksLayer.stats.shown || '—';
|
||||
}
|
||||
|
||||
// ─── restorePublicTracksState ──────────────────────────────────────
|
||||
/**
|
||||
* Восстанавливает состояние слоя публичных треков из localStorage.
|
||||
* Вызывается из rebuildMapOverlays() в app.js.
|
||||
*/
|
||||
function restorePublicTracksState() {
|
||||
const enabled = localStorage.getItem('gps-tracks-enabled') === 'true';
|
||||
const cb = document.getElementById('public-tracks-cb');
|
||||
const filterBtn = document.getElementById('public-tracks-filters-btn');
|
||||
|
||||
const activitiesRaw = localStorage.getItem('gps-tracks-activities');
|
||||
if (activitiesRaw) {
|
||||
try { window.gpsTracksLayer.filters.activities = JSON.parse(activitiesRaw); } catch(e) {}
|
||||
}
|
||||
|
||||
const sourcesRaw = localStorage.getItem('gps-tracks-sources');
|
||||
if (sourcesRaw) {
|
||||
try { window.gpsTracksLayer.filters.sources = JSON.parse(sourcesRaw); } catch(e) {}
|
||||
}
|
||||
|
||||
const colorMode = localStorage.getItem('gps-tracks-color-mode') || 'source';
|
||||
window.gpsTracksLayer.filters.colorMode = colorMode;
|
||||
|
||||
if (cb) cb.checked = enabled;
|
||||
if (filterBtn) filterBtn.style.display = enabled ? 'block' : 'none';
|
||||
window.gpsTracksLayer.enabled = enabled;
|
||||
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
|
||||
if (enabled) {
|
||||
_ensureGpsSources(map);
|
||||
_ensureGpsLayers(map);
|
||||
_setupGpsClickHandler(map);
|
||||
map.off('moveend', onGpsMapMoveEnd);
|
||||
map.on('moveend', onGpsMapMoveEnd);
|
||||
map.off('zoomend', onGpsZoomEnd);
|
||||
map.on('zoomend', onGpsZoomEnd);
|
||||
_syncGpsLayersVisibility(map);
|
||||
applyGpsFilter();
|
||||
if (map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) {
|
||||
fetchAndUpdateGpsGeoJson(map.getBounds());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,17 @@
|
||||
<span>Тропы</span>
|
||||
</label>
|
||||
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
|
||||
<!-- ET-008: публичные GPS-треки -->
|
||||
<label class="terrain-checkbox">
|
||||
<input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
|
||||
<span>Публичные треки</span>
|
||||
</label>
|
||||
<span class="terrain-hint" id="public-tracks-zoom-hint" style="display:none">Зум 8+</span>
|
||||
<button class="terrain-link-btn" id="public-tracks-filters-btn"
|
||||
onclick="togglePublicTracksFiltersSheet()" style="display:none">
|
||||
Фильтры…
|
||||
</button>
|
||||
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
|
||||
<label class="terrain-checkbox">
|
||||
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
|
||||
<span>POI</span>
|
||||
@@ -463,6 +474,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── ET-008: Sheet «GPS-фильтры» ───────────────────────────────── -->
|
||||
<div class="bottom-sheet" id="sheet-gps-filters">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-header">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M7 12h10M11 18h2"/></svg>
|
||||
<h2>Фильтры публичных треков</h2>
|
||||
<button class="sheet-close" onclick="closeAllSheets()">✕</button>
|
||||
</div>
|
||||
<div class="sheet-body">
|
||||
<div class="section-label">ТИП АКТИВНОСТИ</div>
|
||||
<div id="gps-activity-grid" class="gps-filter-grid"></div>
|
||||
<div class="section-label">ИСТОЧНИК</div>
|
||||
<div id="gps-source-grid" class="gps-filter-grid"></div>
|
||||
<div class="section-label">ЦВЕТ ЛИНИЙ</div>
|
||||
<div class="seg-control">
|
||||
<button class="seg-btn active" id="gps-color-by-source" onclick="onGpsColorModeChange('source')">По источнику</button>
|
||||
<button class="seg-btn" id="gps-color-by-activity" onclick="onGpsColorModeChange('activity')">По активности</button>
|
||||
</div>
|
||||
<div class="gps-stats-row" id="gps-stats-row" style="margin-top:12px">
|
||||
<span>Всего в области: <b id="gps-stat-total">—</b></span>
|
||||
<span style="margin-left:12px">Видны (фильтр): <b id="gps-stat-shown">—</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script>
|
||||
<script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script>
|
||||
@@ -471,5 +507,7 @@
|
||||
<script src="app.js"></script>
|
||||
<!-- ET-006: gpx.js подключается после app.js — потребляет его глобали (ADR-002) -->
|
||||
<script src="gpx.js"></script>
|
||||
<!-- ET-008: публичные GPS-треки -->
|
||||
<script src="gps_tracks.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
216
tests/api/test_gps_tracks_dedup.py
Normal file
216
tests/api/test_gps_tracks_dedup.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Unit тесты для дедупликации GPS-треков (ET-008).
|
||||
|
||||
U-10: два трека с одинаковым bbox+length+date → один ключ
|
||||
U-11: разные даты → разные ключи
|
||||
U-12: bbox-округление до 0.01°
|
||||
U-13: merge sources при upsert
|
||||
U-14: merge external_urls
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from src.api.gps_tracks.dedup import compute_dedup_key
|
||||
from src.api.gps_tracks.db import open_db, init_db, upsert_track
|
||||
from src.api.gps_tracks.models import TrackInsert
|
||||
|
||||
|
||||
def _make_track(
|
||||
external_id="T1",
|
||||
source_id="osm",
|
||||
length_m=5000.0,
|
||||
created_at="2024-05-12T10:00:00Z",
|
||||
min_lon=37.61,
|
||||
min_lat=55.75,
|
||||
max_lon=37.62,
|
||||
max_lat=55.76,
|
||||
external_url=None,
|
||||
name=None,
|
||||
source_priority=50,
|
||||
) -> TrackInsert:
|
||||
"""Хелпер для создания TrackInsert с тестовой WKB геометрией."""
|
||||
from shapely.geometry import LineString
|
||||
from shapely import wkb
|
||||
|
||||
coords = [(min_lon, min_lat), (max_lon, max_lat)]
|
||||
geom_wkb = wkb.dumps(LineString(coords))
|
||||
|
||||
return TrackInsert(
|
||||
external_id=external_id,
|
||||
source_id=source_id,
|
||||
external_url=external_url,
|
||||
name=name,
|
||||
description=None,
|
||||
activity_type="other",
|
||||
user=None,
|
||||
created_at=created_at,
|
||||
length_m=length_m,
|
||||
points_count=2,
|
||||
geom_wkb=geom_wkb,
|
||||
min_lon=min_lon,
|
||||
min_lat=min_lat,
|
||||
max_lon=max_lon,
|
||||
max_lat=max_lat,
|
||||
tags=[],
|
||||
source_priority=source_priority,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
"""Создаёт изолированную БД в tmp_path."""
|
||||
db_path = str(tmp_path / "test.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── U-10: одинаковый bbox+length+date → один ключ ───────────────────────────
|
||||
|
||||
def test_u10_same_key_for_same_track():
|
||||
"""U-10: два трека с одинаковым bbox+length+date дают одинаковый ключ."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
|
||||
key1 = compute_dedup_key(bounds, meta)
|
||||
key2 = compute_dedup_key(bounds, meta)
|
||||
|
||||
assert key1 == key2
|
||||
|
||||
|
||||
# ─── U-11: разные даты → разные ключи ────────────────────────────────────────
|
||||
|
||||
def test_u11_different_dates_give_different_keys():
|
||||
"""U-11: треки с разными датами дают разные ключи."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
|
||||
key1 = compute_dedup_key(bounds, {"length_m": 5000.0, "created_at": "2024-05-12"})
|
||||
key2 = compute_dedup_key(bounds, {"length_m": 5000.0, "created_at": "2024-05-13"})
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
|
||||
# ─── U-12: bbox-округление до 0.01° ─────────────────────────────────────────
|
||||
|
||||
def test_u12_bbox_rounding_to_2_decimals():
|
||||
"""U-12: bbox округляется до 0.01°, незначительные отличия игнорируются."""
|
||||
# Оба варианта округляются к (37.61, 55.75, 37.62, 55.76)
|
||||
# Используем значения в середине диапазона, гарантированно округляемые одинаково
|
||||
bounds1 = (37.6111, 55.7512, 37.6192, 55.7563)
|
||||
bounds2 = (37.6144, 55.7533, 37.6188, 55.7571)
|
||||
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12"}
|
||||
|
||||
key1 = compute_dedup_key(bounds1, meta)
|
||||
key2 = compute_dedup_key(bounds2, meta)
|
||||
|
||||
# Оба bbox округляются к (37.61, 55.75, 37.62, 55.76) — ключи одинаковы
|
||||
assert key1 == key2
|
||||
|
||||
|
||||
def test_u12_significantly_different_bbox_gives_different_key():
|
||||
"""U-12: существенно разные bbox дают разные ключи."""
|
||||
bounds1 = (37.61, 55.75, 37.62, 55.76)
|
||||
bounds2 = (38.00, 56.00, 38.10, 56.10)
|
||||
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12"}
|
||||
|
||||
key1 = compute_dedup_key(bounds1, meta)
|
||||
key2 = compute_dedup_key(bounds2, meta)
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
|
||||
# ─── U-13: merge sources при upsert ──────────────────────────────────────────
|
||||
|
||||
def test_u13_merge_sources_on_upsert(db):
|
||||
"""U-13: при upsert с тем же dedup_key sources мержатся (union без дублей)."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
dedup_key = compute_dedup_key(bounds, meta)
|
||||
|
||||
# Первая вставка — от osm
|
||||
track1 = _make_track(external_id="T1", source_id="osm", source_priority=50)
|
||||
result1 = upsert_track(db, track1, dedup_key, source_priority=50)
|
||||
assert result1 == "inserted"
|
||||
|
||||
# Вторая вставка — от другого источника с тем же dedup_key
|
||||
track2 = _make_track(external_id="T2", source_id="enduro_russia", source_priority=10)
|
||||
result2 = upsert_track(db, track2, dedup_key, source_priority=10)
|
||||
assert result2 == "updated"
|
||||
|
||||
# Проверяем merged sources
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT sources_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||||
row = cur.fetchone()
|
||||
sources = json.loads(row["sources_json"])
|
||||
|
||||
assert "osm" in sources
|
||||
assert "enduro_russia" in sources
|
||||
assert len(sources) == 2 # без дублей
|
||||
|
||||
|
||||
def test_u13_no_duplicate_sources_on_repeated_upsert(db):
|
||||
"""U-13: повторный upsert от того же источника не создаёт дублей в sources."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
dedup_key = compute_dedup_key(bounds, meta)
|
||||
|
||||
track = _make_track(external_id="T1", source_id="osm")
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT sources_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||||
row = cur.fetchone()
|
||||
sources = json.loads(row["sources_json"])
|
||||
|
||||
assert sources.count("osm") == 1
|
||||
|
||||
|
||||
# ─── U-14: merge external_urls ───────────────────────────────────────────────
|
||||
|
||||
def test_u14_merge_external_urls_on_upsert(db):
|
||||
"""U-14: external_urls мержатся без дублей при upsert."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
dedup_key = compute_dedup_key(bounds, meta)
|
||||
|
||||
url1 = "https://www.openstreetmap.org/user/alice/traces/12345"
|
||||
url2 = "https://enduro-russia.ru/track/99"
|
||||
|
||||
track1 = _make_track(external_id="T1", source_id="osm", external_url=url1)
|
||||
upsert_track(db, track1, dedup_key, source_priority=50)
|
||||
|
||||
track2 = _make_track(external_id="T2", source_id="enduro_russia", external_url=url2)
|
||||
upsert_track(db, track2, dedup_key, source_priority=10)
|
||||
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT external_urls_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||||
row = cur.fetchone()
|
||||
urls = json.loads(row["external_urls_json"])
|
||||
|
||||
assert url1 in urls
|
||||
assert url2 in urls
|
||||
assert len(urls) == 2
|
||||
|
||||
|
||||
def test_u14_no_duplicate_urls_on_repeated_upsert(db):
|
||||
"""U-14: повторный upsert с тем же URL не дублирует его."""
|
||||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||||
dedup_key = compute_dedup_key(bounds, meta)
|
||||
|
||||
url = "https://www.openstreetmap.org/user/alice/traces/12345"
|
||||
|
||||
track = _make_track(external_id="T1", source_id="osm", external_url=url)
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
upsert_track(db, track, dedup_key, source_priority=50)
|
||||
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT external_urls_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||||
row = cur.fetchone()
|
||||
urls = json.loads(row["external_urls_json"])
|
||||
|
||||
assert urls.count(url) == 1
|
||||
480
tests/api/test_gps_tracks_endpoint.py
Normal file
480
tests/api/test_gps_tracks_endpoint.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""Integration тесты для GPS-треков endpoint (ET-008).
|
||||
|
||||
I-20: GeoJSON с фильтрами
|
||||
I-21: truncation
|
||||
I-22: невалидный bbox → 400
|
||||
I-23: bbox в океане → пустой
|
||||
I-30: MVT тайл отдаётся
|
||||
I-31: cache hit
|
||||
I-40: health endpoint
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from fastapi import FastAPI
|
||||
|
||||
from src.api.gps_tracks.db import open_db, init_db, upsert_track
|
||||
from src.api.gps_tracks.dedup import compute_dedup_key
|
||||
from src.api.gps_tracks.endpoint import create_gps_router
|
||||
from src.api.gps_tracks.models import TrackInsert
|
||||
|
||||
|
||||
def _make_test_app(db_path: str) -> FastAPI:
|
||||
"""Создаёт тестовое FastAPI приложение с GPS router."""
|
||||
app = FastAPI()
|
||||
router = create_gps_router(db_path)
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
def _make_track(
|
||||
external_id="T1",
|
||||
source_id="osm",
|
||||
length_m=5000.0,
|
||||
created_at="2024-05-12T10:00:00Z",
|
||||
min_lon=37.60,
|
||||
min_lat=55.74,
|
||||
max_lon=37.65,
|
||||
max_lat=55.78,
|
||||
activity_type="other",
|
||||
external_url=None,
|
||||
source_priority=50,
|
||||
) -> TrackInsert:
|
||||
from shapely.geometry import LineString
|
||||
from shapely import wkb
|
||||
|
||||
coords = [
|
||||
(min_lon, min_lat),
|
||||
((min_lon + max_lon) / 2, (min_lat + max_lat) / 2),
|
||||
(max_lon, max_lat),
|
||||
]
|
||||
geom_wkb = wkb.dumps(LineString(coords))
|
||||
|
||||
return TrackInsert(
|
||||
external_id=external_id,
|
||||
source_id=source_id,
|
||||
external_url=external_url,
|
||||
name=f"Track {external_id}",
|
||||
description=None,
|
||||
activity_type=activity_type,
|
||||
user=None,
|
||||
created_at=created_at,
|
||||
length_m=length_m,
|
||||
points_count=3,
|
||||
geom_wkb=geom_wkb,
|
||||
min_lon=min_lon,
|
||||
min_lat=min_lat,
|
||||
max_lon=max_lon,
|
||||
max_lat=max_lat,
|
||||
tags=[],
|
||||
source_priority=source_priority,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_with_tracks(tmp_path):
|
||||
"""БД с несколькими тестовыми треками."""
|
||||
db_path = str(tmp_path / "test.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
|
||||
# Добавляем треки вокруг Москвы
|
||||
tracks = [
|
||||
_make_track("T1", "osm", activity_type="enduro", length_m=8000),
|
||||
_make_track("T2", "osm", activity_type="moto", length_m=3000,
|
||||
min_lon=37.70, min_lat=55.80, max_lon=37.75, max_lat=55.85),
|
||||
_make_track("T3", "enduro_russia", activity_type="bicycle", length_m=12000),
|
||||
]
|
||||
|
||||
for track in tracks:
|
||||
dedup_key = compute_dedup_key(
|
||||
(track.min_lon, track.min_lat, track.max_lon, track.max_lat),
|
||||
{"length_m": track.length_m, "created_at": track.created_at},
|
||||
)
|
||||
upsert_track(conn, track, dedup_key, source_priority=50)
|
||||
|
||||
conn.close()
|
||||
yield db_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_with_pipeline_runs(db_with_tracks):
|
||||
"""БД с треками и записями о прогонах pipeline (REQ-F-12).
|
||||
|
||||
Один прогон охватывает два региона и один источник.
|
||||
Имитирует ситуацию когда pipeline записал две строки
|
||||
с одинаковым started_at (один запуск скрипта).
|
||||
"""
|
||||
db_path = db_with_tracks
|
||||
conn = open_db(db_path)
|
||||
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO pipeline_runs
|
||||
(started_at, finished_at, region_id, source_id,
|
||||
status, tracks_new, tracks_updated, errors_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
"2026-05-30T03:00:00Z",
|
||||
"2026-05-30T04:00:00Z",
|
||||
"cfo",
|
||||
"osm",
|
||||
"ok",
|
||||
42,
|
||||
5,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"2026-05-30T03:00:00Z",
|
||||
"2026-05-30T05:14:00Z",
|
||||
"chuvashia",
|
||||
"osm",
|
||||
"ok",
|
||||
10,
|
||||
2,
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield db_path
|
||||
|
||||
|
||||
# ─── I-20: GeoJSON с фильтрами ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i20_geojson_basic(db_with_tracks):
|
||||
"""I-20: базовый запрос GeoJSON."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": "37.5,55.7,37.9,55.9"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "FeatureCollection"
|
||||
assert isinstance(data["features"], list)
|
||||
assert len(data["features"]) > 0
|
||||
assert "total_in_bbox" in data
|
||||
assert "returned" in data
|
||||
assert "truncated" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i20_filter_by_activity(db_with_tracks):
|
||||
"""I-20: фильтрация по activity_type."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": "37.5,55.7,37.9,55.9", "activity": "enduro"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
for feature in data["features"]:
|
||||
assert feature["properties"]["activity_type"] == "enduro"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i20_filter_by_source(db_with_tracks):
|
||||
"""I-20: фильтрация по source."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": "37.5,55.7,37.9,55.9", "source": "enduro_russia"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# Все returned треки должны иметь enduro_russia в sources
|
||||
for feature in data["features"]:
|
||||
assert "enduro_russia" in feature["properties"]["sources"]
|
||||
|
||||
|
||||
# ─── I-21: truncation ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i21_truncation(tmp_path):
|
||||
"""I-21: truncation при limit меньше total."""
|
||||
db_path = str(tmp_path / "trunc.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
|
||||
# Создаём 10 треков с разными bbox
|
||||
for i in range(10):
|
||||
t = _make_track(
|
||||
external_id=f"T{i}",
|
||||
source_id="osm",
|
||||
min_lon=37.60 + i * 0.001,
|
||||
min_lat=55.74,
|
||||
max_lon=37.65 + i * 0.001,
|
||||
max_lat=55.78,
|
||||
length_m=5000 + i * 100,
|
||||
created_at=f"2024-05-{12 + i:02d}T10:00:00Z",
|
||||
)
|
||||
dedup_key = compute_dedup_key(
|
||||
(t.min_lon, t.min_lat, t.max_lon, t.max_lat),
|
||||
{"length_m": t.length_m, "created_at": t.created_at},
|
||||
)
|
||||
upsert_track(conn, t, dedup_key, source_priority=50)
|
||||
conn.close()
|
||||
|
||||
app = _make_test_app(db_path)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": "37.5,55.7,37.9,55.9", "limit": 3},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["returned"] == 3
|
||||
assert data["total_in_bbox"] >= 3
|
||||
assert data["truncated"] is True
|
||||
|
||||
|
||||
# ─── I-22: невалидный bbox → 400 ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("bad_bbox", [
|
||||
"abc,def,ghi,jkl", # не числа
|
||||
"37.5,55.7,37.9", # 3 значения
|
||||
"37.5,55.7,37.9,55.9,1.0", # 5 значений
|
||||
"200,55.7,37.9,55.9", # lon out of range
|
||||
"37.5,95,37.9,55.9", # lat out of range
|
||||
"37.9,55.7,37.5,55.9", # west > east
|
||||
"37.5,55.9,37.9,55.7", # south > north
|
||||
])
|
||||
async def test_i22_invalid_bbox_returns_400(tmp_path, bad_bbox):
|
||||
"""I-22: невалидный bbox → 400."""
|
||||
db_path = str(tmp_path / "test.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
conn.close()
|
||||
|
||||
app = _make_test_app(db_path)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": bad_bbox},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ─── I-23: bbox в океане → пустой ────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i23_ocean_bbox_returns_empty(db_with_tracks):
|
||||
"""I-23: bbox в океане (нет треков) → пустой FeatureCollection."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
# Средина Атлантического океана
|
||||
params={"bbox": "-30.0,0.0,-20.0,10.0"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "FeatureCollection"
|
||||
assert data["features"] == []
|
||||
assert data["total_in_bbox"] == 0
|
||||
assert data["truncated"] is False
|
||||
|
||||
|
||||
# ─── I-30: MVT тайл ──────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i30_mvt_tile_returns(db_with_tracks):
|
||||
"""I-30: MVT тайл с треками возвращается."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
# z=10, x=620, y=320 — покрывает Москву
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/api/gps-tracks/tiles/10/620/320.mvt")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/x-protobuf"
|
||||
assert "X-Cache" in resp.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i30_mvt_tile_empty_ocean(tmp_path):
|
||||
"""I-30: MVT тайл без треков возвращает пустой ответ."""
|
||||
db_path = str(tmp_path / "empty.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
conn.close()
|
||||
|
||||
app = _make_test_app(db_path)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/api/gps-tracks/tiles/10/400/300.mvt")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b""
|
||||
|
||||
|
||||
# ─── I-31: cache hit ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i31_cache_hit(db_with_tracks):
|
||||
"""I-31: второй запрос к тому же тайлу возвращает X-Cache: HIT."""
|
||||
from src.api.gps_tracks.mvt import clear_gps_tile_cache
|
||||
clear_gps_tile_cache()
|
||||
|
||||
app = _make_test_app(db_with_tracks)
|
||||
|
||||
# z=10 x=621 y=319 — близко к Москве, должен вернуть данные
|
||||
z, x, y = 10, 621, 319
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
# Первый запрос — MISS
|
||||
resp1 = await client.get(f"/api/gps-tracks/tiles/{z}/{x}/{y}.mvt")
|
||||
assert resp1.status_code == 200
|
||||
|
||||
# Второй запрос к пустому тайлу — кэш не заполняется для пустых
|
||||
# Используем тайл с треками
|
||||
resp2 = await client.get(f"/api/gps-tracks/tiles/{z}/{x}/{y}.mvt")
|
||||
assert resp2.status_code == 200
|
||||
# Если первый вернул данные, второй должен быть HIT
|
||||
if resp1.content:
|
||||
assert resp2.headers.get("X-Cache") == "HIT"
|
||||
|
||||
|
||||
# ─── I-40: health endpoint ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i40_health_endpoint(db_with_pipeline_runs):
|
||||
"""I-40: health endpoint возвращает корректную статистику.
|
||||
|
||||
REQ-F-12: last_pipeline_run — агрегированный объект, а не сырая строка БД.
|
||||
Структура: started_at, finished_at, regions[], sources_ok[], sources_error[], tracks_added.
|
||||
"""
|
||||
app = _make_test_app(db_with_pipeline_runs)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/api/gps-tracks/health")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "tracks_total" in data
|
||||
assert data["tracks_total"] > 0
|
||||
assert "tracks_by_activity" in data
|
||||
|
||||
# REQ-F-12: агрегированный объект last_pipeline_run
|
||||
assert "last_pipeline_run" in data
|
||||
run = data["last_pipeline_run"]
|
||||
assert run is not None, "last_pipeline_run must not be None when pipeline_runs exist"
|
||||
|
||||
# Обязательные поля
|
||||
assert "started_at" in run
|
||||
assert "finished_at" in run
|
||||
assert "regions" in run
|
||||
assert "sources_ok" in run
|
||||
assert "sources_error" in run
|
||||
assert "tracks_added" in run
|
||||
|
||||
# Типы
|
||||
assert isinstance(run["regions"], list)
|
||||
assert isinstance(run["sources_ok"], list)
|
||||
assert isinstance(run["sources_error"], list)
|
||||
assert isinstance(run["tracks_added"], int)
|
||||
|
||||
# Нет сырых полей строки БД (region_id, source_id — не агрегированные)
|
||||
assert "region_id" not in run, "raw DB field region_id must not be present"
|
||||
assert "source_id" not in run, "raw DB field source_id must not be present"
|
||||
|
||||
# Конкретные агрегированные значения из fixture (2 строки одного прогона)
|
||||
assert run["started_at"] == "2026-05-30T03:00:00Z"
|
||||
assert run["finished_at"] == "2026-05-30T05:14:00Z" # max из двух строк
|
||||
assert set(run["regions"]) == {"cfo", "chuvashia"}
|
||||
assert "osm" in run["sources_ok"]
|
||||
assert run["sources_error"] == []
|
||||
assert run["tracks_added"] == 52 # 42 + 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_i40_health_empty_db(tmp_path):
|
||||
"""I-40: health endpoint для пустой БД."""
|
||||
db_path = str(tmp_path / "empty.sqlite")
|
||||
conn = open_db(db_path)
|
||||
init_db(conn)
|
||||
conn.close()
|
||||
|
||||
app = _make_test_app(db_path)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/api/gps-tracks/health")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["tracks_total"] == 0
|
||||
assert data["last_pipeline_run"] is None
|
||||
|
||||
|
||||
# ─── Cache clear endpoint ─────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_clear_endpoint(db_with_tracks):
|
||||
"""POST /api/gps-tracks/cache/clear очищает кэш."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.post("/api/gps-tracks/cache/clear")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cleared"] is True
|
||||
|
||||
|
||||
# ─── F-01/F-02: GeoJSON normalised properties ─────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f01_f02_geojson_normalised_properties(db_with_tracks):
|
||||
"""F-01/F-02: GeoJSON features carry activity/source (MVT-compatible) and length_km."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/gps-tracks",
|
||||
params={"bbox": "37.5,55.7,37.9,55.9"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["features"]) > 0
|
||||
for feature in data["features"]:
|
||||
props = feature["properties"]
|
||||
# F-01: MVT-compatible aliases
|
||||
assert "activity" in props, "activity field missing (F-01)"
|
||||
assert "source" in props, "source field missing (F-01)"
|
||||
assert isinstance(props["source"], str), "source must be str (F-01)"
|
||||
assert props["activity"] == props["activity_type"], "activity must equal activity_type"
|
||||
# F-02: length in km
|
||||
assert "length_km" in props, "length_km missing (F-02)"
|
||||
assert isinstance(props["length_km"], float), "length_km must be float"
|
||||
if props["length_m"]:
|
||||
assert abs(props["length_km"] - props["length_m"] / 1000) < 0.01
|
||||
|
||||
|
||||
# ─── F-04: health extended fields ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f04_health_extended_fields(db_with_tracks):
|
||||
"""F-04: /health returns db_size_mb, tracks_by_source, tile_cache_size."""
|
||||
app = _make_test_app(db_with_tracks)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/api/gps-tracks/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# db_size_mb
|
||||
assert "db_size_mb" in data, "db_size_mb missing (F-04)"
|
||||
assert isinstance(data["db_size_mb"], (int, float))
|
||||
assert data["db_size_mb"] >= 0
|
||||
# tracks_by_source
|
||||
assert "tracks_by_source" in data, "tracks_by_source missing (F-04)"
|
||||
assert isinstance(data["tracks_by_source"], dict)
|
||||
# tile_cache_size
|
||||
assert "tile_cache_size" in data, "tile_cache_size missing (F-04)"
|
||||
assert isinstance(data["tile_cache_size"], int)
|
||||
assert data["tile_cache_size"] >= 0
|
||||
171
tests/api/test_gps_tracks_mvt.py
Normal file
171
tests/api/test_gps_tracks_mvt.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Unit тесты для MVT тайлов GPS-треков (ET-008).
|
||||
|
||||
U-50: тайл z=10 с треками
|
||||
U-51: упрощение на z=7
|
||||
U-52: min-length фильтр
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from shapely.geometry import LineString
|
||||
from shapely import wkb
|
||||
|
||||
from src.api.gps_tracks.mvt import build_gps_mvt, _simplify_coords, _wkb_to_coords
|
||||
|
||||
|
||||
def _make_mock_row(
|
||||
track_id=1,
|
||||
activity_type="enduro",
|
||||
source_id="osm",
|
||||
length_m=8000.0,
|
||||
name="Test Track",
|
||||
coords=None,
|
||||
min_lon=37.60,
|
||||
min_lat=55.74,
|
||||
max_lon=37.65,
|
||||
max_lat=55.78,
|
||||
):
|
||||
"""Создаёт mock sqlite3.Row как словарь."""
|
||||
if coords is None:
|
||||
coords = [
|
||||
(min_lon, min_lat),
|
||||
((min_lon + max_lon) / 2, (min_lat + max_lat) / 2),
|
||||
(max_lon, max_lat),
|
||||
]
|
||||
|
||||
geom_wkb = wkb.dumps(LineString(coords))
|
||||
|
||||
# Имитируем sqlite3.Row через dict с поддержкой подписки
|
||||
class MockRow(dict):
|
||||
def __getitem__(self, key):
|
||||
return super().__getitem__(key)
|
||||
|
||||
return MockRow({
|
||||
"id": track_id,
|
||||
"activity_type": activity_type,
|
||||
"sources_json": json.dumps([source_id]),
|
||||
"external_urls_json": json.dumps([]),
|
||||
"length_m": length_m,
|
||||
"name": name,
|
||||
"geom": geom_wkb,
|
||||
})
|
||||
|
||||
|
||||
# ─── U-50: тайл z=10 с треками ───────────────────────────────────────────────
|
||||
|
||||
def test_u50_tile_z10_with_tracks():
|
||||
"""U-50: build_gps_mvt возвращает непустой тайл при наличии треков."""
|
||||
rows = [
|
||||
_make_mock_row(1, "enduro", "osm", length_m=8000),
|
||||
_make_mock_row(2, "moto", "osm", length_m=5000,
|
||||
min_lon=37.61, min_lat=55.75, max_lon=37.62, max_lat=55.76),
|
||||
]
|
||||
|
||||
# Тайл z=10, x=620, y=320 — область Москвы
|
||||
result = build_gps_mvt(rows, z=10, x=620, y=320)
|
||||
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_u50_empty_rows_returns_empty_bytes():
|
||||
"""U-50: пустой список строк возвращает b""."""
|
||||
result = build_gps_mvt([], z=10, x=620, y=320)
|
||||
assert result == b""
|
||||
|
||||
|
||||
def test_u50_invalid_geom_row_skipped():
|
||||
"""U-50: строка с невалидной геометрией пропускается."""
|
||||
class BadRow(dict):
|
||||
pass
|
||||
|
||||
bad_row = BadRow({
|
||||
"id": 99,
|
||||
"activity_type": "other",
|
||||
"sources_json": '["osm"]',
|
||||
"external_urls_json": "[]",
|
||||
"length_m": 5000,
|
||||
"name": "bad",
|
||||
"geom": b"\x00\x01\x02", # невалидный WKB
|
||||
})
|
||||
|
||||
good_row = _make_mock_row(1, length_m=5000)
|
||||
|
||||
result = build_gps_mvt([bad_row, good_row], z=10, x=620, y=320)
|
||||
# Не падает, плохая строка пропускается
|
||||
assert isinstance(result, bytes)
|
||||
|
||||
|
||||
# ─── U-51: упрощение на z=7 ──────────────────────────────────────────────────
|
||||
|
||||
def test_u51_simplification_z7_reduces_points():
|
||||
"""U-51: геометрия упрощается на малых зумах."""
|
||||
# Создаём трек из 20 точек
|
||||
coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(20)]
|
||||
|
||||
simplified = _simplify_coords(coords, z=7)
|
||||
|
||||
# При z=7 tolerance=0.008, ожидаем меньше точек
|
||||
assert len(simplified) < len(coords)
|
||||
assert len(simplified) >= 2
|
||||
|
||||
|
||||
def test_u51_no_simplification_z12():
|
||||
"""U-51: на z=12 упрощение не применяется."""
|
||||
coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(10)]
|
||||
result = _simplify_coords(coords, z=12)
|
||||
assert result == coords
|
||||
|
||||
|
||||
def test_u51_simplification_z10_moderate():
|
||||
"""U-51: на z=10 умеренное упрощение."""
|
||||
coords = [(37.60 + i * 0.0001, 55.74 + i * 0.0001) for i in range(30)]
|
||||
|
||||
simplified_z10 = _simplify_coords(coords, z=10)
|
||||
simplified_z7 = _simplify_coords(coords, z=7)
|
||||
|
||||
# z=7 должен сильнее упрощать, чем z=10
|
||||
assert len(simplified_z10) >= len(simplified_z7)
|
||||
|
||||
|
||||
# ─── U-52: min-length фильтр ─────────────────────────────────────────────────
|
||||
|
||||
def test_u52_min_length_filter_z7():
|
||||
"""U-52: на z<=7 треки короче 2000м отфильтровываются."""
|
||||
short_track = _make_mock_row(1, length_m=1500) # меньше 2000м
|
||||
long_track = _make_mock_row(2, length_m=5000) # больше 2000м
|
||||
|
||||
result_with_short = build_gps_mvt([short_track, long_track], z=7, x=77, y=40)
|
||||
result_without_short = build_gps_mvt([long_track], z=7, x=77, y=40)
|
||||
|
||||
# Результаты должны совпадать (короткий трек отфильтрован)
|
||||
assert result_with_short == result_without_short
|
||||
|
||||
|
||||
def test_u52_no_min_length_filter_z10():
|
||||
"""U-52: на z=10 нет min-length фильтра — все треки проходят."""
|
||||
short_track = _make_mock_row(1, length_m=100)
|
||||
long_track = _make_mock_row(2, length_m=5000)
|
||||
|
||||
result_both = build_gps_mvt([short_track, long_track], z=10, x=620, y=320)
|
||||
result_long_only = build_gps_mvt([long_track], z=10, x=620, y=320)
|
||||
|
||||
# При z=10 оба трека должны включаться (если геометрия пересекается с тайлом)
|
||||
# result_both может быть больше result_long_only если короткий трек в тайле
|
||||
assert isinstance(result_both, bytes)
|
||||
assert isinstance(result_long_only, bytes)
|
||||
|
||||
|
||||
def test_u52_min_length_boundary():
|
||||
"""U-52: трек ровно 2000м на z=7 проходит фильтр."""
|
||||
track_2000 = _make_mock_row(1, length_m=2000)
|
||||
track_1999 = _make_mock_row(2, length_m=1999)
|
||||
|
||||
result_2000 = build_gps_mvt([track_2000], z=7, x=77, y=40)
|
||||
result_1999 = build_gps_mvt([track_1999], z=7, x=77, y=40)
|
||||
|
||||
# track_1999 должен быть отфильтрован (строго меньше 2000)
|
||||
# track_2000 проходит (>= 2000 не выполняется для строгого фильтра < 2000)
|
||||
# По коду: if min_length_m > 0 and length_m < min_length_m → skip
|
||||
# 1999 < 2000 → skip, 2000 < 2000 → False → not skipped
|
||||
assert result_2000 != result_1999 or result_1999 == b""
|
||||
248
tests/api/test_gps_tracks_sources_osm.py
Normal file
248
tests/api/test_gps_tracks_sources_osm.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Unit тесты для OSM GPS-источника (ET-008).
|
||||
|
||||
U-42: split_bbox_for_osm разбивает правильно
|
||||
U-43: длина через Haversine
|
||||
U-44: защита от XXE через defusedxml
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from src.api.gps_tracks.sources.osm import (
|
||||
OsmParser,
|
||||
split_bbox_for_osm,
|
||||
_haversine_m,
|
||||
_parse_gpx_trackpoints,
|
||||
)
|
||||
|
||||
|
||||
# ─── U-42: split_bbox_for_osm ────────────────────────────────────────────────
|
||||
|
||||
def test_u42_split_bbox_basic():
|
||||
"""U-42: корректное разбиение на ячейки."""
|
||||
bbox = (37.0, 55.0, 38.0, 56.0) # 1° x 1°
|
||||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||||
|
||||
# 1° / 0.25° = 4 ячейки по каждой оси = 16 ячеек
|
||||
assert len(cells) == 16
|
||||
|
||||
|
||||
def test_u42_split_bbox_cell_size():
|
||||
"""U-42: каждая ячейка не больше cell_size по размеру."""
|
||||
bbox = (29.0, 49.5, 47.5, 60.0) # ЦФО
|
||||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||||
|
||||
for cell in cells:
|
||||
west, south, east, north = cell
|
||||
assert east - west <= 0.25 + 1e-9
|
||||
assert north - south <= 0.25 + 1e-9
|
||||
|
||||
|
||||
def test_u42_split_bbox_covers_region():
|
||||
"""U-42: все ячейки вместе покрывают весь регион."""
|
||||
bbox = (37.0, 55.0, 38.0, 56.0)
|
||||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||||
|
||||
min_lon = min(c[0] for c in cells)
|
||||
min_lat = min(c[1] for c in cells)
|
||||
max_lon = max(c[2] for c in cells)
|
||||
max_lat = max(c[3] for c in cells)
|
||||
|
||||
assert abs(min_lon - 37.0) < 1e-9
|
||||
assert abs(min_lat - 55.0) < 1e-9
|
||||
assert abs(max_lon - 38.0) < 0.25 + 1e-9 # последняя ячейка обрезается
|
||||
assert abs(max_lat - 56.0) < 0.25 + 1e-9
|
||||
|
||||
|
||||
def test_u42_split_small_bbox():
|
||||
"""U-42: bbox меньше cell_size даёт одну ячейку."""
|
||||
bbox = (37.0, 55.0, 37.1, 55.1)
|
||||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||||
assert len(cells) == 1
|
||||
|
||||
|
||||
def test_u42_split_bbox_no_overlap():
|
||||
"""U-42: ячейки не перекрываются (west следующей = east предыдущей)."""
|
||||
bbox = (37.0, 55.0, 37.5, 55.25)
|
||||
cells = split_bbox_for_osm(bbox, cell_size=0.25)
|
||||
|
||||
# При bbox шириной 0.5° и cell_size=0.25 должно быть 2 ячейки по оси lon
|
||||
assert len(cells) == 2
|
||||
# Восток первой ячейки = запад второй
|
||||
cells_sorted = sorted(cells, key=lambda c: c[0])
|
||||
assert abs(cells_sorted[0][2] - cells_sorted[1][0]) < 1e-9
|
||||
|
||||
|
||||
# ─── U-43: Haversine длина ───────────────────────────────────────────────────
|
||||
|
||||
def test_u43_haversine_known_distance():
|
||||
"""U-43: проверка haversine на известном расстоянии."""
|
||||
# Москва (37.617, 55.755) → Химки (37.425, 55.889) ≈ 20 км
|
||||
dist = _haversine_m(37.617, 55.755, 37.425, 55.889)
|
||||
assert 18000 < dist < 22000
|
||||
|
||||
|
||||
def test_u43_haversine_zero_distance():
|
||||
"""U-43: одна точка → расстояние 0."""
|
||||
dist = _haversine_m(37.617, 55.755, 37.617, 55.755)
|
||||
assert dist == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_u43_haversine_symmetry():
|
||||
"""U-43: расстояние A→B = B→A."""
|
||||
d1 = _haversine_m(37.617, 55.755, 37.425, 55.889)
|
||||
d2 = _haversine_m(37.425, 55.889, 37.617, 55.755)
|
||||
assert abs(d1 - d2) < 1e-6
|
||||
|
||||
|
||||
def test_u43_haversine_short_distance():
|
||||
"""U-43: короткое расстояние (~111 м на экваторе при 0.001° по lon)."""
|
||||
dist = _haversine_m(0.0, 0.0, 0.001, 0.0)
|
||||
assert 100 < dist < 120
|
||||
|
||||
|
||||
# ─── U-44: защита от XXE ─────────────────────────────────────────────────────
|
||||
|
||||
def test_u44_xxe_protection():
|
||||
"""U-44: defusedxml блокирует XXE атаку."""
|
||||
fixture_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../tests/fixtures/gps-tracks/xxe-payload.gpx",
|
||||
)
|
||||
|
||||
with open(fixture_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
# Должен либо выбросить исключение, либо вернуть пустой список без чтения /etc/passwd
|
||||
try:
|
||||
tracks = _parse_gpx_trackpoints(content, "osm", "")
|
||||
# Если парсинг прошёл без ошибки — проверяем что /etc/passwd не попал в данные
|
||||
for track in tracks:
|
||||
assert "root:" not in str(track)
|
||||
assert "/bin/" not in str(track)
|
||||
except Exception:
|
||||
# defusedxml выбросил исключение — это ожидаемое поведение
|
||||
pass
|
||||
|
||||
|
||||
def test_u44_valid_gpx_parsed_correctly():
|
||||
"""U-44: корректный GPX с gpx_id парсится правильно."""
|
||||
fixture_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../tests/fixtures/gps-tracks/osm-trackpoints-bbox-moscow.gpx",
|
||||
)
|
||||
|
||||
with open(fixture_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
tracks = _parse_gpx_trackpoints(content, "osm", "")
|
||||
|
||||
assert len(tracks) == 1
|
||||
track = tracks[0]
|
||||
assert track.points_count == 3
|
||||
assert abs(track.min_lat - 55.751) < 0.001
|
||||
assert abs(track.max_lat - 55.753) < 0.001
|
||||
assert track.source_id == "osm"
|
||||
|
||||
|
||||
def test_u44_anonymous_trackpoints_skipped():
|
||||
"""U-44: анонимные точки без gpx_id пропускаются."""
|
||||
gpx_without_ids = b"""<?xml version="1.0"?>
|
||||
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
|
||||
<trk>
|
||||
<trkseg>
|
||||
<trkpt lat="55.751" lon="37.618"><time>2024-05-12T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="55.752" lon="37.619"><time>2024-05-12T10:01:00Z</time></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>"""
|
||||
|
||||
tracks = _parse_gpx_trackpoints(gpx_without_ids, "osm", "")
|
||||
assert len(tracks) == 0
|
||||
|
||||
|
||||
def test_u44_multiple_tracks_in_gpx():
|
||||
"""U-44: несколько gpx_id в одном ответе парсятся как разные треки."""
|
||||
gpx_multi = b"""<?xml version="1.0"?>
|
||||
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
|
||||
<trk>
|
||||
<trkseg>
|
||||
<trkpt lat="55.751" lon="37.618" gpx_id="111"><time>2024-05-12T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="55.752" lon="37.619" gpx_id="111"><time>2024-05-12T10:01:00Z</time></trkpt>
|
||||
<trkpt lat="55.760" lon="37.700" gpx_id="222"><time>2024-05-13T08:00:00Z</time></trkpt>
|
||||
<trkpt lat="55.765" lon="37.710" gpx_id="222"><time>2024-05-13T08:05:00Z</time></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>"""
|
||||
|
||||
tracks = _parse_gpx_trackpoints(gpx_multi, "osm", "")
|
||||
assert len(tracks) == 2
|
||||
|
||||
ids = {t.external_id for t in tracks}
|
||||
assert "111" in ids
|
||||
assert "222" in ids
|
||||
|
||||
|
||||
# ─── U-45: _parse_gpx_meta_response ──────────────────────────────────────────
|
||||
|
||||
def test_u45_meta_response_with_known_tag():
|
||||
"""U-45: _parse_gpx_meta_response extracts activity via MAPPING."""
|
||||
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
|
||||
content = b"""<?xml version="1.0"?>
|
||||
<osm version="0.6">
|
||||
<gpx_file id="123" name="my_ride.gpx" user="alice">
|
||||
<description>Weekend ride</description>
|
||||
<tag>enduro</tag>
|
||||
<tag>motorcycle</tag>
|
||||
</gpx_file>
|
||||
</osm>"""
|
||||
meta = _parse_gpx_meta_response(content)
|
||||
assert meta is not None
|
||||
assert meta["activity_type"] == "enduro"
|
||||
assert meta["name"] == "my_ride.gpx"
|
||||
assert meta["user"] == "alice"
|
||||
assert meta["description"] == "Weekend ride"
|
||||
|
||||
|
||||
def test_u45_meta_response_unknown_tag_returns_none_activity():
|
||||
"""U-45: unknown tag → activity_type is None."""
|
||||
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
|
||||
content = b"""<?xml version="1.0"?>
|
||||
<osm version="0.6">
|
||||
<gpx_file id="99" name="trip.gpx" user="bob">
|
||||
<tag>unknown-sport</tag>
|
||||
</gpx_file>
|
||||
</osm>"""
|
||||
meta = _parse_gpx_meta_response(content)
|
||||
assert meta is not None
|
||||
assert meta["activity_type"] is None
|
||||
|
||||
|
||||
def test_u45_meta_response_motorcycle_maps_to_moto():
|
||||
"""U-45: 'motorcycle' tag maps to 'moto' via MAPPING."""
|
||||
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
|
||||
content = b"""<?xml version="1.0"?>
|
||||
<osm version="0.6">
|
||||
<gpx_file id="77" name="ride.gpx" user="carl">
|
||||
<tag>motorcycle</tag>
|
||||
</gpx_file>
|
||||
</osm>"""
|
||||
meta = _parse_gpx_meta_response(content)
|
||||
assert meta["activity_type"] == "moto"
|
||||
|
||||
|
||||
def test_u45_meta_response_invalid_xml_returns_none():
|
||||
"""U-45: malformed XML returns None."""
|
||||
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
|
||||
meta = _parse_gpx_meta_response(b"not xml at all <<<")
|
||||
assert meta is None
|
||||
|
||||
|
||||
def test_u45_meta_response_no_gpx_file_element():
|
||||
"""U-45: valid XML but no gpx_file element → result has all None values."""
|
||||
from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response
|
||||
content = b"""<?xml version="1.0"?><osm version="0.6"></osm>"""
|
||||
meta = _parse_gpx_meta_response(content)
|
||||
# Function should return the dict with None values, not None itself
|
||||
assert meta is not None
|
||||
assert meta["activity_type"] is None
|
||||
assert meta["name"] is None
|
||||
10
tests/fixtures/gps-tracks/osm-trackpoints-bbox-moscow.gpx
vendored
Normal file
10
tests/fixtures/gps-tracks/osm-trackpoints-bbox-moscow.gpx
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0"?>
|
||||
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
|
||||
<trk>
|
||||
<trkseg>
|
||||
<trkpt lat="55.751" lon="37.618" gpx_id="12345"><time>2024-05-12T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="55.752" lon="37.619" gpx_id="12345"><time>2024-05-12T10:01:00Z</time></trkpt>
|
||||
<trkpt lat="55.753" lon="37.620" gpx_id="12345"><time>2024-05-12T10:02:00Z</time></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
3
tests/fixtures/gps-tracks/xxe-payload.gpx
vendored
Normal file
3
tests/fixtures/gps-tracks/xxe-payload.gpx
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
|
||||
<gpx><trk><trkseg><trkpt lat="55.7" lon="37.6"><ele>200</ele>&xxe;</trkpt></trkseg></trk></gpx>
|
||||
0
tests/web/__init__.py
Normal file
0
tests/web/__init__.py
Normal file
288
tests/web/gps_tracks.test.js
Normal file
288
tests/web/gps_tracks.test.js
Normal file
@@ -0,0 +1,288 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ET-008 — unit-тесты модуля публичных GPS-треков (gps_tracks.js).
|
||||
*
|
||||
* Покрывают:
|
||||
* - _findGpsInsertPosition: логика приоритетного поиска позиции вставки
|
||||
* (F-05: gpx-layer-* > route-*)
|
||||
* - Filter state management: начальное состояние window.gpsTracksLayer.filters
|
||||
* - Color palette mapping: GPS_SOURCE_COLORS, GPS_ACTIVITY_COLORS,
|
||||
* GPS_FALLBACK_COLORS и _buildColorExpression()
|
||||
*
|
||||
* Тесты запускают РЕАЛЬНЫЙ код src/web/gps_tracks.js через new Function,
|
||||
* подставляя мок-окружение (window, document) вместо браузерных глобалов.
|
||||
* Браузерный примитив `maplibregl`, `fetch`, `AbortController` не нужны —
|
||||
* тестируемые пути кода к ним не обращаются при инициализации.
|
||||
*
|
||||
* Запуск: `node --test tests/web/gps_tracks.test.js`
|
||||
* (в CI оборачивается pytest-тестом tests/web/test_gps_tracks.py).
|
||||
*/
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const GPS_TRACKS_PATH = path.join(__dirname, '..', '..', 'src', 'web', 'gps_tracks.js');
|
||||
|
||||
// ─── Загрузчик модуля ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Загружает gps_tracks.js в изолированный контекст new Function,
|
||||
* подставляя мок-объекты вместо браузерных глобалов.
|
||||
*
|
||||
* После загрузки в mockWin появится свойство .gpsTracksLayer с начальным
|
||||
* состоянием модуля. Возвращает приватные функции и константы.
|
||||
*
|
||||
* @param {object} [mockWin={}] мок-объект window
|
||||
* @param {object} [mockDoc={}] мок-объект document
|
||||
* @returns {{
|
||||
* _findGpsInsertPosition: Function,
|
||||
* _buildColorExpression: Function,
|
||||
* GPS_SOURCE_COLORS: object,
|
||||
* GPS_ACTIVITY_COLORS: object,
|
||||
* GPS_FALLBACK_COLORS: string[],
|
||||
* GPS_ACTIVITY_ICONS: object,
|
||||
* GPS_ACTIVITY_LABELS: object,
|
||||
* }}
|
||||
*/
|
||||
function loadGpsTracksModule(mockWin, mockDoc) {
|
||||
const win = mockWin || {};
|
||||
// Stub localStorage — используется в onPublicTracksCheckbox/restorePublicTracksState,
|
||||
// но не при инициализации модуля.
|
||||
win.localStorage = win.localStorage || {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
};
|
||||
|
||||
const doc = mockDoc || {
|
||||
getElementById: () => null,
|
||||
querySelectorAll: () => ({ forEach: () => {} }),
|
||||
};
|
||||
|
||||
const src = fs.readFileSync(GPS_TRACKS_PATH, 'utf8');
|
||||
|
||||
// new Function создаёт функцию в глобальном контексте Node.js,
|
||||
// поэтому clearTimeout/setTimeout доступны как Node.js-глобалы.
|
||||
const factory = new Function(
|
||||
'window', 'document',
|
||||
src +
|
||||
'\nreturn {' +
|
||||
' _findGpsInsertPosition,' +
|
||||
' _buildColorExpression,' +
|
||||
' GPS_SOURCE_COLORS,' +
|
||||
' GPS_ACTIVITY_COLORS,' +
|
||||
' GPS_FALLBACK_COLORS,' +
|
||||
' GPS_ACTIVITY_ICONS,' +
|
||||
' GPS_ACTIVITY_LABELS,' +
|
||||
'};'
|
||||
);
|
||||
|
||||
return factory(win, doc);
|
||||
}
|
||||
|
||||
// ─── Вспомогательные функции ──────────────────────────────────────────────────
|
||||
|
||||
/** Создаёт мок-карту с заданным списком слоёв. */
|
||||
function makeMap(layerIds) {
|
||||
return {
|
||||
getStyle: () => ({ layers: layerIds.map((id) => ({ id })) }),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── _findGpsInsertPosition: логика приоритетов ───────────────────────────────
|
||||
|
||||
test('F-05: нет подходящих слоёв → undefined', () => {
|
||||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||||
const map = makeMap(['background', 'osm-base', 'trails-track', 'poi-circles']);
|
||||
assert.equal(_findGpsInsertPosition(map), undefined);
|
||||
});
|
||||
|
||||
test('F-05: только gpx-layer-* → возвращает первый gpx-layer-*', () => {
|
||||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||||
const map = makeMap(['background', 'gpx-layer-file1', 'gpx-layer-file2', 'trails-track']);
|
||||
assert.equal(_findGpsInsertPosition(map), 'gpx-layer-file1');
|
||||
});
|
||||
|
||||
test('F-05: только route-* → возвращает первый route-*', () => {
|
||||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||||
const map = makeMap(['background', 'osm-base', 'route-line', 'route-alt-1']);
|
||||
assert.equal(_findGpsInsertPosition(map), 'route-line');
|
||||
});
|
||||
|
||||
test('F-05: gpx-layer-* приоритетнее route-* даже когда route-* идёт раньше в списке', () => {
|
||||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||||
const map = makeMap(['background', 'route-line', 'gpx-layer-file1', 'poi-labels']);
|
||||
// gpx-layer-* — приоритет 1, должен победить route-line несмотря на порядок
|
||||
assert.equal(_findGpsInsertPosition(map), 'gpx-layer-file1');
|
||||
});
|
||||
|
||||
test('F-05: gpx-layer-* и route-* присутствуют — возвращает gpx-layer-* (приоритет 1)', () => {
|
||||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||||
const map = makeMap(['background', 'gpx-layer-abc', 'route-line', 'route-alt-2']);
|
||||
assert.equal(_findGpsInsertPosition(map), 'gpx-layer-abc');
|
||||
});
|
||||
|
||||
test('F-05: map.getStyle() возвращает null → undefined', () => {
|
||||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||||
const map = { getStyle: () => null };
|
||||
assert.equal(_findGpsInsertPosition(map), undefined);
|
||||
});
|
||||
|
||||
test('F-05: map.getStyle отсутствует → undefined (нет TypeError)', () => {
|
||||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||||
assert.doesNotThrow(() => {
|
||||
const result = _findGpsInsertPosition({});
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('F-05: style.layers пустой → undefined', () => {
|
||||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||||
const map = { getStyle: () => ({ layers: [] }) };
|
||||
assert.equal(_findGpsInsertPosition(map), undefined);
|
||||
});
|
||||
|
||||
test('F-05: слой с именем ровно "route-" (без суффикса) распознаётся как route-*', () => {
|
||||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||||
const map = makeMap(['background', 'route-']);
|
||||
assert.equal(_findGpsInsertPosition(map), 'route-');
|
||||
});
|
||||
|
||||
// ─── Filter state management ──────────────────────────────────────────────────
|
||||
|
||||
test('Filters: начальный список активностей содержит все 7 типов', () => {
|
||||
const win = {};
|
||||
loadGpsTracksModule(win);
|
||||
const { activities } = win.gpsTracksLayer.filters;
|
||||
const expected = ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'];
|
||||
assert.deepEqual(
|
||||
[...activities].sort(),
|
||||
[...expected].sort(),
|
||||
'начальный filters.activities не совпадает с ожидаемым набором',
|
||||
);
|
||||
});
|
||||
|
||||
test('Filters: начальный colorMode === "source"', () => {
|
||||
const win = {};
|
||||
loadGpsTracksModule(win);
|
||||
assert.equal(win.gpsTracksLayer.filters.colorMode, 'source');
|
||||
});
|
||||
|
||||
test('Filters: начальные источники включают osm, enduro_russia, ttrails', () => {
|
||||
const win = {};
|
||||
loadGpsTracksModule(win);
|
||||
const { sources } = win.gpsTracksLayer.filters;
|
||||
assert.ok(Array.isArray(sources), 'filters.sources должен быть массивом');
|
||||
assert.ok(sources.includes('osm'), 'отсутствует источник osm');
|
||||
assert.ok(sources.includes('enduro_russia'), 'отсутствует источник enduro_russia');
|
||||
assert.ok(sources.includes('ttrails'), 'отсутствует источник ttrails');
|
||||
});
|
||||
|
||||
test('Filters: enabled=false при инициализации', () => {
|
||||
const win = {};
|
||||
loadGpsTracksModule(win);
|
||||
assert.equal(win.gpsTracksLayer.enabled, false);
|
||||
});
|
||||
|
||||
test('Filters: filters.activities — массив, не объект', () => {
|
||||
const win = {};
|
||||
loadGpsTracksModule(win);
|
||||
assert.ok(Array.isArray(win.gpsTracksLayer.filters.activities));
|
||||
});
|
||||
|
||||
// ─── Color palette mapping ────────────────────────────────────────────────────
|
||||
|
||||
test('Colors: GPS_SOURCE_COLORS содержит ключи osm, enduro_russia, ttrails, offmaps, nakarte', () => {
|
||||
const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
|
||||
for (const src of ['osm', 'enduro_russia', 'ttrails', 'offmaps', 'nakarte']) {
|
||||
assert.ok(
|
||||
GPS_SOURCE_COLORS[src],
|
||||
`GPS_SOURCE_COLORS: отсутствует источник ${src}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Colors: GPS_ACTIVITY_COLORS содержит все 7 типов активности', () => {
|
||||
const { GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
|
||||
for (const act of ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other']) {
|
||||
assert.ok(
|
||||
GPS_ACTIVITY_COLORS[act],
|
||||
`GPS_ACTIVITY_COLORS: отсутствует активность ${act}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Colors: GPS_FALLBACK_COLORS — массив из 8 уникальных цветов', () => {
|
||||
const { GPS_FALLBACK_COLORS } = loadGpsTracksModule();
|
||||
assert.ok(Array.isArray(GPS_FALLBACK_COLORS), 'GPS_FALLBACK_COLORS не является массивом');
|
||||
assert.equal(GPS_FALLBACK_COLORS.length, 8, 'GPS_FALLBACK_COLORS должен содержать 8 цветов');
|
||||
const unique = new Set(GPS_FALLBACK_COLORS);
|
||||
assert.equal(
|
||||
unique.size,
|
||||
GPS_FALLBACK_COLORS.length,
|
||||
'GPS_FALLBACK_COLORS содержит дубли',
|
||||
);
|
||||
});
|
||||
|
||||
test('Colors: все цвета GPS_SOURCE_COLORS — строки в формате #RRGGBB', () => {
|
||||
const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
|
||||
const hexRe = /^#[0-9a-fA-F]{6}$/;
|
||||
for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
|
||||
assert.match(color, hexRe, `GPS_SOURCE_COLORS[${src}] = "${color}" не является hex-цветом`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Colors: все цвета GPS_ACTIVITY_COLORS — строки в формате #RRGGBB', () => {
|
||||
const { GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
|
||||
const hexRe = /^#[0-9a-fA-F]{6}$/;
|
||||
for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
|
||||
assert.match(color, hexRe, `GPS_ACTIVITY_COLORS[${act}] = "${color}" не является hex-цветом`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Colors: _buildColorExpression("source") — MapLibre match по полю "source"', () => {
|
||||
const { _buildColorExpression, GPS_SOURCE_COLORS } = loadGpsTracksModule();
|
||||
const expr = _buildColorExpression('source');
|
||||
|
||||
assert.ok(Array.isArray(expr), 'выражение должно быть массивом');
|
||||
assert.equal(expr[0], 'match', 'первый элемент должен быть "match"');
|
||||
assert.deepEqual(expr[1], ['get', 'source'], 'второй элемент должен быть ["get", "source"]');
|
||||
|
||||
// Каждый источник присутствует в выражении с правильным цветом
|
||||
for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
|
||||
const idx = expr.indexOf(src);
|
||||
assert.notEqual(idx, -1, `источник "${src}" не найден в match-выражении`);
|
||||
assert.equal(expr[idx + 1], color, `цвет для источника "${src}" не совпадает`);
|
||||
}
|
||||
|
||||
// Последний элемент — fallback цвет
|
||||
assert.equal(expr[expr.length - 1], '#808080', 'последний элемент должен быть fallback #808080');
|
||||
});
|
||||
|
||||
test('Colors: _buildColorExpression("activity") — MapLibre match по полю "activity"', () => {
|
||||
const { _buildColorExpression, GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
|
||||
const expr = _buildColorExpression('activity');
|
||||
|
||||
assert.ok(Array.isArray(expr), 'выражение должно быть массивом');
|
||||
assert.equal(expr[0], 'match', 'первый элемент должен быть "match"');
|
||||
assert.deepEqual(expr[1], ['get', 'activity'], 'второй элемент должен быть ["get", "activity"]');
|
||||
|
||||
// Каждый тип активности присутствует в выражении с правильным цветом
|
||||
for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
|
||||
const idx = expr.indexOf(act);
|
||||
assert.notEqual(idx, -1, `активность "${act}" не найдена в match-выражении`);
|
||||
assert.equal(expr[idx + 1], color, `цвет для активности "${act}" не совпадает`);
|
||||
}
|
||||
|
||||
// Последний элемент — fallback цвет
|
||||
assert.equal(expr[expr.length - 1], '#808080', 'последний элемент должен быть fallback #808080');
|
||||
});
|
||||
|
||||
test('Colors: _buildColorExpression — незнакомый режим даёт выражение по источнику', () => {
|
||||
const { _buildColorExpression } = loadGpsTracksModule();
|
||||
// Любое значение, отличное от 'activity', даёт режим 'source'
|
||||
const expr = _buildColorExpression('unknown');
|
||||
assert.deepEqual(expr[1], ['get', 'source']);
|
||||
});
|
||||
133
tests/web/test_gps_tracks.py
Normal file
133
tests/web/test_gps_tracks.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""ET-008 — тесты модуля публичных GPS-треков (gps_tracks.js + endpoint).
|
||||
|
||||
Изменение ET-008 — модуль `src/web/gps_tracks.js` + FastAPI endpoint
|
||||
`/api/gps-tracks`. В CI исполняется только `pytest tests/`, поэтому файл
|
||||
покрывает фронтендовую часть двумя способами:
|
||||
|
||||
1. Статические проверки структуры gps_tracks.js — выполняются всегда.
|
||||
2. Поведенческие JS unit-тесты — через встроенный тест-раннер Node
|
||||
(`node --test`). Если `node` отсутствует — часть помечается `skip`.
|
||||
|
||||
API-тесты endpoint живут в tests/api/test_gps_tracks_endpoint.py.
|
||||
|
||||
Запуск JS-тестов напрямую:
|
||||
node --test tests/web/gps_tracks.test.js
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
GPS_TRACKS_JS = REPO_ROOT / "src" / "web" / "gps_tracks.js"
|
||||
JS_TEST = REPO_ROOT / "tests" / "web" / "gps_tracks.test.js"
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
assert path.is_file(), f"не найден {path}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Статические проверки gps_tracks.js
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gps_tracks_module_exists():
|
||||
"""Модуль src/web/gps_tracks.js присутствует в репозитории."""
|
||||
assert GPS_TRACKS_JS.is_file(), "не найден src/web/gps_tracks.js"
|
||||
|
||||
|
||||
def test_gps_tracks_find_insert_position_defined():
|
||||
"""_findGpsInsertPosition() объявлена в модуле."""
|
||||
js = _read(GPS_TRACKS_JS)
|
||||
assert "function _findGpsInsertPosition(" in js, (
|
||||
"_findGpsInsertPosition не объявлена в gps_tracks.js"
|
||||
)
|
||||
|
||||
|
||||
def test_gps_tracks_find_insert_position_priority_gpx_first():
|
||||
"""F-05: поиск gpx-layer-* идёт до route-* (приоритет 1 > приоритет 2)."""
|
||||
js = _read(GPS_TRACKS_JS)
|
||||
fn_start = js.index("function _findGpsInsertPosition(")
|
||||
fn_end = js.index("\n}", fn_start)
|
||||
fn_body = js[fn_start:fn_end]
|
||||
|
||||
gpx_pos = fn_body.find("gpx-layer-")
|
||||
route_pos = fn_body.find("route-")
|
||||
assert gpx_pos != -1, "gpx-layer- не найден в _findGpsInsertPosition"
|
||||
assert route_pos != -1, "route- не найден в _findGpsInsertPosition"
|
||||
assert gpx_pos < route_pos, (
|
||||
"gpx-layer-* должен проверяться ДО route-* (приоритет 1 > приоритет 2)"
|
||||
)
|
||||
|
||||
|
||||
def test_gps_tracks_find_insert_position_no_exact_route_line():
|
||||
"""F-05: старый точный match 'route-line' удалён, используется startsWith."""
|
||||
js = _read(GPS_TRACKS_JS)
|
||||
fn_start = js.index("function _findGpsInsertPosition(")
|
||||
fn_end = js.index("\n}", fn_start)
|
||||
fn_body = js[fn_start:fn_end]
|
||||
assert "l.id === 'route-line'" not in fn_body, (
|
||||
"старый точный матч route-line не должен присутствовать (F-05)"
|
||||
)
|
||||
|
||||
|
||||
def test_gps_tracks_state_object_defined():
|
||||
"""window.gpsTracksLayer инициализируется в модуле."""
|
||||
js = _read(GPS_TRACKS_JS)
|
||||
assert "window.gpsTracksLayer = {" in js, (
|
||||
"gps_tracks.js не инициализирует window.gpsTracksLayer"
|
||||
)
|
||||
|
||||
|
||||
def test_gps_tracks_source_colors_defined():
|
||||
"""GPS_SOURCE_COLORS объявлен для всех основных источников."""
|
||||
js = _read(GPS_TRACKS_JS)
|
||||
for src in ("osm", "enduro_russia", "ttrails", "offmaps", "nakarte"):
|
||||
assert src in js, f"GPS_SOURCE_COLORS не содержит ключ {src}"
|
||||
|
||||
|
||||
def test_gps_tracks_activity_colors_defined():
|
||||
"""GPS_ACTIVITY_COLORS объявлен для всех 7 типов активности."""
|
||||
js = _read(GPS_TRACKS_JS)
|
||||
for act in ("enduro", "moto", "offroad", "bicycle", "hike", "ski", "other"):
|
||||
assert act in js, f"GPS_ACTIVITY_COLORS не содержит ключ {act}"
|
||||
|
||||
|
||||
def test_gps_tracks_build_color_expression_defined():
|
||||
"""_buildColorExpression() объявлена в модуле."""
|
||||
js = _read(GPS_TRACKS_JS)
|
||||
assert "function _buildColorExpression(" in js, (
|
||||
"_buildColorExpression не объявлена в gps_tracks.js"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Поведенческие JS unit-тесты через Node
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
node_required = pytest.mark.skipif(
|
||||
which("node") is None,
|
||||
reason="node не установлен — поведенческие JS unit-тесты пропущены",
|
||||
)
|
||||
|
||||
|
||||
@node_required
|
||||
def test_js_unit_tests_pass():
|
||||
"""F-05 + filters + colors: behavioral JS-тесты gps_tracks.js через `node --test`."""
|
||||
assert JS_TEST.is_file(), f"не найден JS-тест {JS_TEST}"
|
||||
node = which("node")
|
||||
result = subprocess.run(
|
||||
[node, "--test", str(JS_TEST)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"JS unit-тесты GPS-треков упали (код {result.returncode}):\n"
|
||||
f"{result.stdout}\n{result.stderr}"
|
||||
)
|
||||
Reference in New Issue
Block a user