From 9b930c5c83ed776e26dea2b281d469eb7683051f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Fri, 22 May 2026 03:16:20 +0300 Subject: [PATCH 01/13] =?UTF-8?q?docs(ET-006):=20BRD,=20=D0=A2=D0=97,=20AC?= =?UTF-8?q?,=20Test=20Plan=20=E2=80=94=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20GPX-=D1=82=D1=80=D0=B5=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/work-items/ET-006/00-business-request.md | 28 ++ docs/work-items/ET-006/01-brd.md | 81 +++++ docs/work-items/ET-006/02-trz.md | 278 ++++++++++++++++++ .../ET-006/03-acceptance-criteria.md | 234 +++++++++++++++ docs/work-items/ET-006/04-test-plan.yaml | 248 ++++++++++++++++ 5 files changed, 869 insertions(+) create mode 100644 docs/work-items/ET-006/00-business-request.md create mode 100644 docs/work-items/ET-006/01-brd.md create mode 100644 docs/work-items/ET-006/02-trz.md create mode 100644 docs/work-items/ET-006/03-acceptance-criteria.md create mode 100644 docs/work-items/ET-006/04-test-plan.yaml 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..af9de27 --- /dev/null +++ b/docs/work-items/ET-006/01-brd.md @@ -0,0 +1,81 @@ +--- +type: brd +work_item_id: ET-006 +title: "BRD: Загрузка и визуализация GPX-треков" +version: 1 +status: draft +created_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 МБ | + +### Out of scope + +- Сохранение треков на сервер / в БД +- Редактирование трека (обрезка, склейка) +- Конвертация из других форматов (KML, FIT, TCX) +- Упрощение (simplify) точек трека +- Экспорт загруженного трека обратно в GPX +- Роутинг по загруженному треку (snap to road) + +## 4. Метрики успеха + +| Метрика | Критерий | +|---------|----------| +| Загрузка файла | Файл до 50 МБ загружается и парсится без ошибок за ≤ 3 сек (на среднем устройстве) | +| Визуализация | Трек отображается на карте как цветная линия | +| Waypoints | Маркеры с именами видны на карте | +| Fit bounds | Карта автоматически подстраивает zoom/center под трек | +| Множественные треки | 5+ треков отображаются одновременно, различимы по цвету | +| Удаление | Удалённый трек исчезает с карты и из панели | +| Профиль высот | Отображается корректный график высот для выбранного трека | +| Статистика | Длина, набор/сброс высоты отображаются корректно | +| Не ломает существующий функционал | Роутинг, рельеф, 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..5ffa5c0 --- /dev/null +++ b/docs/work-items/ET-006/02-trz.md @@ -0,0 +1,278 @@ +--- +type: trz +work_item_id: ET-006 +title: "ТЗ: Загрузка и визуализация GPX-треков" +version: 1 +status: draft +created_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. + +## 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, но не деактивирует другие режимы. 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..e986575 --- /dev/null +++ b/docs/work-items/ET-006/03-acceptance-criteria.md @@ -0,0 +1,234 @@ +--- +type: acceptance-criteria +work_item_id: ET-006 +title: "AC: Загрузка и визуализация GPX-треков" +version: 1 +status: draft +created_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-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..ff6c45d --- /dev/null +++ b/docs/work-items/ET-006/04-test-plan.yaml @@ -0,0 +1,248 @@ +--- +type: test-plan +work_item_id: ET-006 +title: "Test Plan: Загрузка и визуализация GPX-треков" +version: 1 +status: draft +created_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" + + - 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 с вместо " From 9fc1ef485a27d2a7184269c10b4413c8532ea9c6 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Fri, 22 May 2026 03:21:27 +0300 Subject: [PATCH 02/13] =?UTF-8?q?docs(ET-006):=20status=20=E2=86=92=20appr?= =?UTF-8?q?oved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/work-items/ET-006/01-brd.md | 2 +- docs/work-items/ET-006/02-trz.md | 2 +- docs/work-items/ET-006/03-acceptance-criteria.md | 2 +- docs/work-items/ET-006/04-test-plan.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/work-items/ET-006/01-brd.md b/docs/work-items/ET-006/01-brd.md index af9de27..78d17be 100644 --- a/docs/work-items/ET-006/01-brd.md +++ b/docs/work-items/ET-006/01-brd.md @@ -3,7 +3,7 @@ type: brd work_item_id: ET-006 title: "BRD: Загрузка и визуализация GPX-треков" version: 1 -status: draft +status: approved created_at: 2026-05-22 authors: - "agent:analyst" diff --git a/docs/work-items/ET-006/02-trz.md b/docs/work-items/ET-006/02-trz.md index 5ffa5c0..2b46cf6 100644 --- a/docs/work-items/ET-006/02-trz.md +++ b/docs/work-items/ET-006/02-trz.md @@ -3,7 +3,7 @@ type: trz work_item_id: ET-006 title: "ТЗ: Загрузка и визуализация GPX-треков" version: 1 -status: draft +status: approved created_at: 2026-05-22 authors: - "agent:analyst" diff --git a/docs/work-items/ET-006/03-acceptance-criteria.md b/docs/work-items/ET-006/03-acceptance-criteria.md index e986575..6b428eb 100644 --- a/docs/work-items/ET-006/03-acceptance-criteria.md +++ b/docs/work-items/ET-006/03-acceptance-criteria.md @@ -3,7 +3,7 @@ type: acceptance-criteria work_item_id: ET-006 title: "AC: Загрузка и визуализация GPX-треков" version: 1 -status: draft +status: approved created_at: 2026-05-22 authors: - "agent:analyst" diff --git a/docs/work-items/ET-006/04-test-plan.yaml b/docs/work-items/ET-006/04-test-plan.yaml index ff6c45d..71a3b84 100644 --- a/docs/work-items/ET-006/04-test-plan.yaml +++ b/docs/work-items/ET-006/04-test-plan.yaml @@ -3,7 +3,7 @@ type: test-plan work_item_id: ET-006 title: "Test Plan: Загрузка и визуализация GPX-треков" version: 1 -status: draft +status: approved created_at: 2026-05-22 authors: - "agent:analyst" From a0546abdd1e7ecda4cee565963fd34c312a72910 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Fri, 22 May 2026 03:33:15 +0300 Subject: [PATCH 03/13] =?UTF-8?q?docs(ET-006):=20TRZ=20v2=20=E2=80=94=20pe?= =?UTF-8?q?rsist=20GPX=20layers=20across=20map=20style=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/work-items/ET-006/01-brd.md | 5 ++++- docs/work-items/ET-006/02-trz.md | 13 ++++++++++- .../ET-006/03-acceptance-criteria.md | 22 ++++++++++++++++++- docs/work-items/ET-006/04-test-plan.yaml | 8 ++++++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/work-items/ET-006/01-brd.md b/docs/work-items/ET-006/01-brd.md index 78d17be..41f43c3 100644 --- a/docs/work-items/ET-006/01-brd.md +++ b/docs/work-items/ET-006/01-brd.md @@ -2,9 +2,10 @@ type: brd work_item_id: ET-006 title: "BRD: Загрузка и визуализация GPX-треков" -version: 1 +version: 2 status: approved created_at: 2026-05-22 +updated_at: 2026-05-22 authors: - "agent:analyst" --- @@ -41,6 +42,7 @@ authors: | F-10 | Профиль высот выбранного трека | | F-11 | Статистика трека: длина, набор высоты, сброс высоты, мин/макс высота | | F-12 | Лимит размера файла: 50 МБ | +| F-13 | Сохранение GPX-слоёв при переключении стиля карты (тёмная тема / рельеф) | ### Out of scope @@ -63,6 +65,7 @@ authors: | Удаление | Удалённый трек исчезает с карты и из панели | | Профиль высот | Отображается корректный график высот для выбранного трека | | Статистика | Длина, набор/сброс высоты отображаются корректно | +| Сохранение при смене стиля | GPX-треки остаются на карте после переключения тёмной темы / слоёв рельефа | | Не ломает существующий функционал | Роутинг, рельеф, POI, линейка работают как прежде | ## 5. Риски diff --git a/docs/work-items/ET-006/02-trz.md b/docs/work-items/ET-006/02-trz.md index 2b46cf6..44ba8e1 100644 --- a/docs/work-items/ET-006/02-trz.md +++ b/docs/work-items/ET-006/02-trz.md @@ -2,9 +2,10 @@ type: trz work_item_id: ET-006 title: "ТЗ: Загрузка и визуализация GPX-треков" -version: 1 +version: 2 status: approved created_at: 2026-05-22 +updated_at: 2026-05-22 authors: - "agent:analyst" --- @@ -109,6 +110,15 @@ authors: - При клике на линию трека на карте — этот трек становится активным в панели (показывается статистика + профиль). - Курсор при наведении на трек: 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: Производительность @@ -276,3 +286,4 @@ src/web/ - 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 index 6b428eb..9a729a1 100644 --- a/docs/work-items/ET-006/03-acceptance-criteria.md +++ b/docs/work-items/ET-006/03-acceptance-criteria.md @@ -2,9 +2,10 @@ type: acceptance-criteria work_item_id: ET-006 title: "AC: Загрузка и визуализация GPX-треков" -version: 1 +version: 2 status: approved created_at: 2026-05-22 +updated_at: 2026-05-22 authors: - "agent:analyst" --- @@ -221,6 +222,25 @@ Feature: Параллельная работа с роутингом 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 diff --git a/docs/work-items/ET-006/04-test-plan.yaml b/docs/work-items/ET-006/04-test-plan.yaml index 71a3b84..b2d73c7 100644 --- a/docs/work-items/ET-006/04-test-plan.yaml +++ b/docs/work-items/ET-006/04-test-plan.yaml @@ -2,9 +2,10 @@ type: test-plan work_item_id: ET-006 title: "Test Plan: Загрузка и визуализация GPX-треков" -version: 1 +version: 2 status: approved created_at: 2026-05-22 +updated_at: 2026-05-22 authors: - "agent:analyst" @@ -131,6 +132,11 @@ test_suites: 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: "Профиль высот" From fda40f20fef18d0eb571999b3acc7ecc41799725 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Fri, 22 May 2026 03:38:47 +0300 Subject: [PATCH 04/13] architect(ET-006): ADR-002/003, infra-requirements, data-requirements, tech-risks --- docs/architecture/adr/README.md | 7 +- .../06-adr/ADR-002-gpx-module-structure.md | 143 ++++++++++++++++++ .../06-adr/ADR-003-gpx-parsing-strategy.md | 112 ++++++++++++++ .../ET-006/07-infra-requirements.md | 89 +++++++++++ .../work-items/ET-006/08-data-requirements.md | 102 +++++++++++++ docs/work-items/ET-006/10-tech-risks.md | 122 +++++++++++++++ 6 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 docs/work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md create mode 100644 docs/work-items/ET-006/06-adr/ADR-003-gpx-parsing-strategy.md create mode 100644 docs/work-items/ET-006/07-infra-requirements.md create mode 100644 docs/work-items/ET-006/08-data-requirements.md create mode 100644 docs/work-items/ET-006/10-tech-risks.md 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/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 + (`` после строки 400 + (` + + diff --git a/tests/unit/gpx.test.js b/tests/unit/gpx.test.js new file mode 100644 index 0000000..9a2ac9d --- /dev/null +++ b/tests/unit/gpx.test.js @@ -0,0 +1,472 @@ +'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. + * + * Тесты исполняют РЕАЛЬНЫЙ модуль 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); +}); + +// ─── 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); +}); + +// ─── Контракт модуля ─────────────────────────────────────────────────────── + +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}" + ) From 19354ed90573e18cc74215779e2ac50e8770c656 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Fri, 22 May 2026 01:06:44 +0000 Subject: [PATCH 10/13] reviewer(ET): auto-commit from reviewer run_id=16 --- docs/work-items/ET-006/12-review.md | 221 ++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/work-items/ET-006/12-review.md diff --git a/docs/work-items/ET-006/12-review.md b/docs/work-items/ET-006/12-review.md new file mode 100644 index 0000000..4265723 --- /dev/null +++ b/docs/work-items/ET-006/12-review.md @@ -0,0 +1,221 @@ +--- +type: code-review +work_item_id: ET-006 +title: "Code Review: Загрузка и визуализация GPX-треков" +version: 1 +status: REQUEST_CHANGES +created_at: 2026-05-22 +authors: + - "agent:reviewer" +branch: feature/ET-006-gpx-upload +--- + +# Code Review — ET-006: Загрузка и визуализация GPX-треков + +## Вердикт + +**REQUEST_CHANGES** — найден 1 баг уровня P1 (падение на больших треках, +прямо нарушает REQ-NF-01). Остальное — P2/P3. + +Реализация в целом сильная: контракт интеграции ADR-002 соблюдён точно, +стратегия парсинга ADR-003 выдержана, все REQ-F-01…F-13 присутствуют, +unit-тесты добротные. Блокирует приёмку только P1-1. + +## Что проверено + +- ТЗ: `docs/work-items/ET-006/02-trz.md` (v2) +- AC: `docs/work-items/ET-006/03-acceptance-criteria.md` (v2) +- ADR: `06-adr/ADR-002` (структура модуля), `06-adr/ADR-003` (парсинг) +- Data Requirements: `08-data-requirements.md` +- Diff `main...HEAD`: `src/web/gpx.js` (новый, 1127 строк), + `src/web/index.html`, `src/web/app.css`, `src/web/app.js` (хук REQ-F-13), + `tests/unit/gpx.test.js`, `tests/unit/test_gpx_upload.py` +- CLAUDE.md + +--- + +## Findings + +### P1 — must-fix + +#### P1-1. `trackStats` падает на треках с большим числом точек высот + +`src/web/gpx.js:142-143` + +```js +eleMin: Math.round(Math.min.apply(null, eles)), +eleMax: Math.round(Math.max.apply(null, eles)), +``` + +`Function.prototype.apply` передаёт каждый элемент массива отдельным +аргументом. Для трека с сотнями тысяч точек, у которых есть ``, +массив `eles` превышает лимит числа аргументов JS-движка и +`Math.min.apply` бросает `RangeError: Maximum call stack size exceeded`. + +Путь отказа: `trackStats()` вызывается из `extractGpxModelChunked()` +(`nextTrack`, строка 378) → исключение ловится в `parseGpxAsync().catch` +(строка 453) и оборачивается в `PARSE_ERROR` → пользователь видит toast +«Не удалось прочитать GPX-файл», файл не загружается. + +Это прямо нарушает: +- **REQ-NF-01** — «Рендеринг трека 500K точек… без видимых фризов» + (трек вообще не загрузится); +- **E-03** теста — «Загрузить GPX-файл ~50 МБ»; +- `08-data-requirements.md` §4 — «Предельный файл 50 МБ ≈ 500K+ точек». + +Существующими unit-тестами не ловится — все тесты используют короткие +массивы. + +**Фикс:** считать min/max обычным циклом (как уже сделано для +`distanceKm` в той же функции и для `minE/maxE` в `buildProfileSamples`, +строки 949-953 — там реализовано корректно). Достаточно убрать `apply`. + +--- + +### P2 — should-fix + +#### P2-1. Статистика и профиль высот учитывают только первый трек файла + +`src/web/gpx.js:890` (`renderStats`), `src/web/gpx.js:921` +(`renderElevationProfile`) + +```js +var st = file.tracks.length ? file.tracks[0].stats : null; // renderStats +var track = file.tracks.length ? file.tracks[0] : null; // renderElevationProfile +``` + +Файл с несколькими `` (REQ-F-02; AC-02 «загружен GPX-файл с 3 +треками … в панели — одна запись (имя файла) с 3 треками внутри») +показывает в панели одну строку, но статистика (длина, набор/сброс, +мин/макс) и профиль высот считаются **только по `tracks[0]`**. Длина и +рельеф 2-го и 3-го треков нигде не отображаются. + +REQ-F-10/REQ-F-11 говорят «для активного трека», но активная сущность +панели — файл (REQ-F-09, AC-02). ТЗ не определяет поведение для +многотрекового файла. Нужно либо агрегировать статистику по всем трекам +файла, либо дать выбор трека внутри файла — и зафиксировать это в ТЗ/AC. + +#### P2-2. Расчёт статистики не разбит на чанки (отклонение от ADR-003) + +`src/web/gpx.js:378` — `trackStats(points)` вызывается синхронно по +полному массиву точек. + +ADR-003 §2 (решение, п.2): «Конвертация DOM → GeoJSON **и расчёт +статистики** — чанками». Конвертация DOM → модель действительно разбита +на чанки (`mapChunked`, `CHUNK_SIZE`), но `trackStats` выполняется одним +синхронным проходом Haversine по всем точкам. Доминирующая стоимость +(обход DOM) чанкуется корректно, поэтому намерение ADR в основном +выдержано, но буква решения нарушена. Для 500K-точечного трека это +дополнительный синхронный проход. + +#### P2-3. Объём PR выходит за рамки ET-006 + +`git diff main...HEAD` помимо ET-006 содержит артефакты ET-002 +(`09-review.md`, `12-review.md`, `13-test-report.md`) и **всю фичу +ET-005**: новый `src/web/units.js` (190 строк), ~180 строк правок +`src/web/app.js`, тесты ET-005. Лог ветки содержит мерж-коммит +`6effac9 Merge … ET-005 … into main`. + +Разработчик чужие артефакты не правил (правило CLAUDE.md №2 не нарушено) +— ET-005/ET-002 «приехали» вместе с веткой. Но мерж этого PR молча +вольёт два других work-item. Конвенция CLAUDE.md — одна задача на ветку +`feature/PROJ-NNN-slug`. Owner/reviewer должен осознанно подтвердить, +что это намеренный stacking веток, до мержа. + +--- + +### P3 — nice-to-have + +- **P3-1.** Несогласованность единиц в профиле высот. `renderStats` + форматирует длину через `formatKm` (учитывает `Units` из ET-005 → + может показать мили), а подписи оси (`gpx.js:987-989`) и tooltip + (`gpx.js:1043`) жёстко выводят «км». При выборе миль статистика — + в милях, профиль — в км. +- **P3-2.** `childText()` (`gpx.js:241`) по имени подразумевает поиск + по прямым потомкам, но использует `getElementsByTagName` (поиск по + всем потомкам). На структуре GPX безвредно, имя вводит в заблуждение. +- **P3-3.** Грязная история git: мерж устаревшей ветки (`8dc150a`) + продублировал коммиты ET-006 (`62c2ee8`/`fda40f2`, + `2104f12`/`a0546ab`, `73b29ae`/`9fc1ef4`, `dcf3d24`/`9b930c5`). + Итоговое дерево корректно, но `git log`/`git bisect` запутаны. +- **P3-4.** Дублирующийся `'use strict'` — `gpx.js:1` и `gpx.js:27`. +- **P3-5.** Многосегментные треки: `collectRawTracks` склеивает все + `` одного `` в один плоский `points` → один `LineString`. + Это соответствует модели TRZ §4 / data-requirements §3 (плоский + `points` на трек), то есть формально по ТЗ. Но при разрыве сегментов + рисуется прямая-перемычка и разрыв попадает в `distanceKm`. На будущее + стоит рассмотреть `MultiLineString` (TRZ REQ-F-04 это допускает). +- **P3-6.** Числовые примеры в `04-test-plan.yaml` некорректны: U-10 + «≈28.3 км» (каноническая Haversine даёт ≈25.5 км), U-11 «elevLoss = + 70 м (30 + 20)» (30+20=50). Реализация и тесты следуют TRZ §5 + правильно; расхождение прозрачно задокументировано в шапке + `gpx.test.js`. Примеры теста стоит поправить на этапе Анализа для + консистентности (правило CLAUDE.md №3). + +--- + +## Соответствие требованиям + +### ТЗ — REQ-F / REQ-NF + +| Требование | Статус | Комментарий | +|---|---|---| +| REQ-F-01 кнопка загрузки | OK | `btn-gpx-upload` между компасом и геолокацией; `accept=".gpx" multiple` | +| REQ-F-02 парсинг | OK | DOMParser, GPX 1.1, trk/trkseg/wpt/rte, fallback без namespace | +| REQ-F-03 валидация | OK | лимит 50 МБ, toast для empty и oversize | +| REQ-F-04 отрисовка трека | OK | line 4px, opacity 0.85, палитра 8 цветов, z-order ниже OSRM | +| REQ-F-05 waypoints | OK | circle + symbol, цвет файла, label по `` | +| REQ-F-06 fit bounds | OK | padding 50, только по последнему файлу | +| REQ-F-07 множественная загрузка | OK | накопление, цвет по индексу | +| REQ-F-08 удаление | OK | снимаются слои/источники/маркеры; активный → детали скрываются | +| REQ-F-09 GPX Sheet | OK | `#sheet-gpx` на `.bottom-sheet`, авто-открытие, `tb-gpx` | +| REQ-F-10 профиль высот | ⚠ | canvas 120px, интерактивность OK; **P2-1** (только tracks[0]) | +| REQ-F-11 статистика | ⚠ | 5 полей, «—» без высот OK; **P2-1** (только tracks[0]) | +| REQ-F-12 интерактивность на карте | OK | click активирует трек, cursor pointer | +| REQ-F-13 сохранение при setStyle | OK | хук в `rebuildMapOverlays`, данные на `window`, z-order восстанавливается | +| REQ-NF-01 производительность | ❌ | **P1-1** — падение на больших треках; **P2-2** — стат. не чанкуется | +| REQ-NF-03 UX | OK | toast вместо alert, параллельно с роутингом | +| REQ-NF-04 хранение | OK | только в памяти, без localStorage | + +### ADR + +- **ADR-002** — соблюдён. `gpx.js` — отдельный классический скрипт, + подключён после `app.js`; `app.js` получил ровно одну строку-хук + `if (typeof rebuildGpxOverlays === 'function') rebuildGpxOverlays();`, + защищённую `typeof`; контракт интеграции (`_map`, `openSheet`/ + `closeSheet`, `showToast`) выдержан. +- **ADR-003** — соблюдён по существу. `DOMParser` в основном потоке, + Web Worker не используется, конвертация DOM → модель чанками с отдачей + управления event loop. Частичное отклонение — см. **P2-2**. + +--- + +## Положительные моменты + +- Контракт интеграции ADR-002 выдержан буквально — минимальная + поверхность связности, `app.js` остаётся валидным без `gpx.js`. +- Z-order REQ-F-04 проверен: `ROUTE_BASE_LAYERS` совпадает с реальными + id слоёв OSRM (`route-line-0-outline` / `route-line-0` в `app.js`); + в `rebuildMapOverlays` маршрут пересоздаётся раньше GPX, поэтому + z-order переживает `setStyle` (AC-12, I-07). +- Безопасность: имя файла экранируется (`escapeHtml`), имена waypoints + идут в MapLibre `text-field` (не в HTML), генерируемые id безопасны. +- Обработчики событий карты отслеживаются по файлу (`mapHandlers`) и + корректно снимаются (`clearMapHandlers`) — нет утечки слушателей при + пересоздании слоёв. +- Unit-тесты добротные: исполняют реальный модуль, покрывают U-01…U-21 + плюс чистые функции; компактный `MockDOMParser` без тяжёлого jsdom. + +Интеграционные (I-01…I-12) и e2e (E-01…E-10) сценарии не +автоматизированы — это ожидаемо, они в зоне этапа тестирования +(`test_gpx_upload.py` это явно фиксирует). К разработчику претензий нет. + +--- + +## Что нужно для APPROVED + +1. Исправить **P1-1** (обязательно). +2. Разобраться с **P2-1** — решение по многотрековым файлам + (агрегация либо явная фиксация в ТЗ/AC). +3. **P2-2**, **P2-3** — на усмотрение, желательно адресовать. +4. P3 — по возможности. From 25e4476cf704fdd7ae98c6376b734db670b670d4 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Fri, 22 May 2026 06:01:51 +0000 Subject: [PATCH 11/13] =?UTF-8?q?fix(gpx):=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B0=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88?= =?UTF-8?q?=D0=B8=D1=85=20=D1=82=D1=80=D0=B5=D0=BA=D0=B0=D1=85,=20=D1=83?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D1=82=D1=8C=20=D0=B2=D1=81=D0=B5=20=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правки по код-ревью ET-006 (docs/work-items/ET-006/12-review.md): - P1-1: trackStats считал min/max высот через Math.min/max.apply — на треках в сотни тысяч точек это бросало RangeError и валило загрузку файла (нарушение REQ-NF-01). Расчёт переписан на однопроходный аккумулятор (makeStatsAccumulator/accumulatePoint/finalizeStats) без apply. - P2-1: статистика и профиль высот учитывали только tracks[0]. Добавлены aggregateStats() и buildFileProfileSamples() — сводка и профиль теперь охватывают все треки файла (REQ-F-09, AC-02). - P2-2: расчёт статистики на async-пути парсинга вынесен в чанковый trackStatsChunked() — соответствие букве ADR-003 §2. - P3-1: ось и tooltip профиля высот форматируют расстояние через formatKm() — согласование с выбором км/мили из ET-005. - P3-2: childText() переименована в firstTagText() — имя соответствует фактическому поведению (поиск по всем потомкам). - P3-4: убран дублирующийся 'use strict'. Добавлены регрессионные unit-тесты: большой трек без падения, эквивалентность trackStatsChunked синхронному trackStats (в т.ч. на треке длиннее размера чанка), агрегация статистики и профиля по многотрековому файлу. Refs: ET-006 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/web/gpx.js | 237 ++++++++++++++++++++++++++++++----------- tests/unit/gpx.test.js | 96 ++++++++++++++++- 2 files changed, 271 insertions(+), 62 deletions(-) diff --git a/src/web/gpx.js b/src/web/gpx.js index 8f998e7..e37514c 100644 --- a/src/web/gpx.js +++ b/src/web/gpx.js @@ -1,5 +1,3 @@ -'use strict'; - /** * gpx.js — ET-006: загрузка и визуализация GPX-треков. * @@ -15,8 +13,9 @@ * `removeGpxTrack`), хелпер `showToast()` и хук `rebuildGpxOverlays()` * (вызывается из `rebuildMapOverlays()` app.js — REQ-F-13). * - * Парсинг — `DOMParser` в основном потоке, конвертация DOM → модель - * выполняется чанками с отдачей управления event loop (ADR-003). + * Парсинг — `DOMParser` в основном потоке; конвертация DOM → модель и + * расчёт статистики выполняются чанками с отдачей управления event loop + * (ADR-003). * * Для unit-тестов модуль дополнительно экспортируется через * `module.exports` (среда Node) — публикуются чистые функции и парсер. @@ -90,57 +89,141 @@ } /** - * Считает статистику трека по массиву точек [lon, lat, ele, time]. + * Создаёт аккумулятор статистики трека для однопроходного расчёта. + * + * Однопроходный обход (вместо `Math.min/max.apply` по массиву высот) + * обязателен для больших треков: `Function.prototype.apply` на сотнях + * тысяч аргументов бросает `RangeError: Maximum call stack size + * exceeded` и валит загрузку файла (ревью P1-1, REQ-NF-01). + * + * @returns {{distanceKm:number, elevGain:number, elevLoss:number, + * eleMin:number, eleMax:number, hasEle:boolean, ref:?number}} + */ + function makeStatsAccumulator() { + return { + distanceKm: 0, elevGain: 0, elevLoss: 0, + eleMin: Infinity, eleMax: -Infinity, hasEle: false, ref: null, + }; + } + + /** + * Учитывает точку `points[i]` в аккумуляторе: длина (Haversine от + * предыдущей точки), мин/макс высот и набор/сброс с фильтрацией шума + * < 2 м — мелкие колебания не сдвигают опорную высоту (TRZ §5.2, + * тест U-12). + * @param {object} acc - аккумулятор `makeStatsAccumulator()`. + * @param {Array} points - точки трека. + * @param {number} i - индекс обрабатываемой точки. + */ + function accumulatePoint(acc, points, i) { + if (i > 0) acc.distanceKm += haversineKm(points[i - 1], points[i]); + var e = points[i][2]; + if (e === null || e === undefined || isNaN(e)) return; + acc.hasEle = true; + if (e < acc.eleMin) acc.eleMin = e; + if (e > acc.eleMax) acc.eleMax = e; + if (acc.ref === null) { acc.ref = e; return; } + var d = e - acc.ref; + if (Math.abs(d) >= ELE_NOISE_M) { + if (d > 0) acc.elevGain += d; else acc.elevLoss += -d; + acc.ref = e; + } + } + + /** + * Сворачивает аккумулятор в итоговый объект статистики. При отсутствии + * данных высот поля высот — `null` (TRZ REQ-F-11; тесты U-05, U-14). + * @param {object} acc + * @returns {{distanceKm:number, elevGain:?number, elevLoss:?number, + * eleMin:?number, eleMax:?number}} + */ + function finalizeStats(acc) { + if (!acc.hasEle) { + return { + distanceKm: acc.distanceKm, elevGain: null, elevLoss: null, + eleMin: null, eleMax: null, + }; + } + return { + distanceKm: acc.distanceKm, + elevGain: Math.round(acc.elevGain), + elevLoss: Math.round(acc.elevLoss), + eleMin: Math.round(acc.eleMin), + eleMax: Math.round(acc.eleMax), + }; + } + + /** + * Считает статистику трека по массиву точек [lon, lat, ele, time] + * однопроходно (синхронно). * * Длина — сумма Haversine-сегментов. Набор/сброс высот — сумма дельт - * `ele` с фильтрацией шума: колебания < 2 м не сбрасывают опорную высоту - * (TRZ §5.2). При отсутствии данных высот поля высот возвращают `null` - * (TRZ REQ-F-11; тесты U-05, U-14). + * `ele` с фильтрацией шума < 2 м (TRZ §5.2). При отсутствии данных + * высот поля высот возвращают `null` (TRZ REQ-F-11; тесты U-05, U-14). * * @param {Array} points - точки трека. * @returns {{distanceKm:number, elevGain:?number, elevLoss:?number, * eleMin:?number, eleMax:?number}} */ function trackStats(points) { - var distanceKm = 0; - var i; - for (i = 1; i < points.length; i++) { - distanceKm += haversineKm(points[i - 1], points[i]); - } + var acc = makeStatsAccumulator(); + for (var i = 0; i < points.length; i++) accumulatePoint(acc, points, i); + return finalizeStats(acc); + } - var eles = []; - for (i = 0; i < points.length; i++) { - var e = points[i][2]; - if (e !== null && e !== undefined && !isNaN(e)) eles.push(e); - } - - if (eles.length === 0) { - return { - distanceKm: distanceKm, elevGain: null, elevLoss: null, - eleMin: null, eleMax: null, - }; - } - - var elevGain = 0; - var elevLoss = 0; - var ref = null; - for (i = 0; i < eles.length; i++) { - if (ref === null) { ref = eles[i]; continue; } - var d = eles[i] - ref; - // Шум < 2 м не сдвигает опорную высоту: мелкие колебания вокруг - // одного уровня не накапливаются в набор/сброс (тест U-12). - if (Math.abs(d) >= ELE_NOISE_M) { - if (d > 0) elevGain += d; else elevLoss += -d; - ref = eles[i]; + /** + * Считает статистику трека чанками, отдавая управление event loop между + * порциями. Реализует ADR-003 §2 («расчёт статистики — чанками»); + * применяется на пути асинхронного парсинга больших файлов (ревью P2-2). + * + * @param {Array} points - точки трека. + * @returns {Promise<{distanceKm:number, elevGain:?number, + * elevLoss:?number, eleMin:?number, eleMax:?number}>} + */ + function trackStatsChunked(points) { + return new Promise(function (resolve) { + var acc = makeStatsAccumulator(); + var i = 0; + function run() { + var end = Math.min(i + CHUNK_SIZE, points.length); + for (; i < end; i++) accumulatePoint(acc, points, i); + if (i < points.length) { setTimeout(run, 0); return; } + resolve(finalizeStats(acc)); } - } + run(); + }); + } + /** + * Агрегирует статистику всех треков файла в одну сводку. + * + * Активная сущность панели — файл (TRZ REQ-F-09, AC-02), но файл может + * содержать несколько `` (REQ-F-02). Длина и набор/сброс — суммы + * по трекам; мин/макс — экстремумы по трекам. Набор/сброс считаются в + * каждом треке отдельно и суммируются (а не по сквозному потоку точек): + * так скачок высоты на стыке треков не даёт ложный набор/сброс. Если + * ни у одного трека нет высот — поля высот `null` (ревью P2-1). + * + * @param {Array<{stats:object}>} tracks - треки файла. + * @returns {{distanceKm:number, elevGain:?number, elevLoss:?number, + * eleMin:?number, eleMax:?number}} + */ + function aggregateStats(tracks) { + var distanceKm = 0; + var elevGain = null, elevLoss = null, eleMin = null, eleMax = null; + tracks.forEach(function (t) { + var s = t.stats; + if (!s) return; + distanceKm += s.distanceKm || 0; + if (s.elevGain === null || s.elevGain === undefined) return; + elevGain = (elevGain === null ? 0 : elevGain) + s.elevGain; + elevLoss = (elevLoss === null ? 0 : elevLoss) + s.elevLoss; + eleMin = (eleMin === null) ? s.eleMin : Math.min(eleMin, s.eleMin); + eleMax = (eleMax === null) ? s.eleMax : Math.max(eleMax, s.eleMax); + }); return { - distanceKm: distanceKm, - elevGain: Math.round(elevGain), - elevLoss: Math.round(elevLoss), - eleMin: Math.round(Math.min.apply(null, eles)), - eleMax: Math.round(Math.max.apply(null, eles)), + distanceKm: distanceKm, elevGain: elevGain, elevLoss: elevLoss, + eleMin: eleMin, eleMax: eleMax, }; } @@ -233,12 +316,14 @@ } /** - * Возвращает текст первого дочернего элемента с указанным тегом. + * Возвращает текст первого элемента-потомка с указанным тегом. + * Поиск ведётся по всем потомкам (`getElementsByTagName`), не только + * по прямым детям — для структуры GPX это безопасно. * @param {Element} parent * @param {string} tag * @returns {string} */ - function childText(parent, tag) { + function firstTagText(parent, tag) { var els = parent.getElementsByTagName(tag); return els.length ? String(els[0].textContent || '').trim() : ''; } @@ -264,7 +349,7 @@ * @returns {{lon:number, lat:number, name:?string, ele:?number}} */ function waypointFromEl(el) { - var name = childText(el, 'name'); + var name = firstTagText(el, 'name'); var eleEls = el.getElementsByTagName('ele'); var ele = eleEls.length ? parseFloat(eleEls[0].textContent) : NaN; return { @@ -304,13 +389,13 @@ var tps = segs[j].getElementsByTagName('trkpt'); for (k = 0; k < tps.length; k++) ptEls.push(tps[k]); } - result.push({ name: childText(trk, 'name') || ('Трек ' + (i + 1)), ptEls: ptEls }); + result.push({ name: firstTagText(trk, 'name') || ('Трек ' + (i + 1)), ptEls: ptEls }); } var rtes = doc.getElementsByTagName('rte'); for (i = 0; i < rtes.length; i++) { var rte = rtes[i]; var rps = toArray(rte.getElementsByTagName('rtept')); - result.push({ name: childText(rte, 'name') || ('Маршрут ' + (i + 1)), ptEls: rps }); + result.push({ name: firstTagText(rte, 'name') || ('Маршрут ' + (i + 1)), ptEls: rps }); } return result; } @@ -375,8 +460,11 @@ } return mapChunked(raw[idx].ptEls, pointFromEl).then(function (pts) { var points = pts.filter(isValidPoint); - tracks.push({ name: raw[idx].name, points: points, stats: trackStats(points) }); - return nextTrack(idx + 1); + // Расчёт статистики — тоже чанками (ADR-003 §2; ревью P2-2). + return trackStatsChunked(points).then(function (stats) { + tracks.push({ name: raw[idx].name, points: points, stats: stats }); + return nextTrack(idx + 1); + }); }); } @@ -880,14 +968,15 @@ } /** - * Отрисовывает компактную сетку статистики активного трека - * (TRZ REQ-F-11; AC-08). + * Отрисовывает компактную сетку статистики активного файла — + * сводно по всем его трекам (TRZ REQ-F-11; AC-08; ревью P2-1). * @param {object} file */ function renderStats(file) { var el = document.getElementById('gpx-stats'); if (!el) return; - var st = file.tracks.length ? file.tracks[0].stats : null; + // Сводка по всем трекам файла, а не только tracks[0] (ревью P2-1). + var st = file.tracks.length ? aggregateStats(file.tracks) : null; var hasEle = st && st.elevGain !== null; var cells = [ @@ -908,8 +997,9 @@ // ─── Профиль высот (canvas) ────────────────────────────────────────────── /** - * Отрисовывает canvas-профиль высот активного трека и подключает - * интерактивность (tooltip + маркер-курсор на карте) — TRZ REQ-F-10. + * Отрисовывает canvas-профиль высот активного файла (по всем его + * трекам) и подключает интерактивность (tooltip + маркер-курсор на + * карте) — TRZ REQ-F-10; ревью P2-1. * @param {object} file */ function renderElevationProfile(file) { @@ -918,8 +1008,7 @@ var axisEl = document.getElementById('gpx-elevation-axis'); if (!canvas) return; - var track = file.tracks.length ? file.tracks[0] : null; - var samples = track ? buildProfileSamples(track) : []; + var samples = buildFileProfileSamples(file); if (samples.length < 2) { canvas.style.display = 'none'; @@ -984,9 +1073,11 @@ if (axisEl) { var spans = axisEl.getElementsByTagName('span'); if (spans.length >= 3) { - spans[0].textContent = '0 км'; - spans[1].textContent = (totalKm / 2).toFixed(1) + ' км'; - spans[2].textContent = totalKm.toFixed(1) + ' км'; + // Единицы — через formatKm, чтобы ось согласовалась со + // статистикой при выборе миль в ET-005 (ревью P3-1). + spans[0].textContent = formatKm(0); + spans[1].textContent = formatKm(totalKm / 2); + spans[2].textContent = formatKm(totalKm); } } @@ -1017,6 +1108,26 @@ return samples; } + /** + * Строит точки профиля высот для всего файла: треки склеиваются + * последовательно, расстояние `d` — нарастающим итогом от старта + * первого трека. Покрывает многотрековые файлы — профиль показывает + * все треки файла, а не только tracks[0] (ревью P2-1). + * @param {object} file - элемент модели window.gpxTracks. + * @returns {Array<{d:number, e:number, lon:number, lat:number}>} + */ + function buildFileProfileSamples(file) { + var samples = []; + var offset = 0; + file.tracks.forEach(function (track) { + buildProfileSamples(track).forEach(function (s) { + samples.push({ d: s.d + offset, e: s.e, lon: s.lon, lat: s.lat }); + }); + offset += (track.stats && track.stats.distanceKm) || 0; + }); + return samples; + } + /** * Навешивает обработчики наведения/тапа на canvas профиля: tooltip с * высотой и расстоянием + маркер-курсор на карте (TRZ REQ-F-10; AC-07). @@ -1040,7 +1151,8 @@ var tip = document.getElementById('gpx-elevation-tip'); if (tip) { - tip.textContent = Math.round(nearest.e) + ' м · ' + nearest.d.toFixed(1) + ' км'; + // Расстояние — через formatKm (учёт км/мили из ET-005, ревью P3-1). + tip.textContent = Math.round(nearest.e) + ' м · ' + formatKm(nearest.d); tip.style.display = 'block'; tip.style.left = Math.max(0, Math.min(profileState.cssW - 90, x - 45)) + 'px'; } @@ -1098,10 +1210,13 @@ MAX_FILE_BYTES: MAX_FILE_BYTES, haversineKm: haversineKm, trackStats: trackStats, + trackStatsChunked: trackStatsChunked, + aggregateStats: aggregateStats, colorForIndex: colorForIndex, tracksToGeoJSON: tracksToGeoJSON, waypointsToGeoJSON: waypointsToGeoJSON, fileBounds: fileBounds, + buildFileProfileSamples: buildFileProfileSamples, extractGpxModel: extractGpxModel, parseGpxText: parseGpxText, parseGpxAsync: parseGpxAsync, diff --git a/tests/unit/gpx.test.js b/tests/unit/gpx.test.js index 9a2ac9d..7ca7df3 100644 --- a/tests/unit/gpx.test.js +++ b/tests/unit/gpx.test.js @@ -7,7 +7,10 @@ * - unit-gpx-parser (U-01..U-08) * - unit-gpx-stats (U-10..U-14) * - unit-gpx-colors (U-20, U-21) - * плюс чистые функции построения GeoJSON и bbox. + * плюс чистые функции построения GeoJSON и bbox, плюс регрессии по + * замечаниям код-ревью ET-006: P1-1 (большие треки не валят расчёт + * статистики), P2-1 (агрегация статистики и профиля по всем трекам + * файла), P2-2 (чанковый расчёт статистики — trackStatsChunked). * * Тесты исполняют РЕАЛЬНЫЙ модуль src/web/gpx.js. Браузерный примитив * `DOMParser` (ADR-003) в Node отсутствует, поэтому подставляется @@ -398,6 +401,40 @@ test('trackStats: пустой трек — нулевая длина без п 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: первый файл получает первый цвет палитры', () => { @@ -456,6 +493,63 @@ 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-обработчики', () => { From e1dd7039a7b95dca45e9dcba0be54917b166d292 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Fri, 22 May 2026 06:04:55 +0000 Subject: [PATCH 12/13] reviewer(ET): auto-commit from reviewer run_id=18 --- docs/work-items/ET-006/12-review.md | 241 ++++++++++++---------------- 1 file changed, 107 insertions(+), 134 deletions(-) diff --git a/docs/work-items/ET-006/12-review.md b/docs/work-items/ET-006/12-review.md index 4265723..8a9ab98 100644 --- a/docs/work-items/ET-006/12-review.md +++ b/docs/work-items/ET-006/12-review.md @@ -2,9 +2,10 @@ type: code-review work_item_id: ET-006 title: "Code Review: Загрузка и визуализация GPX-треков" -version: 1 -status: REQUEST_CHANGES +version: 2 +status: APPROVED created_at: 2026-05-22 +updated_at: 2026-05-22 authors: - "agent:reviewer" branch: feature/ET-006-gpx-upload @@ -14,143 +15,113 @@ branch: feature/ET-006-gpx-upload ## Вердикт -**REQUEST_CHANGES** — найден 1 баг уровня P1 (падение на больших треках, -прямо нарушает REQ-NF-01). Остальное — P2/P3. +**APPROVED** — блокирующих findings нет. -Реализация в целом сильная: контракт интеграции ADR-002 соблюдён точно, -стратегия парсинга ADR-003 выдержана, все REQ-F-01…F-13 присутствуют, -unit-тесты добротные. Блокирует приёмку только P1-1. +Повторное ревью после коммита `25e4476` («fix(gpx): устранить падение +статистики на больших треках, учесть все треки файла»), который закрывает +все замечания v1. P1-1, P2-1, P2-2 исправлены и подтверждены чтением кода +и регрессионными тестами. P2-3 снят как ложное срабатывание (см. ниже). +Остаются только три P3 nice-to-have — не блокируют приёмку. -## Что проверено +Реализация сильная: контракт интеграции ADR-002 соблюдён буквально, +стратегия парсинга ADR-003 выдержана полностью, REQ-F-01…F-13 присутствуют, +unit-тесты добротные и пополнены регрессиями по ревью. + +## Что проверено (v2) - ТЗ: `docs/work-items/ET-006/02-trz.md` (v2) - AC: `docs/work-items/ET-006/03-acceptance-criteria.md` (v2) - ADR: `06-adr/ADR-002` (структура модуля), `06-adr/ADR-003` (парсинг) - Data Requirements: `08-data-requirements.md` -- Diff `main...HEAD`: `src/web/gpx.js` (новый, 1127 строк), - `src/web/index.html`, `src/web/app.css`, `src/web/app.js` (хук REQ-F-13), +- Diff `origin/main...HEAD` (а не stale local `main` — см. P2-3): + `src/web/gpx.js` (новый, 1242 строки), `src/web/index.html` (+46), + `src/web/app.js` (+2: хук REQ-F-13), `src/web/app.css` (+148), `tests/unit/gpx.test.js`, `tests/unit/test_gpx_upload.py` +- Коммит-фикс `25e4476` целиком (`gpx.js` +233/-60, `gpx.test.js` +96) - CLAUDE.md --- -## Findings +## Статус findings прошлого ревью (v1) -### P1 — must-fix +### P1-1 — `trackStats` падал на больших треках — **ИСПРАВЛЕНО** -#### P1-1. `trackStats` падает на треках с большим числом точек высот +`Math.min/max.apply` по массиву высот убран. Расчёт переписан на +однопроходный аккумулятор (`makeStatsAccumulator` / `accumulatePoint` / +`finalizeStats`, `gpx.js:102-172`). `apply` в горячем пути отсутствует. +Проверена эквивалентность нового однопроходного расчёта старому: длина и +мин/макс — по всем точкам, набор/сброс — по той же подпоследовательности +точек с высотой, тот же фильтр шума `ELE_NOISE_M`. Добавлена регрессия +`gpx.test.js` — трек 500K точек, `assert.doesNotThrow`. REQ-NF-01 закрыт. -`src/web/gpx.js:142-143` +### P2-1 — статистика/профиль только по `tracks[0]` — **ИСПРАВЛЕНО** -```js -eleMin: Math.round(Math.min.apply(null, eles)), -eleMax: Math.round(Math.max.apply(null, eles)), -``` +Добавлены `aggregateStats()` (`gpx.js:211`) и `buildFileProfileSamples()` +(`gpx.js:1119`). `renderStats` (`gpx.js:979`) и `renderElevationProfile` +(`gpx.js:1011`) теперь сводят статистику и профиль по **всем** трекам +файла. Решение по семантике корректное: длина и набор/сброс суммируются +потрековно (скачок высоты на стыке треков не даёт ложный набор/сброс), +мин/макс — экстремумы. Покрыто тестами (агрегация, трек без высот, все +треки без высот, склейка профиля). Закрывает REQ-F-09/AC-02 для +многотрековых файлов. -`Function.prototype.apply` передаёт каждый элемент массива отдельным -аргументом. Для трека с сотнями тысяч точек, у которых есть ``, -массив `eles` превышает лимит числа аргументов JS-движка и -`Math.min.apply` бросает `RangeError: Maximum call stack size exceeded`. +### P2-2 — расчёт статистики не чанковался — **ИСПРАВЛЕНО** -Путь отказа: `trackStats()` вызывается из `extractGpxModelChunked()` -(`nextTrack`, строка 378) → исключение ловится в `parseGpxAsync().catch` -(строка 453) и оборачивается в `PARSE_ERROR` → пользователь видит toast -«Не удалось прочитать GPX-файл», файл не загружается. +Добавлен `trackStatsChunked()` (`gpx.js:183`), вызывается в +`extractGpxModelChunked` (`gpx.js:464`) — асинхронный путь парсинга +больших файлов. Эквивалентность синхронному `trackStats` подтверждена +тестом, в т.ч. на треке длиннее `CHUNK_SIZE`. ADR-003 §2 выдержан +буквально. Синхронный `trackStats` остаётся в `extractGpxModel` — этот +путь используется только unit-тестами, не загрузкой файла. -Это прямо нарушает: -- **REQ-NF-01** — «Рендеринг трека 500K точек… без видимых фризов» - (трек вообще не загрузится); -- **E-03** теста — «Загрузить GPX-файл ~50 МБ»; -- `08-data-requirements.md` §4 — «Предельный файл 50 МБ ≈ 500K+ точек». +### P2-3 — «объём PR выходит за рамки ET-006» — **СНЯТО (ложное срабатывание)** -Существующими unit-тестами не ловится — все тесты используют короткие -массивы. +Замечание v1 — артефакт измерения. Прошлое ревью считало diff против +**локального `main`** (`832099c`), который был устаревшим. Против +`origin/main` (`6effac9`, уже содержит ET-001/002/005) diff PR — +**строго ET-006**: 19 файлов, `gpx.js` + 3 правки разметки + 2 строки +`app.js` + тесты + docs ET-006. Ни одного чужого work-item. Конвенция +CLAUDE.md «одна задача = одна ветка» не нарушена. Претензий нет. -**Фикс:** считать min/max обычным циклом (как уже сделано для -`distanceKm` в той же функции и для `minE/maxE` в `buildProfileSamples`, -строки 949-953 — там реализовано корректно). Достаточно убрать `apply`. +### P3-1 — единицы профиля высот — **ИСПРАВЛЕНО** + +Ось (`gpx.js:1078-1080`) и tooltip (`gpx.js:1155`) форматируют расстояние +через `formatKm()` — согласовано со статистикой и выбором км/мили (ET-005). + +### P3-2 — имя `childText` — **ИСПРАВЛЕНО** + +Переименована в `firstTagText` (`gpx.js:326`), JSDoc уточнён. + +### P3-4 — дублирующийся `'use strict'` — **ИСПРАВЛЕНО** + +Верхнеуровневый `'use strict'` убран; остался один — внутри IIFE +(`gpx.js:26`), что и нужно для строгого режима модуля. --- -### P2 — should-fix - -#### P2-1. Статистика и профиль высот учитывают только первый трек файла - -`src/web/gpx.js:890` (`renderStats`), `src/web/gpx.js:921` -(`renderElevationProfile`) - -```js -var st = file.tracks.length ? file.tracks[0].stats : null; // renderStats -var track = file.tracks.length ? file.tracks[0] : null; // renderElevationProfile -``` - -Файл с несколькими `` (REQ-F-02; AC-02 «загружен GPX-файл с 3 -треками … в панели — одна запись (имя файла) с 3 треками внутри») -показывает в панели одну строку, но статистика (длина, набор/сброс, -мин/макс) и профиль высот считаются **только по `tracks[0]`**. Длина и -рельеф 2-го и 3-го треков нигде не отображаются. - -REQ-F-10/REQ-F-11 говорят «для активного трека», но активная сущность -панели — файл (REQ-F-09, AC-02). ТЗ не определяет поведение для -многотрекового файла. Нужно либо агрегировать статистику по всем трекам -файла, либо дать выбор трека внутри файла — и зафиксировать это в ТЗ/AC. - -#### P2-2. Расчёт статистики не разбит на чанки (отклонение от ADR-003) - -`src/web/gpx.js:378` — `trackStats(points)` вызывается синхронно по -полному массиву точек. - -ADR-003 §2 (решение, п.2): «Конвертация DOM → GeoJSON **и расчёт -статистики** — чанками». Конвертация DOM → модель действительно разбита -на чанки (`mapChunked`, `CHUNK_SIZE`), но `trackStats` выполняется одним -синхронным проходом Haversine по всем точкам. Доминирующая стоимость -(обход DOM) чанкуется корректно, поэтому намерение ADR в основном -выдержано, но буква решения нарушена. Для 500K-точечного трека это -дополнительный синхронный проход. - -#### P2-3. Объём PR выходит за рамки ET-006 - -`git diff main...HEAD` помимо ET-006 содержит артефакты ET-002 -(`09-review.md`, `12-review.md`, `13-test-report.md`) и **всю фичу -ET-005**: новый `src/web/units.js` (190 строк), ~180 строк правок -`src/web/app.js`, тесты ET-005. Лог ветки содержит мерж-коммит -`6effac9 Merge … ET-005 … into main`. - -Разработчик чужие артефакты не правил (правило CLAUDE.md №2 не нарушено) -— ET-005/ET-002 «приехали» вместе с веткой. Но мерж этого PR молча -вольёт два других work-item. Конвенция CLAUDE.md — одна задача на ветку -`feature/PROJ-NNN-slug`. Owner/reviewer должен осознанно подтвердить, -что это намеренный stacking веток, до мержа. - ---- +## Остаточные findings (не блокируют) ### P3 — nice-to-have -- **P3-1.** Несогласованность единиц в профиле высот. `renderStats` - форматирует длину через `formatKm` (учитывает `Units` из ET-005 → - может показать мили), а подписи оси (`gpx.js:987-989`) и tooltip - (`gpx.js:1043`) жёстко выводят «км». При выборе миль статистика — - в милях, профиль — в км. -- **P3-2.** `childText()` (`gpx.js:241`) по имени подразумевает поиск - по прямым потомкам, но использует `getElementsByTagName` (поиск по - всем потомкам). На структуре GPX безвредно, имя вводит в заблуждение. - **P3-3.** Грязная история git: мерж устаревшей ветки (`8dc150a`) - продублировал коммиты ET-006 (`62c2ee8`/`fda40f2`, + продублировал 4 коммита ET-006 (`62c2ee8`/`fda40f2`, `2104f12`/`a0546ab`, `73b29ae`/`9fc1ef4`, `dcf3d24`/`9b930c5`). - Итоговое дерево корректно, но `git log`/`git bisect` запутаны. -- **P3-4.** Дублирующийся `'use strict'` — `gpx.js:1` и `gpx.js:27`. + Итоговое дерево корректно, diff чистый; запутаны лишь `git log` / + `git bisect`. Исправление потребовало бы rewrite истории — на + усмотрение Owner при мерже (можно squash). - **P3-5.** Многосегментные треки: `collectRawTracks` склеивает все `` одного `` в один плоский `points` → один `LineString`. - Это соответствует модели TRZ §4 / data-requirements §3 (плоский - `points` на трек), то есть формально по ТЗ. Но при разрыве сегментов - рисуется прямая-перемычка и разрыв попадает в `distanceKm`. На будущее - стоит рассмотреть `MultiLineString` (TRZ REQ-F-04 это допускает). + Соответствует модели TRZ §4 / data-requirements §3, формально по ТЗ. + При разрыве сегментов рисуется прямая-перемычка, разрыв попадает в + `distanceKm`. На будущее — рассмотреть `MultiLineString` (REQ-F-04 + это допускает). - **P3-6.** Числовые примеры в `04-test-plan.yaml` некорректны: U-10 - «≈28.3 км» (каноническая Haversine даёт ≈25.5 км), U-11 «elevLoss = - 70 м (30 + 20)» (30+20=50). Реализация и тесты следуют TRZ §5 - правильно; расхождение прозрачно задокументировано в шапке - `gpx.test.js`. Примеры теста стоит поправить на этапе Анализа для - консистентности (правило CLAUDE.md №3). + «≈28.3 км» (Haversine даёт ≈25.5 км), U-11 «elevLoss = 70 м (30+20)» + (30+20=50). Реализация и тесты следуют TRZ §5 правильно; расхождение + задокументировано в шапке `gpx.test.js`. Примеры стоит поправить на + этапе Анализа (правило CLAUDE.md №3) — артефакт не входит в код PR. + +Новых P0/P1/P2 коммит-фикс не внёс — проверено построчно. --- @@ -169,24 +140,25 @@ ET-005**: новый `src/web/units.js` (190 строк), ~180 строк пра | REQ-F-07 множественная загрузка | OK | накопление, цвет по индексу | | REQ-F-08 удаление | OK | снимаются слои/источники/маркеры; активный → детали скрываются | | REQ-F-09 GPX Sheet | OK | `#sheet-gpx` на `.bottom-sheet`, авто-открытие, `tb-gpx` | -| REQ-F-10 профиль высот | ⚠ | canvas 120px, интерактивность OK; **P2-1** (только tracks[0]) | -| REQ-F-11 статистика | ⚠ | 5 полей, «—» без высот OK; **P2-1** (только tracks[0]) | +| REQ-F-10 профиль высот | OK | canvas 120px, интерактивность; сводно по всем трекам файла (P2-1 закрыт) | +| REQ-F-11 статистика | OK | 5 полей, «—» без высот; сводно по всем трекам файла (P2-1 закрыт) | | REQ-F-12 интерактивность на карте | OK | click активирует трек, cursor pointer | | REQ-F-13 сохранение при setStyle | OK | хук в `rebuildMapOverlays`, данные на `window`, z-order восстанавливается | -| REQ-NF-01 производительность | ❌ | **P1-1** — падение на больших треках; **P2-2** — стат. не чанкуется | +| REQ-NF-01 производительность | OK | P1-1 устранён; парсинг и расчёт статистики чанкуются (P2-2 закрыт) | | REQ-NF-03 UX | OK | toast вместо alert, параллельно с роутингом | | REQ-NF-04 хранение | OK | только в памяти, без localStorage | ### ADR -- **ADR-002** — соблюдён. `gpx.js` — отдельный классический скрипт, - подключён после `app.js`; `app.js` получил ровно одну строку-хук - `if (typeof rebuildGpxOverlays === 'function') rebuildGpxOverlays();`, - защищённую `typeof`; контракт интеграции (`_map`, `openSheet`/ - `closeSheet`, `showToast`) выдержан. -- **ADR-003** — соблюдён по существу. `DOMParser` в основном потоке, - Web Worker не используется, конвертация DOM → модель чанками с отдачей - управления event loop. Частичное отклонение — см. **P2-2**. +- **ADR-002** — соблюдён буквально. `gpx.js` — отдельный классический + скрипт, подключён после `app.js`; `app.js` получил ровно один хук + `if (typeof rebuildGpxOverlays === 'function') rebuildGpxOverlays();` + (+ строка-комментарий), защищённый `typeof`; контракт интеграции + (`_map`, `openSheet`/`closeSheet`, `showToast`) выдержан. +- **ADR-003** — соблюдён полностью. `DOMParser` в основном потоке, Web + Worker не используется, конвертация DOM → модель **и расчёт + статистики** идут чанками с отдачей управления event loop. Частичное + отклонение из v1 (P2-2) устранено. --- @@ -194,28 +166,29 @@ ET-005**: новый `src/web/units.js` (190 строк), ~180 строк пра - Контракт интеграции ADR-002 выдержан буквально — минимальная поверхность связности, `app.js` остаётся валидным без `gpx.js`. -- Z-order REQ-F-04 проверен: `ROUTE_BASE_LAYERS` совпадает с реальными - id слоёв OSRM (`route-line-0-outline` / `route-line-0` в `app.js`); - в `rebuildMapOverlays` маршрут пересоздаётся раньше GPX, поэтому - z-order переживает `setStyle` (AC-12, I-07). +- P1-1-фикс сделан правильно: однопроходный аккумулятор переиспользован + и синхронным `trackStats`, и чанковым `trackStatsChunked` — единый + путь расчёта, без дублирования логики. +- P2-1-фикс корректен по семантике: набор/сброс агрегируются потрековно, + а не по сквозному потоку точек — стык треков не порождает ложный + набор высоты. +- Z-order REQ-F-04: `ROUTE_BASE_LAYERS` совпадает с реальными id слоёв + OSRM; в `rebuildMapOverlays` маршрут пересоздаётся раньше GPX — + z-order переживает `setStyle` (AC-12). - Безопасность: имя файла экранируется (`escapeHtml`), имена waypoints идут в MapLibre `text-field` (не в HTML), генерируемые id безопасны. - Обработчики событий карты отслеживаются по файлу (`mapHandlers`) и - корректно снимаются (`clearMapHandlers`) — нет утечки слушателей при - пересоздании слоёв. -- Unit-тесты добротные: исполняют реальный модуль, покрывают U-01…U-21 - плюс чистые функции; компактный `MockDOMParser` без тяжёлого jsdom. + корректно снимаются (`clearMapHandlers`) — нет утечки слушателей. +- Unit-тесты добротные: исполняют реальный модуль, покрывают U-01…U-21, + чистые функции и регрессии по ревью (P1-1, P2-1, P2-2). Интеграционные (I-01…I-12) и e2e (E-01…E-10) сценарии не -автоматизированы — это ожидаемо, они в зоне этапа тестирования -(`test_gpx_upload.py` это явно фиксирует). К разработчику претензий нет. +автоматизированы — ожидаемо, они в зоне этапа тестирования +(`test_gpx_upload.py` это фиксирует). К разработчику претензий нет. --- -## Что нужно для APPROVED +## Итог -1. Исправить **P1-1** (обязательно). -2. Разобраться с **P2-1** — решение по многотрековым файлам - (агрегация либо явная фиксация в ТЗ/AC). -3. **P2-2**, **P2-3** — на усмотрение, желательно адресовать. -4. P3 — по возможности. +Блокирующих findings нет. PR готов к этапу тестирования. P3-3 / P3-5 / +P3-6 — на усмотрение Owner/Анализа, приёмку не задерживают. From 23cc89d416497a6ef5b37263d296c322dcc9a3a6 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Fri, 22 May 2026 06:45:35 +0000 Subject: [PATCH 13/13] tester(ET): auto-commit from tester run_id=19 --- docs/work-items/ET-006/13-test-report.md | 240 +++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/work-items/ET-006/13-test-report.md diff --git a/docs/work-items/ET-006/13-test-report.md b/docs/work-items/ET-006/13-test-report.md new file mode 100644 index 0000000..040200e --- /dev/null +++ b/docs/work-items/ET-006/13-test-report.md @@ -0,0 +1,240 @@ +--- +type: test-report +work_item_id: ET-006 +title: "Test Report: Загрузка и визуализация GPX-треков" +version: 1 +status: PASSED +created_at: 2026-05-22 +updated_at: 2026-05-22 +authors: + - "agent:tester" +branch: feature/ET-006-gpx-upload +verdict: ready-to-deploy +--- + +# Test Report — ET-006: Загрузка и визуализация GPX-треков + +## Вердикт + +**PASSED → stage:ready-to-deploy** + +Полный регресс пройден. Из 33 тест-кейсов плана (`04-test-plan.yaml`) +**31 — PASS**, **2 — BLOCKED** по внешней причине (OSRM-бэкенд недоступен +на test-окружении — инфраструктурная проблема, не дефект ET-006). +**P0/P1-багов нет.** Зафиксировано одно неблокирующее наблюдение P3 по +нефункциональному требованию REQ-NF-01 (время парсинга большого файла). + +| Уровень | Кейсы | PASS | FAIL | BLOCKED | +|---|---|---|---|---| +| Unit (U-01…U-21) | 15 | 15 | 0 | 0 | +| Integration (I-01…I-12) | 10 | 9 | 0 | 1 | +| E2E (E-01…E-10) | 8 | 7 | 0 | 1 | +| **Итого по плану** | **33** | **31** | **0** | **2** | +| Acceptance Criteria (AC-01…AC-12) | 12 | 11 | 0 | 1 | + +--- + +## 1. Тестовое окружение + +| Параметр | Значение | +|---|---| +| Среда | test — `https://openclaw.mva154.duckdns.org/enduro/` | +| Ветка | `feature/ET-006-gpx-upload` @ `e1dd703` | +| Healthcheck | корень `/enduro/` — **HTTP 200**; код ET-006 задеплоен (`gpx.js` 48674 б, `btn-gpx-upload`, `#sheet-gpx`, `