analyst(ET): auto-commit from analyst run_id=59
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Successful in 6s
CI / build (push) Has been skipped

This commit is contained in:
2026-06-03 17:33:09 +00:00
parent d379e48c08
commit 6edf97fe79
5 changed files with 1055 additions and 710 deletions

View File

@@ -1,84 +1,106 @@
---
type: brd
work_item_id: ET-006
title: "BRD: Загрузка и визуализация GPX-треков"
version: 2
title: "BRD: Скачивание трека из popup на карте"
version: 1
status: approved
created_at: 2026-05-22
updated_at: 2026-05-22
created_at: 2026-06-03
updated_at: 2026-06-03
authors:
- "agent:analyst"
---
# BRD — ET-006: Загрузка и визуализация GPX-треков
# BRD — ET-006: Скачивание трека из popup на карте
## 1. Цель
Дать пользователю возможность загрузить GPX-файл с треком и увидеть его на карте: линию маршрута, waypoints, профиль высот и статистику. Это позволяет визуально оценить чужой или ранее записанный трек перед поездкой.
Дать пользователю возможность сохранить публичный GPS-трек (источники
OSM, EnduroRussia, Wikiloc, ttrails — слой ET-008) к себе на устройство
в виде GPX-файла прямо из popup, который открывается по клику на трек
на карте. Это закрывает базовый use case «увидел чужой интересный
трек → забрал к себе → запланировал поездку».
## 2. Контекст
- Приложение уже умеет строить маршруты через OSRM и экспортировать их в GPX (кнопка «Скачать GPX» в sheet-route).
- Обратная операция — импорт GPX — отсутствует.
- Фаза PH-3 (Smart Route) в roadmap включает работу с GPX.
- Фронтенд: MapLibre GL JS + vanilla JS, без фреймворков.
- Backend-изменения не требуются — парсинг GPX происходит на клиенте.
- Слой публичных GPS-треков реализован в ET-008 (`src/web/gps_tracks.js`,
`src/api/gps_tracks/`). При клике на трек открывается MapLibre popup
с метаданными (имя, активность, длина, дата, пользователь, источники).
- Геометрия треков хранится на сервере в SQLite: 2D WKB LineString
(`tracks.geom`, см. `src/api/gps_tracks/db.py` и `mvt.py`). Высот
по точкам в БД нет.
- На клиенте геометрия доступна только при zoom ≥ 12 (режим GeoJSON
через `GET /api/gps-tracks?bbox=...`). При zoom 811 popup
открывается над MVT-фичей, у которой геометрия по тайлу
упрощена/обрезана и непригодна для повторного экспорта.
- Существующий GPX в проекте умеет только парсить локальный файл и
отдаёт OSRM-маршрут (кнопка «Скачать GPX» в `sheet-route`). Экспорт
публичного трека из БД в GPX отсутствует.
- Backend — FastAPI; новый эндпоинт добавляется в существующий
router `/api/gps-tracks` (`src/api/gps_tracks/endpoint.py`).
## 3. Scope
### In scope
| # | Функция |
|---|---------|
| F-01 | Кнопка загрузки GPX в тулбаре карты |
| F-02 | Парсинг GPX 1.1 на клиенте (XML → GeoJSON) |
| F-03 | Поддержка нескольких треков в одном файле |
| F-04 | Отрисовка линии трека на карте (каждый трек — свой цвет) |
| F-05 | Отображение waypoints из GPX как маркеров с именами |
| F-06 | Fit bounds — карта центрируется на загруженном треке |
| F-07 | Загрузка нескольких файлов (треки накапливаются) |
| F-08 | Удаление отдельного трека |
| F-09 | Панель управления треками (список, цвет, удаление) |
| F-10 | Профиль высот выбранного трека |
| F-11 | Статистика трека: длина, набор высоты, сброс высоты, мин/макс высота |
| F-12 | Лимит размера файла: 50 МБ |
| F-13 | Сохранение GPX-слоёв при переключении стиля карты (тёмная тема / рельеф) |
| # | Функция |
|------|---------|
| F-01 | Кнопка/ссылка «Скачать GPX» в popup'е публичного трека (`_renderTrackPopupHtml` в `gps_tracks.js`) |
| F-02 | Backend-эндпоинт `GET /api/gps-tracks/{id}.gpx` — возвращает корректный GPX 1.1 для трека по его БД-id |
| F-03 | Имя файла: `<sanitized-name>.gpx` если есть имя, иначе `track-{id}.gpx`; передаётся в `Content-Disposition: attachment; filename=...` |
| F-04 | Тело GPX: `<gpx version="1.1">` с `<metadata>` (name, desc, time, link на первый external_url) и одним `<trk>` с одним `<trkseg>`, заполненным точками из `tracks.geom` |
| F-05 | MIME-тип ответа: `application/gpx+xml; charset=utf-8` |
| F-06 | Работа единообразна на обоих zoom-режимах: при MVT (z 811) и при GeoJSON (z ≥ 12) popup использует один и тот же id трека и один и тот же URL — клиент НЕ собирает GPX из MVT-геометрии |
| F-07 | Ошибки: 404 для несуществующего id, 500 — через стандартный HTTPException; на клиенте: ошибка качания → toast «Не удалось скачать трек», popup не закрывается |
| F-08 | Атрибуция источников сохранена в `<metadata>`: `<copyright>` и/или `<desc>` содержат source_id'ы и внешние ссылки (ODbL/Wikiloc TOS — обязательно) |
| F-09 | Мобильный UX: кнопка тапабельна (≥ 36 px высота), не ломает раскладку popup'а на узких экранах |
| F-10 | Аналитика клика: событие в консоль через существующий механизм `console.log` / `showToast` (телеметрия не вводится) |
### Out of scope
- Сохранение треков на сервер / в БД
- Редактирование трека (обрезка, склейка)
- Конвертация из других форматов (KML, FIT, TCX)
- Упрощение (simplify) точек трека
- Экспорт загруженного трека обратно в GPX
- Роутинг по загруженному треку (snap to road)
- Скачивание сразу нескольких треков (batch).
- Конвертация в KML / TCX / FIT / KMZ.
- Шаринг ссылки на скачивание (короткая ссылка / OG-карточка).
- Сохранение в личную «библиотеку» пользователя (нет аккаунтов).
- Загрузка скачанного файла обратно на карту — уже покрыто текущим
`gpx.js` (UI «Загрузить GPX»), и пересечения функциональностей нет.
- Авторизация / rate-limit на загрузку — публичные данные, тот же
cors-разрешённый эндпоинт что и GeoJSON.
- Восстановление высот по DEM для GPX (трек не имеет `<ele>`).
- Изменение схемы БД `gps_tracks.sqlite`.
## 4. Метрики успеха
| Метрика | Критерий |
|---------|----------|
| Загрузка файла | Файл до 50 МБ загружается и парсится без ошибок за ≤ 3 сек (на среднем устройстве) |
| Визуализация | Трек отображается на карте как цветная линия |
| Waypoints | Маркеры с именами видны на карте |
| Fit bounds | Карта автоматически подстраивает zoom/center под трек |
| Множественные треки | 5+ треков отображаются одновременно, различимы по цвету |
| Удаление | Удалённый трек исчезает с карты и из панели |
| Профиль высот | Отображается корректный график высот для выбранного трека |
| Статистика | Длина, набор/сброс высоты отображаются корректно |
| Сохранение при смене стиля | GPX-треки остаются на карте после переключения тёмной темы / слоёв рельефа |
| Не ломает существующий функционал | Роутинг, рельеф, POI, линейка работают как прежде |
| Доступность кнопки | Кнопка «Скачать GPX» видна в каждом popup'е публичного трека на desktop и mobile, на обоих zoom-режимах (z = 9 и z = 14) |
| Корректность файла | Скачанный файл валидируется парсером `gpx.js` (drag-and-drop в приложение) и рисуется на карте без ошибок |
| Корректность GPX 1.1 | Файл проходит XSD-валидацию `http://www.topografix.com/GPX/1/1/gpx.xsd` (online valdiator или `xmllint --schema`) |
| Совместимость | Файл открывается в OsmAnd / Locus / `gpx.studio` без ошибок |
| Имя файла | Имя файла читаемое, не содержит запрещённых на Windows/Android символов (`/\:*?"<>|`) |
| Атрибуция | В `<metadata>` явно перечислены все source_id и external_urls трека |
| Производительность | Эндпоинт отвечает ≤ 500 ms p95 для трека до 50 000 точек (среднестатистический эндуро-трек < 5000 точек) |
| Размер ответа | Для трека 5000 точек тело GPX ≤ 600 КБ (без gzip), ≤ 150 КБ (с gzip) |
| Регрессии | Существующий popup, MVT-/GeoJSON-режимы слоя, фильтры активностей/источников, halo на спутнике — не сломаны |
## 5. Риски
| Риск | Вероятность | Влияние | Митигация |
|------|-------------|---------|-----------|
| Большой GPX (50 МБ, 500K+ точек) тормозит рендеринг | Средняя | Среднее | Использовать GeoJSON source + line layer (MapLibre оптимизирует); при необходимости — Web Worker для парсинга |
| GPX без данных высот → профиль пустой | Средняя | Низкое | Показать сообщение «Данные высот отсутствуют» |
| Невалидный GPX → ошибка парсинга | Низкая | Низкое | Показать пользователю понятное сообщение об ошибке |
| Конфликт цветов треков с цветами маршрута OSRM | Низкая | Низкое | Использовать отдельную палитру, отличную от цветов роутинга |
| Геометрия трека отсутствует (`row.geom is None`) | Низкая | Низкое | 404 + toast «Геометрия трека недоступна»; такие записи редки (защита уже в `_wkb_to_coords`) |
| Очень длинный трек (десятки тысяч точек) → ответ > 5 МБ | Низкая | Среднее | Без упрощения геометрии. Документировать лимит. При необходимости — gzip через nginx (уже включён) |
| Wikiloc TOS требует ссылки на оригинал | Высокая | Высокое | F-08: `<metadata><link>` на оригинальную страницу для каждого external_url |
| ODbL атрибуция в GPX | Высокая | Высокое | F-08: `<copyright author="OpenStreetMap contributors" license="https://opendatacommons.org/licenses/odbl/">` для треков с `source = osm` |
| Опасный символ в `name` → XML-инъекция | Низкая | Высокое | Эскейп через стандартный XML-сериализатор (ElementTree / минимальный helper) |
| Имя файла с кириллицей не сохраняется в Safari | Средняя | Низкое | RFC 5987 — `filename*=UTF-8''<percent-encoded>` + ASCII-fallback `filename=...` |
| Кнопка обрезается на узком экране (≤ 360 px) | Средняя | Низкое | Кнопка на отдельной строке, `flex-wrap`, тестируется в UI-тестах TC-UI-02 |
## 6. Зависимости
- Нет внешних зависимостей
- Только фронтенд (vanilla JS + MapLibre GL JS)
- Парсинг XML: нативный DOMParser браузера
- Бэкенд: существующий router `/api/gps-tracks`
(`src/api/gps_tracks/endpoint.py`), функции `_wkb_to_coords` и БД-слой.
Изменения чисто аддитивные.
- Фронтенд: модуль `src/web/gps_tracks.js`, функция
`_renderTrackPopupHtml`. Точечная правка HTML-шаблона.
- Никаких внешних сервисов / новых данных / миграций БД.
- Никаких новых npm/pip-зависимостей: GPX генерируется штатным
`xml.etree.ElementTree` (есть в stdlib Python 3.12).

View File

@@ -1,289 +1,295 @@
---
type: trz
work_item_id: ET-006
title: "ТЗ: Загрузка и визуализация GPX-треков"
version: 2
title: "ТЗ: Скачивание трека из popup на карте"
version: 1
status: approved
created_at: 2026-05-22
updated_at: 2026-05-22
created_at: 2026-06-03
updated_at: 2026-06-03
authors:
- "agent:analyst"
---
# ТЗ — ET-006: Загрузка и визуализация GPX-треков
# ТЗ — ET-006: Скачивание трека из popup на карте
## 1. Функциональные требования
> Документ описывает требования к функциональности. Архитектурные
> решения (выбор XML-библиотеки, формат потоковой генерации и т. п.)
> остаются на этап Architect → ADR.
### REQ-F-01: Кнопка загрузки GPX
## 1. Сводка
- В правой панели кнопок карты (`#map-controls-r`) добавляется кнопка «GPX» с иконкой загрузки (стрелка вверх + документ).
- Позиция: между кнопкой «Компас» и «Моё местоположение» (верхняя часть панели).
- По нажатию открывается системный диалог выбора файла (`<input type="file" accept=".gpx">`).
- Допускается множественный выбор (`multiple`).
Добавить кнопку «Скачать GPX» в popup публичного GPS-трека (слой
ET-008, `gps_tracks.js`). По нажатию браузер инициирует загрузку
файла GPX 1.1 c геометрией и метаданными трека, который сервер
формирует на лету по новому HTTP-эндпоинту
`GET /api/gps-tracks/{id}.gpx`.
### REQ-F-02: Парсинг GPX
## 2. Затрагиваемые компоненты
- Парсинг выполняется на клиенте через `DOMParser` (XML → DOM → GeoJSON).
- Поддерживается GPX 1.1 (namespace `http://www.topografix.com/GPX/1/1`).
- Извлекаются:
- `<trk>` → массив треков, каждый `<trkseg>` → массив точек `[lon, lat, ele?, time?]`
- `<wpt>` → waypoints `{lon, lat, name?, ele?}`
- `<rte>` → route points (трактуются как трек)
- Если файл содержит несколько `<trk>`, каждый трек — отдельная сущность.
- При ошибке парсинга — показать toast-уведомление: «Не удалось прочитать GPX-файл».
| Слой | Файл / модуль | Тип правки |
|---------|--------------|------------|
| Backend | `src/api/gps_tracks/endpoint.py` | новый handler `get_track_gpx` |
| Backend | `src/api/gps_tracks/db.py` (или новый модуль) | новый helper `get_track_by_id(conn, id)` |
| Backend | `src/api/gps_tracks/gpx_builder.py` (новый) | сериализация WKB → GPX 1.1 XML |
| Frontend | `src/web/gps_tracks.js` | дополнение `_renderTrackPopupHtml` (кнопка) |
| Frontend | `src/web/app.css` | стили для `.track-popup-download` |
| Tests | `tests/unit/test_gpx_builder.py` (новый) | unit GPX-сериализатора |
| Tests | `tests/integration/test_gps_tracks_endpoint.py` (расширить) | новый case на `.gpx` endpoint |
| Tests | `tests/e2e/test_track_popup_download.spec.js` (новый) | Playwright e2e |
### REQ-F-03: Валидация
## 3. Функциональные требования
- Максимальный размер файла: 50 МБ. При превышении — toast: «Файл слишком большой (макс. 50 МБ)».
- Если файл не содержит ни одного трека и ни одного waypoint — toast: «GPX-файл не содержит данных».
### 3.1 Backend: GET /api/gps-tracks/{id}.gpx
### REQ-F-04: Отрисовка трека на карте
**REQ-F-01.** Эндпоинт `GET /api/gps-tracks/{track_id}.gpx`
регистрируется в существующем router'е (`prefix="/api/gps-tracks"`),
без auth.
- Каждый трек отрисовывается как `line` layer в MapLibre.
- Source: GeoJSON (`LineString` или `MultiLineString`).
- Цвет: из палитры 8 цветов, циклически. Палитра отличается от цветов роутинга (синий/зелёный/оранжевый).
- Предлагаемая палитра: `#e6194b`, `#3cb44b`, `#ffe119`, `#4363d8`, `#f58231`, `#911eb4`, `#42d4f4`, `#f032e6`.
- Толщина линии: 4px.
- Opacity: 0.85.
- Z-index: выше базовых слоёв, ниже маршрута OSRM (если активен).
**REQ-F-02.** Параметр `track_id: int` валидируется FastAPI как `int`.
Не-int → 422 (стандартное поведение FastAPI).
### REQ-F-05: Отображение waypoints
**REQ-F-03.** Если трек с таким id не найден или `tracks.geom` пустой /
WKB не парсится — ответ `404 Not Found`, тело
`{"detail":"Track not found"}`.
- Каждый `<wpt>` отображается как маркер (circle layer + symbol layer для имени).
- Цвет маркера: совпадает с цветом трека из того же файла (или нейтральный, если waypoints без трека).
- Имя waypoint (`<name>`) отображается как label рядом с маркером.
- Если имя отсутствует — маркер без подписи.
**REQ-F-04.** Успешный ответ:
- `Content-Type: application/gpx+xml; charset=utf-8`
- `Content-Disposition: attachment; filename="<ascii-fallback>"; filename*=UTF-8''<percent-encoded-utf8>`
- `Access-Control-Allow-Origin: *` (как у соседних эндпоинтов).
- Тело — валидный GPX 1.1 XML.
### REQ-F-06: Fit bounds
**REQ-F-05.** Имя файла:
- Базовое имя = `track.name`, если есть и не пустое; иначе `track-{id}`.
- Удалить запрещённые символы Windows/Android: `/\:*?"<>|` и
управляющие < 0x20.
- Свернуть пробелы, обрезать до 64 символов.
- Расширение `.gpx`.
- ASCII-fallback: транслит/удаление не-ASCII, минимум `track-{id}.gpx`.
- После загрузки файла карта выполняет `fitBounds` по bbox всех точек загруженного файла.
- Padding: 50px со всех сторон.
- Если загружено несколько файлов подряд — fit bounds только по последнему загруженному.
### 3.2 Структура GPX 1.1
### REQ-F-07: Множественная загрузка
- Треки из разных файлов накапливаются в сессии.
- Каждый файл получает следующий цвет из палитры.
- Максимальное количество одновременных треков: не ограничено (разумный предел — производительность браузера).
### REQ-F-08: Удаление трека
- В панели управления треками (REQ-F-09) у каждого трека есть кнопка удаления (иконка ✕).
- При удалении: убирается line layer, source, маркеры waypoints с карты.
- Если удалён активный (выбранный) трек — панель профиля высот скрывается.
### REQ-F-09: Панель управления треками (GPX Sheet)
- Реализуется как bottom sheet (`#sheet-gpx`), аналогично существующим sheet-route, sheet-recon.
- Открывается автоматически при загрузке первого трека.
- Содержит:
- Заголовок «GPX-треки» с иконкой и кнопкой свернуть.
- Список загруженных треков: цветной кружок + имя файла (без расширения) + кнопка удаления.
- По тапу на трек в списке — он становится «активным» (выделяется), показывается его статистика и профиль высот.
- Кнопка в тулбаре нижнего toolbar (`#toolbar`): «GPX» — переключает видимость sheet.
### REQ-F-10: Профиль высот
- Отображается в нижней части sheet-gpx (под списком треков) для активного трека.
- График: canvas-элемент, ширина 100% sheet, высота 120px.
- Ось X: расстояние от начала трека (км).
- Ось Y: высота (м).
- Линия графика: цвет трека.
- Заливка под линией: цвет трека с opacity 0.2.
- Если данные высот отсутствуют (`<ele>` нет) — показать текст: «Данные высот отсутствуют».
- При наведении/тапе на график — показать tooltip с высотой и расстоянием, и подсветить соответствующую точку на карте (маркер-курсор).
### REQ-F-11: Статистика трека
- Отображается над профилем высот в sheet-gpx для активного трека.
- Формат: компактная сетка (аналогично recon-grid).
- Поля:
- Длина (км) — сумма расстояний между точками (Haversine).
- Набор высоты (м) — сумма положительных дельт `ele`.
- Сброс высоты (м) — сумма отрицательных дельт `ele` (абсолютное значение).
- Мин. высота (м).
- Макс. высота (м).
- Если данные высот отсутствуют — показать только длину, остальные поля: «—».
### REQ-F-12: Интерактивность трека на карте
- При клике на линию трека на карте — этот трек становится активным в панели (показывается статистика + профиль).
- Курсор при наведении на трек: pointer.
### REQ-F-13: Сохранение треков при переключении стиля карты
- При переключении стиля карты (тёмная тема, восстановление слоёв рельефа) вызывается `map.setStyle()`, который удаляет **все** пользовательские source и layer.
- После смены стиля все загруженные GPX-треки должны быть автоматически восстановлены: линии треков, source, waypoints-маркеры.
- Восстановление выполняется в функции `rebuildMapOverlays()` (`src/web/app.js`) — по аналогии с уже реализованными там маршрутом OSRM, разведкой и scenic-маршрутами.
- Данные треков (`window.gpxTracks`) хранятся в памяти и при `setStyle()` не теряются — пересоздаются только объекты карты (source / layer / маркеры).
- Активный трек, его статистика и профиль высот должны сохраняться после переключения стиля.
- Z-order GPX-слоёв (см. REQ-F-04) корректно восстанавливается и после смены стиля.
## 2. Нефункциональные требования
### REQ-NF-01: Производительность
- Парсинг файла 50 МБ: ≤ 5 секунд на устройстве с 4 ГБ RAM.
- Рендеринг трека 500K точек: без видимых фризов при pan/zoom (MapLibre оптимизирует GeoJSON line layers).
- Во время парсинга показывать индикатор загрузки (spinner или moto-wheel).
### REQ-NF-02: Совместимость
- Работает в Chrome 90+, Firefox 90+, Safari 15+.
- Работает на мобильных (touch events для профиля высот).
### REQ-NF-03: UX
- Кнопка загрузки доступна всегда, независимо от активного режима (роутинг, разведка и т.д.).
- GPX-треки не конфликтуют с активным маршрутом OSRM — отображаются одновременно.
- При ошибках — toast-уведомления (не alert/confirm).
### REQ-NF-04: Хранение
- Данные треков хранятся только в памяти (JS-переменные).
- При перезагрузке страницы — все треки теряются.
- Не используется localStorage/sessionStorage для данных треков (слишком большие).
## 3. UI-спецификация
### 3.1 Кнопка в правой панели (#map-controls-r)
```
┌──────────┐
│ ↑ GPX │ ← новая кнопка (между Компас и Геолокация)
└──────────┘
```
- Класс: `map-btn`
- ID: `btn-gpx-upload`
- Иконка: стрелка вверх из документа (upload file)
- Title: «Загрузить GPX»
### 3.2 Кнопка в нижнем тулбаре (#toolbar)
```
[ Маршрут | Связка | Красивый | Разведка | Линейка | Поиск | Метка | GPX ]
```
- Класс: `tb-btn`
- ID: `tb-gpx`
- Иконка: файл с линией (track)
- Label: «GPX»
- Действие: `toggleGpxSheet()`
### 3.3 Bottom sheet (#sheet-gpx)
```
┌─────────────────────────────────────┐
│ ═══ (handle) │
│ 📄 GPX-треки [свернуть]│
├─────────────────────────────────────┤
│ 🔴 track_morning.gpx [✕] │
│ 🔵 weekend_ride.gpx ✓ [✕] │ ← активный (выделен)
│ 🟢 test_route.gpx [✕] │
├─────────────────────────────────────┤
│ СТАТИСТИКА │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │47км│ │820м│ │650м│ │120м│ │980м│ │
│ │длин│ │наб.│ │сбр.│ │мин │ │макс│ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ │
├─────────────────────────────────────┤
│ ПРОФИЛЬ ВЫСОТ │
│ ┌───────────────────────────────┐ │
│ │ ╱╲ ╱╲╱╲ │ │
│ │╱ ╲╱╲╱ ╲╱╲ │ │
│ └───────────────────────────────┘ │
│ 0 km 23.5 km 47 km │
└─────────────────────────────────────┘
```
### 3.4 Toast-уведомления
- Позиция: верх экрана, по центру.
- Автоскрытие: 4 секунды.
- Стиль: аналогично `#ruler-toast`.
## 4. Данные
### Входные данные (GPX 1.1)
**REQ-F-06.** Корень:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<name>Morning Ride</name>
<trkseg>
<trkpt lat="55.7558" lon="37.6173"><ele>150</ele><time>2026-01-01T08:00:00Z</time></trkpt>
...
</trkseg>
</trk>
<wpt lat="55.76" lon="37.62">
<name>Кафе</name>
<ele>155</ele>
</wpt>
<gpx version="1.1"
creator="enduro-trails (https://openclaw.mva154.duckdns.org/enduro/)"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
...
</gpx>
```
### Внутренняя модель (JS)
**REQ-F-07.** `<metadata>` обязательно содержит:
- `<name>` = имя трека (или `track-{id}`).
- `<desc>` = склейка из `track.description` (если есть), активности
(русское `GPS_ACTIVITY_LABELS[activity]`), длины (км), источников
(список source_id через запятую). Многострочно через `\n`.
- `<time>` = `track.created_at` если есть и парсится в ISO 8601;
иначе — текущее UTC в формате `YYYY-MM-DDTHH:MM:SSZ`.
- `<link href="...">` для **каждого** external_url, с
`<text>{source_id}</text>`. Если ссылок нет — секция опущена.
- `<author><name>{track.user}</name></author>` если есть `user`,
иначе секция опущена.
- `<copyright>` (см. REQ-F-08).
```javascript
// Массив загруженных GPX-файлов
window.gpxTracks = [
{
id: 'gpx-1716336000000', // уникальный ID (timestamp)
filename: 'morning_ride', // имя файла без расширения
color: '#e6194b', // цвет из палитры
tracks: [ // массив треков из файла
{
name: 'Morning Ride',
points: [[lon, lat, ele, time], ...], // массив точек
stats: { distanceKm, elevGain, elevLoss, eleMin, eleMax }
}
],
waypoints: [
{ lon, lat, name, ele }
],
sourceId: 'gpx-source-1716336000000',
layerId: 'gpx-layer-1716336000000',
waypointLayerId: 'gpx-wpt-1716336000000'
}
];
**REQ-F-08.** Атрибуция в `<copyright>`:
- Если `sources` содержит `"osm"` → `<copyright author="OpenStreetMap
contributors"><license>https://opendatacommons.org/licenses/odbl/</license></copyright>`.
- Иначе, если есть `wikiloc` → `<copyright author="Wikiloc
contributors"><license>https://www.wikiloc.com/wikiloc/legalNotice.do</license></copyright>`.
- Иначе, если есть `enduro_russia` → `<copyright
author="EnduroRussia.ru"/>`.
- Иначе — секция опущена.
**REQ-F-09.** Тело трека:
```xml
<trk>
<name>{track.name or "track-{id}"}</name>
<type>{activity_type}</type>
<trkseg>
<trkpt lat="..." lon="...">
<!-- ele опускается: высот в БД нет -->
</trkpt>
...
</trkseg>
</trk>
```
## 5. Алгоритмы
- Один `<trk>` с одним `<trkseg>` (без разбиения на сегменты).
- Координаты выводятся с точностью **6 знаков после запятой**
(≈ 0.1 м на экваторе).
- Порядок точек — как в БД (WKB порядок == порядок исходного трека).
- Тег `<ele>` не выводится (БД 2D).
### 5.1 Расчёт расстояния (Haversine)
**REQ-F-10.** Все текстовые поля экранируются по правилам XML:
`&`, `<`, `>`, `"`, `'` → entity'и. Управляющие символы < 0x20
вырезаются. Никакого ручного склеивания строк — использовать
стандартный XML-сериализатор (`xml.etree.ElementTree` или
аналогичный, без потери порядка детей).
Сумма расстояний между последовательными точками трека. Формула Haversine для каждой пары.
**REQ-F-11.** Сериализация выполняется в память (`bytes`). Потоковый
ответ не требуется: средний трек < 500 КБ, лимит верхней границы —
50 000 точек (~6 МБ) приемлем.
### 5.2 Расчёт набора/сброса высоты
### 3.3 Frontend: кнопка в popup'е
```
elevGain = Σ max(0, ele[i+1] - ele[i]) для всех i
elevLoss = Σ max(0, ele[i] - ele[i+1]) для всех i
**REQ-F-12.** В `_renderTrackPopupHtml` (`src/web/gps_tracks.js`)
после блока `sourcesHtml` добавляется блок:
```html
<div class="track-popup-actions">
<a class="track-popup-download" href="{basePath}/api/gps-tracks/{id}.gpx"
download="{filename}">
⬇ Скачать GPX
</a>
</div>
```
Фильтрация шума: игнорировать дельты < 2 м (GPS-шум).
- `basePath` собирается так же, как в `_ensureGpsSources`
(`window.location.pathname.replace(/\/[^/]*$/, '') || ''`).
- `id` берётся из `feature.properties.id` (это БД-id, оно есть и в
GeoJSON-ответе REQ-F-09 ET-008, и в MVT-атрибутах).
- `download="{filename}"` — клиентский hint для имени файла; сервер
дублирует через `Content-Disposition` (надёжнее в Safari).
### 5.3 Палитра цветов
**REQ-F-13.** Если `feature.properties.id` отсутствует (например, в
edge case'е) — кнопка не рендерится.
Циклический массив из 8 цветов. Индекс = `gpxTracks.length % 8` на момент добавления.
**REQ-F-14.** Клик по кнопке НЕ должен закрывать popup. Стандартный
`<a download>` срабатывает без `preventDefault` и popup остаётся
открытым (MapLibre popup закрывается только при клике вне popup'а или
по крестику; `closeOnClick: true` относится к карте, не к содержимому).
## 6. Файловая структура изменений
**REQ-F-15.** Кнопка стилизуется как акцентный action (не серый текст).
Цвет — `var(--accent)` если объявлен в `app.css`, иначе синий
`#2271b1`. Тёмная и светлая тема: использовать css-переменные,
которые уже есть в `app.css`.
```
src/web/
├── index.html # + кнопка в #map-controls-r, + sheet-gpx, + tb-btn
├── app.js # + gpx-модуль (парсинг, рендеринг, управление)
├── app.css # + стили sheet-gpx, профиля высот, toast
**REQ-F-16.** Минимальная высота тач-таргета — 36 px; padding
`8px 12px`; курсор `pointer`.
### 3.4 Обработка ошибок на клиенте
**REQ-F-17.** Если сервер вернул не-2xx (определяется по событию
`error` через fetch-обёртку), показать `showToast('Не удалось
скачать трек')`. Реализация на клиенте — пассивный `<a download>`,
поэтому достаточно ловить ошибку через `fetch` + `blob` + временный
`<a>` (см. ADR-03 / Architect определит окончательный вариант).
**REQ-F-18.** Существующее поведение popup'а не меняется: клик по
треку открывает popup, клик вне трека — закрывает. Кнопка в popup'е
не интерферирует с MapLibre.
## 4. Нефункциональные требования
| Код | Требование |
|------|-----------|
| NF-01 | Эндпоинт отвечает ≤ 500 ms p95 на треке до 50 000 точек (i7-12700, SSD, БД ~1 ГБ) |
| NF-02 | Память: пик ≤ 10 МБ на запрос (50 000 точек × ~120 байт). Без стриминга |
| NF-03 | Кэширование: ответ помечается `Cache-Control: public, max-age=86400` — данные публичные, обновляются раз в неделю pipeline'ом |
| NF-04 | gzip — на уровне nginx, без ручного сжатия в Python |
| NF-05 | Безопасность: только GET, никаких user-input в SQL кроме `track_id: int` (FastAPI приведёт тип). Никакого XML-external-entity (использовать стандартный сериализатор, не парсер) |
| NF-06 | i18n: UI-надпись «Скачать GPX» только на русском, без переключения языка (на проекте RU-only) |
| NF-07 | Совместимость браузеров: Chrome ≥ 120, Safari iOS ≥ 16, Firefox ≥ 120 — все поддерживают `<a download>` для same-origin |
| NF-08 | Регрессия: все существующие тесты в `tests/unit/test_gps_tracks_*` и `tests/integration/test_gps_tracks_endpoint.py` — зелёные |
## 5. Контракт API
### 5.1 GET /api/gps-tracks/{track_id}.gpx
| Поле | Значение |
|------|----------|
| Method | `GET` |
| Path | `/api/gps-tracks/{track_id}.gpx` |
| Path params | `track_id: int ≥ 1` |
| Query | — |
| Body | — |
| 200 Content-Type | `application/gpx+xml; charset=utf-8` |
| 200 Headers | `Content-Disposition: attachment; filename="..."; filename*=UTF-8''...`, `Cache-Control: public, max-age=86400`, `Access-Control-Allow-Origin: *` |
| 200 Body | GPX 1.1 XML |
| 404 | `{"detail": "Track not found"}` если трека нет или геометрия пустая |
| 422 | `track_id` не int (стандарт FastAPI) |
| 500 | `{"detail": "DB error: ..."}` (HTTPException) |
### 5.2 Пример успешного ответа
```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="enduro-trails (https://openclaw.mva154.duckdns.org/enduro/)"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>Кольцо вокруг Малинок</name>
<desc>Эндуро · 42.5 км · Источники: osm, wikiloc</desc>
<author><name>vasya42</name></author>
<copyright author="OpenStreetMap contributors">
<license>https://opendatacommons.org/licenses/odbl/</license>
</copyright>
<link href="https://www.openstreetmap.org/user/vasya42/traces/123"><text>osm</text></link>
<link href="https://www.wikiloc.com/trail/456"><text>wikiloc</text></link>
<time>2025-08-14T09:30:00Z</time>
</metadata>
<trk>
<name>Кольцо вокруг Малинок</name>
<type>enduro</type>
<trkseg>
<trkpt lat="55.789012" lon="37.123456"/>
<trkpt lat="55.789543" lon="37.124012"/>
...
</trkseg>
</trk>
</gpx>
```
Альтернативно, GPX-логику можно вынести в отдельный файл `gpx.js` (аналогично `units.js`).
## 6. Сценарии работы
## 7. Взаимодействие с существующими режимами
### 6.1 Happy path — desktop, z = 14
- GPX-треки отображаются **параллельно** с любым активным режимом (роутинг, разведка, красивый маршрут).
- Z-order: GPX-треки ниже активного маршрута OSRM, но выше базовых слоёв (trails, terrain).
- Кнопка загрузки в `#map-controls-r` доступна всегда.
- Кнопка «GPX» в toolbar переключает sheet, но не деактивирует другие режимы.
- При смене стиля карты (`setStyle` — тёмная тема, слои рельефа) GPX-слои восстанавливаются через `rebuildMapOverlays()` — см. REQ-F-13.
1. Пользователь включает слой «Публичные треки».
2. Зумит на z=14, кликает по линии трека.
3. Открывается popup с метаданными.
4. Видит кнопку «⬇ Скачать GPX».
5. Кликает — браузер начинает загрузку файла `<name>.gpx`.
6. Popup остаётся открытым.
### 6.2 Happy path — mobile, z = 9 (MVT)
1. Пользователь на мобильном устройстве включил слой и зумит до z=9.
2. Тапает по треку — открывается popup.
3. Тапает кнопку «Скачать GPX» — то же поведение что и на desktop.
### 6.3 Edge — нет геометрии в БД
1. Клик по треку → popup открывается (запись есть).
2. Тап на «Скачать GPX» → сервер вернул 404.
3. Показать toast «Не удалось скачать трек».
4. Popup открыт, остальные кнопки работают.
### 6.4 Edge — невалидное имя
1. Трек с именем `OSM/Trail*?<2024>`.
2. Имя файла очищается до `OSM-Trail-2024.gpx` (по REQ-F-05).
3. Файл скачивается; внутри GPX `<name>` сохранён в исходном виде
(экранированный XML).
## 7. Невырезаемые ссылки на источники
- ET-008 / `02-trz.md` (этот же work_item folder в прошлой жизни ID
ET-008): эндпоинт `GET /api/gps-tracks` и popup-логика.
- ET-009 / `01-brd.md`: правила атрибуции источников.
- `docs/architecture/README.md` §«GPS Tracks Pipeline» — общая
архитектура слоя публичных треков.
- ADR-007 §6 «Licensing guard» — обязательство сохранять атрибуцию
во всех клиентских артефактах.
## 8. Принятые ограничения
1. Высоты в GPX отсутствуют — потому что их нет в БД. Возможное
дополнение (DEM-обогащение) — отдельный work item.
2. Нет batch-скачивания — отдельный work item при необходимости.
3. Нет аналитики кликов — телеметрия в проекте не введена.
4. Нет авторизации — все данные публичные.

View File

@@ -1,254 +1,296 @@
---
type: acceptance-criteria
work_item_id: ET-006
title: "AC: Загрузка и визуализация GPX-треков"
version: 2
title: "AC: Скачивание трека из popup на карте"
version: 1
status: approved
created_at: 2026-05-22
updated_at: 2026-05-22
created_at: 2026-06-03
updated_at: 2026-06-03
authors:
- "agent:analyst"
---
# Acceptance Criteria — ET-006: Загрузка и визуализация GPX-треков
# Критерии приёмки — ET-006: Скачивание трека из popup на карте
## AC-01: Загрузка файла через кнопку
Формат — Given / When / Then. Каждый AC должен быть проверяем
автоматическим тестом из `04-test-plan.yaml` или UI-кейсом из
`04b-ui-test-cases.md`.
```gherkin
Feature: Загрузка GPX-файла
---
Scenario: Успешная загрузка одного файла
Given пользователь находится на карте
When пользователь нажимает кнопку «Загрузить GPX» в правой панели
And выбирает валидный GPX-файл размером < 50 МБ
Then файл парсится без ошибок
And трек отображается на карте цветной линией
And карта выполняет fit bounds по треку
And в панели GPX-треков появляется запись с именем файла
## AC-01 — Кнопка отображается в popup'е публичного трека (desktop)
Scenario: Файл превышает лимит
Given пользователь находится на карте
When пользователь выбирает GPX-файл размером > 50 МБ
Then показывается toast «Файл слишком большой (макс. 50 МБ)»
And трек не загружается
**Given** Пользователь открыл приложение на desktop, включил слой
«Публичные треки», карта на zoom = 14 и в окне есть хотя бы один трек.
Scenario: Невалидный файл
Given пользователь находится на карте
When пользователь выбирает файл с невалидным XML
Then показывается toast «Не удалось прочитать GPX-файл»
And трек не загружается
**When** Пользователь кликает на линию трека.
Scenario: Пустой GPX (без треков и waypoints)
Given пользователь находится на карте
When пользователь выбирает GPX-файл без <trk> и без <wpt>
Then показывается toast «GPX-файл не содержит данных»
```
**Then** Появляется popup, в котором среди прочих элементов
присутствует кликабельная ссылка-кнопка `⬇ Скачать GPX`.
## AC-02: Визуализация трека
**Связь:** TRZ REQ-F-12, REQ-F-15, REQ-F-16. Покрытие: TC-UI-01.
```gherkin
Feature: Отрисовка трека на карте
---
Scenario: Один трек в файле
Given загружен GPX-файл с одним треком
Then на карте отображается линия трека
And линия имеет цвет из палитры
And толщина линии 4px
## AC-02 — Кнопка отображается в popup'е публичного трека (mobile)
Scenario: Несколько треков в одном файле
Given загружен GPX-файл с 3 треками
Then на карте отображаются 3 линии
And все линии одного цвета (цвет файла)
And в панели одна запись (имя файла) с 3 треками внутри
**Given** Пользователь открыл приложение на мобильном устройстве
(viewport 375 × 812), включил слой «Публичные треки», zoom = 14.
Scenario: Несколько файлов
Given загружены 3 GPX-файла
Then на карте отображаются треки из всех файлов
And каждый файл имеет свой цвет из палитры
And в панели 3 записи
```
**When** Пользователь тапает на линию трека.
## AC-03: Waypoints
**Then** Popup открывается, кнопка `⬇ Скачать GPX` видна целиком (не
обрезается), высота тач-таргета ≥ 36 px.
```gherkin
Feature: Отображение waypoints
**Связь:** TRZ REQ-F-16. Покрытие: TC-UI-02.
Scenario: Waypoints с именами
Given загружен GPX-файл с waypoints, у которых есть <name>
Then на карте отображаются маркеры в позициях waypoints
And рядом с каждым маркером отображается имя
---
Scenario: Waypoints без имён
Given загружен GPX-файл с waypoints без <name>
Then на карте отображаются маркеры без подписей
## AC-03 — Кнопка отображается на zoom 811 (MVT-режим)
Scenario: Файл без waypoints
Given загружен GPX-файл без <wpt>
Then маркеры waypoints не отображаются
And трек отображается нормально
```
**Given** Слой «Публичные треки» включён, zoom = 9 (MVT-режим), в
видимой области есть треки.
## AC-04: Fit bounds
**When** Пользователь кликает на трек.
```gherkin
Feature: Автоцентрирование карты
**Then** Popup открывается, кнопка `⬇ Скачать GPX` присутствует.
Scenario: Карта центрируется на загруженном треке
Given карта показывает произвольную область
When пользователь загружает GPX-файл
Then карта выполняет fit bounds по всем точкам файла
And padding составляет 50px со всех сторон
**Связь:** TRZ REQ-F-06. Покрытие: TC-UI-03.
Scenario: Загрузка второго файла
Given на карте уже есть загруженный трек
When пользователь загружает второй GPX-файл
Then карта выполняет fit bounds только по второму файлу
```
---
## AC-05: Удаление трека
## AC-04 — Скачивание возвращает валидный GPX 1.1
```gherkin
Feature: Удаление загруженного трека
**Given** Существует трек с id = N в БД с непустой геометрией.
Scenario: Удаление трека из панели
Given загружены 2 GPX-файла
When пользователь нажимает кнопку удаления () у первого трека
Then первый трек исчезает с карты (линия + waypoints)
And первый трек исчезает из панели
And второй трек остаётся на карте
**When** Браузер отправляет `GET /api/gps-tracks/{N}.gpx`.
Scenario: Удаление активного трека
Given загружены 2 GPX-файла, второй активный (показана статистика)
When пользователь удаляет второй трек
Then профиль высот и статистика скрываются
And первый трек остаётся на карте
And первый трек не становится автоматически активным
**Then** Сервер отвечает `200 OK`, `Content-Type:
application/gpx+xml; charset=utf-8`, тело — валидный XML GPX 1.1
(проходит XSD-валидацию против
`http://www.topografix.com/GPX/1/1/gpx.xsd`).
Scenario: Удаление последнего трека
Given загружен 1 GPX-файл
When пользователь удаляет его
Then карта пуста (нет GPX-слоёв)
And панель GPX показывает пустое состояние
```
**Связь:** TRZ REQ-F-01, REQ-F-04, REQ-F-06, REQ-F-10. Покрытие:
TC-INT-01, TC-UNIT-01.
## AC-06: Панель управления (sheet-gpx)
---
```gherkin
Feature: Панель GPX-треков
## AC-05 — GPX содержит все точки геометрии без потерь
Scenario: Открытие панели при загрузке
Given панель GPX закрыта
When пользователь загружает первый GPX-файл
Then панель GPX открывается автоматически
**Given** Трек с id = N и геометрией длиной K точек (≤ 50 000).
Scenario: Переключение через toolbar
Given панель GPX закрыта
When пользователь нажимает кнопку «GPX» в нижнем тулбаре
Then панель GPX открывается
**When** Сервер сериализует трек в GPX.
Scenario: Выбор активного трека
Given загружены 3 GPX-файла
When пользователь тапает на второй трек в списке
Then второй трек выделяется визуально
And показывается его статистика и профиль высот
```
**Then** В `<trkseg>` ровно K элементов `<trkpt>`, координаты
совпадают с БД с точностью ≥ 6 знаков, порядок точек сохранён.
## AC-07: Профиль высот
**Связь:** TRZ REQ-F-09. Покрытие: TC-UNIT-02.
```gherkin
Feature: Профиль высот
---
Scenario: Трек с данными высот
Given выбран активный трек с данными <ele>
Then в панели отображается график профиля высот
And ось X расстояние (км)
And ось Y высота (м)
And линия графика цвет трека
## AC-06 — GPX содержит обязательные метаданные
Scenario: Трек без данных высот
Given выбран активный трек без данных <ele>
Then вместо графика отображается текст «Данные высот отсутствуют»
**Given** Трек с заполненными полями `name`, `activity_type`,
`created_at`, `user`, `sources_json`, `external_urls_json`.
Scenario: Интерактивность профиля
Given отображается профиль высот
When пользователь наводит курсор (или тапает) на точку графика
Then показывается tooltip с высотой и расстоянием
And на карте подсвечивается соответствующая точка трека
```
**When** Запрошен GPX.
## AC-08: Статистика трека
**Then** В `<metadata>` присутствуют:
- `<name>` с именем трека;
- `<desc>` со склейкой description / активности / длины / источников;
- `<author><name>` если есть `user`;
- `<copyright>` (см. AC-08);
- `<link>` для каждой external_url;
- `<time>` (`created_at` или сейчас UTC).
- В `<trk>``<name>`, `<type>` (activity_type).
```gherkin
Feature: Статистика трека
**Связь:** TRZ REQ-F-07, REQ-F-09. Покрытие: TC-UNIT-03.
Scenario: Полная статистика (с высотами)
Given выбран активный трек с данными <ele>
Then отображаются: длина (км), набор высоты (м), сброс высоты (м), мин. высота (м), макс. высота (м)
---
Scenario: Частичная статистика (без высот)
Given выбран активный трек без данных <ele>
Then отображается только длина (км)
And остальные поля показывают «»
```
## AC-07 — Имя файла безопасное и читаемое
## AC-09: Интерактивность на карте
**Given** Трек с именем `OSM/Trail*?<2024>` (id = 42).
```gherkin
Feature: Клик по треку на карте
**When** Браузер получает ответ.
Scenario: Выбор трека кликом
Given на карте отображаются 3 трека из разных файлов
When пользователь кликает на линию второго трека
Then второй трек становится активным в панели
And показывается его статистика и профиль высот
```
**Then** `Content-Disposition` содержит:
- ASCII fallback `filename="OSM-Trail-2024.gpx"` (или другая чистая
ASCII-строка без `/\:*?"<>|`);
- UTF-8 вариант `filename*=UTF-8''…` (percent-encoded).
- Если у трека нет имени — `filename=track-42.gpx`.
## AC-10: Совместимость с другими режимами
**Связь:** TRZ REQ-F-05. Покрытие: TC-UNIT-04, TC-INT-02.
```gherkin
Feature: Параллельная работа с роутингом
---
Scenario: GPX + активный маршрут
Given пользователь построил маршрут через OSRM
And загрузил GPX-файл
Then оба отображаются на карте одновременно
And маршрут OSRM визуально выше GPX-трека
And оба интерактивны
## AC-08 — Атрибуция OSM-источника зафиксирована в GPX
Scenario: GPX + режим разведки
Given пользователь в режиме разведки
When загружает GPX-файл
Then трек отображается на карте
And режим разведки продолжает работать
```
**Given** Трек с `sources_json` = `["osm"]`.
## AC-12: Сохранение при переключении стиля карты
**When** Сериализован в GPX.
```gherkin
Feature: Сохранение GPX-треков при смене стиля карты
**Then** В `<metadata>` есть `<copyright author="OpenStreetMap
contributors"><license>https://opendatacommons.org/licenses/odbl/</license></copyright>`.
Scenario: Переключение тёмной темы
Given загружен GPX-трек и отображается на карте
When пользователь переключает тёмную тему
Then трек остаётся на карте после смены стиля
And waypoints остаются на карте
And активный трек, его статистика и профиль высот сохраняются
**Связь:** TRZ REQ-F-08, ADR-007 §6. Покрытие: TC-UNIT-05.
Scenario: Переключение слоёв рельефа
Given загружены 2 GPX-трека
When пользователь включает или выключает слой рельефа (hillshade / TRI)
Then оба трека остаются на карте с прежними цветами
And z-order GPX-слоёв сохраняется (ниже маршрута OSRM)
```
---
## AC-11: Индикатор загрузки
## AC-09 — Атрибуция Wikiloc-источника зафиксирована в GPX
```gherkin
Feature: Индикатор при парсинге
**Given** Трек с `sources_json` = `["wikiloc"]`.
Scenario: Большой файл
Given пользователь выбирает GPX-файл > 10 МБ
Then показывается индикатор загрузки (spinner)
And после завершения парсинга индикатор скрывается
And трек отображается на карте
```
**When** Сериализован в GPX.
**Then** В `<metadata>` есть `<copyright author="Wikiloc
contributors"><license>https://www.wikiloc.com/wikiloc/legalNotice.do</license></copyright>`.
**Связь:** TRZ REQ-F-08. Покрытие: TC-UNIT-06.
---
## AC-10 — Трек без геометрии → 404
**Given** Существует трек id = N, у которого `geom IS NULL` или WKB
не парсится.
**When** Запрошен `.gpx` для id = N.
**Then** Сервер возвращает `404 Not Found` с телом
`{"detail":"Track not found"}`.
**Связь:** TRZ REQ-F-03. Покрытие: TC-INT-03.
---
## AC-11 — Несуществующий id → 404
**Given** В БД нет трека id = 99999999.
**When** Запрошен `.gpx` для id = 99999999.
**Then** Сервер возвращает `404 Not Found`.
**Связь:** TRZ REQ-F-03. Покрытие: TC-INT-04.
---
## AC-12 — Не-int track_id → 422
**Given** В пути URL передано не число (`/api/gps-tracks/abc.gpx`).
**When** Сервер принимает запрос.
**Then** Сервер возвращает `422 Unprocessable Entity` (стандарт FastAPI).
**Связь:** TRZ REQ-F-02. Покрытие: TC-INT-05.
---
## AC-13 — Скачанный файл успешно открывается приложением
**Given** Скачан GPX-файл валидного трека.
**When** Пользователь открывает «Загрузить GPX» в существующем UI
приложения и выбирает скачанный файл.
**Then** Парсер `gpx.js` принимает файл без ошибок, трек рисуется на
карте, точки совпадают с оригиналом.
**Связь:** Регрессия с ET-006 GPX upload (исторически). Покрытие:
TC-E2E-01.
---
## AC-14 — Клик по кнопке не закрывает popup
**Given** Открыт popup на треке.
**When** Пользователь кликает «Скачать GPX».
**Then** Загрузка стартует, popup остаётся открытым.
**Связь:** TRZ REQ-F-14. Покрытие: TC-UI-04.
---
## AC-15 — При ошибке сервера показывается toast
**Given** Сервер настроен на 500 при `/api/gps-tracks/{id}.gpx`
(или временная сетевая ошибка эмулирована).
**When** Пользователь кликает кнопку.
**Then** Появляется toast «Не удалось скачать трек», popup не
закрывается, остальной UI работает.
**Связь:** TRZ REQ-F-17. Покрытие: TC-E2E-02.
---
## AC-16 — Существующий функционал не сломан
**Given** Слой «Публичные треки» работал до изменений: MVT z=9,
GeoJSON z=14, фильтры, halo на спутнике, hint «Зум 8+».
**When** Развёрнуто текущее изменение.
**Then** Все перечисленные сценарии работают как и раньше: MVT
тайлы загружаются, GeoJSON подгружается на moveend, фильтры по
активностям/источникам применяются, halo появляется на спутнике,
hint «Зум 8+» показывается на z<8.
**Связь:** TRZ NF-08. Покрытие: TC-INT-06, TC-UI-05.
---
## AC-17 — Производительность эндпоинта
**Given** В БД трек с 5000 точек.
**When** Сервер обрабатывает GET `.gpx`.
**Then** p95 latency ≤ 500 ms (на CI или dev-машине). p95 ≤ 1000 ms на
треке 50 000 точек.
**Связь:** TRZ NF-01. Покрытие: TC-PERF-01.
---
## AC-18 — Кэш-заголовок выставлен
**Given** Успешный ответ `.gpx`.
**When** Просмотрены ответные заголовки.
**Then** `Cache-Control: public, max-age=86400`.
**Связь:** TRZ NF-03. Покрытие: TC-INT-07.
---
## AC-19 — Кнопка не рендерится при отсутствии id
**Given** В свойствах фичи `id` отсутствует (например, MVT-фича без
поля id — теоретический edge case).
**When** Открывается popup.
**Then** Кнопка `⬇ Скачать GPX` не отображается, остальной popup
рендерится без ошибок (нет `undefined`-ссылки).
**Связь:** TRZ REQ-F-13. Покрытие: TC-UNIT-07 (popup-html).
---
## AC-20 — XML-инъекция через name невозможна
**Given** Трек с `name = '"><script>alert(1)</script>'`.
**When** Сериализуется в GPX.
**Then** Выходной XML валиден, `<script>` присутствует только как
экранированный текст (`&lt;script&gt;`), теги не появляются.
**Связь:** TRZ REQ-F-10, NF-05. Покрытие: TC-UNIT-08.

View File

@@ -1,254 +1,318 @@
---
type: test-plan
work_item_id: ET-006
title: "Test Plan: Загрузка и визуализация GPX-треков"
version: 2
title: "Test Plan: Скачивание трека из popup на карте"
version: 1
status: approved
created_at: 2026-05-22
updated_at: 2026-05-22
created_at: 2026-06-03
updated_at: 2026-06-03
authors:
- "agent:analyst"
---
test_suites:
# Test Plan — ET-006: Скачивание трека из popup на карте
# Структура: каждый тест-кейс ссылается на AC и на TRZ-требование.
# UI-тесты (Playwright визуальные) описаны в 04b-ui-test-cases.md.
suites:
unit:
framework: pytest
path: tests/unit/
- name: unit-gpx-parser
type: unit
description: "Парсинг GPX XML → внутренняя модель"
cases:
- id: U-01
name: "Парсинг валидного GPX 1.1 с одним треком"
input: "GPX-файл с 1 trk, 1 trkseg, 10 trkpt (lat, lon, ele, time)"
expected: "Возвращает объект с 1 треком, 10 точками, корректными координатами и высотами"
- id: U-02
name: "Парсинг GPX с несколькими треками"
input: "GPX-файл с 3 trk"
expected: "Возвращает массив из 3 треков"
- id: TC-UNIT-01
title: "gpx_builder.build_gpx — корректный корень и XML 1.0 декларация"
covers:
ac: [AC-04]
req: [REQ-F-04, REQ-F-06, REQ-F-10]
given: "TrackRow-фикстура с name='X', 3 точки, sources=['osm']"
when: "build_gpx(track_row) → bytes"
then:
- "первая строка начинается с '<?xml version=\"1.0\" encoding=\"UTF-8\"?>'"
- "корневой элемент <gpx version='1.1' xmlns='http://www.topografix.com/GPX/1/1'"
- "содержит xsi:schemaLocation на GPX 1.1 XSD"
- "содержит атрибут creator='enduro-trails (...)'"
- "XML парсится без ошибок (ET.fromstring)"
- id: U-03
name: "Парсинг waypoints"
input: "GPX-файл с 5 wpt (lat, lon, name, ele)"
expected: "Возвращает массив из 5 waypoints с именами и координатами"
- id: TC-UNIT-02
title: "gpx_builder — точки сохраняются с точностью 6 знаков и в исходном порядке"
covers:
ac: [AC-05]
req: [REQ-F-09]
given: "track с coords=[(37.123456789, 55.987654321), (37.111111, 55.222222), (37.999999, 55.000001)]"
when: "сериализация в GPX"
then:
- "в <trkseg> ровно 3 <trkpt>"
- "первый <trkpt lat='55.987654' lon='37.123457'/> (округление до 6 знаков)"
- "порядок элементов совпадает с входным"
- id: U-04
name: "Парсинг route (rte)"
input: "GPX-файл с 1 rte, 20 rtept"
expected: "Возвращает как трек с 20 точками"
- id: TC-UNIT-03
title: "gpx_builder — metadata содержит name/desc/author/time/link"
covers:
ac: [AC-06]
req: [REQ-F-07]
given: "track с name='Кольцо', activity_type='enduro', user='vasya', created_at='2025-08-14T09:30:00Z', external_urls=['https://example.com/a','https://example.com/b'], length_m=42500, sources=['osm','wikiloc']"
when: "build_gpx"
then:
- "metadata/name == 'Кольцо'"
- "metadata/desc содержит 'Эндуро' и '42.5' и 'osm' и 'wikiloc'"
- "metadata/author/name == 'vasya'"
- "metadata/time == '2025-08-14T09:30:00Z'"
- "metadata содержит две <link> в порядке external_urls"
- "trk/name == 'Кольцо', trk/type == 'enduro'"
- id: U-05
name: "GPX без данных высот"
input: "GPX-файл с trkpt без <ele>"
expected: "Точки имеют ele=null, stats.elevGain=null"
- id: TC-UNIT-04
title: "filename builder — очистка запрещённых символов"
covers:
ac: [AC-07]
req: [REQ-F-05]
cases:
- input: { name: "OSM/Trail*?<2024>", id: 42 }
expect_ascii_match: "^[A-Za-z0-9._-]+\\.gpx$"
expect_utf8: "OSM-Trail-2024.gpx"
- input: { name: "", id: 7 }
expect_ascii: "track-7.gpx"
- input: { name: null, id: 7 }
expect_ascii: "track-7.gpx"
- input: { name: " ", id: 9 }
expect_ascii: "track-9.gpx"
- input: { name: "Очень-длинное-имя-".ljust(200, "X"), id: 1 }
expect_max_basename_len: 64
- id: U-06
name: "Невалидный XML"
input: "Файл с битым XML"
expected: "Выбрасывает ошибку с сообщением"
- id: TC-UNIT-05
title: "copyright — OSM ODbL"
covers:
ac: [AC-08]
req: [REQ-F-08]
given: "track.sources_json = '[\"osm\"]'"
when: "build_gpx"
then:
- "metadata/copyright@author == 'OpenStreetMap contributors'"
- "metadata/copyright/license == 'https://opendatacommons.org/licenses/odbl/'"
- id: U-07
name: "Пустой GPX (нет trk, wpt, rte)"
input: "Валидный XML, но без данных"
expected: "Выбрасывает ошибку 'no data'"
- id: TC-UNIT-06
title: "copyright — Wikiloc"
covers:
ac: [AC-09]
req: [REQ-F-08]
given: "track.sources_json = '[\"wikiloc\"]'"
when: "build_gpx"
then:
- "metadata/copyright@author == 'Wikiloc contributors'"
- "metadata/copyright/license содержит wikiloc.com/wikiloc/legalNotice"
- id: U-08
name: "GPX с namespace и без"
input: "GPX без xmlns атрибута"
expected: "Парсится корректно (fallback без namespace)"
- id: TC-UNIT-07
title: "popup HTML — кнопка не рендерится при отсутствии id"
covers:
ac: [AC-19]
req: [REQ-F-13]
framework_override: jest # JS unit
given: "_renderTrackPopupHtml({ name: 'X' }) // без id"
when: "вызывается функция"
then:
- "результат НЕ содержит класс 'track-popup-download'"
- "результат не содержит подстроки '/api/gps-tracks//'"
- id: TC-UNIT-08
title: "XML escaping — name с угловыми скобками и амперсандом"
covers:
ac: [AC-20]
req: [REQ-F-10, NF-05]
given: "track.name = '\"><script>alert(1)</script>'"
when: "build_gpx → bytes → fromstring(...)"
then:
- "ET.fromstring не выбрасывает исключение"
- "metadata/name.text == исходная строка"
- "в bytes нет тега <script (литерально)"
- id: TC-UNIT-09
title: "gpx_builder — copyright опущен если нет известного source"
covers:
ac: [AC-08]
req: [REQ-F-08]
given: "track.sources_json = '[\"unknown-source\"]'"
when: "build_gpx"
then:
- "metadata/copyright отсутствует"
- id: TC-UNIT-10
title: "popup HTML — кнопка содержит корректный basePath и id"
covers:
ac: [AC-01, AC-03]
req: [REQ-F-12]
framework_override: jest
given: "window.location.pathname = '/enduro/', feature.properties = { id: 42, name: 'X' }"
when: "_renderTrackPopupHtml(props)"
then:
- "html содержит href='/enduro/api/gps-tracks/42.gpx'"
- "html содержит 'Скачать GPX'"
- "html содержит атрибут download="
integration:
framework: pytest + TestClient
path: tests/integration/
- name: unit-gpx-stats
type: unit
description: "Расчёт статистики трека"
cases:
- id: U-10
name: "Расчёт длины (Haversine)"
input: "Трек из 3 точек: [37.6, 55.7], [37.7, 55.8], [37.8, 55.9]"
expected: "Длина ≈ 28.3 км (±0.5 км)"
- id: U-11
name: "Расчёт набора высоты"
input: "Точки с ele: [100, 150, 120, 200, 180]"
expected: "elevGain = 130 м (50 + 80), elevLoss = 70 м (30 + 20)"
- id: TC-INT-01
title: "GET .gpx — 200 OK + правильные headers"
covers:
ac: [AC-04]
req: [REQ-F-04]
setup: "в тестовую sqlite вставлен трек id=1 c name='Test', 5 точек"
when: "GET /api/gps-tracks/1.gpx"
then:
- "status == 200"
- "header Content-Type == 'application/gpx+xml; charset=utf-8'"
- "header Content-Disposition содержит 'attachment'"
- "header Content-Disposition содержит 'filename='"
- "header Content-Disposition содержит 'filename*=UTF-8'\"'\"''"
- "header Cache-Control == 'public, max-age=86400'"
- "header Access-Control-Allow-Origin == '*'"
- "body начинается с '<?xml'"
- id: U-12
name: "Фильтрация шума высот (дельта < 2м)"
input: "Точки с ele: [100, 101, 100, 101, 150]"
expected: "elevGain = 50 м (только 100→150), мелкие колебания игнорируются"
- id: TC-INT-02
title: "Content-Disposition — RFC 5987 UTF-8 для кириллицы"
covers:
ac: [AC-07]
req: [REQ-F-05]
setup: "трек с name='Кольцо вокруг Малинок'"
when: "GET /api/gps-tracks/{id}.gpx"
then:
- "header содержит filename='ascii-fallback'"
- "header содержит filename*=UTF-8''%D0%9A%D0%BE%D0%BB%D1%8C..."
- id: U-13
name: "Мин/макс высота"
input: "Точки с ele: [100, 250, 80, 300, 150]"
expected: "eleMin=80, eleMax=300"
- id: TC-INT-03
title: "GET .gpx — 404 при NULL geom"
covers:
ac: [AC-10]
req: [REQ-F-03]
setup: "трек id=2 с geom=NULL"
when: "GET /api/gps-tracks/2.gpx"
then:
- "status == 404"
- "body == {'detail': 'Track not found'}"
- id: U-14
name: "Статистика без данных высот"
input: "Точки без ele"
expected: "distanceKm рассчитан, elevGain/elevLoss/eleMin/eleMax = null"
- id: TC-INT-04
title: "GET .gpx — 404 при несуществующем id"
covers:
ac: [AC-11]
req: [REQ-F-03]
when: "GET /api/gps-tracks/9999999.gpx"
then:
- "status == 404"
- id: TC-INT-05
title: "GET .gpx — 422 при не-int id"
covers:
ac: [AC-12]
req: [REQ-F-02]
when: "GET /api/gps-tracks/abc.gpx"
then:
- "status == 422"
- id: TC-INT-06
title: "Регрессия — соседние эндпоинты не сломаны"
covers:
ac: [AC-16]
req: [NF-08]
when:
- "GET /api/gps-tracks?bbox=37,55,38,56"
- "GET /api/gps-tracks/tiles/9/297/154.mvt"
- "GET /api/gps-tracks/health"
then:
- "все три отвечают 200 как до изменений"
- "формат ответа идентичен (диф = 0 байт vs baseline)"
- id: TC-INT-07
title: "Cache-Control header выставлен"
covers:
ac: [AC-18]
req: [NF-03]
when: "GET /api/gps-tracks/{id}.gpx → headers"
then:
- "Cache-Control == 'public, max-age=86400'"
- id: TC-INT-08
title: "GPX скачанный с сервера проходит XSD-валидацию"
covers:
ac: [AC-04]
req: [REQ-F-06]
setup: "произвольный трек id=1; локальная копия GPX 1.1 XSD в tests/fixtures/gpx11.xsd"
when: "GET .gpx → xmlschema.validate(body, xsd)"
then:
- "валидация успешна (нет ошибок)"
e2e:
framework: playwright
path: tests/e2e/
base_url: https://openclaw.mva154.duckdns.org/enduro/
- name: unit-gpx-colors
type: unit
description: "Назначение цветов из палитры"
cases:
- id: U-20
name: "Первый файл получает первый цвет"
input: "Загрузка первого файла"
expected: "Цвет = #e6194b"
- id: U-21
name: "Девятый файл получает первый цвет (цикл)"
input: "Загрузка 9-го файла"
expected: "Цвет = #e6194b (индекс 8 % 8 = 0)"
- id: TC-E2E-01
title: "Download → upload roundtrip"
covers:
ac: [AC-13]
req: [REQ-F-04, REQ-F-09]
steps:
- "включить слой 'Публичные треки'"
- "зум до 14 на координатах с известными треками"
- "кликнуть на трек"
- "перехватить URL загрузки из href кнопки 'Скачать GPX'"
- "скачать файл через page.context().request().get(url)"
- "пройти через парсер gpx.js (загрузить через input[type=file] '#gpx-file')"
then:
- "появляется sheet-gpx с одним треком"
- "имя трека совпадает с popup.name"
- "длина совпадает (±1%) с popup.length_km"
- id: TC-E2E-02
title: "Сетевая ошибка → toast"
covers:
ac: [AC-15]
req: [REQ-F-17]
steps:
- "route('**/api/gps-tracks/*.gpx', route => route.fulfill({status:500}))"
- "включить слой, кликнуть на трек"
- "кликнуть 'Скачать GPX'"
then:
- "появляется toast 'Не удалось скачать трек'"
- "popup НЕ закрылся"
perf:
framework: pytest + httpx + statistics
path: tests/perf/
- name: integration-gpx-map
type: integration
description: "Интеграция GPX с MapLibre"
cases:
- id: I-01
name: "Добавление source и layer при загрузке"
input: "Загрузка валидного GPX"
expected: "map.getSource(sourceId) !== null, map.getLayer(layerId) !== null"
- id: I-02
name: "Удаление source и layer при удалении трека"
input: "Удаление загруженного трека"
expected: "map.getSource(sourceId) === null, map.getLayer(layerId) === null"
- id: TC-PERF-01
title: "GET .gpx — p95 latency"
covers:
ac: [AC-17]
req: [NF-01]
setup: "трек id=N с 5000 точек"
when: "100 sequential GET .gpx"
then:
- "p95 ≤ 500 ms"
- "максимум ≤ 1000 ms"
- id: I-03
name: "Fit bounds после загрузки"
input: "Загрузка GPX с bbox [37.5, 55.6, 37.9, 55.9]"
expected: "map.getBounds() содержит указанный bbox"
# Итоговые счётчики
totals:
unit: 10
integration: 8
e2e: 2
perf: 1
ui_visual: see 04b-ui-test-cases.md
- id: I-04
name: "Waypoints как маркеры"
input: "GPX с 3 waypoints"
expected: "На карте 3 маркера с подписями"
- id: I-05
name: "Клик по треку активирует его"
input: "Клик на линию трека"
expected: "Трек становится активным, показывается статистика"
- id: I-06
name: "GPX-слои ниже маршрута OSRM"
input: "Загружен GPX + построен маршрут"
expected: "Layer order: gpx-layer before route-layer"
- id: I-07
name: "Треки сохраняются после setStyle (переключение стиля карты)"
input: "Загружен GPX-трек (линия + waypoints), вызывается switchMapStyle() / map.setStyle()"
expected: "После события idle: map.getLayer(layerId) !== null, map.getSource(sourceId) !== null, waypoint-маркеры присутствуют, активный трек и его статистика/профиль сохранены"
- name: integration-gpx-elevation
type: integration
description: "Профиль высот"
cases:
- id: I-10
name: "Рендеринг canvas профиля"
input: "Активный трек с 100 точками и ele"
expected: "Canvas отрисован, ширина = ширина контейнера, высота = 120px"
- id: I-11
name: "Tooltip при наведении"
input: "Mousemove по canvas на позиции 50%"
expected: "Tooltip показывает высоту и расстояние средней точки"
- id: I-12
name: "Маркер-курсор на карте при наведении на профиль"
input: "Mousemove по canvas"
expected: "На карте появляется маркер в соответствующей точке трека"
- name: e2e-gpx-workflow
type: e2e
description: "Полный пользовательский сценарий"
cases:
- id: E-01
name: "Загрузка → визуализация → статистика → удаление"
steps:
- "Открыть приложение"
- "Нажать кнопку GPX в правой панели"
- "Выбрать файл test-track.gpx (1 трек, 500 точек, с ele)"
- "Убедиться: трек на карте, панель открыта, статистика показана"
- "Проверить профиль высот"
- "Удалить трек"
- "Убедиться: карта пуста, панель пуста"
- id: E-02
name: "Множественная загрузка и различение цветов"
steps:
- "Загрузить 3 GPX-файла последовательно"
- "Убедиться: 3 трека на карте разных цветов"
- "Убедиться: 3 записи в панели"
- "Кликнуть на второй трек в панели"
- "Убедиться: показана статистика второго трека"
- id: E-03
name: "Большой файл (50 МБ)"
steps:
- "Загрузить GPX-файл ~50 МБ"
- "Убедиться: показан индикатор загрузки"
- "Убедиться: трек отображается после парсинга"
- "Убедиться: pan/zoom работают без фризов"
- id: E-04
name: "Файл с waypoints"
steps:
- "Загрузить GPX с 5 waypoints"
- "Убедиться: 5 маркеров на карте с подписями"
- "Удалить трек"
- "Убедиться: маркеры исчезли"
- id: E-05
name: "GPX параллельно с роутингом"
steps:
- "Построить маршрут через OSRM"
- "Загрузить GPX-файл"
- "Убедиться: оба отображаются, маршрут выше GPX"
- "Удалить GPX"
- "Убедиться: маршрут не затронут"
- id: E-06
name: "Ошибки: невалидный файл, превышение лимита"
steps:
- "Попытаться загрузить .txt файл переименованный в .gpx"
- "Убедиться: toast с ошибкой"
- "Попытаться загрузить файл > 50 МБ"
- "Убедиться: toast с ошибкой"
- "Убедиться: предыдущие треки не затронуты"
- id: E-07
name: "Мобильное устройство (touch)"
steps:
- "Открыть на мобильном (или эмуляция)"
- "Загрузить GPX"
- "Тапнуть на трек в панели"
- "Тапнуть на профиль высот"
- "Убедиться: tooltip и маркер-курсор работают"
- name: e2e-gpx-toolbar
type: e2e
description: "Кнопка GPX в toolbar"
cases:
- id: E-10
name: "Переключение панели через toolbar"
steps:
- "Нажать кнопку GPX в нижнем тулбаре"
- "Убедиться: панель GPX открылась"
- "Нажать ещё раз"
- "Убедиться: панель свернулась"
test_data:
- name: "test-track-simple.gpx"
description: "1 трек, 10 точек, с ele и time"
- name: "test-track-multi.gpx"
description: "3 трека в одном файле"
- name: "test-track-waypoints.gpx"
description: "1 трек + 5 waypoints с именами"
- name: "test-track-no-ele.gpx"
description: "1 трек без данных высот"
- name: "test-track-large.gpx"
description: "~50 МБ, 500K+ точек"
- name: "test-track-invalid.gpx"
description: "Битый XML"
- name: "test-track-empty.gpx"
description: "Валидный GPX без trk/wpt/rte"
- name: "test-track-route.gpx"
description: "GPX с <rte> вместо <trk>"
# Тестовые фикстуры
fixtures:
- path: tests/fixtures/gps_tracks_test.sqlite
description: "тестовая БД с 5 треками: 1 osm, 1 wikiloc, 1 enduro_russia, 1 без имени, 1 с NULL geom"
- path: tests/fixtures/gpx11.xsd
description: "официальная XSD-схема GPX 1.1 (скачана с topografix.com при подготовке тестов)"
- path: tests/fixtures/perf_track_5000.json
description: "трек 5000 точек для perf-теста"

View File

@@ -0,0 +1,211 @@
---
type: ui-test-cases
work_item_id: ET-006
title: "UI Test Cases: Скачивание трека из popup на карте"
version: 1
status: approved
created_at: 2026-06-03
updated_at: 2026-06-03
authors:
- "agent:analyst"
---
# UI Test Cases — ET-006
Тесты выполняются Playwright'ом против test-окружения
`https://openclaw.mva154.duckdns.org/enduro/`.
Селекторы:
- `#map` — контейнер MapLibre
- `#terrain-toggle` — кнопка вызова terrain-popup со списком слоёв
- `#public-tracks-cb` — чекбокс «Публичные треки»
- `.maplibregl-popup` — корневой класс popup'а MapLibre
- `.track-popup` — корневой класс контента нашего popup'а
- `.track-popup-name`, `.track-popup-row`, `.track-popup-sources`
существующие блоки (см. `_renderTrackPopupHtml`)
- `.track-popup-actions` — НОВЫЙ блок (TRZ REQ-F-12)
- `.track-popup-download` — НОВАЯ кнопка-ссылка (TRZ REQ-F-12)
- `#app-toast` — toast-уведомления
---
### TC-UI-01 — Кнопка «Скачать GPX» видна в popup'е (desktop)
- тип: ui
- viewport: desktop
- покрывает: AC-01
Шаги:
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: 1500
7. screenshot: 01-layer-enabled-desktop
8. # Зумим до 14 на известной локации с треками (Подмосковье)
9. # zoom-to: [37.5, 55.8, 14] — реализуется через page.evaluate(map.setZoom)
10. wait: 3000
11. click: #map # клик по центру карты, где должен быть трек
12. wait: 1000
13. screenshot: 02-popup-open-desktop
14. check-visual: popup `.maplibregl-popup` виден, внутри `.track-popup-download` существует, текст содержит «Скачать GPX»
---
### TC-UI-02 — Кнопка видна и тапабельна (mobile)
- тип: ui
- viewport: mobile (375 × 812)
- покрывает: AC-02, TRZ REQ-F-16
Шаги:
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: 1500
7. # zoom-to: [37.5, 55.8, 14]
8. wait: 3000
9. click: #map
10. wait: 1000
11. screenshot: 03-popup-mobile
12. check-visual:
- popup полностью помещается в viewport, не уходит за правый край
- `.track-popup-download` виден целиком (не обрезан)
- bounding rect высоты `.track-popup-download` ≥ 36 px
- текст «Скачать GPX» не переносится посередине слова
---
### TC-UI-03 — Кнопка отображается на zoom 9 (MVT-режим)
- тип: ui
- viewport: desktop
- покрывает: AC-03
Шаги:
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: 1500
7. # zoom-to: [37.5, 55.8, 9]
8. wait: 3000
9. click: #map # клик на трек, отрисованный из MVT
10. wait: 1000
11. screenshot: 04-popup-mvt-z9
12. check-visual: `.track-popup-download` присутствует и текст «Скачать GPX»
---
### TC-UI-04 — Клик по кнопке не закрывает popup (визуально)
- тип: ui
- viewport: desktop
- покрывает: AC-14, TRZ REQ-F-14
Шаги:
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: 1500
7. # zoom-to: [37.5, 55.8, 14]
8. wait: 3000
9. click: #map
10. wait: 1000
11. screenshot: 05-popup-before-click
12. click: .track-popup-download
13. wait: 1500
14. screenshot: 06-popup-after-click
15. check-visual:
- `.maplibregl-popup` всё ещё виден (popup НЕ закрылся)
- в браузере инициирован download (см. Playwright `page.on('download')`)
---
### TC-UI-05 — Существующий terrain-popup и фильтры не сломаны
- тип: ui
- viewport: desktop
- покрывает: AC-16
Шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: #terrain-toggle
4. wait: 500
5. screenshot: 07-terrain-popup
6. check-visual: чекбоксы «Тени рельефа», «Перепады», «Грунтовки»,
«Тропы», «Публичные треки», «POI», переключатель «Подложка
Схема/Спутник», переключатель «Единицы км/мили» — все на своих
местах, без визуальных артефактов.
7. click: #public-tracks-cb
8. wait: 1500
9. # ожидаем что появилась кнопка «Фильтры…»
10. screenshot: 08-public-tracks-active
11. check-visual: видна кнопка «Фильтры…» под чекбоксом «Публичные треки»
12. click: #public-tracks-filters-btn
13. wait: 1000
14. screenshot: 09-filters-sheet
15. check-visual: открылся sheet с группами «Активности», «Источники»,
«Цвет линий», блок «Показано / в области» — формат и стили
идентичны baseline (до изменения).
---
### TC-UI-06 — Стили кнопки в тёмной теме
- тип: ui
- viewport: desktop
- покрывает: TRZ REQ-F-15
Шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. # Тёмная тема — дефолт (`body.theme-dark`), убедиться
4. click: #terrain-toggle
5. click: #public-tracks-cb
6. wait: 1500
7. # zoom-to: [37.5, 55.8, 14]
8. wait: 3000
9. click: #map
10. wait: 1000
11. screenshot: 10-popup-dark-theme
12. check-visual:
- кнопка `.track-popup-download` контрастна к фону popup'а
- цвет текста явно отличается от обычных серых строк popup'а
- hover (page.hover('.track-popup-download')) меняет состояние
(подчёркивание / темнее фон)
13. screenshot: 11-popup-dark-theme-hover
---
### TC-UI-07 — Toast при сетевой ошибке (визуальный вариант TC-E2E-02)
- тип: ui
- viewport: desktop
- покрывает: AC-15
Шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. # перед загрузкой страницы — page.route('**/api/gps-tracks/*.gpx',
r => r.fulfill({ status: 500, body: '{}' }))
3. wait: 5000
4. click: #terrain-toggle
5. click: #public-tracks-cb
6. wait: 1500
7. # zoom-to: [37.5, 55.8, 14]
8. wait: 3000
9. click: #map
10. wait: 1000
11. click: .track-popup-download
12. wait: 2000
13. screenshot: 12-toast-error
14. check-visual:
- `#app-toast` виден с текстом «Не удалось скачать трек»
- popup остался открытым