From 0840818c9a3642a7b0761c740754f8e14ec76c2b Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 1 Jun 2026 11:44:40 +0000 Subject: [PATCH] analyst(ET-008): BRD, TRZ, AC, TestPlan, UI tests v2 --- docs/work-items/ET-008/01-brd.md | 262 +++- docs/work-items/ET-008/02-trz.md | 1201 ++++++++++++----- .../ET-008/03-acceptance-criteria.md | 540 +++++--- docs/work-items/ET-008/04-test-plan.yaml | 721 ++++++---- docs/work-items/ET-008/04b-ui-test-cases.md | 755 ++++++----- 5 files changed, 2222 insertions(+), 1257 deletions(-) diff --git a/docs/work-items/ET-008/01-brd.md b/docs/work-items/ET-008/01-brd.md index 821f8bd..bae2965 100644 --- a/docs/work-items/ET-008/01-brd.md +++ b/docs/work-items/ET-008/01-brd.md @@ -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/-licensing.md`: + +1. Что говорит ToS источника о скрейпинге / массовой загрузке GPX. +2. Что говорит robots.txt. +3. На каких условиях разрешена публикация чужих треков + (имя/анонимизация/атрибуция). +4. Rate-limit, который мы будем соблюдать (default: 1 req / 5 sec, с + корректным `User-Agent: enduro-trails/ (+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/.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. diff --git a/docs/work-items/ET-008/02-trz.md b/docs/work-items/ET-008/02-trz.md index a2ef347..763dd71 100644 --- a/docs/work-items/ET-008/02-trz.md +++ b/docs/work-items/ET-008/02-trz.md @@ -2,10 +2,12 @@ type: trz work_item_id: ET-008 title: "ТЗ: GPS-треки с публичных платформ на карте" -version: 1 +version: 2 status: draft created_at: 2026-06-01 updated_at: 2026-06-01 +changelog: + - "v2 (2026-06-01): полная переработка под BRD v2 — серверная агрегация по региону, дедупликация, MVT-тайлы публичных треков, фильтры по активности/источнику. Предыдущая v1 описывала URL-импорт + OSM live-поиск (не соответствовало бизнес-цели)." authors: - "agent:analyst" --- @@ -14,460 +16,935 @@ authors: ## 1. Функциональные требования -### REQ-F-01: Расширение sheet `#sheet-gpx` +### REQ-F-01: Конфигурация источников -В верхней части `#sheet-gpx` (под header, над списком треков) добавить -секцию «Источники» с двумя вкладками-кнопками (segmented control): +Файл `config/gps_sources.yaml` в репозитории: -- **Из файла** — текущее поведение ET-006 (`#btn-gpx-upload`). -- **По ссылке** — поле ввода URL + кнопка «Загрузить». -- **Найти рядом** — кнопка «Найти публичные треки в этой области карты». +```yaml +sources: + - id: osm + name: "OSM Public GPS Traces" + enabled: true + license_adr: "06-adr/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" -При первом открытии активна вкладка **Из файла** (обратная совместимость). + - id: enduro_russia + name: "EnduroRussia.ru" + enabled: true + license_adr: "06-adr/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" -### REQ-F-02: Импорт по URL - -- Поле `` с placeholder - «https://example.com/track.gpx». -- Кнопка `#btn-gpx-fetch-url` рядом — «Загрузить». -- При нажатии: - 1. Клиентская валидация URL (`new URL()`, схема `https?:`). - 2. Запрос `GET /api/gpx/fetch?url=`. - 3. Полученный текст GPX парсится тем же `parseGpx()` из `gpx.js`. - 4. Результат добавляется в `window.gpxTracks` как обычно. Поле - `source` = `{kind: 'url', url: ''}`. - 5. `filename` для отображения: последний segment URL без `.gpx` или - `` если есть. -- Поддерживается также Enter в поле ввода. - -### REQ-F-03: Прокси-эндпоинт `/api/gpx/fetch` - -``` -GET /api/gpx/fetch?url= + # ... ``` -- Валидация: - - Схема URL ∈ {`http`, `https`}. - - Хост резолвится в публичный IP (не RFC1918, не loopback, не link-local). - Проверка через `socket.getaddrinfo()` + `ipaddress.ip_address().is_global`. - - Запрет редиректов на приватные IP (`httpx.AsyncClient(follow_redirects=False)`, - ручная обработка max 3 редиректов с повторной валидацией хоста). -- Загрузка: - - Таймаут 15 секунд. - - Лимит размера ответа: 50 МБ (стримом, прервать при превышении). - - Заголовок `User-Agent: enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)`. -- Кэш: - - Ключ = SHA-256(url). - - In-memory LRU, max 64 записи, TTL 24 ч. - - При cache hit — отдаётся из кэша. -- Ответ: - - `200 OK`, `Content-Type: application/gpx+xml`, тело GPX. - - Заголовок `X-Cache: HIT|MISS`. -- Ошибки → JSON `{error: "..."}`: - - `400` — невалидный URL / приватный IP / запрещённая схема. - - `404` — внешний сервер вернул 404. - - `413` — превышен лимит размера. - - `502` — внешний сервер недоступен / таймаут. - - `504` — таймаут на нашей стороне. +Поле `enabled: false` исключает источник из pipeline (но БД сохраняет +ранее собранные треки). -### REQ-F-04: Кнопка «Найти публичные треки» +### REQ-F-02: Конфигурация регионов -- Кнопка `#btn-gpx-find-nearby` в секции «Источники». -- Текст: «Найти треки в этой области». -- При нажатии: - 1. Получить bbox видимой области карты: `map.getBounds()`. - 2. Валидация: площадь bbox ≤ 0.25 deg² (OSM API limit — иначе ошибка). - Если больше — toast «Слишком большая область, увеличьте zoom». - 3. Запрос `GET /api/gpx/osm/traces?bbox=west,south,east,north`. - 4. Открыть подсекцию «Найденные треки» (REQ-F-05). +Файл `config/gps_regions.yaml`: -### REQ-F-05: Прокси-эндпоинт `/api/gpx/osm/traces` +```yaml +regions: + - id: tsfo_plus_chuvashia + name: "ЦФО + Чувашия" + bbox: [29.0, 49.5, 47.5, 60.0] # [west, south, east, north] + enabled: true + sources: [osm, enduro_russia, ttrails] # ID из gps_sources.yaml -``` -GET /api/gpx/osm/traces?bbox=,,,&page= + - id: north_caucasus + name: "Северный Кавказ" + bbox: [37.0, 41.5, 49.0, 47.0] + enabled: false + sources: [osm, enduro_russia] ``` -- Параметры: - - `bbox` — обязательный, 4 числа через запятую. - - `page` — опциональный, целое ≥ 0, default 0. -- Валидация: - - Каждая координата — валидный float, в допустимом диапазоне. - - Площадь bbox ≤ 0.25 deg² — иначе `400`. -- Запрос к OSM: - ``` - GET https://api.openstreetmap.org/api/0.6/trackpoints - ?bbox=&page= - ``` - - Таймаут 10 секунд. - - User-Agent как в REQ-F-03. -- Парсинг ответа: - - OSM возвращает GPX 1.0 с `` и атрибутом `gpx_id` у некоторых - точек (см. формат OSM API). Группируем точки по `gpx_id` → - массив треков-метаданных. - - Анонимные треки (без `gpx_id`) объединяются в один общий «Анонимные треки этой области». -- Кэш: - - Ключ = `(bbox_rounded_to_4_digits, page)`. - - In-memory LRU, max 256 записей, TTL 24 ч. -- Ответ (JSON): - ```json - { - "bbox": [w, s, e, n], - "page": 0, - "has_more": false, - "tracks": [ - { - "osm_id": 12345, - "name": "Trail in the woods", - "description": "...", - "user": "username", - "points_count": 320, - "distance_km": 12.4, - "url": "https://www.openstreetmap.org/user/.../traces/12345", - "gpx_url": "https://api.openstreetmap.org/api/0.6/gpx/12345/data" - } - ] - } - ``` -- Поле `distance_km` — посчитано на сервере (Haversine). -- Ошибки → JSON `{error: "..."}`: - - `400` — невалидный bbox / слишком большая область. - - `502` — OSM API недоступен. - - `504` — таймаут. +Добавление региона = новая запись (≤ 30 строк) — REQ-F-04 BRD. -### REQ-F-06: UI списка найденных треков +### REQ-F-03: Pipeline сбора `scripts/gps_collect.py` -В подсекции `#gpx-nearby-results` под кнопкой «Найти треки»: +CLI: +``` +python scripts/gps_collect.py [--region ] [--source ] [--dry-run] +``` -- Заголовок: «Найдено N треков в этой области». -- Список карточек, каждая: - - Иконка-индикатор источника (OSM-логотип маленький). - - Имя трека (или «Без названия»). - - Метаданные: длина (км, через `units.js`), автор (если есть). - - Кнопка «Показать» — импортирует трек на карту. - - Кнопка «↗» — открывает страницу трека на osm.org в новой вкладке. -- Если `has_more` — кнопка «Показать ещё» внизу списка (увеличивает page). -- Если треков нет — текст «В этой области нет публичных GPS-треков». +Без `--region` — обрабатываются все `enabled: true` регионы. Без +`--source` — все `enabled: true` источники, перечисленные в регионе. -### REQ-F-07: Импорт выбранного OSM-трека +Логика прогона: +``` +1. Загрузить config/gps_sources.yaml и config/gps_regions.yaml. +2. Для каждого (region, source) в декартовом произведении: + 2.1. Вызвать parser_module.collect(region.bbox, db) → + yield {external_id, geom, metadata} + 2.2. Для каждого трека: + - dedup_key = compute_dedup_key(geom, metadata) + - Если запись с тем же dedup_key уже есть — обновить + metadata (sources += [source_id]). + - Иначе — INSERT. + 2.3. Логировать статус: tracks_new, tracks_updated, errors. +3. Записать в БД таблицу `pipeline_runs`: + (run_id, started_at, finished_at, region, source, status, + tracks_new, tracks_updated, errors_json) +4. Exit code: 0 если все source ≥ 1 трек или dry-run; 1 иначе. +``` -При клике на «Показать»: -1. Запрос `GET /api/gpx/fetch?url=` — тот же эндпоинт, что для - произвольного URL (переиспользование кэша и валидации). -2. После загрузки — те же шаги, что REQ-F-02 (парсинг → модель → рендеринг). -3. Поле `source` = `{kind: 'osm', osm_id: , url: }`. -4. Карточка в списке найденных треков получает индикатор «✓ Загружен». -5. Повторный клик «Показать» — no-op (toast «Уже загружен»). +Все per-source модули обязаны: +- Уважать `rate_limit_sec`: `await asyncio.sleep(rate_limit_sec)` между + HTTP-запросами. +- Использовать `User-Agent` из конфига. +- Делать `backoff` (3 повтора, exponential) на 5xx/429. +- На необработанную ошибку — `raise` → pipeline ловит, логирует, идёт + дальше к следующему источнику. -### REQ-F-08: Отображение источника в карточке трека +### REQ-F-04: Парсер OSM Public GPS Traces -В существующей карточке трека в списке `#gpx-list` (ET-006): +Модуль `src/api/gps_tracks/sources/osm.py`. -- Под именем файла мелким шрифтом добавить строку «источник»: - - Локальный файл: «📁 локальный файл» (без изменения для ET-006). - - URL: «🔗 » (например, «🔗 github.com»). - - OSM: «🌍 OSM #» — кликабельная ссылка на страницу osm.org. +OSM API: `GET https://api.openstreetmap.org/api/0.6/trackpoints?bbox=W,S,E,N&page=P`. -### REQ-F-09: Расширение модели `window.gpxTracks` +Лимиты: +- bbox площадь ≤ 0.25 deg² (OSM API requirement). +- Регион (ЦФО ≈ 18×10 = 180 deg²) разбивается на тайл-сетку 0.25 + deg-cells (≈ 720 cells на ЦФО+Чувашию). +- Пагинация: page 0…N, до `has_more=false`. -Каждый элемент `window.gpxTracks` дополнительно содержит: +Извлекаем: +- Группировка точек по `gpx_id` атрибуту (там, где есть) → отдельные + треки. Анонимные точки (без `gpx_id`) — пропускаем (нет публичного + ID, не дедуплицируется). +- Для треков с `gpx_id` — дополнительный запрос `GET /api/0.6/gpx/` + для метаданных (name, description, tags, user, timestamp). + Этот запрос делаем **отложенно** в batch: накопить 100 id → запросить. + Тип активности — из tags GPX-трека (`Tag: enduro/moto/mtb/hike/...`). -```javascript +Метаданные на выходе: +```python { - // ... существующие поля ET-006 (id, filename, color, tracks, waypoints, ...) - source: { - kind: 'file' | 'url' | 'osm', - url: string | null, // для kind='url' и 'osm' - osm_id: number | null, // для kind='osm' - } + "external_id": f"osm-{gpx_id}", + "external_url": f"https://www.openstreetmap.org/user/{user}/traces/{gpx_id}", + "source_id": "osm", + "name": str | None, + "description": str | None, + "user": str | None, + "activity_type": str | None, # см. REQ-F-07 + "tags": List[str], + "created_at": ISO-date | None, + "geom": LineString, + "points_count": int, + "length_m": float, } ``` -Для треков ET-006 (загруженных из файла) `source.kind = 'file'` -(обратная совместимость через миграцию на лету: если `source` отсутствует, -читать как `{kind: 'file'}`). +### REQ-F-05: Парсер EnduroRussia.ru -### REQ-F-10: Обработка ошибок и toast-уведомления +Модуль `src/api/gps_tracks/sources/enduro_russia.py`. -| Ситуация | Toast | -|----------|-------| -| Невалидный URL | «Невалидная ссылка» | -| URL → приватный IP | «Эта ссылка недоступна» | -| Внешний 404 | «Файл не найден по этой ссылке» | -| Внешний таймаут / 502 | «Сервер не отвечает, попробуйте позже» | -| Файл > 50 МБ | «Файл слишком большой (макс. 50 МБ)» | -| Не GPX (DOMParser fail) | «По этой ссылке не GPX-файл» | -| OSM: bbox > 0.25 deg² | «Слишком большая область, увеличьте zoom» | -| OSM: 0 треков | «В этой области нет публичных GPS-треков» (не toast, а inline-сообщение) | -| OSM: rate limit (429) | «Слишком много запросов к OSM, попробуйте через минуту» | +Стратегия (зависит от структуры сайта на момент реализации; +фиксируется в ADR `06-adr/enduro-russia-licensing.md` после ревью): -### REQ-F-11: Сохранение при смене стиля карты +``` +1. Получить список регионов: GET /regions/ +2. Для каждого региона, пересекающегося с bbox: + 2.1. Получить список треков: GET /treki/?page=N + 2.2. Для каждого трека: + - Открыть страницу трека + - Найти прямую ссылку на GPX + - Скачать GPX + - Парсить через тот же парсер, что в gpx.js (или DOMParser + серверный — через defusedxml) +``` -Импортированные треки переживают `map.setStyle()` через тот же механизм -`rebuildGpxOverlays()`, что и локальные ET-006. Никаких изменений в -этой функции не требуется — модель данных совместима. +Метаданные на выходе — та же структура, что REQ-F-04 (общий контракт). + +`activity_type` — из категории на сайте источника. + +### REQ-F-06: Парсер Тропинки.ру / ttrails.ru + +Модуль `src/api/gps_tracks/sources/ttrails.py`. По аналогии с REQ-F-05, +точный алгоритм — в ADR после ревью структуры сайта. + +### REQ-F-07: Унификация типа активности + +Все источники маппят свою категоризацию в фиксированный enum: + +```python +ACTIVITY_TYPES = [ + "enduro", # эндуро-мотоцикл (приоритет проекта) + "moto", # мото (не эндуро): шоссе, dual-sport + "offroad", # off-road авто, квадроциклы + "bicycle", # любые велосипеды (mtb, road) + "hike", # пешком, бег + "ski", # лыжи + "other", # неопределено +] +``` + +Маппинг per-source — в `parser_module.MAPPING` константой. Категории +источника, не маппящиеся ни во что — в `other`. + +### REQ-F-08: Дедупликация + +Алгоритм (детали в ADR `06-adr/dedup-algorithm.md`): + +```python +def compute_dedup_key(geom: LineString, metadata: dict) -> str: + bbox = geom.bounds # (w, s, e, n) + bbox_rounded = tuple(round(c, 2) for c in bbox) # ≈ 1 км + length_bucket = round(metadata["length_m"] / 1000) * 1000 # 1 км + date_bucket = metadata.get("created_at", "")[:10] # YYYY-MM-DD + return f"{bbox_rounded}|{length_bucket}|{date_bucket}" +``` + +При коллизии — мержим записи: +- `sources` (массив) ← union. +- `external_urls` (массив) ← union. +- `metadata` ← данные источника с большим приоритетом (порядок в + `gps_sources.yaml`). + +Если у одного из треков `created_at` отсутствует — date_bucket пустой +для обоих → считаем коллизией. Это даст ложные коллизии для треков из +разных дат, но без даты мы и не отличим их. + +### REQ-F-09: Схема БД + +Отдельная БД: `data/gps_tracks.sqlite` (SQLite + Spatialite). + +```sql +CREATE TABLE tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dedup_key TEXT NOT NULL UNIQUE, + name TEXT, + description TEXT, + activity_type TEXT, -- ACTIVITY_TYPES + user TEXT, -- автор (опционально) + created_at TEXT, -- ISO date + 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 + sources_json TEXT NOT NULL, -- ["osm", "enduro_russia"] + external_urls_json TEXT NOT NULL, -- ["https://...", "https://..."] + tags_json TEXT, -- ["forest", "river-crossing"] + inserted_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_tracks_bbox ON tracks(min_lon, max_lon, min_lat, max_lat); +CREATE INDEX idx_tracks_activity ON tracks(activity_type); +CREATE INDEX idx_tracks_created ON tracks(created_at); + +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 + tracks_new INTEGER DEFAULT 0, + tracks_updated INTEGER DEFAULT 0, + errors_json TEXT -- {error_type: count} +); + +CREATE INDEX idx_pipeline_started ON pipeline_runs(started_at); +``` + +`sources_json`/`external_urls_json`/`tags_json` — JSON-strings, потому +что SQLite без JSON1 не индексирует массивы; для фильтра по source +читаем в Python после bbox-выборки. + +### REQ-F-10: Endpoint `GET /api/gps-tracks` + +``` +GET /api/gps-tracks?bbox=W,S,E,N&activity=enduro,moto&source=osm,ttrails&limit=500 +``` + +Параметры: +- `bbox` — обязательный, 4 float. +- `activity` — опционально, comma-separated из ACTIVITY_TYPES. + Default: все. +- `source` — опционально, comma-separated source IDs. Default: все. +- `limit` — опционально, default 500, max 2000. + +Ответ — GeoJSON FeatureCollection: +```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, + "sources": ["osm", "enduro_russia"], + "external_urls": ["https://...", "https://..."] + } + }, + ... + ], + "total_in_bbox": 743, + "returned": 500, + "truncated": true +} +``` + +Если `truncated: true` — клиент показывает в popup: «Показаны 500 +треков из 743. Увеличьте zoom для полной выборки». + +### REQ-F-11: Endpoint `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` + +Для эффективной отдачи на низких зумах (z ≤ 11). Структура аналогична +существующему `/api/tiles/{z}/{x}/{y}.mvt`: + +- Layer: `gps_tracks`. +- Features: LineString с properties `{activity, source, length_km, + name, ext_url}`. +- На z ≤ 7: упрощение через `simplify_coords(coords, z)` (уже есть в + `main.py`) + `length_m >= 1000`. +- На z 8–10: упрощение тоньше, без min-length. +- На z ≥ 11: без упрощения. +- LIMIT треков в тайле — как в `/api/tiles` (3000 на z ≤ 7, 8000 на z + ≤ 9, 15000 на z ≤ 11). +- Серверный LRU-кэш 1024 тайла (как для основных тайлов). + +Клиент выбирает источник по зуму: +- z ≤ 11: vector tiles (`gps-tracks-tiles` MapLibre source). +- z ≥ 12: GeoJSON через `/api/gps-tracks?bbox=…` (более свежие данные + + интерактивность popup). + +### REQ-F-12: Endpoint `GET /api/gps-tracks/health` + +Ответ: +```json +{ + "db_path": "/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", "enduro_russia"], + "sources_error": [{"source": "ttrails", "error": "HTTP 503"}] + }, + "tile_cache_size": 412 +} +``` + +### REQ-F-13: Чекбокс «Публичные треки» в `#terrain-popup` + +В существующий попап (после блока «Тропы», перед «POI»): + +```html +
+ + +``` + +При включённом чекбоксе: +- Добавить vector source `gps-tracks-tiles` и raster — нет, MVT — + `vector` source. +- Добавить line layer `gps-tracks-layer` поверх `trails-*` слоёв, ниже + `route-line` (если есть). +- На z ≥ 12 — добавить GeoJSON source `gps-tracks-geo` (загружается по + `moveend`-событию карты) и line layer `gps-tracks-layer-geo` с теми + же стилями. +- Кнопка «Фильтры…» становится видна. + +При выключении: +- visibility = 'none' для обоих слоёв. +- Сохранить настройки фильтров в `window._gpsFilters`. + +### REQ-F-14: Sheet `#sheet-gps-filters` + +Bottom sheet (аналогично `#sheet-recon`): + +``` +┌─────────────────────────────────────┐ +│ ═══ │ +│ 🌍 Фильтры публичных треков [✕] │ +├─────────────────────────────────────┤ +│ ТИП АКТИВНОСТИ │ +│ ☑ Эндуро ☑ Мото ☑ Off-road │ +│ ☑ Велосипед ☑ Пешком ☑ Лыжи │ +│ ☑ Другое │ +│ │ +│ ИСТОЧНИК │ +│ ☑ OSM ☑ EnduroRussia.ru │ +│ ☑ ttrails.ru ☐ Offmaps.ru │ +│ ☐ Nakarte.me │ +│ │ +│ ЦВЕТ ЛИНИЙ │ +│ ◉ По источнику ○ По активности │ +│ │ +│ Всего треков в области: 743 │ +│ Видны: 412 (фильтр) │ +└─────────────────────────────────────┘ +``` + +Поведение: +- Чекбоксы multi-select. +- Изменение фильтра → клиентская фильтрация уже загруженных features + через `setFilter()` MapLibre (без нового запроса). +- При смене bbox карты — повторный запрос только видимой выборки. +- Состояние фильтров сохраняется в localStorage (REQ-F-15). + +### REQ-F-15: Сохранение состояния (localStorage) + +| Ключ | Значение | Default | +| ------------------------------- | ------------------------------ | ------------------------------------ | +| `gps-tracks-enabled` | `"true"` \| `"false"` | `"false"` | +| `gps-tracks-activities` | JSON-array из ACTIVITY_TYPES | все | +| `gps-tracks-sources` | JSON-array source IDs | все enabled | +| `gps-tracks-color-mode` | `"source"` \| `"activity"` | `"source"` | + +Чтение при старте через `restorePublicTracksState()` (по аналогии с +`restoreTerrainState()` и `restoreBaseLayerState()` из ET-007). + +### REQ-F-16: Палитра цветов + +**По источнику** (default): +- `osm` — `#3cb44b` (зелёный) +- `enduro_russia` — `#e6194b` (красный) +- `ttrails` — `#4363d8` (синий) +- `offmaps` — `#f58231` (оранжевый) +- `nakarte` — `#911eb4` (фиолетовый) +- остальные — циклически из палитры из 8 цветов (как в ET-006). + +**По активности**: +- `enduro` — `#e6194b` +- `moto` — `#f58231` +- `offroad` — `#ffe119` +- `bicycle` — `#3cb44b` +- `hike` — `#4363d8` +- `ski` — `#42d4f4` +- `other` — `#808080` + +Цвет применяется через MapLibre `match` expression в layer paint: + +```js +'line-color': [ + 'match', ['get', 'source'], + 'osm', '#3cb44b', + 'enduro_russia', '#e6194b', + ... + '#808080' +] +``` + +### REQ-F-17: Стили слоя `gps-tracks-layer` + +```js +{ + id: 'gps-tracks-layer', + type: 'line', + source: 'gps-tracks-tiles', + 'source-layer': 'gps_tracks', + paint: { + 'line-color': /* match по REQ-F-16 */, + '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' } +} +``` + +Halo-слой для спутника (по аналогии с ET-007): +```js +{ + id: 'gps-tracks-halo-satellite', + type: 'line', + source: 'gps-tracks-tiles', + 'source-layer': 'gps_tracks', + 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' } +} +``` + +Halo включается только если `(public-tracks ON) AND (base === +'satellite')` — по тому же паттерну, что halo троп в ET-007 §5.7. + +### REQ-F-18: Popup при клике на трек + +При клике на feature слоя `gps-tracks-layer`: + +``` +┌─────────────────────────────────┐ +│ Утренний эндуро ✕ │ +│ ───────────────────────── │ +│ 🏍 Эндуро │ +│ 📏 47.3 км · 1240 точек │ +│ 📅 12 мая 2024 │ +│ 👤 Vasya │ +│ │ +│ Источники: │ +│ • OSM ↗ • EnduroRussia.ru ↗ │ +└─────────────────────────────────┘ +``` + +Каждая ссылка-источник открывает оригинал в новой вкладке. + +### REQ-F-19: Интеграция с `rebuildMapOverlays()` + +В `app.js`, в существующей функции `rebuildMapOverlays()` добавить +вызов: + +```js +function rebuildMapOverlays() { + restoreBaseLayerState(); // ET-007 (уже есть) + restoreTerrainState(); // существующее + restoreTrailsState(); // существующее + restorePublicTracksState(); // НОВОЕ ET-008 + restorePoiState(); // существующее + // ... GPX (ET-006) и route — без изменений +} +``` + +`restorePublicTracksState()`: +1. Читать `gps-tracks-enabled` из localStorage. +2. Если включено — пересоздать vector source / layer / halo. +3. Применить фильтры из localStorage. +4. Синхронизировать UI (чекбокс, состояние «Фильтры…»). + +### REQ-F-20: Поведение на low-zoom (защита от шторма запросов) + +- На z < 8 — слой публичных треков скрывается автоматически (даже если + включён). Подсказка в попапе слоёв (рядом с чекбоксом, по аналогии с + «Тени рельефа Зум 10+»): «Зум 8+». +- На z 8–11 — данные из MVT-тайлов (нет GeoJSON запросов). +- На z ≥ 12 — переключение на GeoJSON через `moveend` debounced 500ms. ## 2. Нефункциональные требования ### REQ-NF-01: Безопасность -- Прокси `/api/gpx/fetch` защищён от SSRF (REQ-F-03): - - Whitelist схем. - - Резолв и проверка хоста на публичность. - - Ручная обработка редиректов с повторной валидацией. - - Лимит размера ответа стримом. -- Парсинг XML на бэкенде (если потребуется — для OSM-ответа) через - `defusedxml.ElementTree` — защита от XXE / billion laughs. -- Парсинг GPX на клиенте — нативный `DOMParser`, XXE отключён по умолчанию. -- CORS на новых эндпоинтах — наследуется от существующей конфигурации - (`allow_origins=["*"]`), отдельных правил не требуется. +- Pipeline идёт **исходящими** запросами с mva154 — нет открытых + входных точек скрейпинга. +- Endpoints `/api/gps-tracks/*` — только GET, идемпотентны, без + пользовательского ввода в SQL (параметры — числа и ENUM). +- Парсинг XML на сервере (для скачанных GPX) — через `defusedxml` + (защита от XXE / billion laughs). +- Bbox-параметр валидируется (диапазон координат, площадь). +- CORS наследуется (`allow_origins=["*"]`). ### REQ-NF-02: Производительность -- Запрос OSM с кэш-хитом: ≤ 50 мс. -- Запрос OSM без кэша: ≤ 3 сек (зависит от OSM API). -- URL-импорт GPX 1 МБ: ≤ 2 сек. -- URL-импорт GPX 50 МБ: ≤ 10 сек (с учётом сети). -- Bbox-валидация и серилизация на бэкенде: ≤ 5 мс. +- `GET /api/gps-tracks?bbox=…` p95 ≤ 300 мс на ≤ 500 треков (zoom 10+). +- `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` p95 ≤ 200 мс на cold-cache, + ≤ 20 мс на cache hit. +- Pipeline на ЦФО + Чувашию: ≤ 6 часов на полный прогон (cron-окно). +- Запрос `compute_dedup_key()`: O(1), без БД. +- INSERT с конфликтом по `dedup_key`: ON CONFLICT UPDATE — один SQL. -### REQ-NF-03: Кэширование +### REQ-NF-03: Хранение и ротация -- LRU-кэш `/api/gpx/fetch`: 64 записи × до 50 МБ = до 3.2 ГБ памяти — - **слишком много**. Решение: хранить только treki ≤ 5 МБ, остальные не - кэшировать. Корректировка: кэш до 64 записей размером ≤ 5 МБ каждая. -- LRU-кэш `/api/gpx/osm/traces`: 256 записей × ≤ 200 КБ JSON ≈ 50 МБ. -- Оба кэша — in-memory, не персистентные, теряются при рестарте контейнера. -- TTL: 24 часа. -- Метрики кэша (`/api/health`): `gpx_fetch_cache_size`, `gpx_osm_cache_size`. +- `data/gps_tracks.sqlite`: размер ≤ 2 ГБ для ЦФО + Чувашии. +- Ротация: треки с `updated_at < NOW() - 5 years` (default) удаляются + при запуске pipeline с `--gc` (cron 1 раз в месяц). +- Бэкап: ежедневный snapshot (см. `07-infra-requirements.md`). -### REQ-NF-04: Совместимость +### REQ-NF-04: Кэширование тайлов -- Браузеры: те же, что ET-006 (Chrome 90+, Firefox 90+, Safari 15+). -- Мобильные: input type=url с режимом клавиатуры url. -- Backend: Python 3.12, FastAPI, httpx (уже есть), `defusedxml` (новая). +- LRU-кэш в памяти процесса FastAPI: 1024 записи (как для основных + тайлов). +- При запуске pipeline — кэш сбрасывается через + `POST /api/cache/clear`? Нет — отдельный endpoint + `POST /api/gps-tracks/cache/clear`. Pipeline вызывает его в конце + прогона. -### REQ-NF-05: UX +### REQ-NF-05: Совместимость -- Во время сетевого запроса показывать индикатор (повторно используем - `#gpx-loading` из ET-006). -- Кнопка «Найти треки» дизейблится во время запроса. -- Все toast-уведомления — через существующий механизм `showToast()` из `gpx.js`. +- Браузеры: Chrome 90+, Firefox 90+, Safari 15+ (как ET-006/ET-007). +- Backend: Python 3.12, FastAPI, httpx, lxml/defusedxml, shapely (есть). +- MapLibre GL JS 4.7.0 (есть). + +### REQ-NF-06: UX + +- Включение слоя — мгновенное (тайлы загружаются параллельно). +- Загрузка тайлов на медленной сети — без блокировки UI (асинхронно). +- Фильтры — клиентские, моментальные (≤ 200 мс). +- Все ошибки — toast-уведомления, не alert/confirm. +- Атрибуция источников — в правом нижнем углу карты (MapLibre + встроенная панель), привязывается к source attribution. + +### REQ-NF-07: Наблюдаемость + +- `/api/gps-tracks/health` отдаёт полную картину состояния (REQ-F-12). +- Pipeline пишет structured logs (JSON-lines) с полями run_id, region, + source, status, tracks_new, error. +- Алерт (через существующий механизм проекта) при двух неудачных + прогонах подряд для одного source. ## 3. UI-спецификация -### 3.1 Расширение `#sheet-gpx` — секция «Источники» +### 3.1 Изменения в `#terrain-popup` -``` -┌─────────────────────────────────────┐ -│ ═══ (handle) │ -│ 📄 GPX-треки [свернуть]│ -├─────────────────────────────────────┤ -│ ИСТОЧНИКИ │ -│ [📁 Из файла] [🔗 По ссылке] [🌍 Найти рядом] │ -│ │ -│ ─ если активна «По ссылке»: ─ │ -│ ┌──────────────────────────┐ ┌────┐ │ -│ │https://example.com/...gpx│ │Загр│ │ -│ └──────────────────────────┘ └────┘ │ -│ │ -│ ─ если активна «Найти рядом»: ─ │ -│ [ Найти треки в этой области карты ]│ -│ Найдено 5 треков: │ -│ ┌─────────────────────────────────┐ │ -│ │🌍 Trail in the woods [Показ.] │ │ -│ │ 12.4 км · автор: user42 [↗] │ │ -│ └─────────────────────────────────┘ │ -│ ┌─────────────────────────────────┐ │ -│ │🌍 Без названия [✓ Загр.]│ │ -│ │ 3.1 км · аноним [↗]│ │ -│ └─────────────────────────────────┘ │ -│ [ Показать ещё ] │ -├─────────────────────────────────────┤ -│ ЗАГРУЖЕННЫЕ ТРЕКИ (как в ET-006) │ -│ 🔴 morning.gpx [✕] │ -│ 📁 локальный файл │ -│ 🔵 trail_woods [✕] │ -│ 🌍 OSM #12345 │ -│ 🟢 strava-export [✕] │ -│ 🔗 github.com │ -└─────────────────────────────────────┘ -``` - -### 3.2 Segmented control «Источники» - -- Контейнер: `
`. -- Кнопки: ` - -
- ``` - -### 3.4 Расширение карточки трека в `#gpx-list` - -Добавить под именем файла строку: +Добавляем между секцией «Тропы» и «POI» (после соответствующего `
`): ```html -
- - 📁 локальный файл - - 🔗 github.com - - 🌍 OSM #12345 + +
+ + + +``` + +CSS: +```css +.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; +} +``` + +### 3.2 Bottom sheet `#sheet-gps-filters` + +```html +
+
+
+ ... +

Фильтры публичных треков

+ +
+
+ +
+ +
+ +
+ +
+ +
+ + +
+
+ Всего в области: + Видны (фильтр): +
+
``` -## 4. Данные +Открывается через `togglePublicTracksFiltersSheet()` из попапа. -### 4.1 Формат OSM Public GPS Traces API +### 3.3 Popup трека -OSM возвращает GPX 1.0: +Реализуется как MapLibre `Popup` (без bottom sheet — компактнее для +этого UX): -```xml - - - - Anonymous tracks - - - - - ... - - - +```js +new maplibregl.Popup({closeOnClick: true}) + .setLngLat(e.lngLat) + .setHTML(renderTrackPopupHtml(feature.properties)) + .addTo(map); ``` -`gpx_id` атрибут точек официально устарел; вместо группировки треков по -gpx_id отдаём весь bbox-ответ как «Публичные треки этой области (N точек)» -— **единая карточка**, импорт всей выборки как одного трека. -Метаданные индивидуальных треков (user, name) недоступны через -`trackpoints` endpoint без дополнительного запроса. +### 3.4 Адаптив для мобильных -**Уточнение требования REQ-F-05/F-06** (исходя из реального API): +- Sheet `#sheet-gps-filters` — full-width на мобильных (как остальные). +- Чипы фильтров — wrap в 2 колонки на мобильных через CSS Grid. +- Popup трека — width: 280px, чтобы не перекрывал тулбар. -- Список найденных «треков» — это страницы trackpoints (page 0, 1, 2…). -- Карточка отображает: page N, количество точек, длину, bbox-центр. -- Импорт = загрузить эту страницу как один GPX-трек. -- Кнопка «Показать ещё» → следующая страница. +## 4. Данные -Это упрощает реализацию и соответствует ограничениям OSM API. +### 4.1 Модель в SQL -### 4.2 Внутренняя модель — расширение +См. REQ-F-09. -```javascript -window.gpxTracks = [ - { - // существующие поля ET-006 - id: 'gpx-1716336000000', - filename: 'trail_woods', - color: '#3cb44b', - tracks: [...], - waypoints: [...], - sourceId: 'gpx-source-...', - layerId: 'gpx-layer-...', - waypointLayerId: 'gpx-wpt-...', - // новое поле ET-008 - source: { - kind: 'file' | 'url' | 'osm', - url: 'https://...', // null для kind='file' - osm_page: 0, // только для kind='osm' - osm_bbox: [w, s, e, n] // только для kind='osm' - } - } -]; +### 4.2 GeoJSON API контракт + +См. REQ-F-10. + +### 4.3 MVT layer schema + +Layer name: `gps_tracks`. + +Properties в каждом feature: + +| Поле | Тип | Описание | +| -------------- | ------ | ------------------------------------------- | +| `id` | int | track.id из БД | +| `activity` | string | ACTIVITY_TYPE | +| `source` | string | первый source_id из sources (для цвета) | +| `sources` | string | comma-separated все sources (для popup) | +| `length_km` | float | length_m / 1000 | +| `name` | string | name (может быть пустым) | +| `ext_url` | string | первый URL из external_urls (для ↗) | + +### 4.4 Клиентская модель `window.gpsTracksLayer` + +```js +window.gpsTracksLayer = { + enabled: false, + filters: { + activities: ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'], + sources: ['osm', 'enduro_russia', 'ttrails', /* … */], + colorMode: 'source' // 'source' | 'activity' + }, + sourceId: 'gps-tracks-tiles', + sourceGeoId: 'gps-tracks-geo', + layerId: 'gps-tracks-layer', + layerHaloId: 'gps-tracks-halo-satellite', + geojsonAbortController: null, + geojsonReqDebounceTimer: null, + stats: { total: 0, shown: 0 } +}; ``` ## 5. Файловая структура изменений ``` src/api/ -├── main.py # + 2 эндпоинта, импорт нового модуля -├── gpx_proxy.py # НОВЫЙ: SSRF-валидация, fetch, кэш -├── osm_traces.py # НОВЫЙ: OSM trackpoints клиент, парсинг -├── requirements.txt # + defusedxml +├── main.py # + регистрация роутов +├── requirements.txt # + defusedxml, lxml +├── gps_tracks/ # НОВЫЙ пакет +│ ├── __init__.py +│ ├── models.py # Pydantic, ACTIVITY_TYPES +│ ├── db.py # SQLite + Spatialite обвязка +│ ├── dedup.py # compute_dedup_key +│ ├── mvt.py # MVT-генерация +│ ├── endpoint.py # FastAPI routes +│ ├── config.py # загрузка YAML +│ └── sources/ +│ ├── __init__.py +│ ├── base.py # абстрактный SourceParser +│ ├── osm.py +│ ├── enduro_russia.py +│ └── ttrails.py src/web/ -├── index.html # + секция «Источники» в #sheet-gpx -├── gpx.js # + URL-импорт, OSM-поиск, расширение модели -├── app.css # + стили .source-seg, .gpx-nearby-card, .gpx-source-row +├── index.html # + чекбокс, sheet-gps-filters +├── app.css # + .terrain-link-btn, .gps-filter-grid, .gps-stats-row +├── app.js # + restorePublicTracksState, popup-handler, + # расширение rebuildMapOverlays +├── gps_tracks.js # НОВЫЙ: слой, фильтры, popup +├── style.json # + halo-layer для спутника +├── style-dark.json # + halo-layer для спутника + +scripts/ +├── gps_collect.py # НОВЫЙ pipeline-entry + +config/ +├── gps_sources.yaml # НОВЫЙ +├── gps_regions.yaml # НОВЫЙ + +migrations/ +├── gps_tracks_001_init.sql # CREATE TABLE tests/ -├── api/test_gpx_proxy.py # НОВЫЙ -├── api/test_osm_traces.py # НОВЫЙ -├── web/gpx.test.js # + тесты на URL/OSM источники +├── api/test_gps_tracks_endpoint.py # bbox/filter/limit +├── api/test_gps_tracks_mvt.py # MVT-генерация +├── api/test_gps_tracks_dedup.py # compute_dedup_key +├── api/test_gps_tracks_sources_osm.py # парсер OSM (с фикстурами) +├── web/gps_tracks.test.js # фильтры, цветовая палитра docs/work-items/ET-008/ ├── 06-adr/ -│ ├── ADR-001-ssrf-protection.md -│ └── ADR-002-osm-trackpoints-aggregation.md +│ ├── ADR-001-storage-schema.md +│ ├── ADR-002-dedup-algorithm.md +│ ├── ADR-003-osm-licensing.md +│ ├── ADR-004-enduro-russia-licensing.md # обязательно перед коммитом source +│ └── ADR-005-ttrails-licensing.md # обязательно перед коммитом source +├── 07-infra-requirements.md +├── 08-data-requirements.md +├── 10-tech-risks.md ``` ## 6. Алгоритмы -### 6.1 SSRF-защита `/api/gpx/fetch` +### 6.1 `compute_dedup_key(geom, metadata)` + +См. REQ-F-08. + +### 6.2 Bbox-разбиение региона на OSM-cells ```python -def is_safe_url(url: str) -> bool: - parsed = urlparse(url) - if parsed.scheme not in ('http', 'https'): - return False - try: - infos = socket.getaddrinfo(parsed.hostname, None) - except socket.gaierror: - return False - for info in infos: - ip = ipaddress.ip_address(info[4][0]) - if not ip.is_global or ip.is_loopback or ip.is_private: - return False - return True +def split_bbox_for_osm(region_bbox, cell_size=0.25): + w, s, e, n = region_bbox + cells = [] + lat = s + while lat < n: + lon = w + while lon < e: + cells.append((lon, lat, min(lon+cell_size, e), min(lat+cell_size, n))) + lon += cell_size + lat += cell_size + return cells ``` -При следовании редиректам — повторная валидация хоста на каждом шаге. +Для ЦФО + Чувашии (≈ 670K км²) → ≈ 700 cells × ≤ 5 pages × 1 sec rate +limit ≈ 1 час. -### 6.2 Bbox area check +### 6.3 Backoff для скрейпинга ```python -def bbox_area_deg2(w, s, e, n): - return abs(e - w) * abs(n - s) - -if bbox_area_deg2(*bbox) > 0.25: - raise HTTPException(400, "bbox too large") +async def fetch_with_backoff(url, max_retries=3, base_delay=2.0): + for attempt in range(max_retries): + try: + resp = await client.get(url, timeout=30) + if resp.status_code == 429: + delay = base_delay * (2 ** attempt) + await asyncio.sleep(delay) + continue + resp.raise_for_status() + return resp + except (httpx.TimeoutException, httpx.NetworkError): + await asyncio.sleep(base_delay * (2 ** attempt)) + raise RuntimeError(f"Max retries exceeded: {url}") ``` -### 6.3 Кэш-ключ для bbox +### 6.4 Клиентская сторона: debounced GeoJSON-загрузка -Округление до 4 знаков (≈ 11 метров на экваторе): -```python -key = (round(w, 4), round(s, 4), round(e, 4), round(n, 4), page) +```js +function onMapMoveEnd() { + if (!window.gpsTracksLayer.enabled) return; + if (map.getZoom() < 12) return; // на низком zoom — только тайлы + + clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer); + window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => { + fetchAndUpdateGeoJson(map.getBounds()); + }, 500); +} + +async function fetchAndUpdateGeoJson(bounds) { + // Отменить предыдущий запрос + if (window.gpsTracksLayer.geojsonAbortController) { + window.gpsTracksLayer.geojsonAbortController.abort(); + } + const ctrl = new AbortController(); + window.gpsTracksLayer.geojsonAbortController = ctrl; + + const { activities, sources } = window.gpsTracksLayer.filters; + const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`; + const url = `/api/gps-tracks?bbox=${bbox}` + + `&activity=${activities.join(',')}` + + `&source=${sources.join(',')}` + + `&limit=500`; + try { + const resp = await fetch(url, { signal: ctrl.signal }); + const json = await resp.json(); + map.getSource('gps-tracks-geo').setData(json); + window.gpsTracksLayer.stats = { total: json.total_in_bbox, shown: json.returned }; + syncGpsFiltersStatsUI(); + } catch (e) { + if (e.name === 'AbortError') return; + showToast('Не удалось загрузить треки'); + } +} ``` -Это обеспечивает попадание в кэш при незначительном движении карты. +### 6.5 Клиентская фильтрация по `setFilter()` + +При изменении чекбокса активности/источника — без нового запроса: + +```js +function applyGpsFilter() { + const { activities, sources } = window.gpsTracksLayer.filters; + const filter = [ + 'all', + ['in', ['get', 'activity'], ['literal', activities]], + ['in', ['get', 'source'], ['literal', sources]] + ]; + map.setFilter('gps-tracks-layer', filter); + if (map.getLayer('gps-tracks-layer-geo')) { + map.setFilter('gps-tracks-layer-geo', filter); + } + if (map.getLayer('gps-tracks-halo-satellite')) { + map.setFilter('gps-tracks-halo-satellite', filter); + } +} +``` ## 7. Взаимодействие с существующими модулями -- **ET-006 `gpx.js`** — расширяем, не переписываем. Существующие функции - (`parseGpx`, `addGpxTrack`, `rebuildGpxOverlays`) остаются. Добавляются: - `importGpxFromUrl(url)`, `findOsmTracesInView()`, - `importOsmTrace(osm_url)`. -- **`units.js`** — используется для форматирования длины треков в списке. -- **`#sheet-gpx`** — единственный sheet для всех источников. Никаких - новых sheet не создаётся. -- **`#toolbar`** — кнопка `#tb-gpx` уже открывает `#sheet-gpx`. Не меняется. -- **`/api/health`** — расширить выдачей размеров кэшей (REQ-NF-03). +### 7.1 ET-006 (`gpx.js`) + +- Не пересекается: `window.gpxTracks` (личные треки) и + `window.gpsTracksLayer` (публичный слой) — разные модели. +- На карте оба видны параллельно; z-order: + `gps-tracks-layer` < `gpx-layer-*` (личные треки выше). + +### 7.2 ET-007 (спутник) + +- Halo `gps-tracks-halo-satellite` включается/выключается по тому же + паттерну, что halo троп (`trails-track-halo-satellite`). +- В `applyBaseLayer()` (ET-007) добавить шаг: + ```js + // ET-008: halo публичных треков + const haloOn = (currentBase === 'satellite' && layerState.publicTracks); + setLayoutProperty('gps-tracks-halo-satellite', 'visibility', + haloOn ? 'visible' : 'none'); + ``` + +### 7.3 Поиск, маршрут, разведка, scenic, ruler + +- Слой публичных треков не блокирует и не модифицирует существующие + режимы. +- Клик по карте: маршрут/разведка имеют приоритет; popup трека только + если ни один режим не активен и `map.queryRenderedFeatures` возвращает + `gps-tracks-layer`. + +## 8. Открытые вопросы для ADR + +- **ADR-001**: единая БД vs отдельная (`data/gps_tracks.sqlite`). + Рекомендация ТЗ — отдельная (см. BRD §6 риск «размер БД»). +- **ADR-002**: точный алгоритм дедупликации — bbox-bucket vs + Frechet-distance vs hash-of-resampled-points. +- **ADR-003, 004, 005**: licensing review для каждого внешнего источника + (OSM однозначен; для остальных — обязательно перед merge кода + per-source модуля). +- **Открытое**: показывать ли popup трека или открывать `#sheet-route`- + подобный bottom sheet (ради единого UX). По умолчанию — MapLibre + Popup как компромисс компактности. +- **Открытое**: цветовая палитра «По активности» — окончательная + валидация на тёмной/светлой теме и на спутнике. +- **Открытое**: на низких зумах прятать слой или показывать сильно + упрощённый. Рекомендация ТЗ — прятать с подсказкой «Зум 8+». diff --git a/docs/work-items/ET-008/03-acceptance-criteria.md b/docs/work-items/ET-008/03-acceptance-criteria.md index 6f556c8..6eeadda 100644 --- a/docs/work-items/ET-008/03-acceptance-criteria.md +++ b/docs/work-items/ET-008/03-acceptance-criteria.md @@ -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= - 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 ``` diff --git a/docs/work-items/ET-008/04-test-plan.yaml b/docs/work-items/ET-008/04-test-plan.yaml index 53d3ac0..eff8693 100644 --- a/docs/work-items/ET-008/04-test-plan.yaml +++ b/docs/work-items/ET-008/04-test-plan.yaml @@ -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 , 1 , 50 " - 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: "enduromotorcycle" + 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= — измерить 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/)" + - "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" diff --git a/docs/work-items/ET-008/04b-ui-test-cases.md b/docs/work-items/ET-008/04b-ui-test-cases.md index 0ce0d52..4da49ab 100644 --- a/docs/work-items/ET-008/04b-ui-test-cases.md +++ b/docs/work-items/ET-008/04b-ui-test-cases.md @@ -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» (после соответствующего разделителя `
`) видна строка «Публичные треки» с чекбоксом #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) остались видимыми и без изменений."