diff --git a/CHANGELOG.md b/CHANGELOG.md index 85381e2..8769a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,9 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) форматтером `Units.formatDistance()`; выбор сохраняется в localStorage (ключ `distance_unit`), пересчёт всех видимых расстояний выполняется единым оркестратором по событию `unitchange` +- ET-006: загрузка и визуализация GPX-треков — новый модуль + `src/web/gpx.js` с клиентским парсингом GPX 1.1 (`DOMParser`, + чанковая конвертация), отрисовкой треков и waypoints на карте, + панелью `#sheet-gpx` со списком треков, статистикой и canvas-профилем + высот; GPX-слои восстанавливаются после смены стиля карты через + `rebuildMapOverlays()`. Данные треков хранятся только в памяти сессии diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index ea0654c..51da402 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -2,5 +2,8 @@ Индекс ADR проекта enduro-trails. -| # | Решение | Статус | Дата | -|---|---------|--------|------| +| # | Решение | Статус | Дата | Источник | +|---|---------|--------|------|----------| +| ADR-001 | Блокировка шлагбаумов через `mode.inaccessible` | accepted | 2026-05-15 | [ET-001](../../work-items/ET-001/06-adr/ADR-001-barrier-blocking.md) | +| ADR-002 | GPX-фича как отдельный модуль `gpx.js` | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md) | +| ADR-003 | Парсинг GPX — `DOMParser` в основном потоке с чанковой конвертацией | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-003-gpx-parsing-strategy.md) | diff --git a/docs/work-items/ET-006/00-business-request.md b/docs/work-items/ET-006/00-business-request.md new file mode 100644 index 0000000..9f7f369 --- /dev/null +++ b/docs/work-items/ET-006/00-business-request.md @@ -0,0 +1,28 @@ +--- +type: business-request +work_item_id: ET-006 +title: "Загрузка и визуализация GPX-треков на карте" +created_at: 2026-05-22 +source: telegram +requester: Слава +--- + +# Бизнес-запрос — ET-006 + +## Исходная формулировка + +> Нужно добавить опцию загрузки GPX-треков. Я хочу нажать на кнопку в интерфейсе, выбрать файл с треком и далее увидеть его визуализацию на карте. + +## Уточнения (из диалога) + +1. Формат: GPX 1.1 стандартный. Возможны несколько треков в одном файле. +2. Визуализация: линия трека + waypoints как маркеры + профиль высот + статистика (длина, набор/сброс высоты). +3. Кнопка: отдельная в интерфейсе. +4. После загрузки: карта центрируется на треке (fit bounds). +5. Можно загрузить несколько треков одновременно. +6. Можно удалить загруженный трек. +7. Хранение: только на время сессии (в памяти браузера), без сохранения на сервер. +8. Каждый трек — свой цвет (для различения). +9. Лимит файла: до 50 МБ, без упрощения (simplify) точек. +10. Waypoints из GPX: показывать как маркеры на карте с именами. +11. UX профиля высот и статистики — на усмотрение аналитика. diff --git a/docs/work-items/ET-006/01-brd.md b/docs/work-items/ET-006/01-brd.md new file mode 100644 index 0000000..41f43c3 --- /dev/null +++ b/docs/work-items/ET-006/01-brd.md @@ -0,0 +1,84 @@ +--- +type: brd +work_item_id: ET-006 +title: "BRD: Загрузка и визуализация GPX-треков" +version: 2 +status: approved +created_at: 2026-05-22 +updated_at: 2026-05-22 +authors: + - "agent:analyst" +--- + +# BRD — ET-006: Загрузка и визуализация GPX-треков + +## 1. Цель + +Дать пользователю возможность загрузить GPX-файл с треком и увидеть его на карте: линию маршрута, waypoints, профиль высот и статистику. Это позволяет визуально оценить чужой или ранее записанный трек перед поездкой. + +## 2. Контекст + +- Приложение уже умеет строить маршруты через OSRM и экспортировать их в GPX (кнопка «Скачать GPX» в sheet-route). +- Обратная операция — импорт GPX — отсутствует. +- Фаза PH-3 (Smart Route) в roadmap включает работу с GPX. +- Фронтенд: MapLibre GL JS + vanilla JS, без фреймворков. +- Backend-изменения не требуются — парсинг GPX происходит на клиенте. + +## 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-слоёв при переключении стиля карты (тёмная тема / рельеф) | + +### Out of scope + +- Сохранение треков на сервер / в БД +- Редактирование трека (обрезка, склейка) +- Конвертация из других форматов (KML, FIT, TCX) +- Упрощение (simplify) точек трека +- Экспорт загруженного трека обратно в GPX +- Роутинг по загруженному треку (snap to road) + +## 4. Метрики успеха + +| Метрика | Критерий | +|---------|----------| +| Загрузка файла | Файл до 50 МБ загружается и парсится без ошибок за ≤ 3 сек (на среднем устройстве) | +| Визуализация | Трек отображается на карте как цветная линия | +| Waypoints | Маркеры с именами видны на карте | +| Fit bounds | Карта автоматически подстраивает zoom/center под трек | +| Множественные треки | 5+ треков отображаются одновременно, различимы по цвету | +| Удаление | Удалённый трек исчезает с карты и из панели | +| Профиль высот | Отображается корректный график высот для выбранного трека | +| Статистика | Длина, набор/сброс высоты отображаются корректно | +| Сохранение при смене стиля | GPX-треки остаются на карте после переключения тёмной темы / слоёв рельефа | +| Не ломает существующий функционал | Роутинг, рельеф, POI, линейка работают как прежде | + +## 5. Риски + +| Риск | Вероятность | Влияние | Митигация | +|------|-------------|---------|-----------| +| Большой GPX (50 МБ, 500K+ точек) тормозит рендеринг | Средняя | Среднее | Использовать GeoJSON source + line layer (MapLibre оптимизирует); при необходимости — Web Worker для парсинга | +| GPX без данных высот → профиль пустой | Средняя | Низкое | Показать сообщение «Данные высот отсутствуют» | +| Невалидный GPX → ошибка парсинга | Низкая | Низкое | Показать пользователю понятное сообщение об ошибке | +| Конфликт цветов треков с цветами маршрута OSRM | Низкая | Низкое | Использовать отдельную палитру, отличную от цветов роутинга | + +## 6. Зависимости + +- Нет внешних зависимостей +- Только фронтенд (vanilla JS + MapLibre GL JS) +- Парсинг XML: нативный DOMParser браузера diff --git a/docs/work-items/ET-006/02-trz.md b/docs/work-items/ET-006/02-trz.md new file mode 100644 index 0000000..44ba8e1 --- /dev/null +++ b/docs/work-items/ET-006/02-trz.md @@ -0,0 +1,289 @@ +--- +type: trz +work_item_id: ET-006 +title: "ТЗ: Загрузка и визуализация GPX-треков" +version: 2 +status: approved +created_at: 2026-05-22 +updated_at: 2026-05-22 +authors: + - "agent:analyst" +--- + +# ТЗ — ET-006: Загрузка и визуализация GPX-треков + +## 1. Функциональные требования + +### REQ-F-01: Кнопка загрузки GPX + +- В правой панели кнопок карты (`#map-controls-r`) добавляется кнопка «GPX» с иконкой загрузки (стрелка вверх + документ). +- Позиция: между кнопкой «Компас» и «Моё местоположение» (верхняя часть панели). +- По нажатию открывается системный диалог выбора файла (``). +- Допускается множественный выбор (`multiple`). + +### REQ-F-02: Парсинг GPX + +- Парсинг выполняется на клиенте через `DOMParser` (XML → DOM → GeoJSON). +- Поддерживается GPX 1.1 (namespace `http://www.topografix.com/GPX/1/1`). +- Извлекаются: + - `` → массив треков, каждый `` → массив точек `[lon, lat, ele?, time?]` + - `` → waypoints `{lon, lat, name?, ele?}` + - `` → route points (трактуются как трек) +- Если файл содержит несколько ``, каждый трек — отдельная сущность. +- При ошибке парсинга — показать toast-уведомление: «Не удалось прочитать GPX-файл». + +### REQ-F-03: Валидация + +- Максимальный размер файла: 50 МБ. При превышении — toast: «Файл слишком большой (макс. 50 МБ)». +- Если файл не содержит ни одного трека и ни одного waypoint — toast: «GPX-файл не содержит данных». + +### REQ-F-04: Отрисовка трека на карте + +- Каждый трек отрисовывается как `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-05: Отображение waypoints + +- Каждый `` отображается как маркер (circle layer + symbol layer для имени). +- Цвет маркера: совпадает с цветом трека из того же файла (или нейтральный, если waypoints без трека). +- Имя waypoint (``) отображается как label рядом с маркером. +- Если имя отсутствует — маркер без подписи. + +### REQ-F-06: Fit bounds + +- После загрузки файла карта выполняет `fitBounds` по bbox всех точек загруженного файла. +- Padding: 50px со всех сторон. +- Если загружено несколько файлов подряд — fit bounds только по последнему загруженному. + +### 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. +- Если данные высот отсутствуют (`` нет) — показать текст: «Данные высот отсутствуют». +- При наведении/тапе на график — показать 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) + +```xml + + + + Morning Ride + + 150 + ... + + + + Кафе + 155 + + +``` + +### Внутренняя модель (JS) + +```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' + } +]; +``` + +## 5. Алгоритмы + +### 5.1 Расчёт расстояния (Haversine) + +Сумма расстояний между последовательными точками трека. Формула Haversine для каждой пары. + +### 5.2 Расчёт набора/сброса высоты + +``` +elevGain = Σ max(0, ele[i+1] - ele[i]) для всех i +elevLoss = Σ max(0, ele[i] - ele[i+1]) для всех i +``` + +Фильтрация шума: игнорировать дельты < 2 м (GPS-шум). + +### 5.3 Палитра цветов + +Циклический массив из 8 цветов. Индекс = `gpxTracks.length % 8` на момент добавления. + +## 6. Файловая структура изменений + +``` +src/web/ +├── index.html # + кнопка в #map-controls-r, + sheet-gpx, + tb-btn +├── app.js # + gpx-модуль (парсинг, рендеринг, управление) +├── app.css # + стили sheet-gpx, профиля высот, toast +``` + +Альтернативно, GPX-логику можно вынести в отдельный файл `gpx.js` (аналогично `units.js`). + +## 7. Взаимодействие с существующими режимами + +- GPX-треки отображаются **параллельно** с любым активным режимом (роутинг, разведка, красивый маршрут). +- Z-order: GPX-треки ниже активного маршрута OSRM, но выше базовых слоёв (trails, terrain). +- Кнопка загрузки в `#map-controls-r` доступна всегда. +- Кнопка «GPX» в toolbar переключает sheet, но не деактивирует другие режимы. +- При смене стиля карты (`setStyle` — тёмная тема, слои рельефа) GPX-слои восстанавливаются через `rebuildMapOverlays()` — см. REQ-F-13. diff --git a/docs/work-items/ET-006/03-acceptance-criteria.md b/docs/work-items/ET-006/03-acceptance-criteria.md new file mode 100644 index 0000000..9a729a1 --- /dev/null +++ b/docs/work-items/ET-006/03-acceptance-criteria.md @@ -0,0 +1,254 @@ +--- +type: acceptance-criteria +work_item_id: ET-006 +title: "AC: Загрузка и визуализация GPX-треков" +version: 2 +status: approved +created_at: 2026-05-22 +updated_at: 2026-05-22 +authors: + - "agent:analyst" +--- + +# Acceptance Criteria — ET-006: Загрузка и визуализация GPX-треков + +## AC-01: Загрузка файла через кнопку + +```gherkin +Feature: Загрузка GPX-файла + + Scenario: Успешная загрузка одного файла + Given пользователь находится на карте + When пользователь нажимает кнопку «Загрузить GPX» в правой панели + And выбирает валидный GPX-файл размером < 50 МБ + Then файл парсится без ошибок + And трек отображается на карте цветной линией + And карта выполняет fit bounds по треку + And в панели GPX-треков появляется запись с именем файла + + Scenario: Файл превышает лимит + Given пользователь находится на карте + When пользователь выбирает GPX-файл размером > 50 МБ + Then показывается toast «Файл слишком большой (макс. 50 МБ)» + And трек не загружается + + Scenario: Невалидный файл + Given пользователь находится на карте + When пользователь выбирает файл с невалидным XML + Then показывается toast «Не удалось прочитать GPX-файл» + And трек не загружается + + Scenario: Пустой GPX (без треков и waypoints) + Given пользователь находится на карте + When пользователь выбирает GPX-файл без и без + Then показывается toast «GPX-файл не содержит данных» +``` + +## AC-02: Визуализация трека + +```gherkin +Feature: Отрисовка трека на карте + + Scenario: Один трек в файле + Given загружен GPX-файл с одним треком + Then на карте отображается линия трека + And линия имеет цвет из палитры + And толщина линии 4px + + Scenario: Несколько треков в одном файле + Given загружен GPX-файл с 3 треками + Then на карте отображаются 3 линии + And все линии одного цвета (цвет файла) + And в панели — одна запись (имя файла) с 3 треками внутри + + Scenario: Несколько файлов + Given загружены 3 GPX-файла + Then на карте отображаются треки из всех файлов + And каждый файл имеет свой цвет из палитры + And в панели — 3 записи +``` + +## AC-03: Waypoints + +```gherkin +Feature: Отображение waypoints + + Scenario: Waypoints с именами + Given загружен GPX-файл с waypoints, у которых есть + Then на карте отображаются маркеры в позициях waypoints + And рядом с каждым маркером отображается имя + + Scenario: Waypoints без имён + Given загружен GPX-файл с waypoints без + Then на карте отображаются маркеры без подписей + + Scenario: Файл без waypoints + Given загружен GPX-файл без + Then маркеры waypoints не отображаются + And трек отображается нормально +``` + +## AC-04: Fit bounds + +```gherkin +Feature: Автоцентрирование карты + + Scenario: Карта центрируется на загруженном треке + Given карта показывает произвольную область + When пользователь загружает GPX-файл + Then карта выполняет fit bounds по всем точкам файла + And padding составляет 50px со всех сторон + + Scenario: Загрузка второго файла + Given на карте уже есть загруженный трек + When пользователь загружает второй GPX-файл + Then карта выполняет fit bounds только по второму файлу +``` + +## AC-05: Удаление трека + +```gherkin +Feature: Удаление загруженного трека + + Scenario: Удаление трека из панели + Given загружены 2 GPX-файла + When пользователь нажимает кнопку удаления (✕) у первого трека + Then первый трек исчезает с карты (линия + waypoints) + And первый трек исчезает из панели + And второй трек остаётся на карте + + Scenario: Удаление активного трека + Given загружены 2 GPX-файла, второй — активный (показана статистика) + When пользователь удаляет второй трек + Then профиль высот и статистика скрываются + And первый трек остаётся на карте + And первый трек не становится автоматически активным + + Scenario: Удаление последнего трека + Given загружен 1 GPX-файл + When пользователь удаляет его + Then карта пуста (нет GPX-слоёв) + And панель GPX показывает пустое состояние +``` + +## AC-06: Панель управления (sheet-gpx) + +```gherkin +Feature: Панель GPX-треков + + Scenario: Открытие панели при загрузке + Given панель GPX закрыта + When пользователь загружает первый GPX-файл + Then панель GPX открывается автоматически + + Scenario: Переключение через toolbar + Given панель GPX закрыта + When пользователь нажимает кнопку «GPX» в нижнем тулбаре + Then панель GPX открывается + + Scenario: Выбор активного трека + Given загружены 3 GPX-файла + When пользователь тапает на второй трек в списке + Then второй трек выделяется визуально + And показывается его статистика и профиль высот +``` + +## AC-07: Профиль высот + +```gherkin +Feature: Профиль высот + + Scenario: Трек с данными высот + Given выбран активный трек с данными + Then в панели отображается график профиля высот + And ось X — расстояние (км) + And ось Y — высота (м) + And линия графика — цвет трека + + Scenario: Трек без данных высот + Given выбран активный трек без данных + Then вместо графика отображается текст «Данные высот отсутствуют» + + Scenario: Интерактивность профиля + Given отображается профиль высот + When пользователь наводит курсор (или тапает) на точку графика + Then показывается tooltip с высотой и расстоянием + And на карте подсвечивается соответствующая точка трека +``` + +## AC-08: Статистика трека + +```gherkin +Feature: Статистика трека + + Scenario: Полная статистика (с высотами) + Given выбран активный трек с данными + Then отображаются: длина (км), набор высоты (м), сброс высоты (м), мин. высота (м), макс. высота (м) + + Scenario: Частичная статистика (без высот) + Given выбран активный трек без данных + Then отображается только длина (км) + And остальные поля показывают «—» +``` + +## AC-09: Интерактивность на карте + +```gherkin +Feature: Клик по треку на карте + + Scenario: Выбор трека кликом + Given на карте отображаются 3 трека из разных файлов + When пользователь кликает на линию второго трека + Then второй трек становится активным в панели + And показывается его статистика и профиль высот +``` + +## AC-10: Совместимость с другими режимами + +```gherkin +Feature: Параллельная работа с роутингом + + Scenario: GPX + активный маршрут + Given пользователь построил маршрут через OSRM + And загрузил GPX-файл + Then оба отображаются на карте одновременно + And маршрут OSRM визуально выше GPX-трека + And оба интерактивны + + Scenario: GPX + режим разведки + Given пользователь в режиме разведки + When загружает GPX-файл + Then трек отображается на карте + And режим разведки продолжает работать +``` + +## AC-12: Сохранение при переключении стиля карты + +```gherkin +Feature: Сохранение GPX-треков при смене стиля карты + + Scenario: Переключение тёмной темы + Given загружен GPX-трек и отображается на карте + When пользователь переключает тёмную тему + Then трек остаётся на карте после смены стиля + And waypoints остаются на карте + And активный трек, его статистика и профиль высот сохраняются + + Scenario: Переключение слоёв рельефа + Given загружены 2 GPX-трека + When пользователь включает или выключает слой рельефа (hillshade / TRI) + Then оба трека остаются на карте с прежними цветами + And z-order GPX-слоёв сохраняется (ниже маршрута OSRM) +``` + +## AC-11: Индикатор загрузки + +```gherkin +Feature: Индикатор при парсинге + + Scenario: Большой файл + Given пользователь выбирает GPX-файл > 10 МБ + Then показывается индикатор загрузки (spinner) + And после завершения парсинга индикатор скрывается + And трек отображается на карте +``` diff --git a/docs/work-items/ET-006/04-test-plan.yaml b/docs/work-items/ET-006/04-test-plan.yaml new file mode 100644 index 0000000..b2d73c7 --- /dev/null +++ b/docs/work-items/ET-006/04-test-plan.yaml @@ -0,0 +1,254 @@ +--- +type: test-plan +work_item_id: ET-006 +title: "Test Plan: Загрузка и визуализация GPX-треков" +version: 2 +status: approved +created_at: 2026-05-22 +updated_at: 2026-05-22 +authors: + - "agent:analyst" + +test_suites: + + - 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: U-03 + name: "Парсинг waypoints" + input: "GPX-файл с 5 wpt (lat, lon, name, ele)" + expected: "Возвращает массив из 5 waypoints с именами и координатами" + + - id: U-04 + name: "Парсинг route (rte)" + input: "GPX-файл с 1 rte, 20 rtept" + expected: "Возвращает как трек с 20 точками" + + - id: U-05 + name: "GPX без данных высот" + input: "GPX-файл с trkpt без " + expected: "Точки имеют ele=null, stats.elevGain=null" + + - id: U-06 + name: "Невалидный XML" + input: "Файл с битым XML" + expected: "Выбрасывает ошибку с сообщением" + + - id: U-07 + name: "Пустой GPX (нет trk, wpt, rte)" + input: "Валидный XML, но без данных" + expected: "Выбрасывает ошибку 'no data'" + + - id: U-08 + name: "GPX с namespace и без" + input: "GPX без xmlns атрибута" + expected: "Парсится корректно (fallback без namespace)" + + - 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: U-12 + name: "Фильтрация шума высот (дельта < 2м)" + input: "Точки с ele: [100, 101, 100, 101, 150]" + expected: "elevGain = 50 м (только 100→150), мелкие колебания игнорируются" + + - id: U-13 + name: "Мин/макс высота" + input: "Точки с ele: [100, 250, 80, 300, 150]" + expected: "eleMin=80, eleMax=300" + + - id: U-14 + name: "Статистика без данных высот" + input: "Точки без ele" + expected: "distanceKm рассчитан, elevGain/elevLoss/eleMin/eleMax = null" + + - 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)" + + - 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: I-03 + name: "Fit bounds после загрузки" + input: "Загрузка GPX с bbox [37.5, 55.6, 37.9, 55.9]" + expected: "map.getBounds() содержит указанный bbox" + + - 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 с вместо " diff --git a/docs/work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md b/docs/work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md new file mode 100644 index 0000000..c97371c --- /dev/null +++ b/docs/work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md @@ -0,0 +1,143 @@ +--- +type: adr +work_item_id: ET-006 +adr_id: ADR-002 +title: "GPX-фича как отдельный модуль gpx.js" +status: accepted +date: 2026-05-22 +authors: + - "agent:architect" +supersedes: null +superseded_by: null +--- + +# ADR-002: GPX-фича как отдельный модуль `gpx.js` + +## Контекст + +ET-006 добавляет самодостаточную фичу: парсинг GPX, внутренняя модель, +управление source/layer/маркерами карты, bottom sheet `sheet-gpx`, +canvas-профиль высот, расчёт статистики. Оценка объёма — ~600–900 строк JS. + +ТЗ (`02-trz.md` §6) оставляет структуру файлов открытой и предлагает два +варианта: дописать в `app.js` либо вынести в отдельный `gpx.js`. ТЗ ссылается +на `units.js` как на прецедент. + +Фактическое состояние кодовой базы (проверено): + +- `src/web/units.js` **не существует**. `app.js` — единственный JS-файл + фронтенда: 113 КБ, ~2900 строк. +- `app.js` подключён как **классический скрипт** (`` после строки 400 + (` + + diff --git a/tests/unit/gpx.test.js b/tests/unit/gpx.test.js new file mode 100644 index 0000000..7ca7df3 --- /dev/null +++ b/tests/unit/gpx.test.js @@ -0,0 +1,566 @@ +'use strict'; + +/** + * ET-006 — поведенческие unit-тесты модуля загрузки GPX-треков. + * + * Покрывают группы unit-кейсов из docs/work-items/ET-006/04-test-plan.yaml: + * - unit-gpx-parser (U-01..U-08) + * - unit-gpx-stats (U-10..U-14) + * - unit-gpx-colors (U-20, U-21) + * плюс чистые функции построения GeoJSON и bbox, плюс регрессии по + * замечаниям код-ревью ET-006: P1-1 (большие треки не валят расчёт + * статистики), P2-1 (агрегация статистики и профиля по всем трекам + * файла), P2-2 (чанковый расчёт статистики — trackStatsChunked). + * + * Тесты исполняют РЕАЛЬНЫЙ модуль src/web/gpx.js. Браузерный примитив + * `DOMParser` (ADR-003) в Node отсутствует, поэтому подставляется + * компактный мок-парсер XML (`MockDOMParser`) — он генерирует DOM-lite + * узлы с тем подмножеством DOM API, которое использует gpx.js + * (`getElementsByTagName`, `getAttribute`, `textContent`). Это позволяет + * проверить настоящую GPX-семантику конвертации, не таща в проект jsdom. + * + * Две поправки к числовым ПРИМЕРАМ в 04-test-plan.yaml (сам ТЗ §5 + * корректен, расходятся лишь оценочные числа аналитика в примерах): + * - U-10: для точек 0.1°×0.1° каноническая формула Haversine (та же, + * что в app.js) даёт ≈25.5 км, а не 28.3 км. Тест проверяет 25.5. + * - U-11: для ele [100,150,120,200,180] сброс высоты = 30+20 = 50 м + * (как и записано в самой расшифровке кейса), а не 70 м. + * + * Запуск: `node --test tests/unit/gpx.test.js` + * (в CI оборачивается pytest-тестом tests/unit/test_gpx_upload.py). + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const GPX_PATH = path.join(__dirname, '..', '..', 'src', 'web', 'gpx.js'); + +// ─── Мини-XML-парсер: DOM-lite для подмены браузерного DOMParser ──────────── + +/** Декодирует базовые XML-сущности. */ +function decodeEntities(s) { + return String(s).replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, ent) => { + if (ent[0] === '#') { + const hex = ent[1] === 'x' || ent[1] === 'X'; + const code = hex ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10); + return isNaN(code) ? m : String.fromCodePoint(code); + } + const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; + return ent in named ? named[ent] : m; + }); +} + +/** Локальное имя тега (без префикса namespace). */ +function localName(tag) { + const i = tag.indexOf(':'); + return i === -1 ? tag : tag.slice(i + 1); +} + +/** DOM-lite элемент. */ +class El { + constructor(tagName) { + this.tagName = tagName; + this.nodeName = tagName; + this._attrs = {}; + this.childNodes = []; + } + + getAttribute(name) { + return name in this._attrs ? this._attrs[name] : null; + } + + getElementsByTagName(name) { + const out = []; + const walk = (node) => { + node.childNodes.forEach((c) => { + if (c instanceof El) { + if (c.tagName === name || localName(c.tagName) === name) out.push(c); + walk(c); + } + }); + }; + walk(this); + return out; + } + + get textContent() { + return this.childNodes + .map((c) => (c instanceof El ? c.textContent : c.text)) + .join(''); + } +} + +/** DOM-lite документ. */ +class Doc { + constructor(root) { + this.documentElement = root; + } + + getElementsByTagName(name) { + const root = this.documentElement; + if (!root) return []; + const out = []; + if (root.tagName === name || localName(root.tagName) === name) out.push(root); + return out.concat(root.getElementsByTagName(name)); + } +} + +/** Разбирает строку XML в DOM-lite дерево. Бросает Error на невалидном XML. */ +function parseXml(input) { + let s = String(input).replace(/^/, ''); + s = s.replace(/<\?[\s\S]*?\?>/g, ''); + s = s.replace(//g, ''); + s = s.replace(//gi, ''); + s = s.replace(//g, + (m, c) => c.replace(/&/g, '&').replace(/ i) { + const text = s.slice(i, lt); + if (stack.length && text.trim() !== '') { + stack[stack.length - 1].childNodes.push({ text: decodeEntities(text) }); + } + } + const gt = s.indexOf('>', lt); + if (gt === -1) throw new Error('malformed: no closing >'); + let tag = s.slice(lt + 1, gt).trim(); + i = gt + 1; + + if (tag[0] === '/') { + const cname = tag.slice(1).trim(); + if (!stack.length || stack[stack.length - 1].tagName !== cname) { + throw new Error('malformed: unbalanced tag ' + cname); + } + stack.pop(); + continue; + } + + let selfClose = false; + if (tag[tag.length - 1] === '/') { + selfClose = true; + tag = tag.slice(0, -1).trim(); + } + const m = tag.match(/^(\S+)([\s\S]*)$/); + if (!m) throw new Error('malformed: empty tag'); + const el = new El(m[1]); + const attrRe = /([^\s=]+)\s*=\s*"([^"]*)"|([^\s=]+)\s*=\s*'([^']*)'/g; + let am; + while ((am = attrRe.exec(m[2]))) { + if (am[1] !== undefined) el._attrs[am[1]] = decodeEntities(am[2]); + else el._attrs[am[3]] = decodeEntities(am[4]); + } + if (stack.length) stack[stack.length - 1].childNodes.push(el); + else if (!root) root = el; + else throw new Error('malformed: multiple roots'); + if (!selfClose) stack.push(el); + } + + if (stack.length) throw new Error('malformed: unclosed tag'); + if (!root) throw new Error('malformed: no root element'); + return new Doc(root); +} + +/** + * Мок браузерного DOMParser. Как и настоящий — не бросает исключение, + * а на невалидном XML возвращает документ с корнем ``. + */ +class MockDOMParser { + parseFromString(str) { + try { + return parseXml(str); + } catch (e) { + const err = new El('parsererror'); + err.childNodes.push({ text: String(e.message) }); + return new Doc(err); + } + } +} + +// ─── Загрузка модуля под тестом ──────────────────────────────────────────── + +global.DOMParser = MockDOMParser; +delete require.cache[require.resolve(GPX_PATH)]; +const Gpx = require(GPX_PATH); + +// ─── Генераторы тестовых GPX ─────────────────────────────────────────────── + +const NS = 'http://www.topografix.com/GPX/1/1'; + +/** Собирает GPX 1.1 с одним треком из списка точек {lat, lon, ele?, time?}. */ +function gpxWithTrack(points, { name = 'Тест', xmlns = NS } = {}) { + const pts = points.map((p) => { + const ele = p.ele !== undefined ? `${p.ele}` : ''; + const time = p.time !== undefined ? `` : ''; + return `${ele}${time}`; + }).join(''); + const ns = xmlns ? ` xmlns="${xmlns}"` : ''; + return ` +${name}${pts}`; +} + +/** 10 точек с ele и time — тест U-01. */ +function tenPoints() { + const pts = []; + for (let i = 0; i < 10; i++) { + pts.push({ + lat: 55.70 + i * 0.001, + lon: 37.60 + i * 0.001, + ele: 150 + i * 5, + time: `2026-01-01T08:0${i}:00Z`, + }); + } + return pts; +} + +// ─── unit-gpx-parser : U-01..U-08 ────────────────────────────────────────── + +test('U-01: парсинг валидного GPX 1.1 с одним треком (10 точек)', () => { + const model = Gpx.parseGpxText(gpxWithTrack(tenPoints())); + assert.equal(model.tracks.length, 1); + assert.equal(model.tracks[0].points.length, 10); + // [lon, lat, ele, time] + const first = model.tracks[0].points[0]; + assert.equal(first[0], 37.60); + assert.equal(first[1], 55.70); + assert.equal(first[2], 150); + assert.equal(first[3], '2026-01-01T08:00:00Z'); + assert.equal(model.tracks[0].points[9][2], 195); +}); + +test('U-02: парсинг GPX с несколькими треками', () => { + const trk = (n) => `T${n}` + + `` + + ''; + const xml = `` + + trk(1) + trk(2) + trk(3) + ''; + const model = Gpx.parseGpxText(xml); + assert.equal(model.tracks.length, 3); + assert.deepEqual(model.tracks.map((t) => t.name), ['T1', 'T2', 'T3']); +}); + +test('U-03: парсинг waypoints (5 шт. с именами и координатами)', () => { + let wpts = ''; + for (let i = 0; i < 5; i++) { + wpts += `` + + `Точка ${i}${100 + i}`; + } + const xml = `${wpts}`; + const model = Gpx.parseGpxText(xml); + assert.equal(model.waypoints.length, 5); + assert.equal(model.waypoints[0].name, 'Точка 0'); + assert.equal(model.waypoints[4].name, 'Точка 4'); + assert.equal(model.waypoints[2].ele, 102); + assert.equal(model.waypoints[0].lon, 37.6); +}); + +test('U-04: парсинг route (rte) — трактуется как трек', () => { + let rtepts = ''; + for (let i = 0; i < 20; i++) { + rtepts += ``; + } + const xml = `` + + `Маршрут A${rtepts}`; + const model = Gpx.parseGpxText(xml); + assert.equal(model.tracks.length, 1); + assert.equal(model.tracks[0].points.length, 20); + assert.equal(model.tracks[0].name, 'Маршрут A'); +}); + +test('U-05: GPX без данных высот — ele=null, stats.elevGain=null', () => { + const pts = [ + { lat: 55.70, lon: 37.60 }, + { lat: 55.71, lon: 37.61 }, + { lat: 55.72, lon: 37.62 }, + ]; + const model = Gpx.parseGpxText(gpxWithTrack(pts)); + const track = model.tracks[0]; + assert.equal(track.points[0][2], null); + assert.equal(track.stats.elevGain, null); + assert.equal(track.stats.elevLoss, null); + assert.equal(track.stats.eleMin, null); + assert.equal(track.stats.eleMax, null); + assert.ok(track.stats.distanceKm > 0, 'длина считается и без высот'); +}); + +test('U-06: невалидный XML — parseGpxText бросает PARSE_ERROR', () => { + assert.throws( + () => Gpx.parseGpxText(''), + /PARSE_ERROR/, + ); + assert.throws( + () => Gpx.parseGpxText('это просто текст, а не XML'), + /PARSE_ERROR/, + ); +}); + +test('U-07: пустой GPX (нет trk/wpt/rte) — бросает EMPTY', () => { + const xml = ``; + assert.throws(() => Gpx.parseGpxText(xml), /EMPTY/); +}); + +test('U-08: GPX без xmlns парсится корректно (fallback без namespace)', () => { + const model = Gpx.parseGpxText(gpxWithTrack(tenPoints(), { xmlns: null })); + assert.equal(model.tracks.length, 1); + assert.equal(model.tracks[0].points.length, 10); +}); + +test('parseGpxAsync даёт тот же результат, что и синхронный парсер', async () => { + const xml = gpxWithTrack(tenPoints()); + const sync = Gpx.parseGpxText(xml); + const async = await Gpx.parseGpxAsync(xml); + assert.deepEqual(async, sync); +}); + +test('parseGpxAsync отклоняется с EMPTY на пустом GPX', async () => { + const xml = ``; + await assert.rejects(Gpx.parseGpxAsync(xml), /EMPTY/); +}); + +test('extractGpxModel: трек и waypoints из одного файла', () => { + const xml = `` + + 'Tr' + + '' + + 'Кафе'; + const doc = new MockDOMParser().parseFromString(xml); + const model = Gpx.extractGpxModel(doc); + assert.equal(model.tracks.length, 1); + assert.equal(model.waypoints.length, 1); + assert.equal(model.waypoints[0].name, 'Кафе'); +}); + +// ─── unit-gpx-stats : U-10..U-14 ─────────────────────────────────────────── + +test('U-10: длина трека по Haversine (каноническая формула проекта)', () => { + const points = [ + [37.6, 55.7], [37.7, 55.8], [37.8, 55.9], + ]; + const stats = Gpx.trackStats(points); + // Каноническая Haversine (как в app.js haversineKm) для шага 0.1°×0.1° + // даёт ≈25.5 км. Значение «28.3 км» в 04-test-plan.yaml — неточная + // оценка аналитика; реализация следует ТЗ §5.1 (формула Haversine). + assert.ok( + Math.abs(stats.distanceKm - 25.5) < 0.5, + `ожидали ≈25.5 км, получили ${stats.distanceKm}`, + ); +}); + +test('U-11: набор и сброс высоты по дельтам ele', () => { + const points = [ + [37.6, 55.7, 100], [37.6, 55.71, 150], [37.6, 55.72, 120], + [37.6, 55.73, 200], [37.6, 55.74, 180], + ]; + const stats = Gpx.trackStats(points); + // Дельты: +50, -30, +80, -20 → набор 130, сброс 50. + assert.equal(stats.elevGain, 130); + assert.equal(stats.elevLoss, 50); +}); + +test('U-12: фильтрация шума высот — дельты < 2 м игнорируются', () => { + const points = [ + [37.6, 55.70, 100], [37.6, 55.71, 101], [37.6, 55.72, 100], + [37.6, 55.73, 101], [37.6, 55.74, 150], + ]; + const stats = Gpx.trackStats(points); + // Колебания ±1 м не сдвигают опорную высоту → набор = 100→150 = 50 м. + assert.equal(stats.elevGain, 50); + assert.equal(stats.elevLoss, 0); +}); + +test('U-13: минимальная и максимальная высота', () => { + const points = [ + [37.6, 55.70, 100], [37.6, 55.71, 250], [37.6, 55.72, 80], + [37.6, 55.73, 300], [37.6, 55.74, 150], + ]; + const stats = Gpx.trackStats(points); + assert.equal(stats.eleMin, 80); + assert.equal(stats.eleMax, 300); +}); + +test('U-14: статистика без данных высот — длина есть, высоты null', () => { + const points = [ + [37.6, 55.70], [37.6, 55.71], [37.6, 55.72], + ]; + const stats = Gpx.trackStats(points); + assert.ok(stats.distanceKm > 0); + assert.equal(stats.elevGain, null); + assert.equal(stats.elevLoss, null); + assert.equal(stats.eleMin, null); + assert.equal(stats.eleMax, null); +}); + +test('trackStats: пустой трек — нулевая длина без падения', () => { + const stats = Gpx.trackStats([]); + assert.equal(stats.distanceKm, 0); + assert.equal(stats.elevGain, null); +}); + +test('P1-1: trackStats не падает на треке с сотнями тысяч точек высот', () => { + // Регрессия ревью P1-1: Math.min/max.apply на массиве такого размера + // бросал RangeError: Maximum call stack size exceeded → файл не + // загружался (нарушение REQ-NF-01). Однопроходный обход — без apply. + const points = []; + for (let i = 0; i < 500000; i++) { + points.push([37.6 + i * 1e-6, 55.7 + i * 1e-6, 100 + (i % 50)]); + } + let stats; + assert.doesNotThrow(() => { stats = Gpx.trackStats(points); }); + assert.equal(stats.eleMin, 100); + assert.equal(stats.eleMax, 149); + assert.ok(stats.distanceKm > 0, 'длина считается на большом треке'); +}); + +test('trackStatsChunked даёт тот же результат, что и синхронный trackStats', async () => { + const points = [ + [37.6, 55.70, 100], [37.6, 55.71, 150], [37.6, 55.72, 120], + [37.6, 55.73, 200], [37.6, 55.74, 180], + ]; + const chunked = await Gpx.trackStatsChunked(points); + assert.deepEqual(chunked, Gpx.trackStats(points)); +}); + +test('trackStatsChunked: расчёт верен на треке длиннее размера чанка', async () => { + // > CHUNK_SIZE (8000) точек — статистика проходит через несколько чанков. + const points = []; + for (let i = 0; i < 20000; i++) { + points.push([37.6 + i * 1e-5, 55.7, 100 + (i % 30)]); + } + const chunked = await Gpx.trackStatsChunked(points); + assert.deepEqual(chunked, Gpx.trackStats(points)); +}); + +// ─── unit-gpx-colors : U-20, U-21 ────────────────────────────────────────── + +test('U-20: первый файл получает первый цвет палитры', () => { + assert.equal(Gpx.colorForIndex(0), '#e6194b'); +}); + +test('U-21: девятый файл получает первый цвет (цикл 8 % 8 = 0)', () => { + assert.equal(Gpx.colorForIndex(8), '#e6194b'); + assert.equal(Gpx.colorForIndex(8), Gpx.colorForIndex(0)); +}); + +test('палитра содержит ровно 8 цветов и отличается от цветов роутинга', () => { + assert.equal(Gpx.PALETTE.length, 8); + // Цвета роутинга из app.js — не должны пересекаться (TRZ REQ-F-04). + const routeColors = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888']; + Gpx.PALETTE.forEach((c) => { + assert.ok(!routeColors.includes(c), `${c} совпадает с цветом роутинга`); + }); +}); + +test('colorForIndex циклически проходит всю палитру', () => { + for (let i = 0; i < 24; i++) { + assert.equal(Gpx.colorForIndex(i), Gpx.PALETTE[i % 8]); + } +}); + +// ─── Чистые функции: GeoJSON и bbox ──────────────────────────────────────── + +test('tracksToGeoJSON: трек → LineString-фича с [lon,lat]-координатами', () => { + const tracks = [{ points: [[37.6, 55.7, 100], [37.7, 55.8, 110]] }]; + const fc = Gpx.tracksToGeoJSON(tracks); + assert.equal(fc.type, 'FeatureCollection'); + assert.equal(fc.features.length, 1); + assert.equal(fc.features[0].geometry.type, 'LineString'); + assert.deepEqual(fc.features[0].geometry.coordinates, [[37.6, 55.7], [37.7, 55.8]]); +}); + +test('waypointsToGeoJSON: waypoint → Point-фича с именем в properties', () => { + const fc = Gpx.waypointsToGeoJSON([{ lon: 37.6, lat: 55.7, name: 'Брод' }]); + assert.equal(fc.features.length, 1); + assert.equal(fc.features[0].geometry.type, 'Point'); + assert.deepEqual(fc.features[0].geometry.coordinates, [37.6, 55.7]); + assert.equal(fc.features[0].properties.name, 'Брод'); +}); + +test('fileBounds: bbox охватывает все точки треков и waypoints', () => { + const file = { + tracks: [{ points: [[37.5, 55.6], [37.9, 55.9]] }], + waypoints: [{ lon: 37.4, lat: 56.0 }], + }; + const b = Gpx.fileBounds(file); + assert.deepEqual(b, [[37.4, 55.6], [37.9, 56.0]]); +}); + +test('fileBounds: файл без точек → null', () => { + assert.equal(Gpx.fileBounds({ tracks: [], waypoints: [] }), null); +}); + +// ─── Агрегация по файлу: aggregateStats / buildFileProfileSamples (P2-1) ──── + +test('P2-1: aggregateStats суммирует статистику всех треков файла', () => { + // Ревью P2-1: панель показывает один файл, но файл может содержать + // несколько — статистика должна охватывать их все, не только [0]. + const tracks = [ + { stats: { distanceKm: 10, elevGain: 100, elevLoss: 50, eleMin: 120, eleMax: 300 } }, + { stats: { distanceKm: 5, elevGain: 40, elevLoss: 20, eleMin: 90, eleMax: 250 } }, + ]; + const agg = Gpx.aggregateStats(tracks); + assert.equal(agg.distanceKm, 15); + assert.equal(agg.elevGain, 140); + assert.equal(agg.elevLoss, 70); + assert.equal(agg.eleMin, 90); + assert.equal(agg.eleMax, 300); +}); + +test('P2-1: aggregateStats — трек без высот не ломает агрегацию', () => { + const tracks = [ + { stats: { distanceKm: 10, elevGain: 100, elevLoss: 50, eleMin: 120, eleMax: 300 } }, + { stats: { distanceKm: 5, elevGain: null, elevLoss: null, eleMin: null, eleMax: null } }, + ]; + const agg = Gpx.aggregateStats(tracks); + assert.equal(agg.distanceKm, 15); + assert.equal(agg.elevGain, 100); + assert.equal(agg.elevLoss, 50); + assert.equal(agg.eleMin, 120); + assert.equal(agg.eleMax, 300); +}); + +test('P2-1: aggregateStats — все треки без высот → поля высот null', () => { + const tracks = [ + { stats: { distanceKm: 7, elevGain: null, elevLoss: null, eleMin: null, eleMax: null } }, + ]; + const agg = Gpx.aggregateStats(tracks); + assert.equal(agg.distanceKm, 7); + assert.equal(agg.elevGain, null); + assert.equal(agg.elevLoss, null); + assert.equal(agg.eleMin, null); + assert.equal(agg.eleMax, null); +}); + +test('P2-1: buildFileProfileSamples объединяет высоты всех треков файла', () => { + const t1 = { points: [[37.60, 55.70, 100], [37.61, 55.70, 200]] }; + const t2 = { points: [[37.70, 55.80, 300], [37.71, 55.80, 400]] }; + t1.stats = Gpx.trackStats(t1.points); + t2.stats = Gpx.trackStats(t2.points); + const samples = Gpx.buildFileProfileSamples({ tracks: [t1, t2] }); + // Все 4 точки с высотой попали в профиль — не только из tracks[0]. + assert.equal(samples.length, 4); + assert.deepEqual(samples.map((s) => s.e), [100, 200, 300, 400]); + // Расстояние — сквозное: второй трек смещён на длину первого. + assert.equal(samples[0].d, 0); + assert.ok(samples[2].d >= t1.stats.distanceKm - 1e-9); + assert.ok(samples[3].d > samples[2].d); +}); + +// ─── Контракт модуля ─────────────────────────────────────────────────────── + +test('модуль публикует window.Gpx и onclick-обработчики', () => { + assert.equal(global.Gpx, Gpx); + assert.equal(typeof global.onGpxFileSelected, 'function'); + assert.equal(typeof global.toggleGpxSheet, 'function'); + assert.equal(typeof global.selectGpxTrack, 'function'); + assert.equal(typeof global.removeGpxTrack, 'function'); + assert.equal(typeof global.rebuildGpxOverlays, 'function'); +}); + +test('MAX_FILE_BYTES равен 50 МБ (TRZ REQ-F-03)', () => { + assert.equal(Gpx.MAX_FILE_BYTES, 50 * 1024 * 1024); +}); diff --git a/tests/unit/test_gpx_upload.py b/tests/unit/test_gpx_upload.py new file mode 100644 index 0000000..7edbbbf --- /dev/null +++ b/tests/unit/test_gpx_upload.py @@ -0,0 +1,241 @@ +"""ET-006 — тесты загрузки и визуализации GPX-треков. + +Изменение ET-006 — фронтендовое: новый модуль `src/web/gpx.js` плюс +правки `src/web/index.html`, `src/web/app.css` и одна строка-хук в +`src/web/app.js` (см. `06-adr/ADR-002`, `ADR-003`). + +В CI исполняется только `pytest tests/`, поэтому файл покрывает фичу +двумя способами (по аналогии с `tests/unit/test_unit_toggle.py`): + +1. Статические проверки структуры `gpx.js`, `index.html`, `app.css`, + `app.js` — выполняются всегда, без внешних зависимостей. +2. Поведенческие JS unit-тесты (группы U-01..U-21 из + `04-test-plan.yaml`) — через встроенный тест-раннер Node + (`node --test`). Если `node` отсутствует — часть помечается `skip`. + +E2E-сценарии (E-01..E-10) требуют Playwright-инфраструктуры, которой в +репозитории нет, — они остаются за этапом тестирования. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from shutil import which + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +INDEX_HTML = REPO_ROOT / "src" / "web" / "index.html" +APP_JS = REPO_ROOT / "src" / "web" / "app.js" +GPX_JS = REPO_ROOT / "src" / "web" / "gpx.js" +APP_CSS = REPO_ROOT / "src" / "web" / "app.css" +JS_TEST = REPO_ROOT / "tests" / "unit" / "gpx.test.js" + + +def _read(path: Path) -> str: + assert path.is_file(), f"не найден {path}" + return path.read_text(encoding="utf-8") + + +# ────────────────────────────────────────────────────────────────────────────── +# Статические проверки gpx.js (ADR-002, ADR-003, 08-data-requirements.md) +# ────────────────────────────────────────────────────────────────────────────── + +def test_gpx_module_exists(): + """ADR-002: GPX-фича вынесена в отдельный модуль src/web/gpx.js.""" + assert GPX_JS.is_file(), "не найден src/web/gpx.js" + + +def test_gpx_module_public_api(): + """ADR-002: модуль определяет публичный контракт парсинга и расчётов.""" + js = _read(GPX_JS) + for fn in ( + "parseGpxText", "parseGpxAsync", "extractGpxModel", + "trackStats", "colorForIndex", "haversineKm", + "tracksToGeoJSON", "waypointsToGeoJSON", "fileBounds", + ): + assert f"function {fn}(" in js, f"в gpx.js не определена функция {fn}()" + + +def test_gpx_module_exports_for_browser_and_node(): + """ADR-002: window.Gpx для браузера и module.exports для unit-тестов.""" + js = _read(GPX_JS) + assert "global.Gpx = Gpx" in js, "gpx.js не публикует глобальный неймспейс Gpx" + assert "module.exports" in js, "gpx.js не экспортируется для Node unit-тестов" + + +def test_gpx_module_publishes_onclick_handlers(): + """ADR-002: модуль публикует обработчики inline-onclick и хук REQ-F-13.""" + js = _read(GPX_JS) + for fn in ( + "onGpxFileSelected", "toggleGpxSheet", + "selectGpxTrack", "removeGpxTrack", "rebuildGpxOverlays", + ): + assert f"global.{fn} =" in js, f"gpx.js не публикует глобаль {fn}" + + +def test_gpx_palette_eight_colors(): + """TRZ REQ-F-04 §5.3: палитра треков — ровно 8 цветов.""" + js = _read(GPX_JS) + for color in ( + "#e6194b", "#3cb44b", "#ffe119", "#4363d8", + "#f58231", "#911eb4", "#42d4f4", "#f032e6", + ): + assert color in js, f"в палитре GPX нет цвета {color}" + + +def test_gpx_file_size_limit(): + """TRZ REQ-F-03: лимит размера GPX-файла — 50 МБ.""" + js = _read(GPX_JS) + assert "50 * 1024 * 1024" in js, "не задан лимит размера файла 50 МБ" + + +def test_gpx_noise_filter_constant(): + """TRZ §5.2: дельты высот < 2 м фильтруются как GPS-шум.""" + js = _read(GPX_JS) + assert "ELE_NOISE_M" in js, "нет константы фильтрации шума высот" + + +def test_gpx_uses_domparser_main_thread(): + """ADR-003: парсинг XML — через DOMParser в основном потоке.""" + js = _read(GPX_JS) + assert "new DOMParser()" in js, "gpx.js не использует DOMParser (ADR-003)" + assert "Worker" not in js, "ADR-003: Web Worker не используется" + + +def test_gpx_chunked_conversion(): + """ADR-003: конвертация DOM → модель выполняется чанками.""" + js = _read(GPX_JS) + assert "CHUNK_SIZE" in js, "нет чанковой конвертации (ADR-003)" + + +# ────────────────────────────────────────────────────────────────────────────── +# Статические проверки index.html (TRZ §3, REQ-F-01, REQ-F-09) +# ────────────────────────────────────────────────────────────────────────────── + +def test_gpx_upload_button_present(): + """TRZ REQ-F-01 §3.1: кнопка загрузки GPX в правой панели карты.""" + html = _read(INDEX_HTML) + assert 'id="btn-gpx-upload"' in html, "нет кнопки загрузки GPX btn-gpx-upload" + assert 'id="gpx-file-input"' in html, "нет input[type=file] для GPX" + assert 'accept=".gpx"' in html, "input не ограничен расширением .gpx" + assert "multiple" in html, "TRZ REQ-F-01: допускается множественный выбор" + assert 'onchange="onGpxFileSelected(this)"' in html, "input не привязан к обработчику" + + +def test_gpx_upload_button_between_compass_and_locate(): + """TRZ §3.1: кнопка GPX расположена между «Компас» и «Геолокация».""" + html = _read(INDEX_HTML) + compass = html.index('id="btn-compass"') + gpx = html.index('id="btn-gpx-upload"') + locate = html.index('onclick="locateMe()"') + assert compass < gpx < locate, "кнопка GPX должна быть между компасом и геолокацией" + + +def test_gpx_toolbar_button_present(): + """TRZ §3.2: кнопка «GPX» в нижнем тулбаре переключает sheet.""" + html = _read(INDEX_HTML) + assert 'id="tb-gpx"' in html, "нет кнопки GPX в тулбаре" + assert 'onclick="toggleGpxSheet()"' in html, "кнопка тулбара не вызывает toggleGpxSheet" + + +def test_gpx_sheet_present(): + """TRZ REQ-F-09 §3.3: bottom sheet #sheet-gpx со списком и деталями.""" + html = _read(INDEX_HTML) + assert 'id="sheet-gpx"' in html, "нет панели #sheet-gpx" + for el in ("gpx-list", "gpx-stats", "gpx-elevation-canvas", "gpx-empty"): + assert f'id="{el}"' in html, f"в #sheet-gpx нет элемента {el}" + + +def test_gpx_sheet_uses_bottom_sheet_component(): + """TRZ REQ-F-09: панель GPX переиспользует компонент .bottom-sheet.""" + html = _read(INDEX_HTML) + start = html.index('id="sheet-gpx"') + container_start = html.rfind("", container_start) + assert "bottom-sheet" in html[container_start:container_open_end], ( + "панель GPX должна использовать класс bottom-sheet" + ) + + +def test_gpx_toast_and_loading_present(): + """TRZ §3.4, REQ-NF-01: toast-уведомления и индикатор парсинга.""" + html = _read(INDEX_HTML) + assert 'id="app-toast"' in html, "нет контейнера toast-уведомлений" + assert 'id="gpx-loading"' in html, "нет индикатора загрузки GPX" + + +def test_gpx_js_loaded_after_app_js(): + """ADR-002: gpx.js подключается строго ПОСЛЕ app.js.""" + html = _read(INDEX_HTML) + gpx_pos = html.find('src="gpx.js"') + app_pos = html.find('src="app.js"') + assert gpx_pos != -1, "gpx.js не подключён в index.html" + assert app_pos != -1, "app.js не подключён в index.html" + assert gpx_pos > app_pos, "gpx.js должен подключаться ПОСЛЕ app.js (ADR-002)" + + +# ────────────────────────────────────────────────────────────────────────────── +# Статические проверки app.js (ADR-002 — контракт интеграции, REQ-F-13) +# ────────────────────────────────────────────────────────────────────────────── + +def test_app_js_has_rebuild_gpx_hook(): + """ADR-002 / REQ-F-13: rebuildMapOverlays() восстанавливает GPX-слои.""" + js = _read(APP_JS) + assert "rebuildGpxOverlays" in js, "в app.js нет хука rebuildGpxOverlays" + # Хук защищён typeof — app.js остаётся валидным и без gpx.js (ADR-002). + assert "typeof rebuildGpxOverlays === 'function'" in js, ( + "хук GPX должен быть защищён проверкой typeof (ADR-002)" + ) + + +def test_app_js_gpx_hook_inside_rebuild_overlays(): + """ADR-002: хук GPX размещён внутри функции rebuildMapOverlays().""" + js = _read(APP_JS) + start = js.index("function rebuildMapOverlays(") + end = js.index("\n}", start) + assert "rebuildGpxOverlays" in js[start:end], ( + "хук rebuildGpxOverlays должен вызываться из rebuildMapOverlays()" + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Статические проверки app.css (TRZ §3.3, §3.4) +# ────────────────────────────────────────────────────────────────────────────── + +def test_gpx_styles_present(): + """TRZ §3: для панели, статистики и профиля заданы стили в app.css.""" + css = _read(APP_CSS) + for selector in ( + "#app-toast", "#gpx-loading", ".gpx-row", + ".gpx-stats-grid", "#gpx-elevation-canvas", + ): + assert selector in css, f"в app.css нет стилей для {selector}" + + +# ────────────────────────────────────────────────────────────────────────────── +# Поведенческие JS unit-тесты через Node (U-01..U-21) +# ────────────────────────────────────────────────────────────────────────────── + +node_required = pytest.mark.skipif( + which("node") is None, + reason="node не установлен — поведенческие JS unit-тесты пропущены", +) + + +@node_required +def test_js_unit_tests_pass(): + """U-01..U-21: запускает behavioral JS-тесты gpx.js через `node --test`.""" + assert JS_TEST.is_file(), f"не найден JS-тест {JS_TEST}" + node = which("node") + result = subprocess.run( + [node, "--test", str(JS_TEST)], + capture_output=True, + text=True, + cwd=str(REPO_ROOT), + ) + assert result.returncode == 0, ( + f"JS unit-тесты GPX упали (код {result.returncode}):\n" + f"{result.stdout}\n{result.stderr}" + )