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

This commit is contained in:
2026-06-03 19:52:57 +00:00
parent 446d3e7d98
commit 473fd76f67
5 changed files with 1293 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
# BRD: Скачивание трека из popup на карте (ET-011)
Work Item ID: ET-011
Phase: PH-3.smart-route (расширение публичных треков из PH-3/PH-5)
Stage: analysis
Author: analyst-bot
Date: 2026-06-03
## 1. Краткое описание
Пользователь видит на карте публичные GPS-треки (фича ET-008). Сейчас при тапе
по треку открывается popup с метаданными (название, активность, длина, дата,
источник). Возможности скачать трек к себе на устройство нет — приходится идти
во внешний источник по ссылке, который может быть недоступен / требовать
регистрации.
В этом WI добавляем кнопку «Скачать» прямо в popup трека. По нажатию
браузер скачивает файл трека в формате GPX. Опционально — выбор формата GPX/KML.
## 2. Цели и метрики
### Бизнес-цели
- **G1.** Дать пользователю возможность забрать понравившийся трек в свой
GPS-навигатор / телефон без ухода со страницы и без авторизации на внешних
сервисах.
- **G2.** Снизить число «отказов» (close popup без действия): сейчас единственное
доступное действие — переход по внешней ссылке.
- **G3.** Подготовить инфраструктуру отдачи трека для будущих фич (импорт
публичного трека в свой список GPX-треков — ET-006 backlog).
### Продуктовые метрики (post-launch)
- M1. Доля сессий с хотя бы одним кликом «Скачать» среди сессий, открывших
popup публичного трека: целевое значение ≥ 25%.
- M2. CTR кнопки «Скачать» в popup: ≥ 40% (от всех открытий popup).
- M3. 95-й перцентиль времени отдачи файла трека: < 800 мс для треков
≤ 5000 точек.
- M4. Доля 5xx ошибок эндпоинта скачивания: < 0.5%.
## 3. Аудитория и сценарии
### Персоны
- **P1 — Эндуро-райдер на десктопе**, планирует выезд: листает публичные треки,
отбирает интересные, скачивает GPX, грузит в свой Garmin / TwoNav / OsmAnd.
- **P2 — Эндуро-райдер с телефона перед выездом**: открыл карту на смартфоне,
тапнул трек, нажал «Скачать», файл уехал в загрузки телефона, передан в OsmAnd
через «Открыть с помощью».
### Основные сценарии
#### UC-1. Скачивание GPX с десктопа
1. Пользователь включил слой «Публичные треки» на карте.
2. Кликнул по линии трека.
3. Открылся popup с метаданными трека и кнопкой «Скачать GPX».
4. Пользователь нажал кнопку.
5. Браузер начал загрузку файла `<имя_трека>.gpx`.
6. Popup остаётся открытым (можно скачать другой формат или закрыть вручную).
#### UC-2. Скачивание с мобильного
1. То же, но тап по треку на мобильном.
2. Popup открыт; кнопка «Скачать» доступна с минимальной hit-зоной 44×44 px.
3. По тапу запускается стандартное сохранение файла в Downloads / Files iOS.
#### UC-3. Выбор формата GPX vs KML (опционально, может уехать в backlog)
1. В popup кнопка «Скачать» с выпадающим выбором формата (GPX / KML).
2. По умолчанию — GPX.
3. KML включён только если фича-флаг `featureKmlExport=true` (см. §6).
#### UC-4. Ошибочные ситуации
- Трек не найден (удалён из БД между моментом отображения и кликом) →
тост-уведомление «Трек больше недоступен», popup закрывается.
- Бэкенд недоступен / 5xx → тост «Не удалось скачать трек, попробуйте позже»,
popup остаётся.
## 4. Скоуп
### In scope
- Кнопка «Скачать» в существующем popup публичных GPS-треков (см. `gps_tracks.js`
`_renderTrackPopupHtml`).
- Backend-эндпоинт отдачи трека в формате GPX по `id` из БД `gps_tracks.sqlite`.
- Корректный `Content-Disposition: attachment; filename="..."` с безопасным
именем файла.
- UI-обратная связь (тост при ошибке, минимальный визуальный feedback при
старте загрузки).
- Логи / X-Cache-style заголовок для observability.
### Out of scope
- Скачивание GPX-треков, загруженных пользователем локально (фича ET-006 — у
них уже есть локальный экспорт через `downloadGPX()` в app.js, отдельный UX).
- Скачивание построенных маршрутов из «Маршрут / Красивый / Связка» — отдельный
WI (часть UI у них уже есть, см. `sheet-icon-btn onclick=downloadGPX()`).
- Массовая выгрузка треков (Bulk download) — backlog.
- Авторизация / лимиты по IP — отдельный security WI.
### Tentative (под решение в TRZ)
- **KML экспорт**. Технически тривиально, но добавляет UI-усложнение (dropdown
вместо одной кнопки). Решение принимается в TRZ. Дефолт: GPX обязателен,
KML — фича-флаг.
## 5. Бизнес-требования (что должно стать возможным)
- **BR-1.** Пользователь, открывший popup публичного трека, видит кнопку
«Скачать» в popup.
- **BR-2.** Нажатие кнопки приводит к скачиванию файла трека на устройство
пользователя без перезагрузки страницы.
- **BR-3.** Скачанный GPX корректно открывается в OsmAnd / Garmin BaseCamp /
любом стандартном GPX-клиенте (валидный GPX 1.1).
- **BR-4.** Имя файла читаемое: содержит название трека (или дефолт
`track-<id>.gpx`, если имени нет).
- **BR-5.** В GPX сохраняется как минимум: треклог (LineString), название
трека, и (если есть) описание + ссылка на источник в качестве комментария.
- **BR-6.** При ошибке пользователь получает понятное сообщение и popup не
ломается.
## 6. Бизнес-флаги, ограничения
- **Не нарушать лицензии источников.** Если трек получен из внешнего
источника (Strava heatmap, Wikiloc и т.п.), в GPX `<metadata><link>`
должна быть ссылка на оригинал. Текст BR-5.
- **Без авторизации.** Скачивание доступно анонимно — это публичные треки.
- **Без rate-limiting в MVP**, но эндпоинт должен логировать запросы для
будущей защиты от парсинга.
- **Бюджет на UI.** Максимум +1 кнопка в popup. Без отдельных модалок,
bottom-sheet, диалогов.
## 7. Бизнес-риски
| ID | Риск | Митигация |
|----|------|-----------|
| BR-R1 | Источник запрещает редистрибуцию (Strava ToS) | Включить ссылку на оригинал в GPX-метаданные; явно показать в popup, что трек агрегирован, а не «наш». В TRZ определить per-source флаг разрешения скачивания. |
| BR-R2 | Большой трек (50000+ точек) → таймаут сборки GPX | Ограничение размера / упрощение (Douglas-Peucker) — решение в TRZ. |
| BR-R3 | Парсинг базы автоматизированными ботами | MVP: только логирование. Будущее: cache-busting токен / rate-limit. |
| BR-R4 | Имя трека содержит спецсимволы → битый Content-Disposition | Санитизация имени + RFC 5987 (filename\*) — детали в TRZ. |
## 8. Зависимости
- **ET-008** (публичные GPS-треки): эта фича расширяет уже реализованный popup.
Без ET-008 показывать кнопку негде. Если ET-008 откатывается — ET-011
тоже не имеет смысла.
- БД `gps_tracks.sqlite` с таблицей `tracks` (схема — `migrations/gps_tracks_001_init.sql`):
поля `id`, `name`, `description`, `geom` (WKB LineString),
`sources_json`, `external_urls_json`.
- FastAPI роутер `/api/gps-tracks` (`src/api/gps_tracks/endpoint.py`).
- Frontend: `src/web/gps_tracks.js`, функция `_renderTrackPopupHtml`.
## 9. Открытые вопросы (адресуются в TRZ)
- Q1. KML обязателен или фича-флаг? → **Дефолт: фича-флаг, MVP без KML.**
- Q2. Включать ли в GPX waypoints / точки треков с timestamps? → треки в
БД хранятся как LineString без timestamp; включаем только координаты.
- Q3. Лимит размера трека? → жёсткий лимит 100k точек, soft warning > 20k.
- Q4. Per-source флаг «разрешена редистрибуция»? → MVP: разрешено всем;
ссылка на оригинал в GPX обязательна. Решение принимается в ADR.

View File

@@ -0,0 +1,362 @@
# ТЗ (TRZ): Скачивание трека из popup на карте (ET-011)
Work Item ID: ET-011
Phase: PH-3.smart-route (расширение ET-008)
Stage: analysis
Author: analyst-bot
Date: 2026-06-03
## 1. Контекст
В ET-008 реализован слой публичных GPS-треков с popup при клике, в котором
отображаются метаданные трека. Кнопки «Скачать» нет — пользователь может
только перейти по внешней ссылке на источник.
Файлы / точки расширения:
- **Frontend:** `src/web/gps_tracks.js` — функция `_renderTrackPopupHtml(props)`
(строки ~463501) генерирует HTML popup. Обработчик клика —
`_setupGpsClickHandler(map)` (строки ~503523), использует `maplibregl.Popup`.
- **Backend:** `src/api/gps_tracks/endpoint.py` — FastAPI APIRouter
с `prefix="/api/gps-tracks"`. Уже есть `GET ""`, `GET /tiles/{z}/{x}/{y}.mvt`,
`GET /health`, `POST /cache/clear`.
- **БД:** `gps_tracks.sqlite`, таблица `tracks`. Поля релевантные ET-011:
`id` (INTEGER PK), `name`, `description`, `created_at`, `user`,
`length_m`, `points_count`, `geom` (BLOB WKB LineString),
`sources_json`, `external_urls_json`.
- **WKB парсер:** `src/api/gps_tracks/mvt.py::_wkb_to_coords(blob)` — уже
используется в `_row_to_geojson_feature`.
## 2. Функциональные требования
### REQ-F-01. Backend: эндпоинт скачивания GPX
- **Endpoint:** `GET /api/gps-tracks/{track_id}/download`
- **Параметры пути:** `track_id: int` — primary key из таблицы `tracks`.
- **Query-параметры:**
- `format: str = "gpx"` — формат файла, допустимые значения: `gpx`, `kml`.
При запросе `kml` если фича-флаг `featureKmlExport=false` (см. REQ-NF-06) —
ответ `400 Bad Request` с телом `{"detail": "Format kml is not enabled"}`.
- **Ответы:**
- `200 OK`:
- `Content-Type: application/gpx+xml` (для gpx) или
`application/vnd.google-earth.kml+xml` (для kml).
- `Content-Disposition: attachment; filename="track-<id>.gpx";
filename*=UTF-8''<percent-encoded-name>.gpx` (RFC 5987).
- `Cache-Control: public, max-age=3600` (треки иммутабельны на горизонте
часа; updated_at рассматривается отдельно).
- `Access-Control-Allow-Origin: *`.
- Тело — валидный GPX 1.1 / KML 2.2.
- `404 Not Found`: тело `{"detail": "Track not found"}`.
- `400 Bad Request`: формат не поддержан / фича-флаг выключен.
- `503 Service Unavailable`: при ошибке БД.
### REQ-F-02. Состав GPX 1.1
GPX MUST содержать:
- Объявление XML: `<?xml version="1.0" encoding="UTF-8"?>`
- Корневой элемент `<gpx version="1.1" creator="enduro-trails"
xmlns="http://www.topografix.com/GPX/1/1">`.
- `<metadata>` с дочерними:
- `<name>` — название трека (или `Track <id>`, если `name IS NULL`).
- `<desc>` — `description`, если есть (CDATA-обёрнуто при наличии < или &).
- `<author><name>` — `user`, если есть.
- `<time>` — `created_at`, если есть; иначе опустить.
- По одному `<link href="<url>"><text>Источник: <source_id></text></link>`
на каждый элемент `external_urls_json` (REQ-NF-04, BR-R1).
- Один `<trk>` с:
- `<name>` — то же, что в metadata.
- Одним `<trkseg>` с N точек `<trkpt lat="..." lon="..."/>` без timestamps
(в БД времён точек нет).
### REQ-F-03. Состав KML 2.2 (если включён фича-флаг)
- `<?xml version="1.0" encoding="UTF-8"?>`
- `<kml xmlns="http://www.opengis.net/kml/2.2">`
- `<Document><name>...</name><description>...</description>`
- `<Placemark>`
- `<name>` — название.
- `<description>` — описание + ссылки на источники (CDATA).
- `<LineString><coordinates>` — список `lon,lat,0` через пробел.
### REQ-F-04. Frontend: кнопка «Скачать» в popup
- В функции `_renderTrackPopupHtml(props)` добавить кнопку «Скачать» под
блоком источников, перед закрывающим `</div>` track-popup.
- Кнопка имеет CSS-класс `track-popup-download-btn` и `data-track-id="<id>"`.
- Текст кнопки: «⬇ Скачать GPX».
- Hit-area минимум 36×36 px на десктопе и 44×44 px на мобиле
(CSS, REQ-NF-02).
- Кнопка ВСЕГДА видна, если `props.id` определён. Если `id` отсутствует
(legacy данные) — кнопка не рендерится.
### REQ-F-05. Frontend: обработчик клика
- В `_setupGpsClickHandler(map)` (или новой функции, привязанной через
делегирование на `document` к классу `track-popup-download-btn`) повесить
обработчик.
- По клику:
1. Прочитать `data-track-id`.
2. Сформировать URL: `/api/gps-tracks/<id>/download?format=gpx`.
3. Инициировать скачивание через `<a href="..." download>` (программный
`click()`).
4. Popup НЕ закрывать (`closeOnClick: true` остаётся, но programmatic click
на скачивание не пузырится до карты).
- При ошибке сети — `try/catch` вокруг fetch HEAD-проверки (опционально для
MVP — допустимо обойтись `<a download>` без предварительной проверки).
### REQ-F-06. Frontend: выбор формата (опционально, фича-флаг)
Если `window.ET_FEATURE_KML_EXPORT === true`:
- Кнопка превращается в split-button или dropdown: основное действие — GPX,
стрелка справа — выбор KML.
- Без флага — только GPX-кнопка.
В MVP флаг по умолчанию `false`. Сам код dropdown В скоупе MVP, но скрыт
за флагом.
### REQ-F-07. Имя файла
- Базовое имя: значение `name` из БД.
- Санитизация (server-side):
- Удалить управляющие символы.
- Заменить `/\\<>:"|?*` и `\0` на `_`.
- Обрезать до 100 символов.
- Если пусто после санитизации — `track-<id>`.
- Расширение: `.gpx` или `.kml`.
- ASCII fallback: латинская транслитерация имени для `filename=` (RFC 5987:
ASCII-only) + UTF-8 percent-encoded для `filename*=`.
### REQ-F-08. Обработка ошибок UI
- При HTTP ошибке (4xx/5xx) в момент скачивания (если есть fetch wrapper) —
вызов `showToast('Не удалось скачать трек')`.
- В MVP допустимо положиться на стандартное поведение `<a download>` —
браузер сам покажет «failed download». Тост — best effort через
`fetch HEAD` перед `<a download>` (опционально).
## 3. Нефункциональные требования
### REQ-NF-01. Производительность
- p95 latency `/api/gps-tracks/{id}/download` для треков ≤ 5000 точек:
**< 300 мс** на test-стенде.
- Сборка GPX в памяти (string concat / `xml.etree`), без записи во временный
файл.
### REQ-NF-02. UI hit-area
- Кнопка «Скачать» в popup: минимум `min-height: 36px; min-width: 36px` на
десктопе, `min-height: 44px; min-width: 44px` при `(max-width: 768px)`.
### REQ-NF-03. Размер трека
- Жёсткий лимит: **100 000 точек**. Если трек больше — 413 Payload Too Large
с `{"detail": "Track too large"}`.
- Soft warning > 20 000 точек — только логирование на бэкенде, на UI
не отображается.
### REQ-NF-04. Лицензионная атрибуция
- GPX `<metadata><link>` MUST содержать все элементы из `external_urls_json`
и `sources_json`, спаренные по индексу:
- `<link href="<url>"><text>Источник: <source_id></text></link>`.
- Если `external_urls_json` пуст, но `sources_json` непуст — `<link>` всё
равно создаётся с `href=""` НЕ создавать; вместо этого помещаем строку
«Источники: src1, src2» в `<metadata><desc>` (либо дополняем существующий
`desc`).
### REQ-NF-05. Безопасность
- `track_id` параметризован через FastAPI (Pydantic `int`) — SQL-инъекций
быть не может.
- XML-экранирование (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`)
всех текстовых полей. Использовать `xml.sax.saxutils.escape` или
`xml.etree.ElementTree.tostring`.
- Content-Disposition: имя файла санитизировано (REQ-F-07).
### REQ-NF-06. Фича-флаги
- `featureKmlExport` (env var `FEATURE_KML_EXPORT`, default `false`).
Контролирует приём `format=kml` на бэкенде и доступность KML в UI
(через window-глобал `ET_FEATURE_KML_EXPORT`, прокинутый в HTML / JS).
- В MVP флаг выключен. Включается отдельным PR после стабилизации GPX.
### REQ-NF-07. Совместимость
- GPX MUST открываться в:
- OsmAnd 4.x+
- Garmin BaseCamp 4.x+
- gpx.studio (https://gpx.studio)
- Strava upload (https://strava.com/upload/select)
- Тест — ручной (см. acceptance), автоматический — валидация по XSD GPX 1.1.
### REQ-NF-08. Логирование
- На каждый запрос `/api/gps-tracks/{id}/download` логировать (через uvicorn
access log + кастомный print): `track_id`, `format`, `length_m`,
`points_count`, `client_ip` (из `X-Forwarded-For`).
- Цель — будущее rate-limiting и аналитика M1/M2.
### REQ-NF-09. Кэширование
- HTTP-кэш браузера: `Cache-Control: public, max-age=3600`.
- ETag = `W/"<id>-<updated_at>"` для условных запросов (`If-None-Match` →
304). MVP: ETag опционален, но `Cache-Control` обязателен.
- LRU-кэш сгенерированных GPX в памяти процесса: НЕ внедряем в MVP (треки
небольшие, нагрузка низкая).
## 4. Архитектура и контракты
### 4.1. Backend контракт
```
GET /api/gps-tracks/{track_id}/download?format=gpx
200 OK
Content-Type: application/gpx+xml; charset=utf-8
Content-Disposition: attachment;
filename="my-track.gpx";
filename*=UTF-8''%D0%BC%D0%BE%D0%B9-%D1%82%D1%80%D0%B5%D0%BA.gpx
Cache-Control: public, max-age=3600
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="enduro-trails"
xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Мой трек</name>
<desc><![CDATA[Покатушки выходного дня]]></desc>
<author><name>vasya</name></author>
<time>2024-08-15T10:00:00Z</time>
<link href="https://strava.com/activities/123"><text>Источник: strava</text></link>
</metadata>
<trk>
<name>Мой трек</name>
<trkseg>
<trkpt lat="55.7558" lon="37.6173"/>
<trkpt lat="55.7560" lon="37.6175"/>
...
</trkseg>
</trk>
</gpx>
```
### 4.2. Frontend: изменения в `_renderTrackPopupHtml`
Добавляется:
```html
<div class="track-popup-actions">
<button type="button" class="track-popup-download-btn"
data-track-id="${props.id}"
onclick="onDownloadGpsTrack(event)">
⬇ Скачать GPX
</button>
</div>
```
И новая функция в `gps_tracks.js`:
```js
function onDownloadGpsTrack(ev) {
ev.stopPropagation();
const btn = ev.currentTarget;
const id = btn.dataset.trackId;
if (!id) return;
const a = document.createElement('a');
a.href = `/api/gps-tracks/${id}/download?format=gpx`;
a.rel = 'noopener';
// a.download не нужен — сервер шлёт Content-Disposition
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
```
### 4.3. Модуль `gpx_exporter` (backend)
Новый файл: `src/api/gps_tracks/exporters.py`. Функции:
- `def build_gpx(track_row, coords: list[tuple[float, float]]) -> str`
- `def build_kml(track_row, coords: list[tuple[float, float]]) -> str`
- `def sanitize_filename(name: str | None, track_id: int) -> str`
- `def make_content_disposition(filename_no_ext: str, ext: str) -> str`
— формирует строку с `filename=` (ASCII fallback) и `filename*=`
(RFC 5987).
## 5. CSS
В `src/web/app.css` добавить (рядом с `.track-popup-*`):
```css
.track-popup-actions {
margin-top: 8px;
display: flex;
gap: 6px;
}
.track-popup-download-btn {
min-height: 36px;
min-width: 36px;
padding: 6px 12px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.track-popup-download-btn:hover { opacity: 0.9; }
@media (max-width: 768px) {
.track-popup-download-btn {
min-height: 44px;
width: 100%;
}
}
```
## 6. Подзадачи разработки
| ID | Описание | Файлы |
|----|----------|-------|
| ST-01 | `exporters.py`: `build_gpx`, `build_kml`, sanitize, content-disposition | `src/api/gps_tracks/exporters.py` (новый) |
| ST-02 | Эндпоинт `/api/gps-tracks/{id}/download` в роутере | `src/api/gps_tracks/endpoint.py` |
| ST-03 | Юнит-тесты экспортёров | `tests/unit/test_gpx_exporters.py` (новый) |
| ST-04 | Интеграционный тест эндпоинта (TestClient) | `tests/integration/test_gps_download.py` (новый) |
| ST-05 | Frontend: кнопка в popup + обработчик | `src/web/gps_tracks.js` |
| ST-06 | CSS для кнопки | `src/web/app.css` |
| ST-07 | E2E (Playwright) UI тест-кейс | `tests/e2e/test_track_download.py` (новый) |
| ST-08 | ADR: дизайн фича-флага KML и атрибуция источников | `docs/work-items/ET-011/06-adr/01-export-formats.md` |
## 7. Риски и митигации
| ID | Риск | Митигация |
|----|------|-----------|
| R1 | Большие треки → таймаут на сборке GPX | REQ-NF-03 hard limit 100k points + `LIMIT 1` SELECT по id. |
| R2 | Имена с unicode / спецсимволами → битый Content-Disposition | RFC 5987 + ASCII fallback (REQ-F-07). |
| R3 | XML injection через name / description | XML escape всех текстовых полей (REQ-NF-05). |
| R4 | Невалидный GPX → не открывается в OsmAnd / Garmin | XSD-валидация в тестах + ручной smoke-тест в gpx.studio (REQ-NF-07). |
| R5 | Фронт `<a download>` без `download` атрибута может в Safari открывать файл в новой вкладке | Полагаемся на `Content-Disposition: attachment` — Safari 14+ корректно скачивает. Запасной план: добавить `a.download = filename` с дублированием имени на клиенте. |
| R6 | Источник запрещает редистрибуцию (Strava ToS, BR-R1) | ADR определяет per-source политику. MVP: показываем в GPX-метаданных ссылку на оригинал. |
| R7 | CORS preflight для прямого `<a download>` | Не возникает: `GET` + same-origin (FastAPI отдаёт и фронт, и API на одном домене). |
## 8. Зависимости / контракты с другими WI
- **ET-008** (`docs/work-items/ET-008/`): источник данных и UI popup —
ET-011 не правит логику попапа, только добавляет одну кнопку и обработчик.
- ADR-013 (атрибуция источников): продолжаем линию, добавляем атрибуцию
в файл трека.
## 9. Из BRD: решения по открытым вопросам
- **Q1 (KML).** Решено: KML за фича-флагом `featureKmlExport`, по умолчанию
выключен. В MVP реализуем только GPX-путь; KML-код пишется, но скрыт.
- **Q2 (timestamps в trkpt).** Не включаем — в БД нет таймстемпов точек.
- **Q3 (лимит размера).** 100 000 точек hard limit (REQ-NF-03).
- **Q4 (per-source redistribution).** Перенесено в ADR (`06-adr/01-export-formats.md`).
MVP: разрешено всем; атрибуция в GPX обязательна.

View File

@@ -0,0 +1,237 @@
# Acceptance Criteria: Скачивание трека из popup на карте (ET-011)
Work Item ID: ET-011
Stage: analysis
Author: analyst-bot
Date: 2026-06-03
Формат: Given / When / Then. Каждый критерий покрывает соответствующее
REQ-F-NN или REQ-NF-NN из `02-trz.md`.
---
## AC-01. Кнопка «Скачать» появляется в popup публичного трека
**Покрывает:** REQ-F-04.
- **Given** включён слой «Публичные треки» на карте и в видимой области
карты есть хотя бы один трек.
- **When** пользователь кликает (тапает) по линии трека на карте.
- **Then** открывается popup, в котором, помимо существующих полей (название,
активность, длина, источники), есть кнопка с текстом «⬇ Скачать GPX»
и CSS-классом `track-popup-download-btn`.
- **And** атрибут `data-track-id` кнопки равен `id` трека из API.
## AC-02. Кнопка не рендерится, если `id` отсутствует
**Покрывает:** REQ-F-04 (последний абзац).
- **Given** свойства фичи (feature.properties) не содержат `id`
(legacy / повреждённые данные).
- **When** генерируется HTML popup.
- **Then** в HTML нет элемента с классом `track-popup-download-btn`.
## AC-03. Скачивание GPX по клику
**Покрывает:** REQ-F-05.
- **Given** открыт popup трека с `id=42`.
- **When** пользователь кликает «⬇ Скачать GPX».
- **Then** браузер инициирует GET-запрос на `/api/gps-tracks/42/download?format=gpx`.
- **And** ответ имеет статус 200 и `Content-Disposition: attachment; ...`.
- **And** браузер сохраняет файл в Downloads.
- **And** popup остаётся открытым.
## AC-04. Backend отдаёт валидный GPX 1.1
**Покрывает:** REQ-F-01, REQ-F-02.
- **Given** в БД есть трек `id=42` с `name="Мой трек"`, `description=null`,
`created_at="2024-08-15T10:00:00Z"`, `user=null`, geom — LineString из
трёх точек, `external_urls_json=["https://strava.com/activities/123"]`,
`sources_json=["strava"]`.
- **When** клиент делает `GET /api/gps-tracks/42/download?format=gpx`.
- **Then** статус ответа `200`.
- **And** `Content-Type` начинается с `application/gpx+xml`.
- **And** тело ответа — валидный XML, соответствующий GPX 1.1 XSD
(проверка через `xmlschema` в тестах).
- **And** тело содержит элементы:
- `<gpx version="1.1" creator="enduro-trails" xmlns="http://www.topografix.com/GPX/1/1">`
- `<metadata><name>Мой трек</name>`
- `<metadata><time>2024-08-15T10:00:00Z</time>`
- `<metadata><link href="https://strava.com/activities/123"><text>Источник: strava</text></link>`
- `<trk><name>Мой трек</name><trkseg>` с тремя `<trkpt lat=".." lon=".."/>`.
## AC-05. Backend: 404 для несуществующего трека
**Покрывает:** REQ-F-01 (404).
- **Given** в БД нет трека `id=999999`.
- **When** клиент делает `GET /api/gps-tracks/999999/download`.
- **Then** статус ответа `404`.
- **And** тело: `{"detail": "Track not found"}`.
## AC-06. Backend: 400 при неподдерживаемом формате
**Покрывает:** REQ-F-01.
- **Given** трек `id=42` существует, фича-флаг `FEATURE_KML_EXPORT=false`.
- **When** клиент делает `GET /api/gps-tracks/42/download?format=kml`.
- **Then** статус ответа `400`.
- **And** тело: `{"detail": "Format kml is not enabled"}`.
**И обратный кейс:**
- **Given** трек `id=42`, `FEATURE_KML_EXPORT=true`.
- **When** клиент делает тот же запрос.
- **Then** статус `200`, `Content-Type: application/vnd.google-earth.kml+xml`,
тело — валидный KML 2.2 (REQ-F-03).
## AC-07. Backend: 413 для слишком большого трека
**Покрывает:** REQ-NF-03.
- **Given** в БД есть трек `id=88` с `points_count = 150000`.
- **When** клиент делает `GET /api/gps-tracks/88/download`.
- **Then** статус ответа `413`.
- **And** тело: `{"detail": "Track too large"}`.
## AC-08. Content-Disposition с unicode-именем
**Покрывает:** REQ-F-07, REQ-NF-05.
- **Given** трек `id=42` с `name="Мой трек / Лесные дороги"`.
- **When** GET download.
- **Then** заголовок `Content-Disposition` содержит:
- `filename="track-42.gpx"` ИЛИ ASCII-транслит вида `filename="moj-trek-lesnye-dorogi.gpx"`,
- и `filename*=UTF-8''%D0%9C%D0%BE%D0%B9%20%D1%82%D1%80%D0%B5%D0%BA%20_%20%D0%9B%D0%B5%D1%81%D0%BD%D1%8B%D0%B5%20%D0%B4%D0%BE%D1%80%D0%BE%D0%B3%D0%B8.gpx`
- слэш `/` заменён на `_` (REQ-F-07 список запрещённых символов).
- **And** браузер (Chrome/Firefox/Safari) сохраняет файл с именем
«Мой трек _ Лесные дороги.gpx».
## AC-09. Content-Disposition с пустым именем
**Покрывает:** REQ-F-07.
- **Given** трек `id=42` с `name=null`.
- **When** GET download.
- **Then** `Content-Disposition` содержит `filename="track-42.gpx"` и без `filename*`.
## AC-10. XML-экранирование специальных символов
**Покрывает:** REQ-NF-05.
- **Given** трек с `name="A & B <ok>"`, `description='Test "quotes"'`.
- **When** GET download.
- **Then** GPX содержит `<name>A &amp; B &lt;ok&gt;</name>` либо CDATA-обёртку.
- **And** `description` обёрнут в `<![CDATA[Test "quotes"]]>` либо
экранирован.
- **And** XML парсится без ошибок (`xml.etree.ElementTree.fromstring`).
## AC-11. Атрибуция источников
**Покрывает:** REQ-NF-04, BR-R1 из BRD.
- **Given** трек с `sources_json=["strava","wikiloc"]`,
`external_urls_json=["https://strava.com/activities/123",
"https://wikiloc.com/123"]`.
- **When** GET download (gpx).
- **Then** в GPX `<metadata>` ровно 2 элемента `<link>`:
- `<link href="https://strava.com/activities/123"><text>Источник: strava</text></link>`
- `<link href="https://wikiloc.com/123"><text>Источник: wikiloc</text></link>`.
**И вариант без URL:**
- **Given** `sources_json=["strava"]`, `external_urls_json=[]`.
- **Then** ни одного `<link>`, но в `<metadata><desc>` присутствует подстрока
`Источники: strava`.
## AC-12. UI: hit-area на мобильном
**Покрывает:** REQ-NF-02.
- **Given** viewport ≤ 768px (мобильное устройство).
- **When** popup публичного трека открыт.
- **Then** computed style кнопки `track-popup-download-btn`:
- `min-height >= 44px`,
- кнопка занимает 100% ширины popup (`width: 100%`).
**И на десктопе (viewport > 768px):**
- `min-height >= 36px`, `min-width >= 36px`.
## AC-13. Кэширование на стороне браузера
**Покрывает:** REQ-NF-09.
- **When** GET download (200 ответ).
- **Then** в ответе есть заголовок `Cache-Control: public, max-age=3600`.
## AC-14. Логирование запросов
**Покрывает:** REQ-NF-08.
- **When** запрос на download.
- **Then** в access-log uvicorn видна строка с `GET /api/gps-tracks/<id>/download`.
- **And** в кастомный логгер пишется запись с полями: `track_id`,
`format`, `length_m`, `points_count`, `client_ip`
(проверяется интеграционным тестом через captured `caplog`).
## AC-15. Несуществующий формат
**Покрывает:** REQ-F-01.
- **Given** `format=xyz` (не gpx, не kml).
- **When** GET download.
- **Then** статус `400`, тело `{"detail": "Unsupported format: xyz"}`.
## AC-16. Производительность
**Покрывает:** REQ-NF-01.
- **Given** трек с `points_count = 5000`.
- **When** замер p95 latency `/api/gps-tracks/<id>/download` на test-стенде
(10 параллельных клиентов × 100 запросов).
- **Then** p95 < 300 мс.
## AC-17. E2E happy path в браузере
**Покрывает:** REQ-F-04, REQ-F-05.
- **Given** Playwright запускает сценарий на https://openclaw.mva154.duckdns.org/enduro/.
- **When** включается слой «Публичные треки», карта зумится в район с треками,
происходит клик по линии трека, в popup нажимается «⬇ Скачать GPX».
- **Then** Playwright фиксирует событие `download` через `page.expect_download()`.
- **And** `download.suggested_filename` оканчивается на `.gpx`.
- **And** содержимое скачанного файла начинается с `<?xml` и содержит
подстроку `<gpx`.
## AC-18. Совместимость GPX с внешними клиентами (ручной smoke)
**Покрывает:** REQ-NF-07.
- **Given** скачан GPX любого реального трека.
- **When** файл открыт в:
- gpx.studio (https://gpx.studio) — отображается на карте;
- OsmAnd на Android — импортируется без ошибок;
- Garmin BaseCamp — отображается на карте.
- **Then** во всех трёх клиентах трек виден полностью, без warning о
неизвестных элементах.
## AC-19. Регрессия: popup не сломался без флага скачивания
**Покрывает:** обратная совместимость с ET-008.
- **Given** код ET-011 откатан / отключён (`window.ET_FEATURE_DOWNLOAD = false`
— если такой флаг будет, либо просто старая версия).
- **When** клик по треку.
- **Then** popup открывается так же, как до ET-011, ничего не падает.
- **And** существующие поля (название, активность, длина, источники) на месте.
## AC-20. Безопасность: SQL-инъекция через id
**Покрывает:** REQ-NF-05.
- **When** клиент делает `GET /api/gps-tracks/1%20OR%201=1/download`.
- **Then** FastAPI / Pydantic отдаёт `422 Unprocessable Entity` (не int).
- **And** в БД не выполнен SQL вне параметризованного запроса.

View File

@@ -0,0 +1,392 @@
# Test plan: ET-011 — Скачивание трека из popup на карте
# Stage: analysis
# Author: analyst-bot
# Date: 2026-06-03
#
# Категории: unit, integration, e2e.
# UI-визуальные тесты — см. 04b-ui-test-cases.md (Playwright).
work_item: ET-011
title: "Скачивание трека из popup на карте"
categories:
unit:
description: "Чистые функции экспортёров GPX/KML и санитизации."
framework: pytest
files:
- tests/unit/test_gpx_exporters.py
tests:
- id: U-01
name: "build_gpx: минимальный валидный трек"
covers: [REQ-F-02, AC-04]
given: "track_row с name='X', без description/user/created_at; coords из 2 точек"
when: "вызвать build_gpx(track_row, coords)"
then:
- "результат — строка с XML declaration"
- "содержит '<gpx version=\"1.1\" creator=\"enduro-trails\"'"
- "содержит '<name>X</name>'"
- "содержит ровно 2 элемента <trkpt"
- "xml.etree.ElementTree.fromstring(result) не бросает"
- id: U-02
name: "build_gpx: с метаданными и атрибуцией"
covers: [REQ-F-02, REQ-NF-04, AC-11]
given: "track с created_at, user, sources=['strava','wikiloc'], external_urls=['u1','u2']"
when: "build_gpx"
then:
- "ровно 2 <link href=...> в <metadata>"
- "<author><name>user</name></author> присутствует"
- "<time> совпадает с created_at"
- id: U-03
name: "build_gpx: атрибуция без URL"
covers: [REQ-NF-04, AC-11]
given: "sources=['strava'], external_urls=[]"
when: "build_gpx"
then:
- "ни одного <link>"
- "<metadata><desc> содержит подстроку 'Источники: strava'"
- id: U-04
name: "build_gpx: XML-экранирование"
covers: [REQ-NF-05, AC-10]
given: "name='A & B <ok>', description='X \"y\"'"
when: "build_gpx"
then:
- "результат — валидный XML (fromstring не бросает)"
- "'&amp;' или CDATA-обёртка присутствует"
- "'<ok>' НЕ присутствует как сырой тег внутри <name>"
- id: U-05
name: "build_gpx: пустой name → дефолт"
covers: [REQ-F-02]
given: "name=None, track_id=42"
when: "build_gpx"
then:
- "<name>Track 42</name>"
- id: U-06
name: "build_gpx: 5000 точек укладывается за 200мс"
covers: [REQ-NF-01]
given: "coords из 5000 случайных (lon,lat)"
when: "замер time.perf_counter(); build_gpx"
then:
- "elapsed < 0.2 секунды"
- "len(result) > 100000"
- id: U-07
name: "build_kml: минимальный валидный"
covers: [REQ-F-03, AC-06]
given: "track c name='X', coords из 2 точек"
when: "build_kml"
then:
- "содержит '<kml xmlns=\"http://www.opengis.net/kml/2.2\">'"
- "содержит '<LineString><coordinates>'"
- "строка coordinates содержит 2 тройки 'lon,lat,0'"
- id: U-08
name: "sanitize_filename: запрещённые символы"
covers: [REQ-F-07, AC-08]
given: "name='Мой/трек:с\"спец\"символами'"
when: "sanitize_filename(name, 42)"
then:
- "в результате нет символов '/ : \" < > |'"
- "пробелы сохранены"
- "длина <= 100"
- id: U-09
name: "sanitize_filename: пустое имя → дефолт"
covers: [REQ-F-07, AC-09]
given: "name=None, track_id=42"
when: "sanitize_filename"
then:
- "результат == 'track-42'"
- id: U-10
name: "sanitize_filename: только запрещённые символы"
covers: [REQ-F-07]
given: "name='///', track_id=7"
when: "sanitize_filename"
then:
- "результат начинается с 'track-7' (полностью санитизированное пусто → дефолт)"
- id: U-11
name: "make_content_disposition: RFC 5987"
covers: [REQ-F-07, AC-08]
given: "filename_no_ext='Мой трек', ext='gpx'"
when: "make_content_disposition"
then:
- "содержит 'attachment;'"
- "содержит 'filename=' с ASCII-fallback"
- "содержит 'filename*=UTF-8'' с percent-encoded именем"
- "оканчивается на '.gpx' в обоих местах"
- id: U-12
name: "make_content_disposition: pure ASCII"
covers: [REQ-F-07]
given: "filename_no_ext='moj-trek', ext='gpx'"
when: "make_content_disposition"
then:
- "содержит 'filename=\"moj-trek.gpx\"'"
- "filename* опционален (может быть, может не быть)"
integration:
description: "FastAPI TestClient против реальной in-memory БД с фикстурами."
framework: pytest + FastAPI TestClient
files:
- tests/integration/test_gps_download.py
fixtures:
- "tmp_db: SQLite БД с миграциями + 4 трека: small (3 точки), normal (5000),
huge (150000), no_name (name=NULL)"
- "client: FastAPI TestClient с подмонтированным gps_router"
tests:
- id: I-01
name: "GET download: happy path GPX"
covers: [REQ-F-01, AC-03, AC-04]
steps:
- "GET /api/gps-tracks/<small_id>/download"
expect:
- "status == 200"
- "Content-Type starts with 'application/gpx+xml'"
- "Content-Disposition содержит 'attachment'"
- "Cache-Control == 'public, max-age=3600'"
- "тело начинается с '<?xml'"
- "ElementTree.fromstring(body) не бросает"
- "число элементов trkpt == 3"
- id: I-02
name: "GET download: 404 для несуществующего id"
covers: [REQ-F-01, AC-05]
steps:
- "GET /api/gps-tracks/999999/download"
expect:
- "status == 404"
- "json()['detail'] == 'Track not found'"
- id: I-03
name: "GET download: 422 для не-int id"
covers: [REQ-NF-05, AC-20]
steps:
- "GET /api/gps-tracks/abc/download"
expect:
- "status == 422"
- id: I-04
name: "GET download: 413 для огромного трека"
covers: [REQ-NF-03, AC-07]
steps:
- "GET /api/gps-tracks/<huge_id>/download"
expect:
- "status == 413"
- "json()['detail'] == 'Track too large'"
- id: I-05
name: "GET download: KML при FEATURE_KML_EXPORT=false → 400"
covers: [REQ-NF-06, AC-06]
env:
FEATURE_KML_EXPORT: "false"
steps:
- "GET /api/gps-tracks/<small_id>/download?format=kml"
expect:
- "status == 400"
- "json()['detail'] == 'Format kml is not enabled'"
- id: I-06
name: "GET download: KML при FEATURE_KML_EXPORT=true → 200"
covers: [REQ-F-03, AC-06]
env:
FEATURE_KML_EXPORT: "true"
steps:
- "GET /api/gps-tracks/<small_id>/download?format=kml"
expect:
- "status == 200"
- "Content-Type starts with 'application/vnd.google-earth.kml+xml'"
- "тело содержит '<kml'"
- "тело содержит '<LineString>'"
- id: I-07
name: "GET download: неподдерживаемый формат"
covers: [REQ-F-01, AC-15]
steps:
- "GET /api/gps-tracks/<small_id>/download?format=xyz"
expect:
- "status == 400"
- "json()['detail'].startswith('Unsupported format')"
- id: I-08
name: "Content-Disposition с unicode-именем"
covers: [REQ-F-07, AC-08]
steps:
- "INSERT трек с name='Мой трек / Лесные дороги'"
- "GET /api/gps-tracks/<id>/download"
expect:
- "Content-Disposition содержит 'filename*=UTF-8'''"
- "перцент-кодированное имя содержит %D0 (кириллица в UTF-8)"
- "ASCII-часть filename не содержит '/' (заменено на _)"
- id: I-09
name: "GPX с трека без name → 'Track <id>'"
covers: [AC-09]
steps:
- "GET /api/gps-tracks/<no_name_id>/download"
expect:
- "тело содержит '<name>Track <no_name_id></name>'"
- "Content-Disposition содержит 'filename=\"track-<no_name_id>.gpx\"'"
- id: I-10
name: "GPX-валидация против XSD"
covers: [REQ-F-02, REQ-NF-07, AC-04]
steps:
- "GET /api/gps-tracks/<small_id>/download"
- "загрузить XSD GPX 1.1 (http://www.topografix.com/GPX/1/1/gpx.xsd, локальная копия)"
- "xmlschema.validate(body, xsd)"
expect:
- "валидация без ошибок"
- id: I-11
name: "Атрибуция: <link> для каждого external_url"
covers: [REQ-NF-04, AC-11]
steps:
- "INSERT трек с sources=['strava','wikiloc'], external_urls=['u1','u2']"
- "GET /api/gps-tracks/<id>/download"
expect:
- "ровно 2 элемента <link href=...> в <metadata>"
- "<link href=\"u1\">…<text>Источник: strava</text>"
- id: I-12
name: "Атрибуция без URL → подстрока в desc"
covers: [REQ-NF-04, AC-11]
steps:
- "INSERT трек с sources=['strava'], external_urls=[]"
- "GET /api/gps-tracks/<id>/download"
expect:
- "0 элементов <link>"
- "<desc> содержит 'Источники: strava'"
- id: I-13
name: "Логирование запроса"
covers: [REQ-NF-08, AC-14]
steps:
- "запустить с caplog/pytest-LogCaptureFixture"
- "GET /api/gps-tracks/<small_id>/download"
expect:
- "в caplog запись содержит подстроку 'track_id=<id>'"
- "запись содержит 'format=gpx'"
- id: I-14
name: "Cache-Control присутствует"
covers: [REQ-NF-09, AC-13]
steps:
- "GET /api/gps-tracks/<small_id>/download"
expect:
- "Cache-Control == 'public, max-age=3600'"
e2e:
description: "Browser-level Playwright тесты против test-стенда."
framework: Playwright (python)
files:
- tests/e2e/test_track_download.py
target_url: https://openclaw.mva154.duckdns.org/enduro/
tests:
- id: E-01
name: "Happy path: open popup → click download → file lands"
covers: [AC-03, AC-17]
steps:
- "page.goto(target_url)"
- "page.click('#terrain-toggle')"
- "page.check('#public-tracks-cb')"
- "wait 4000ms (треки загружаются)"
- "программный pan/zoom на bbox с гарантированными треками (фикстура)"
- "клик по линии трека на canvas (координаты из фикстуры)"
- "wait '.track-popup-download-btn' (timeout 5s)"
- "with page.expect_download() as dl_info: click '.track-popup-download-btn'"
- "download = dl_info.value"
expect:
- "download.suggested_filename оканчивается на '.gpx'"
- "содержимое (download.path() → read) начинается с '<?xml'"
- "содержимое содержит '<gpx'"
- id: E-02
name: "Popup остаётся открытым после клика"
covers: [AC-03]
steps:
- "тот же сценарий что E-01"
- "после download проверить, что popup в DOM присутствует"
expect:
- "page.locator('.track-popup').count() >= 1"
- id: E-03
name: "Mobile viewport: кнопка во всю ширину"
covers: [REQ-NF-02, AC-12]
steps:
- "context с viewport 390x844 (iPhone 14)"
- "повторить E-01 до открытия popup"
- "получить bounding_box кнопки '.track-popup-download-btn'"
expect:
- "bbox.height >= 44"
- "bbox.width >= 0.9 * popup.width"
- id: E-04
name: "Несуществующий id (через прямой URL)"
covers: [AC-05]
steps:
- "page.goto(target_url + 'api/gps-tracks/999999/download')"
expect:
- "response status 404"
manual_smoke:
- id: M-01
name: "GPX открывается в gpx.studio"
covers: [REQ-NF-07, AC-18]
steps:
- "скачать GPX любого трека через UI"
- "открыть https://gpx.studio, drag-n-drop файл"
- "убедиться, что трек отображён на карте без warnings"
- id: M-02
name: "GPX импортируется в OsmAnd"
covers: [REQ-NF-07, AC-18]
steps:
- "передать GPX на Android, открыть с помощью OsmAnd"
- "проверить что трек виден"
- id: M-03
name: "GPX открывается в Garmin BaseCamp"
covers: [REQ-NF-07, AC-18]
steps:
- "File → Import → выбрать GPX"
- "проверить отображение"
performance:
- id: P-01
name: "p95 latency для трека 5000 точек"
covers: [REQ-NF-01, AC-16]
tool: "locust или ab"
setup: "10 concurrent clients × 100 requests"
expect: "p95 < 300ms"
regression:
- id: REG-01
name: "ET-008 popup без скачивания не сломан"
covers: [AC-19]
note: "Перепроверить, что popup без props.id не падает; что
удаление кнопки не ломает остальную разметку."
coverage_matrix:
REQ-F-01: [I-01, I-02, I-03, I-07, E-04]
REQ-F-02: [U-01, U-02, U-05, I-10]
REQ-F-03: [U-07, I-06]
REQ-F-04: [E-01] # покрытие также через UI test cases 04b
REQ-F-05: [E-01, E-02]
REQ-F-06: [] # MVP скрыт за флагом
REQ-F-07: [U-08, U-09, U-10, U-11, U-12, I-08, I-09]
REQ-F-08: [] # MVP best-effort через <a download>
REQ-NF-01: [U-06, P-01]
REQ-NF-02: [E-03] # + UI test case TC-UI-02
REQ-NF-03: [I-04]
REQ-NF-04: [U-02, U-03, I-11, I-12]
REQ-NF-05: [U-04, I-03]
REQ-NF-06: [I-05, I-06]
REQ-NF-07: [I-10, M-01, M-02, M-03]
REQ-NF-08: [I-13]
REQ-NF-09: [I-14]

View File

@@ -0,0 +1,140 @@
# UI Test Cases (Playwright): ET-011
Work Item ID: ET-011
Цель: визуальная проверка кнопки «Скачать» в popup публичного трека.
Target URL: https://openclaw.mva154.duckdns.org/enduro/
Все тесты предполагают:
- слой «Публичные треки» включается через `#terrain-toggle``#public-tracks-cb`;
- после включения нужно подождать загрузку (3-5 секунд) и спозиционировать
карту над регионом с гарантированно присутствующими треками. Для
стабильности тестов используется фикстура bbox: центр (55.7558, 37.6173)
± 0.5°, zoom 12.
CSS-селектор popup из ET-008 — `.track-popup` (рендерится в шаблоне
`_renderTrackPopupHtml`). Кнопка скачивания — `.track-popup-download-btn`.
---
### TC-UI-01 — Кнопка «Скачать» видна в popup трека
Тип: ui
Viewport: desktop
Шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 4000
3. click: #terrain-toggle
4. wait: 500
5. click: #public-tracks-cb
6. wait: 5000
7. screenshot: 01-map-with-public-tracks
8. click: #map (тап в центр карты, ожидается попадание по треку из фикстуры;
в реальном e2e — клик по конкретным координатам канваса)
9. wait: 2000
10. check-visual: на скриншоте над картой виден popup `.track-popup`
с названием трека, активностью и кнопкой «⬇ Скачать GPX»
11. screenshot: 02-popup-with-download-btn
12. check-visual: цвет фона кнопки контрастирует с фоном popup (accent vs
surface), кнопка не обрезана, иконка стрелки вниз видна
### TC-UI-02 — Hit-area кнопки на мобильном viewport
Тип: ui
Viewport: mobile (390×844)
Шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 4000
3. click: #terrain-toggle
4. wait: 500
5. click: #public-tracks-cb
6. wait: 5000
7. click: #map (клик по треку)
8. wait: 2000
9. screenshot: 03-popup-mobile
10. check-visual: кнопка `.track-popup-download-btn` занимает ~100% ширины
popup (растянута по горизонтали)
11. check-visual: высота кнопки на скриншоте визуально не менее 44 px
(≈ той же высоты, что и тулбар-иконки)
### TC-UI-03 — Popup остаётся открытым после клика по «Скачать»
Тип: ui
Viewport: desktop
Шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 4000
3. click: #terrain-toggle
4. wait: 500
5. click: #public-tracks-cb
6. wait: 5000
7. click: #map
8. wait: 2000
9. screenshot: 04-popup-before-download
10. click: .track-popup-download-btn
11. wait: 1500
12. screenshot: 05-popup-after-download
13. check-visual: на скриншоте `05-popup-after-download` popup
`.track-popup` всё ещё виден на том же месте, кнопка «⬇ Скачать GPX»
активна, состояние popup не изменилось
### TC-UI-04 — Кнопка не ломает остальную разметку popup
Тип: ui
Viewport: desktop
Шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 4000
3. click: #terrain-toggle
4. wait: 500
5. click: #public-tracks-cb
6. wait: 5000
7. click: #map
8. wait: 2000
9. screenshot: 06-popup-full
10. check-visual: в popup присутствуют (в порядке сверху вниз):
- название трека (`.track-popup-name`)
- строки активности / длины / даты / пользователя
(`.track-popup-row` × N)
- блок источников (`.track-popup-sources`), если есть
- кнопка скачивания (`.track-popup-download-btn`) — последней
11. check-visual: между блоком источников и кнопкой есть видимый отступ
(≥ 6 px), кнопка не перекрывает текст
### TC-UI-05 — Тёмная тема: кнопка читаема
Тип: ui
Viewport: desktop
Шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 3000
3. check-visual: на body должен быть класс `theme-dark` (тёмная — дефолт)
4. click: #terrain-toggle
5. wait: 500
6. click: #public-tracks-cb
7. wait: 5000
8. click: #map
9. wait: 2000
10. screenshot: 07-popup-dark-theme
11. check-visual: текст на кнопке `.track-popup-download-btn` контрастен с
фоном кнопки (белый текст / иконка на цветном фоне). Не белый-на-белом,
не серый-на-сером.
### TC-UI-06 — Светлая тема: кнопка читаема
Тип: ui
Viewport: desktop
Шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 3000
3. click: #btn-theme
4. wait: 500
5. check-visual: на body больше нет класса `theme-dark`
6. click: #terrain-toggle
7. wait: 500
8. click: #public-tracks-cb
9. wait: 5000
10. click: #map
11. wait: 2000
12. screenshot: 08-popup-light-theme
13. check-visual: кнопка `.track-popup-download-btn` читаема (контраст
фон/текст), иконка стрелки вниз видна