analyst(ET): auto-commit from analyst run_id=63
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 20:05:12 +00:00
parent 44b7af9ad0
commit 2bf08a10e3
5 changed files with 984 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
# BRD: Скачивание трека из popup на карте
**Work Item:** ET-011
**Стадия:** Анализ
**Автор:** analyst
**Дата:** 2026-06-03
---
## 1. Контекст и проблема
Пользователь (мотоциклист-эндурист) изучает карту, видит публичные GPS-треки
(слой ET-008 «Публичные треки»), тапает понравившийся трек и видит во
всплывающем окне его метаданные: название, активность, длину, точки, дату,
источники. Однако сейчас **нет способа сохранить трек к себе** — приходится
переходить по внешней ссылке источника (если она есть) и искать там кнопку
скачивания, либо вообще нет возможности (например, в OSM-источнике).
**Боль:** мотоциклист, готовясь к выезду в офлайн-режиме, не может за один
тап забрать понравившийся трек в свой GPS-навигатор (Garmin, OsmAnd,
Locus, smartphone) или планировщик.
## 2. Цель
Дать пользователю **скачать понравившийся трек прямо из popup на карте**
одним нажатием — получить файл в стандартном формате (GPX), пригодный
для импорта в любой GPS-софт.
## 3. Целевая аудитория
- Мотоциклист-эндурист, изучающий маршруты перед поездкой
- Велосипедист / турист, скачивающий чужой трек для повторного прохождения
- Турфирма / организатор, готовящая раздаточный материал
## 4. Бизнес-ценность
| Метрика | Эффект ожидаемый |
|------------------------------------------------------|-------------------------------------------------|
| Доля сессий с тапом по треку → действие | Сейчас 0% (только просмотр), цель ≥ 20% |
| Возвраты пользователя за треками | ↑ (приложение становится «полезным», а не «смотровым») |
| Конверсия публичных треков в реальные пройденные | ↑ (треки начинают перетекать в GPS) |
## 5. Область (Scope)
### В скоупе
1. **UI:** в существующем popup публичного трека (`_renderTrackPopupHtml`
в `src/web/gps_tracks.js`) появляется кнопка/иконка «Скачать».
2. **Backend:** новый эндпоинт отдачи GPX-файла по идентификатору трека
из таблицы `tracks` БД `gps_tracks.sqlite`.
3. **Формат:** GPX 1.1 — обязательно.
4. **Формат:** KML 2.2 — опционально, если бюджет позволяет (R-K-01,
см. ниже).
5. **Имя файла:** человекочитаемое, из имени трека (см. NFR-04).
### Вне скоупа
- Авторизация / приватные треки — все треки в БД публичны.
- Массовое скачивание (пачкой) — только по одному.
- Кастомизация GPX (waypoints, расширения Garmin, цвета) — отдаём
«голую» трассу.
- Скачивание загруженных пользователем GPX (ET-006) — там уже есть
кнопка скачивания в panel `sheet-gpx`, и это другой источник данных.
- Скачивание построенного маршрута (Route / Scenic / Link) — это
отдельный поток `downloadGPX()` в `sheet-route`, не трогаем.
- Регулирование rate limit и квоты — нет, трафик низкий.
## 6. Пользовательские истории
**US-1 (Mandatory):** Как мотоциклист, я хочу тапнуть трек на карте,
увидеть popup с его метаданными и нажать «Скачать», чтобы получить GPX-файл
в загрузках браузера — без перехода на сторонний сайт.
**US-2 (Mandatory):** Как пользователь мобильного браузера, я хочу получить
файл в формате, который мой телефон сразу предложит «Открыть в…» или
«Сохранить» (стандартный `Content-Disposition: attachment`).
**US-3 (Optional, R-K-01):** Как пользователь Google Earth / некоторых
старых навигаторов, я хочу выбрать формат KML вместо GPX.
**US-4 (Mandatory):** Как пользователь, я хочу, чтобы имя файла отражало
название трека (а не голый `id.gpx`), чтобы не путаться в загрузках.
## 7. Ограничения и допущения
- A1: треки в БД хранятся как WKB LineString в столбце `tracks.geom`,
координаты EPSG:4326 (lon, lat).
- A2: высоты (`ele`) в БД **не хранятся** — отдаём GPX без `<ele>`.
Время точек (`time`) — тоже не хранится, отдаём без `<time>`.
- A3: трек идентифицируется числовым `tracks.id`.
- A4: атрибуция источника (OSM / EnduroRussia / Wikiloc / ttrails) уже
попадает в popup как ссылки и должна **попасть в GPX как metadata**
(см. NFR-03).
- C1: размер ответа разумно ограничить (см. NFR-02) — кейс трека на
десятки тысяч точек редок, но возможен.
## 8. Риски
| ID | Риск | Митигация |
|--------|----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|
| R-1 | iOS Safari не отдаёт файл по `Content-Disposition`, открывает inline | Тестировать на iOS Safari, при необходимости использовать `<a download="...">` с `URL.createObjectURL` |
| R-2 | Имя файла с кириллицей ломается в некоторых браузерах | RFC 5987 `filename*=UTF-8''...` (NFR-04) |
| R-3 | Треки с десятками тысяч точек дают тяжёлый XML (> 5 МБ) | Логировать размер, NFR-02 устанавливает потолок |
| R-4 | Лицензия источника (Wikiloc ARR) запрещает реэкспорт | Решение: для OSM (ODbL) — можно; для остальных — обсудить с Owner. См. **Открытые вопросы Q-1** |
| R-5 | Лицензия должна попасть в файл (OSM ODbL требует атрибуции) | NFR-03: metadata в GPX содержит атрибуцию источника |
## 9. Открытые вопросы для Owner
| ID | Вопрос | Дефолт (если не ответят) |
|-----|---------------------------------------------------------------------------------------------------------|-----------------------------------------------------|
| Q-1 | Можно ли отдавать треки источников Wikiloc / EnduroRussia / ttrails? Их лицензии — All Rights Reserved. | **Только OSM-источник**. Для остальных — 403 + tooltip «Источник запрещает скачивание, перейдите на сайт источника». |
| Q-2 | KML делаем в этой итерации или откладываем? | **Откладываем.** Только GPX (R-K-01 переезжает в backlog). |
| Q-3 | Кнопку рисовать иконкой (как в `sheet-route`) или текстовой кнопкой «Скачать GPX»? | **Иконка ⬇** + tooltip «Скачать GPX», по тапу на мобильных — лейбл. |
> Эти вопросы должны быть закрыты до перехода в Architecture. Если ответы
> не получены — реализация идёт по дефолтам.
## 10. Acceptance summary
См. `03-acceptance-criteria.md`. Кратко: пользователь нажимает «Скачать»
в popup трека → браузер скачивает валидный GPX 1.1 с именем
`<trail-name>.gpx`, который импортируется в OsmAnd, Garmin BaseCamp и
QGIS без ошибок.

View File

@@ -0,0 +1,234 @@
# ТЗ: Скачивание трека из popup на карте
**Work Item:** ET-011
**Стадия:** Анализ → Architecture
**Автор:** analyst
**Дата:** 2026-06-03
---
## 1. Сводка
Добавить в существующий popup публичного GPS-трека (слой ET-008) кнопку
«Скачать», которая запрашивает с сервера GPX-файл и сохраняет его в
загрузки пользователя. Новый backend-эндпоинт собирает GPX 1.1 из
геометрии трека в БД `gps_tracks.sqlite`.
## 2. Функциональные требования
### REQ-F-01 — Кнопка «Скачать» в popup трека
В popup публичного трека (создаётся в `_renderTrackPopupHtml(props)`,
`src/web/gps_tracks.js`, l.463) **должна появляться кнопка «Скачать»**.
- Иконка: download (SVG, как в `sheet-route` `downloadGPX`, l.135137 в
`index.html`).
- Tooltip / aria-label: «Скачать GPX».
- Размещение: в правом верхнем углу popup, рядом с названием трека,
или отдельной строкой в конце popup перед источниками — на усмотрение
архитектора, но **всегда видна без скролла**.
- Тапабельная зона: ≥ 32×32 CSS px (mobile-friendly, REQ-NF-04 ниже).
### REQ-F-02 — Backend: эндпоинт скачивания
Реализовать в роутере `src/api/gps_tracks/endpoint.py` новый GET-эндпоинт:
```
GET /api/gps-tracks/{track_id}/download
GET /api/gps-tracks/{track_id}/download?format=gpx (синоним)
```
Параметры:
- `track_id` (path, int, обязательный) — `tracks.id` из БД.
- `format` (query, optional, default=`gpx`) — формат файла.
Допустимые значения для текущей итерации: `gpx`.
(При закрытии Q-2 = «делаем KML» — добавится `kml`.)
Поведение:
- 200 + `Content-Type: application/gpx+xml` (для GPX) или
`application/vnd.google-earth.kml+xml` (для KML).
- `Content-Disposition: attachment; filename="<safe-name>.gpx"; filename*=UTF-8''<urlencoded-name>.gpx`
(RFC 5987, REQ-NF-05 ниже).
- 404, если `track_id` не существует.
- 400, если `format` не входит в whitelist.
- 403, если источник трека запрещает реэкспорт (см. REQ-F-06 и Q-1 в BRD).
### REQ-F-03 — Содержимое GPX
GPX-файл должен соответствовать схеме GPX 1.1
(http://www.topografix.com/GPX/1/1) и содержать:
- Корневой `<gpx>` с атрибутами:
- `version="1.1"`
- `creator="Enduro Trails"`
- `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>``tracks.name` или «Без названия».
- `<desc>``tracks.description` (если есть).
- `<time>``tracks.created_at` в ISO-8601 (если есть, иначе пропустить).
- `<author><name>``tracks.user` (если есть).
- `<link href="<external_url>"><text>Источник: <source_id></text></link>`
— по одному `<link>` на каждый элемент `external_urls`.
- `<copyright author="Enduro Trails"><license>https://www.openstreetmap.org/copyright</license></copyright>`
— для OSM-источника. Для других — без `<copyright>` либо со ссылкой
на исходный URL.
- Ровно один `<trk>` с:
- `<name>``tracks.name`.
- `<type>``activity_type` (например, `enduro`).
- Ровно один `<trkseg>` с `<trkpt lat="..." lon="...">` для каждой
координаты из WKB-геометрии `tracks.geom`. **Без** `<ele>` и `<time>`
(см. BRD A2).
### REQ-F-04 — Имя файла
Имя файла (для `Content-Disposition` и `filename*`) формируется так:
1. Берём `tracks.name`. Если пустое / NULL — используем `track-<id>`.
2. Заменяем все недопустимые для FAT/NTFS символы (`/ \ : * ? " < > |`)
на `_`.
3. Триммим до 80 символов.
4. Транслитерация **не нужна** — современные браузеры понимают
`filename*=UTF-8''…` (RFC 5987).
5. Расширение: `.gpx` (или `.kml`).
Например: `tracks.name = "По грязи к Чёрному озеру"`
`По грязи к Чёрному озеру.gpx` (через `filename*=UTF-8''%D0%9F%D0%BE…`).
### REQ-F-05 — Поведение на фронте
При клике на кнопку «Скачать»:
1. Не закрывать popup (или закрывать — на усмотрение архитектора, главное
консистентно с остальными кнопками в проекте). Рекомендация: **не
закрывать**, чтобы пользователь видел индикатор/успех.
2. Сделать GET-запрос на `/api/gps-tracks/{id}/download` через
`<a href="..." download="...">.click()` (стандартный паттерн, отлично
работает в desktop и mobile-браузерах) **или** через `fetch` + `Blob`
+ `URL.createObjectURL` — выбор за архитектором, см. R-1 в BRD.
3. На время запроса показать спиннер/индикатор на самой кнопке (опц.) —
нужно если бэк > 200 ms. Hint: трек на 50 000 точек собирается
≈ 80150 ms (см. NFR-01), так что индикатор большинству не нужен.
4. При ошибке (HTTP ≠ 200) — показать `showToast(...)` (функция уже
есть в проекте) с человекочитаемым сообщением:
- 403 → «Источник запрещает скачивание. Откройте трек на сайте
источника.»
- 404 → «Трек не найден.»
- 5xx / network → «Не удалось скачать. Попробуйте ещё раз.»
### REQ-F-06 — Защита по лицензии источника (зависит от Q-1)
Если Owner закрывает Q-1 как «только OSM»:
- Backend проверяет `tracks.sources_json`. Если **ни одного** из
источников не относится к разрешённому whitelist'у (по умолчанию
`["osm"]`) — возвращает 403 c JSON `{"detail":"source_forbidden",
"external_urls":[...]}`.
- Frontend в обработчике 403 показывает toast и, если есть
`external_urls`, кнопку «Открыть на сайте источника».
Если Owner отвечает «всё разрешено» — этот REQ становится no-op
(вырезать).
### REQ-F-07 — Логирование
Каждое успешное скачивание логируется server-side:
`uvicorn` access-log + (опц.) отдельная строка в stdout формата
`track_download id=<id> source=<sources> size_bytes=<n> ip=<remote>`.
Это нужно для NFR-06 (наблюдаемость).
## 3. Нефункциональные требования
### REQ-NF-01 — Производительность
Сборка GPX и отдача для трека до **50 000 точек** — не дольше **300 ms**
от запроса до начала ответа (P95 на текущем железе test-среды).
Размер ответа для типичного трека 100 км / 5 000 точек — до **800 КБ**
(чистый XML, без gzip; ответ может быть gzip'нут средствами uvicorn).
### REQ-NF-02 — Потолок размера ответа
Если число точек в треке `> 200 000` (защита от patho-кейсов) —
возвращать 413 `Payload Too Large` с сообщением «Трек слишком большой
для скачивания». Реализация: проверка `tracks.points_count` до сборки XML.
### REQ-NF-03 — Соответствие схеме GPX 1.1
Полученный файл должен проходить валидацию по схеме
http://www.topografix.com/GPX/1/1/gpx.xsd без warnings/errors. Тест в
`tests/api/test_gps_tracks_download.py` (см. test plan).
### REQ-NF-04 — UX mobile
- Кнопка «Скачать» должна быть удобно тапабельной на мобильных
(≥ 32×32 CSS px).
- Popup не должен «прыгать» из-за появления кнопки — высота
фиксирована или растёт плавно.
- При ширине viewport < 420 px кнопка остаётся видимой (popup имеет
`max-width: 300px` — см. `gps_tracks.js` l.514).
### REQ-NF-05 — Заголовок Content-Disposition
Заголовок должен поддерживать UTF-8 имена через RFC 5987:
```
Content-Disposition: attachment; filename="track.gpx"; filename*=UTF-8''%D0%9F%D0%BE…
```
Параметр `filename` (без `*`) — ASCII-fallback (транслит или `track-<id>.gpx`).
### REQ-NF-06 — Наблюдаемость
- 200/4xx/5xx ответы видны в `uvicorn` access-log.
- Стек-трейсы 5xx уходят в stderr (текущая практика FastAPI/uvicorn).
- Метрики (RPS / latency) — не требуются в этой итерации.
### REQ-NF-07 — Безопасность
- `track_id` — int, парсится FastAPI, защита от SQL-инjection
встроенная.
- Имя файла санитизуется (REQ-F-04) — защита от path-traversal в
загрузках.
- `Access-Control-Allow-Origin: *` уже стоит в CORS middleware — не
трогаем; iframe-embed возможен.
## 4. Out of scope (явно)
- KML — в backlog (см. Q-2). Если Owner закрывает Q-2 как «делаем» —
REQ-F-02 расширяется (`format=kml`), но это не предмет данной итерации.
- Сохранение скачанного трека в IndexedDB / в `sheet-gpx` (как
пользовательский GPX по ET-006) — отдельная фича.
- Bulk-download (несколько треков). Только один за запрос.
- Конвертация формата (waypoints, маркеры).
## 5. Артефакты, к которым прикасаемся
- `src/web/gps_tracks.js` — функция `_renderTrackPopupHtml(props)` и
(вероятно) обработчик клика на новую кнопку.
- `src/web/app.css` (или `gps_tracks.js` inline-стили) — стиль кнопки.
- `src/api/gps_tracks/endpoint.py` — добавляется новый route.
- `src/api/gps_tracks/db.py` (возможно) — функция `get_track_by_id()`.
- `tests/api/test_gps_tracks_download.py` — новые тесты (см. test plan).
- `tests/web/test_gps_tracks_popup.spec.ts` или аналог — UI-тесты
(Playwright, см. `04b-ui-test-cases.md`).
- ADR `docs/work-items/ET-011/06-adr/*.md` (создаст architect): про
механизм отдачи (link vs blob), про обработку лицензии источника.
## 6. Зависимости
- Слой ET-008 «Публичные треки» уже в проде (тестовая среда). Этот
work item **расширяет** его popup.
- БД `gps_tracks.sqlite` инициализируется через миграцию
`migrations/gps_tracks_001_init.sql` — её менять не нужно (все
необходимые поля уже есть: `id`, `name`, `description`,
`activity_type`, `user`, `created_at`, `length_m`, `points_count`,
`geom`, `sources_json`, `external_urls_json`).
## 7. Глоссарий
- **Public track** — публичный GPS-трек из таблицы `tracks` в БД
`gps_tracks.sqlite`. Источник — OSM, EnduroRussia, Wikiloc, ttrails и
т.п.
- **GPX** — GPS Exchange Format 1.1, XML-формат для треков и точек.
- **KML** — Keyhole Markup Language 2.2, XML-формат Google Earth.
- **Popup** — MapLibre `maplibregl.Popup`, всплывающее окно по клику на
feature.

View File

@@ -0,0 +1,197 @@
# Acceptance Criteria: Скачивание трека из popup на карте
**Work Item:** ET-011
Формат: GivenWhenThen. Каждый AC связан с REQ из `02-trz.md`.
---
## AC-1 — Кнопка появляется в popup трека
**Given** на карте включён слой «Публичные треки» (ET-008) и в видимой
области есть треки
**When** пользователь тапает по линии трека и видит popup
**Then** в popup, помимо имеющихся полей (название, активность, длина и т.д.),
**должна присутствовать кнопка «Скачать»** (иконка ⬇ + tooltip «Скачать GPX»)
**Покрывает:** REQ-F-01
## AC-2 — Скачивание GPX
**Given** popup трека открыт и в нём есть кнопка «Скачать»
**When** пользователь нажимает на кнопку «Скачать»
**Then**
- Браузер инициирует скачивание файла с расширением `.gpx`.
- Имя файла основано на `tracks.name` (см. AC-4).
- Содержимое — валидный GPX 1.1 (см. AC-5).
- Popup при этом не закрывается (или закрывается консистентно по
решению архитектора).
**Покрывает:** REQ-F-02, REQ-F-03, REQ-F-05
## AC-3 — Backend-эндпоинт возвращает 200
**Given** в БД есть трек с `id=42`
**When** клиент делает `GET /api/gps-tracks/42/download`
**Then**
- Статус 200.
- `Content-Type: application/gpx+xml`.
- `Content-Disposition: attachment; filename="…"; filename*=UTF-8''…`.
- Тело — XML, начинается с `<?xml version="1.0"`, корневой элемент
`<gpx version="1.1" …>`.
**Покрывает:** REQ-F-02
## AC-4 — Имя файла
**Given** трек называется `По грязи к Чёрному озеру 100км`
**When** клиент скачивает этот трек
**Then**
- `Content-Disposition` содержит `filename*=UTF-8''<urlencoded>.gpx`,
где `<urlencoded>` — percent-encoded UTF-8 имя трека.
- ASCII-fallback `filename="…"` пустых символов не содержит, длина ≤ 80.
- В случае пустого `tracks.name` имя файла — `track-<id>.gpx`.
- Запрещённые символы (`/ \ : * ? " < > |`) заменены на `_`.
**Покрывает:** REQ-F-04, REQ-NF-05
## AC-5 — Валидность GPX
**Given** скачанный GPX-файл
**When** валидируется по схеме `http://www.topografix.com/GPX/1/1/gpx.xsd`
утилитой `xmllint --schema gpx.xsd file.gpx --noout`
**Then** валидация проходит без ошибок и предупреждений
**Покрывает:** REQ-NF-03
## AC-6 — Импорт в GPS-софт
**Given** GPX-файл, скачанный по AC-2
**When** файл открывается в OsmAnd / Garmin BaseCamp / QGIS / gpx.studio
**Then** трек отображается полностью (число точек совпадает с
`tracks.points_count`), без ошибок парсинга
**Покрывает:** REQ-F-03 (косвенно — через схему GPX 1.1)
> **Тестирование:** AC-6 проверяется вручную как часть smoke-тестов
> приёмки. Автоматизируется опосредованно через AC-5 (валидация по
> схеме).
## AC-7 — Несуществующий трек
**Given** в БД нет трека с `id=99999999`
**When** клиент делает `GET /api/gps-tracks/99999999/download`
**Then** статус 404, JSON `{"detail": "track_not_found"}` (или аналог)
**Покрывает:** REQ-F-02
## AC-8 — Невалидный формат
**Given** запрос `GET /api/gps-tracks/42/download?format=fit`
**When** обработка достигает валидации параметра
**Then** статус 400, тело содержит человекочитаемое описание ошибки
**Покрывает:** REQ-F-02
## AC-9 — Защита от patho-треков
**Given** в БД есть трек с `points_count = 300000`
**When** клиент делает `GET /api/gps-tracks/<id>/download`
**Then** статус 413 `Payload Too Large`
**Покрывает:** REQ-NF-02
## AC-10 — Метаданные источника в GPX
**Given** трек с `sources=["osm"]` и `external_urls=["https://www.openstreetmap.org/way/123"]`
**When** GPX скачан
**Then**
- В `<metadata>` присутствует `<link href="https://www.openstreetmap.org/way/123"><text>Источник: osm</text></link>`.
- Присутствует `<copyright author="Enduro Trails"><license>https://www.openstreetmap.org/copyright</license></copyright>`.
**Покрывает:** REQ-F-03
## AC-11 — Лицензионный фильтр (если Q-1 = «только OSM»)
> Активируется только если Owner закроет Q-1 как ограничительный.
**Given** трек с `sources=["wikiloc"]` (не в whitelist)
**When** клиент делает GET `/api/gps-tracks/<id>/download`
**Then**
- Статус 403.
- Frontend показывает toast «Источник запрещает скачивание…».
- Если `external_urls` непустой — в toast/popup есть ссылка на
внешний источник.
**Покрывает:** REQ-F-06
## AC-12 — Производительность
**Given** трек с 50 000 точек
**When** клиент делает GET `/api/gps-tracks/<id>/download`
**Then** время от запроса до окончания заголовков ≤ 300 ms (P95 на
test-среде, 4 worker uvicorn)
**Покрывает:** REQ-NF-01
## AC-13 — Mobile UX
**Given** viewport 375×667 (iPhone SE), включён слой публичных треков
**When** пользователь тапает трек
**Then**
- Popup помещается на экране (max-width 300px уже задан).
- Кнопка «Скачать» видна без скролла.
- Тапабельная зона ≥ 32×32 CSS px.
**Покрывает:** REQ-NF-04
## AC-14 — Tooltip / a11y
**Given** popup с кнопкой «Скачать» открыт
**When** screen-reader пользователь фокусируется на кнопке (Tab)
**Then** объявляется текст «Скачать GPX» (через `aria-label` или
текстовый узел)
**Покрывает:** REQ-F-01
## AC-15 — Существующее поведение не сломано
**Given** релиз ET-011 задеплоен
**When** пользователь
- тапает трек → видит popup со всеми старыми полями
- открывает `sheet-gpx` для своих загруженных GPX
- использует слой публичных треков (фильтры, цвета)
- скачивает построенный маршрут через кнопку в `sheet-route`
**Then** все эти потоки работают как прежде, регрессий нет
**Покрывает:** Регрессия (общий принцип, не привязан к одному REQ)

View File

@@ -0,0 +1,250 @@
work_item: ET-011
title: Скачивание трека из popup на карте
version: 1
generated_by: analyst
# Категории тестов:
# - unit — изолированные функции (сборщик GPX, санитизатор имени)
# - integration — FastAPI endpoint через TestClient
# - e2e — Playwright, end-to-end в браузере
# UI-кейсы для визуальной/интерактивной проверки — см. 04b-ui-test-cases.md
tests:
# ─── UNIT ─────────────────────────────────────────────────────
- id: UT-01
type: unit
name: build_gpx — корректная структура GPX 1.1
file: tests/api/test_gps_tracks_gpx_builder.py
covers: [REQ-F-03, REQ-NF-03]
steps:
- Подать на вход искусственный трек (5 точек, name, description, activity_type="enduro", sources=["osm"], external_urls=["https://www.openstreetmap.org/way/1"]).
- Получить строку GPX.
- Распарсить через ElementTree.
assertions:
- root.tag == "{http://www.topografix.com/GPX/1/1}gpx"
- root.attrib["version"] == "1.1"
- root.attrib["creator"] == "Enduro Trails"
- в metadata присутствует <name> с переданным именем
- в metadata присутствует <link href="https://www.openstreetmap.org/way/1">
- ровно один <trk>, ровно один <trkseg>
- число <trkpt> == 5
- у trkpt атрибуты lat и lon — float
- id: UT-02
type: unit
name: build_gpx — пустые/NULL поля
file: tests/api/test_gps_tracks_gpx_builder.py
covers: [REQ-F-03]
steps:
- Трек с name=None, description=None, created_at=None, user=None, external_urls=[].
assertions:
- GPX валиден (по схеме)
- <name> = "Без названия" или его аналог
- элементы <desc>, <time>, <author>, <link> отсутствуют (а не пустые)
- id: UT-03
type: unit
name: build_gpx — соответствие схеме XSD
file: tests/api/test_gps_tracks_gpx_builder.py
covers: [REQ-NF-03]
steps:
- Сгенерировать GPX из 3 разных треков (минимальный, типичный, с UTF-8).
- Валидировать каждый через lxml.etree.XMLSchema (gpx.xsd закоммитить в tests/fixtures/).
assertions:
- schema.validate(tree) == True для всех 3 случаев
- id: UT-04
type: unit
name: safe_filename — санитизация
file: tests/api/test_gps_tracks_filename.py
covers: [REQ-F-04]
cases:
- input: "По грязи к Чёрному озеру"
expected_ascii_fallback: содержит только ASCII, длина ≤ 80
expected_utf8: percent-encoded UTF-8 строка
- input: "Trail/with:bad*chars?"
expected_ascii: подчёркивания вместо запрещённых символов
- input: ""
track_id: 42
expected: "track-42"
- input: "X" * 200
expected_length: ≤ 80
- id: UT-05
type: unit
name: wkb_to_coords — повторное использование существующего парсера
file: tests/api/test_gps_tracks_gpx_builder.py
covers: [REQ-F-03]
note: уже покрыто косвенно в ET-008, но добавить smoke-проверку на пограничный случай (2 точки).
# ─── INTEGRATION ───────────────────────────────────────────────
- id: IT-01
type: integration
name: GET /api/gps-tracks/{id}/download — happy path
file: tests/api/test_gps_tracks_download.py
covers: [REQ-F-02, AC-3]
steps:
- Инициализировать тестовую БД с одним треком (id=1, geom=LineString из 10 точек).
- GET /api/gps-tracks/1/download через TestClient.
assertions:
- status_code == 200
- response.headers["content-type"] == "application/gpx+xml"
- "attachment" in response.headers["content-disposition"]
- "filename*=UTF-8''" in response.headers["content-disposition"]
- response.text.startswith("<?xml")
- "<gpx" in response.text and 'version="1.1"' in response.text
- response.text.count("<trkpt") == 10
- id: IT-02
type: integration
name: GET /api/gps-tracks/{id}/download — 404 для несуществующего id
file: tests/api/test_gps_tracks_download.py
covers: [REQ-F-02, AC-7]
steps:
- GET /api/gps-tracks/99999999/download
assertions:
- status_code == 404
- response.json()["detail"] упоминает неайден / not_found / track_not_found
- id: IT-03
type: integration
name: GET /api/gps-tracks/{id}/download — невалидный format
file: tests/api/test_gps_tracks_download.py
covers: [REQ-F-02, AC-8]
steps:
- GET /api/gps-tracks/1/download?format=fit
assertions:
- status_code == 400
- id: IT-04
type: integration
name: Patho-трек > 200k точек → 413
file: tests/api/test_gps_tracks_download.py
covers: [REQ-NF-02, AC-9]
steps:
- Подложить в БД запись с points_count=300000 (можно фиктивную, geom не нужен — проверка идёт по points_count до сборки).
- GET /api/gps-tracks/<id>/download
assertions:
- status_code == 413
- id: IT-05
type: integration
name: Лицензионный фильтр — 403 для запрещённого источника (Q-1 conditional)
file: tests/api/test_gps_tracks_download.py
covers: [REQ-F-06, AC-11]
enabled_if: "Owner закрыл Q-1 как 'только OSM'"
steps:
- Трек с sources=["wikiloc"], external_urls=["https://wikiloc.com/..."]
- GET /api/gps-tracks/<id>/download
assertions:
- status_code == 403
- response.json()["external_urls"] == ["https://wikiloc.com/..."]
- id: IT-06
type: integration
name: UTF-8 имя файла в Content-Disposition
file: tests/api/test_gps_tracks_download.py
covers: [REQ-F-04, REQ-NF-05, AC-4]
steps:
- Трек с name="По грязи к Чёрному озеру"
- GET .../download
assertions:
- "filename*=UTF-8''" в Content-Disposition
- decoded UTF-8 имя == "По грязи к Чёрному озеру.gpx"
- "filename=" (без звёздочки) — ASCII-fallback, без кириллицы
- id: IT-07
type: integration
name: Валидация GPX-ответа по XSD
file: tests/api/test_gps_tracks_download.py
covers: [REQ-NF-03, AC-5]
steps:
- Скачать GPX через TestClient.
- Валидировать ответ через lxml.etree.XMLSchema по gpx.xsd.
assertions:
- validation passes без warnings/errors
- id: IT-08
type: integration
name: Регрессия — существующие GPS-эндпоинты живы
file: tests/api/test_gps_tracks_endpoint.py
covers: [AC-15]
note: smoke-проверка, что добавление нового route не сломало GET /api/gps-tracks, /tiles/..., /health.
# ─── E2E (Playwright, mounted browser) ─────────────────────────
- id: E2E-01
type: e2e
name: Тап трека → popup → клик «Скачать» → файл в загрузках (desktop)
file: tests/web/test_track_download.spec.ts
covers: [REQ-F-01, REQ-F-05, AC-1, AC-2]
viewport: desktop
steps:
- Открыть https://openclaw.mva154.duckdns.org/enduro/
- Включить слой «Публичные треки» (раскрыть terrain-popup, поставить #public-tracks-cb).
- Дождаться загрузки тайлов (~5000ms).
- Кликнуть в координату с известным треком (либо использовать map.queryRenderedFeatures + map.click).
- Дождаться появления popup (.maplibregl-popup .track-popup).
- Ожидать кнопку с aria-label="Скачать GPX" внутри popup.
- Кликнуть на кнопку и перехватить событие download через context.waitForEvent('download').
assertions:
- download.suggestedFilename().endsWith('.gpx')
- размер файла > 100 байт
- первые 100 байт содержат "<?xml" и "<gpx"
- id: E2E-02
type: e2e
name: Mobile — popup и кнопка видны
file: tests/web/test_track_download.spec.ts
covers: [REQ-NF-04, AC-13]
viewport: mobile (375x667)
steps:
- см. E2E-01, но с deviceScaleFactor=2, isMobile=true.
assertions:
- кнопка «Скачать» видима (visible) и имеет bounding box ≥ 32×32 px
- popup не выходит за пределы viewport
- id: E2E-03
type: e2e
name: Ошибка 404 — toast пользователю
file: tests/web/test_track_download.spec.ts
covers: [REQ-F-05, AC-7]
steps:
- Замокать ответ /api/gps-tracks/*/download через page.route() — вернуть 404.
- Триггернуть download.
assertions:
- появляется #app-toast с текстом «Трек не найден» (либо аналог)
- id: E2E-04
type: e2e
name: Лицензионный фильтр — toast «Источник запрещает» (conditional)
file: tests/web/test_track_download.spec.ts
covers: [REQ-F-06, AC-11]
enabled_if: "Owner закрыл Q-1 как 'только OSM'"
steps:
- Замокать ответ /api/gps-tracks/*/download → 403 с body {"detail":"source_forbidden","external_urls":["https://wikiloc.com/x"]}.
assertions:
- toast содержит текст про «источник»
- есть кликабельная ссылка / кнопка на wikiloc URL
# ─── Покрытие AC ────────────────────────────────────────────────
coverage_matrix:
AC-1: [E2E-01, E2E-02]
AC-2: [E2E-01]
AC-3: [IT-01]
AC-4: [UT-04, IT-06]
AC-5: [UT-03, IT-07]
AC-6: ['manual smoke (см. acceptance §AC-6)']
AC-7: [IT-02, E2E-03]
AC-8: [IT-03]
AC-9: [IT-04]
AC-10: [UT-01]
AC-11: [IT-05, E2E-04]
AC-12: ['manual perf check, не блокирует merge']
AC-13: [E2E-02]
AC-14: ['покрывается визуально через UI test cases 04b']
AC-15: [IT-08]

View File

@@ -0,0 +1,180 @@
# UI Test Cases — ET-011: Скачивание трека из popup
Playwright-сценарии для визуальной проверки. Все запускаются на
`https://openclaw.mva154.duckdns.org/enduro/`.
> Селекторы базируются на текущем DOM `src/web/index.html` и popup'е,
> создаваемом в `src/web/gps_tracks.js` (`_renderTrackPopupHtml`). Когда
> architect/builder уточнит CSS-классы новой кнопки — обновить
> селекторы в этом файле.
---
### TC-UI-01 — Кнопка «Скачать» в popup трека (desktop)
**Тип:** ui
**Viewport:** desktop (1280×800)
**Шаги:**
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: 4000
7. screenshot: 01-public-tracks-enabled
8. check-visual: слой публичных треков отрисован (видны цветные линии на карте)
9. click: #map (в точке, где есть трек — координаты подобрать вручную/программно)
10. wait: 1500
11. screenshot: 02-track-popup-opened
12. check-visual: появилось всплывающее окно `.maplibregl-popup` с классом `.track-popup` внутри, видны название, активность, длина
13. check-visual: внутри popup присутствует кнопка/иконка «Скачать» с aria-label="Скачать GPX"
14. screenshot: 03-popup-with-download-button
---
### TC-UI-02 — Popup и кнопка на мобильном
**Тип:** ui
**Viewport:** mobile (375×667)
**Шаги:**
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: 4000
7. click: #map (тап в координате трека)
8. wait: 1500
9. screenshot: mobile-popup
10. check-visual: popup помещается в ширину viewport (≤ 375px), не обрезан
11. check-visual: кнопка «Скачать» видна без скролла внутри popup
12. check-visual: bounding box кнопки «Скачать» ≥ 32×32 CSS px
---
### TC-UI-03 — Тёмная тема: контраст кнопки
**Тип:** ui
**Viewport:** desktop
**Шаги:**
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. check-visual: body имеет класс `theme-dark`
4. click: #terrain-toggle
5. click: #public-tracks-cb
6. wait: 4000
7. click: #map (тап в координате трека)
8. wait: 1500
9. screenshot: dark-popup-with-download
10. check-visual: иконка «Скачать» имеет читаемый контраст на тёмном фоне popup (текст / стрелка видна, не сливается с фоном)
---
### TC-UI-04 — Светлая тема: контраст кнопки
**Тип:** ui
**Viewport:** desktop
**Шаги:**
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: #btn-theme
4. wait: 500
5. check-visual: body НЕ имеет класса `theme-dark` (или имеет `theme-light`)
6. click: #terrain-toggle
7. click: #public-tracks-cb
8. wait: 4000
9. click: #map (тап в координате трека)
10. wait: 1500
11. screenshot: light-popup-with-download
12. check-visual: иконка «Скачать» читаема в светлой теме
---
### TC-UI-05 — Скачивание срабатывает (e2e download event)
**Тип:** ui
**Viewport:** desktop
**Шаги:**
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: #terrain-toggle
4. click: #public-tracks-cb
5. wait: 4000
6. click: #map (тап в координате трека)
7. wait: 1500
8. Подготовить page.waitForEvent('download') ДО клика на кнопку
9. click: кнопка «Скачать» внутри `.maplibregl-popup .track-popup` (точный селектор — после Architecture, например `.track-popup-download-btn` или `button[aria-label="Скачать GPX"]`)
10. screenshot: download-triggered
11. check-visual: download event получен, `download.suggestedFilename()` заканчивается на `.gpx`
12. check-visual: файл сохранён, размер > 100 байт, начинается с `<?xml`
---
### TC-UI-06 — Popup не «прыгает» из-за кнопки
**Тип:** ui
**Viewport:** desktop
**Шаги:**
1. Открыть popup трека (как в TC-UI-01).
2. wait: 500
3. Снять bbox popup (getBoundingClientRect через JS).
4. wait: 1500
5. Снять bbox повторно.
6. check-visual: размеры popup не меняются (нет «дёрганий» из-за поздно подгруженного контента кнопки).
---
### TC-UI-07 — Регрессия: остальные элементы popup остались
**Тип:** ui
**Viewport:** desktop
**Шаги:**
1. Открыть popup трека.
2. screenshot: regression-popup
3. check-visual: видны все исторические поля
- название трека
- строка с иконкой активности и лейблом
- строка `📏 X.X км · N точек`
- дата (если есть)
- пользователь (если есть)
- блок «Источники: …» (если есть)
4. check-visual: новая кнопка «Скачать» добавлена, но не вытеснила/не заместила другие поля
---
### TC-UI-08 — Регрессия: панель `sheet-gpx` и downloadGPX маршрута
**Тип:** ui
**Viewport:** desktop
**Шаги:**
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: #tb-gpx
4. wait: 500
5. screenshot: regression-sheet-gpx
6. check-visual: панель `#sheet-gpx` открывается как раньше, заголовок «GPX-треки», текст-подсказка о загрузке.
7. closeAllSheets via tap on backdrop
8. click: #tb-route
9. wait: 500
10. screenshot: regression-sheet-route
11. check-visual: панель `#sheet-route` открывается, кнопка-иконка «Скачать GPX» (для маршрута) присутствует и работает как прежде.
---
## Примечания по селекторам
Конкретные классы / id новой кнопки внутри popup трека определит
architect / builder. В качестве разумных рабочих имён предлагаются:
- `button.track-popup-download-btn` или
- `.track-popup .track-popup-actions button[aria-label="Скачать GPX"]`
После Architecture стадии обновить селекторы в этом файле.