feat(web): POI visibility checkbox in terrain popup (ET-002) #5
@@ -11,3 +11,5 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
- Agent system prompts (architect, developer, reviewer, tester, deployer)
|
||||
- CI pipeline (Gitea Actions)
|
||||
- Docker configuration
|
||||
- ET-002: чекбокс «POI» в попапе рельефа — показ/скрытие маркеров POI
|
||||
с сохранением состояния в localStorage (ключ `poi-visible`)
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -48,6 +48,22 @@
|
||||
6. Коммиты от имени claude-bot (git config user.name/email уже настроен).
|
||||
7. Не использовать `--no-verify` без явного одобрения Owner.
|
||||
|
||||
## Фазы
|
||||
|
||||
| # | Название | Описание |
|
||||
|---|----------|----------|
|
||||
| PH-1 | MVP | Карта грунтовок + MVT тайлы |
|
||||
| PH-2 | Routing | OSRM роутинг + базовый UI |
|
||||
| PH-3 | Smart Route | Альтернативы, статистика, GPX |
|
||||
| PH-4 | Advanced Routing | Красивый маршрут, связка, разведка |
|
||||
| PH-5 | Redesign | Тёмная тема, mobile UI, UX |
|
||||
| PH-6 | Terrain | Hillshade + гипсометрия + TRI |
|
||||
| PH-7 | Barriers | Шлагбаумы, тротуары, слой препятствий |
|
||||
| PH-8 | Elevation Profile | Профиль высот, режим «Горка» |
|
||||
| PH-9 | PWA | Офлайн режим |
|
||||
|
||||
Детали каждой фазы: [docs/phases/](docs/phases/)
|
||||
|
||||
## Данные
|
||||
- Terrain tiles: /home/slin/enduro-trails/data/terrain/ (hillshade, TRI, hypso)
|
||||
- OSM данные: /home/slin/enduro-trails/data/osm/
|
||||
|
||||
16
README.md
16
README.md
@@ -68,6 +68,22 @@ migrations/ — миграции БД
|
||||
.openclaw/ — system prompts агентов
|
||||
```
|
||||
|
||||
## Фазы
|
||||
|
||||
| # | Название | Описание |
|
||||
|---|----------|----------|
|
||||
| PH-1 | MVP | Карта грунтовок + MVT тайлы |
|
||||
| PH-2 | Routing | OSRM роутинг + базовый UI |
|
||||
| PH-3 | Smart Route | Альтернативы, статистика, GPX |
|
||||
| PH-4 | Advanced Routing | Красивый маршрут, связка, разведка |
|
||||
| PH-5 | Redesign | Тёмная тема, mobile UI, UX |
|
||||
| PH-6 | Terrain | Hillshade + гипсометрия + TRI |
|
||||
| PH-7 | Barriers | Шлагбаумы, тротуары, слой препятствий |
|
||||
| PH-8 | Elevation Profile | Профиль высот, режим «Горка» |
|
||||
| PH-9 | PWA | Офлайн режим |
|
||||
|
||||
Детали каждой фазы: [docs/phases/](docs/phases/)
|
||||
|
||||
## Лицензия
|
||||
|
||||
Данные: © OpenStreetMap contributors (ODbL)
|
||||
|
||||
@@ -6,3 +6,15 @@
|
||||
- [design/](./design/) — дизайн-токены, компоненты
|
||||
- [operations/](./operations/) — runbook, мониторинг
|
||||
- [api/](./api/) — OpenAPI спецификация
|
||||
|
||||
## Фазы
|
||||
|
||||
- [PH-1.mvp](./phases/PH-1.mvp/) — MVP: карта грунтовок + MVT тайлы
|
||||
- [PH-2.routing](./phases/PH-2.routing/) — OSRM роутинг + базовый UI
|
||||
- [PH-3.smart-route](./phases/PH-3.smart-route/) — Альтернативы, статистика, GPX
|
||||
- [PH-4.advanced-routing](./phases/PH-4.advanced-routing/) — Красивый маршрут, связка, разведка
|
||||
- [PH-5.redesign](./phases/PH-5.redesign/) — Тёмная тема, mobile UI, UX
|
||||
- [PH-6.terrain](./phases/PH-6.terrain/) — Hillshade + гипсометрия + TRI
|
||||
- [PH-7.barriers](./phases/PH-7.barriers/) — Шлагбаумы, тротуары, слой препятствий
|
||||
- [PH-8.elevation-profile](./phases/PH-8.elevation-profile/) — Профиль высот, режим «Горка»
|
||||
- [PH-9.pwa](./phases/PH-9.pwa/) — Офлайн режим
|
||||
|
||||
46
docs/phases/PH-1.mvp/00-phase-brd.md
Normal file
46
docs/phases/PH-1.mvp/00-phase-brd.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: PH-1.mvp
|
||||
title: "MVP: карта грунтовок ЦФО + MVT тайлы"
|
||||
version: 1
|
||||
status: done
|
||||
created_at: 2026-05-18
|
||||
authors:
|
||||
- "agent:stream"
|
||||
---
|
||||
|
||||
# PH-1 — MVP: карта грунтовок ЦФО
|
||||
|
||||
## Цель
|
||||
|
||||
Создать веб-карту с фокусом на грунтовые дороги: грунтовки и тропы — главный слой, асфальт — тусклый фон. Данные из OSM, отображение через MapLibre GL JS с кастомным стилем.
|
||||
|
||||
## Scope
|
||||
|
||||
- Загрузка OSM PBF (Geofabrik, ЦФО + Чувашия)
|
||||
- Конвертация в SQLite/Spatialite (1.1M треков, 14K POI)
|
||||
- Self-hosted MVT (vector tiles) через FastAPI
|
||||
- MapLibre GL JS с кастомным стилем (грунтовки яркие, асфальт тусклый)
|
||||
- Деплой через Docker Compose на mva154
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- FastAPI backend с эндпоинтами для MVT тайлов
|
||||
- SQLite/Spatialite БД с данными ЦФО + Чувашия
|
||||
- Кастомный стиль MapLibre: цветовая дифференциация по типу покрытия (track grade 1-5)
|
||||
- Docker Compose конфигурация
|
||||
- nginx reverse proxy (`/enduro/` → контейнер)
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
| Решение | Причина |
|
||||
|---------|---------|
|
||||
| MapLibre GL JS (не Leaflet) | WebGL, производительность, vector tiles |
|
||||
| Vanilla JS (не React) | Простота, нет build step, быстрый старт |
|
||||
| FastAPI (не Django) | Лёгкий, async, минимум зависимостей |
|
||||
| SQLite/Spatialite (не PostGIS) | Портативность, zero-config, достаточно для 1 региона |
|
||||
| Self-hosted MVT (не TileServer GL) | Меньше зависимостей, контроль над фильтрацией |
|
||||
|
||||
## Дата завершения
|
||||
|
||||
02.05.2026
|
||||
43
docs/phases/PH-2.routing/00-phase-brd.md
Normal file
43
docs/phases/PH-2.routing/00-phase-brd.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: PH-2.routing
|
||||
title: "Роутинг: OSRM с кастомным эндуро-профилем"
|
||||
version: 1
|
||||
status: done
|
||||
created_at: 2026-05-18
|
||||
authors:
|
||||
- "agent:stream"
|
||||
---
|
||||
|
||||
# PH-2 — Роутинг: «Дикий путь»
|
||||
|
||||
## Цель
|
||||
|
||||
Добавить построение маршрутов с приоритетом грунтовых дорог. Использовать OSRM с кастомным Lua-профилем, оптимизированным под эндуро.
|
||||
|
||||
## Scope
|
||||
|
||||
- Кастомный профиль `enduro.lua` для OSRM (приоритет грунтовок, штраф за асфальт)
|
||||
- Пересборка OSRM графа из OSM PBF (~5.2 GB)
|
||||
- Базовый UI для построения маршрута (точка А → Б)
|
||||
- Отображение маршрута на карте
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- OSRM с профилем enduro.lua (веса: track > path > unclassified > tertiary > secondary)
|
||||
- API эндпоинт `/api/route` (FastAPI → OSRM)
|
||||
- UI: клик по карте для установки точек старта/финиша
|
||||
- Отображение маршрута (GeoJSON LineString на карте)
|
||||
- Docker-сервис OSRM в compose
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
| Решение | Причина |
|
||||
|---------|---------|
|
||||
| OSRM (не GraphHopper) | Быстрый, проверенный, кастомный lua-профиль |
|
||||
| Кастомный enduro.lua | Стандартные профили не учитывают грунтовки как приоритет |
|
||||
| Swap 6 GB | OSRM граф требует ~5.2 GB RAM |
|
||||
|
||||
## Дата завершения
|
||||
|
||||
03.05.2026
|
||||
43
docs/phases/PH-3.smart-route/00-phase-brd.md
Normal file
43
docs/phases/PH-3.smart-route/00-phase-brd.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: PH-3.smart-route
|
||||
title: "Умный маршрут: альтернативы, статистика, GPX"
|
||||
version: 1
|
||||
status: done
|
||||
created_at: 2026-05-18
|
||||
authors:
|
||||
- "agent:stream"
|
||||
---
|
||||
|
||||
# PH-3 — Умный маршрут
|
||||
|
||||
## Цель
|
||||
|
||||
Предоставить пользователю выбор из нескольких вариантов маршрута с разным балансом грунт/асфальт. Показать статистику покрытия и дать возможность экспорта в GPX.
|
||||
|
||||
## Scope
|
||||
|
||||
- До 5 альтернативных маршрутов с разным балансом грунт/асфальт
|
||||
- Промежуточные точки (до 8)
|
||||
- Статистика покрытия (% по типам: track, path, unclassified, tertiary, secondary, primary)
|
||||
- GPX экспорт (трек + waypoints)
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- Мульти-запрос к OSRM с варьированием весов профиля
|
||||
- UI выбора альтернатив (карточки с превью и статистикой)
|
||||
- Drag-and-drop промежуточных точек на карте
|
||||
- Панель статистики: дистанция, время, % грунта/асфальта по типам
|
||||
- GPX экспорт с метаданными (имя маршрута, waypoints, timestamps)
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
| Решение | Причина |
|
||||
|---------|---------|
|
||||
| Варьирование весов (не OSRM alternatives) | OSRM alternatives даёт похожие маршруты; варьирование весов — реально разные |
|
||||
| До 8 промежуточных точек | Баланс между гибкостью и UX |
|
||||
| GPX 1.1 формат | Совместимость с большинством навигаторов |
|
||||
|
||||
## Дата завершения
|
||||
|
||||
04.05.2026
|
||||
42
docs/phases/PH-4.advanced-routing/00-phase-brd.md
Normal file
42
docs/phases/PH-4.advanced-routing/00-phase-brd.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: PH-4.advanced-routing
|
||||
title: "Продвинутый роутинг: красивый маршрут, связка, разведка"
|
||||
version: 1
|
||||
status: done
|
||||
created_at: 2026-05-18
|
||||
authors:
|
||||
- "agent:stream"
|
||||
---
|
||||
|
||||
# PH-4 — Продвинутый роутинг
|
||||
|
||||
## Цель
|
||||
|
||||
Добавить три специализированных режима маршрутизации: «Красивый маршрут» (замкнутый круг через POI), «Связка» (соединить два трека грунтовками), «Разведка» (статистика грунтовок в радиусе).
|
||||
|
||||
## Scope
|
||||
|
||||
- **Красивый маршрут** — замкнутый круг заданной длины через живописные POI (озёра, виды, руины, броды)
|
||||
- **Связка** — соединить два загруженных трека оптимальным грунтовым участком
|
||||
- **Разведка** — показать статистику грунтовок в радиусе 20/50/100 км от точки
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- Алгоритм «Красивый маршрут»: выбор POI в радиусе → TSP-оптимизация порядка → OSRM route
|
||||
- UI загрузки GPX для режима «Связка»
|
||||
- Алгоритм соединения: найти ближайшие точки двух треков → построить грунтовый мост
|
||||
- «Разведка»: spatial query по SQLite → агрегация по типам → визуализация на карте (heatmap)
|
||||
- Переключение режимов в toolbar
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
| Решение | Причина |
|
||||
|---------|---------|
|
||||
| TSP через nearest-neighbor heuristic | Достаточно для 5-10 POI, O(n²) приемлемо |
|
||||
| Spatialite для spatial queries | Уже есть в стеке, не нужен отдельный сервис |
|
||||
| Радиусы 20/50/100 км | Покрывают типичные дневные маршруты |
|
||||
|
||||
## Дата завершения
|
||||
|
||||
04.05.2026
|
||||
50
docs/phases/PH-5.redesign/00-phase-brd.md
Normal file
50
docs/phases/PH-5.redesign/00-phase-brd.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: PH-5.redesign
|
||||
title: "Редизайн: тёмная тема, mobile UI, UX"
|
||||
version: 1
|
||||
status: done
|
||||
created_at: 2026-05-18
|
||||
authors:
|
||||
- "agent:stream"
|
||||
---
|
||||
|
||||
# PH-5 — Редизайн
|
||||
|
||||
## Цель
|
||||
|
||||
Переработать UI/UX приложения: добавить тёмную/светлую тему с автопереключением, адаптировать под мобильные устройства, улучшить юзабилити (линейка, метки, поиск).
|
||||
|
||||
## Scope
|
||||
|
||||
- Тёмная/светлая тема (авто по SunCalc — переключение по закату/рассвету)
|
||||
- Mobile UI: bottom sheets, toolbar, touch-оптимизация
|
||||
- Линейка (измерение расстояний на карте)
|
||||
- Метки (сохранение точек интереса, localStorage)
|
||||
- Поиск (Nominatim geocoding)
|
||||
- CSS custom properties для theming
|
||||
- Синхронизация темы карты и UI
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- Система тем: CSS custom properties, авто (SunCalc), ручное переключение
|
||||
- Стиль карты MapLibre синхронизирован с UI-темой
|
||||
- Bottom sheet для мобильных (маршрут, статистика, настройки)
|
||||
- Toolbar с иконками (режимы, инструменты)
|
||||
- Touch-события: long press для метки, swipe для bottom sheet
|
||||
- Линейка: клик-клик для измерения, отображение дистанции
|
||||
- Метки: создание, редактирование, удаление, persist в localStorage
|
||||
- Поиск: Nominatim API, debounce, fly-to результату
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
| Решение | Причина |
|
||||
|---------|---------|
|
||||
| SunCalc для авто-темы | Точное время заката для координат пользователя |
|
||||
| CSS custom properties | Нативный theming без препроцессоров |
|
||||
| Bottom sheets (не sidebar) | Мобильный паттерн, thumb-friendly |
|
||||
| localStorage для меток | Простота, нет backend-зависимости |
|
||||
|
||||
## Дата завершения
|
||||
|
||||
05-06.05.2026
|
||||
48
docs/phases/PH-6.terrain/00-phase-brd.md
Normal file
48
docs/phases/PH-6.terrain/00-phase-brd.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: PH-6.terrain
|
||||
title: "Рельеф: гипсометрия + hillshade + TRI"
|
||||
version: 1
|
||||
status: done
|
||||
created_at: 2026-05-18
|
||||
authors:
|
||||
- "agent:stream"
|
||||
---
|
||||
|
||||
# PH-6 — Рельеф
|
||||
|
||||
## Цель
|
||||
|
||||
Добавить визуализацию рельефа: гипсометрическая раскраска, hillshade (теневой рельеф), TRI (Terrain Ruggedness Index) для оценки сложности местности.
|
||||
|
||||
## Scope
|
||||
|
||||
- Загрузка и обработка SRTM 30м (NASA, public domain)
|
||||
- Генерация raster tiles: гипсометрия, hillshade, TRI
|
||||
- Раздача через nginx (статические файлы)
|
||||
- UI: переключение слоёв рельефа, прозрачность
|
||||
- Легенда для гипсометрии и TRI
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- Pipeline обработки SRTM: скачивание → merge → reproject → tile generation
|
||||
- Гипсометрия: цветовая шкала высот (зелёный → коричневый → белый)
|
||||
- Hillshade: azimuth 315°, altitude 45°, z-factor 1.5
|
||||
- TRI: классификация (flat, nearly flat, slightly rugged, rugged, very rugged)
|
||||
- Raster tiles zoom 8-14, формат PNG
|
||||
- nginx location для раздачи tiles
|
||||
- UI: layer switcher с opacity slider
|
||||
- Легенда с цветовой шкалой
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
| Решение | Причина |
|
||||
|---------|---------|
|
||||
| Raster tiles (не Mapbox Terrain RGB) | Простота генерации, nginx отдаёт статику |
|
||||
| SRTM 30м | Бесплатно, достаточно для ЦФО (равнина) |
|
||||
| Zoom 8-14 | Баланс между детализацией и объёмом данных |
|
||||
| TRI как отдельный слой | Помогает оценить сложность без профиля высот |
|
||||
|
||||
## Дата завершения
|
||||
|
||||
12-14.05.2026
|
||||
51
docs/phases/PH-7.barriers/00-phase-brd.md
Normal file
51
docs/phases/PH-7.barriers/00-phase-brd.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: PH-7.barriers
|
||||
title: "Барьеры: исключить шлагбаумы и тротуары, слой препятствий"
|
||||
version: 1
|
||||
status: active
|
||||
created_at: 2026-05-18
|
||||
authors:
|
||||
- "agent:stream"
|
||||
---
|
||||
|
||||
# PH-7 — Барьеры
|
||||
|
||||
## Цель
|
||||
|
||||
Сделать роутинг безопасным: маршрут не проходит через физические препятствия (шлагбаумы) и запрещённые для мотоциклов дороги (тротуары, пешеходные зоны). Добавить визуальный слой препятствий на карту.
|
||||
|
||||
## Scope
|
||||
|
||||
### F-07: Исключить шлагбаумы из OSRM
|
||||
- Ноды с `barrier=gate|bollard|lift_gate|chain|cycle_barrier|motorcycle_barrier|border_control|block` → `mode.inaccessible` в OSRM
|
||||
- `cattle_grid` и `ford` — оставить (проезжие)
|
||||
|
||||
### F-08: Исключить тротуары из OSRM
|
||||
- Ways с `highway=footway|pedestrian|steps|corridor` → исключить из графа (return в process_way)
|
||||
|
||||
### F-10: Слой препятствий на карте
|
||||
- Визуализация шлагбаумов, ворот, блоков на карте
|
||||
- Иконки по типу барьера
|
||||
- Popup с информацией (тип, OSM ID)
|
||||
|
||||
## Метрики успеха
|
||||
|
||||
- Маршрут через точку с шлагбаумом → OSRM обходит или возвращает "не найден"
|
||||
- Маршрут в городе → не проходит по тротуарам
|
||||
- Время пересборки графа ≤ 60 мин
|
||||
- Существующие маршруты без шлагбаумов/тротуаров — не ломаются
|
||||
|
||||
## Зависимости
|
||||
|
||||
- OSRM граф (пересборка с обновлённым enduro.lua)
|
||||
- OSM PBF данные (уже есть)
|
||||
- Work item: [ET-001](../../work-items/ET-001/)
|
||||
|
||||
## Риски
|
||||
|
||||
| Риск | Митигация |
|
||||
|------|-----------|
|
||||
| Пересборка графа ~40 мин (сервис недоступен) | Пересобирать ночью или в low-traffic |
|
||||
| Слишком много заблокированных нод → маршруты не строятся | cattle_grid и ford оставлены; тестировать на реальных маршрутах |
|
||||
| OSRM RAM при пересборке | Swap 6 GB уже настроен |
|
||||
35
docs/phases/PH-8.elevation-profile/00-phase-brd.md
Normal file
35
docs/phases/PH-8.elevation-profile/00-phase-brd.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: PH-8.elevation-profile
|
||||
title: "Профиль высот на маршруте, режим «Горка»"
|
||||
version: 1
|
||||
status: planned
|
||||
created_at: 2026-05-18
|
||||
authors:
|
||||
- "agent:stream"
|
||||
---
|
||||
|
||||
# PH-8 — Профиль высот
|
||||
|
||||
## Цель
|
||||
|
||||
Показать профиль высот построенного маршрута. Добавить режим «Горка» — поиск участков с максимальным перепадом высот (подъёмы/спуски).
|
||||
|
||||
## Scope
|
||||
|
||||
- Профиль высот: график elevation по дистанции для построенного маршрута
|
||||
- Данные высот из SRTM 30м (уже есть в PH-6)
|
||||
- Интерактивный график: hover → маркер на карте
|
||||
- Статистика: набор высоты, сброс высоты, макс/мин высота
|
||||
- Режим «Горка»: поиск участков с перепадом > N метров в радиусе
|
||||
|
||||
## Зависимости
|
||||
|
||||
- PH-6 (SRTM данные, terrain pipeline)
|
||||
- PH-2/PH-3 (роутинг, маршруты)
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Формат графика: Canvas или SVG?
|
||||
- Сэмплирование точек: каждые N метров или адаптивное?
|
||||
- «Горка»: порог перепада настраиваемый или фиксированный?
|
||||
37
docs/phases/PH-9.pwa/00-phase-brd.md
Normal file
37
docs/phases/PH-9.pwa/00-phase-brd.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: PH-9.pwa
|
||||
title: "PWA: офлайн режим, кэширование тайлов"
|
||||
version: 1
|
||||
status: planned
|
||||
created_at: 2026-05-18
|
||||
authors:
|
||||
- "agent:stream"
|
||||
---
|
||||
|
||||
# PH-9 — PWA офлайн
|
||||
|
||||
## Цель
|
||||
|
||||
Сделать приложение доступным офлайн: Service Worker для кэширования, предзагрузка тайлов для выбранного региона, сохранение маршрутов локально.
|
||||
|
||||
## Scope
|
||||
|
||||
- Service Worker: кэширование статики (JS, CSS, шрифты, иконки)
|
||||
- Предзагрузка тайлов: выбрать область на карте → скачать тайлы для офлайн
|
||||
- Кэширование vector tiles (MVT) и raster tiles (terrain)
|
||||
- Офлайн-роутинг: сохранение построенных маршрутов для просмотра без сети
|
||||
- Install prompt (Add to Home Screen)
|
||||
- Manifest.json
|
||||
|
||||
## Зависимости
|
||||
|
||||
- PH-1 (MVT тайлы)
|
||||
- PH-6 (raster terrain tiles)
|
||||
- Все предыдущие фазы (UI, роутинг)
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Лимит кэша (сколько тайлов можно скачать?)
|
||||
- Стратегия инвалидации кэша
|
||||
- Офлайн-роутинг: только просмотр сохранённых или локальный OSRM?
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
type: phase-brd
|
||||
phase_id: pilot
|
||||
title: "Enduro Trails — пилотный проект мультиагентной разработки"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-05-15
|
||||
authors:
|
||||
- "agent:stream"
|
||||
- "human:slava"
|
||||
---
|
||||
|
||||
# BRD — Enduro Trails (пилотный проект)
|
||||
|
||||
## 1. Цель и метрика успеха
|
||||
|
||||
**Цель:** Создать веб-приложение для планирования эндуро-маршрутов с визуализацией грунтовых дорог, рельефа и навигацией — как пилотный проект мультиагентной системы разработки.
|
||||
|
||||
**Метрики успеха:**
|
||||
- Рабочее приложение доступно по URL (https://openclaw.mva154.duckdns.org/enduro/)
|
||||
- Покрытие региона: ЦФО + Чувашия (1.1M треков, 14K POI)
|
||||
- Построение маршрута < 5 секунд
|
||||
- Мобильный UI (PWA-ready)
|
||||
- Агентный конвейер: фича от постановки до деплоя ≤ 4 часа
|
||||
|
||||
## 2. Стейкхолдеры
|
||||
|
||||
| Роль | Кто | Интерес |
|
||||
|------|-----|---------|
|
||||
| Заказчик / Owner | Слава | Использует для планирования поездок |
|
||||
| Analyst | Стрим (OpenClaw) | BRD, ТЗ, координация |
|
||||
| Разработка | Claude Code CLI агенты | Architect, Developer, Reviewer, Tester, Deployer |
|
||||
|
||||
## 3. Scope
|
||||
|
||||
### В скоупе
|
||||
- Карта грунтовых дорог (MapLibre GL JS, кастомный стиль)
|
||||
- Роутинг «Дикий путь» (OSRM, кастомный профиль enduro.lua)
|
||||
- Альтернативные маршруты (до 5 вариантов)
|
||||
- Промежуточные точки (до 8)
|
||||
- Статистика покрытия (% грунт/асфальт по типам)
|
||||
- «Красивый маршрут» (замкнутый круг через POI)
|
||||
- «Связка» (соединить два трека)
|
||||
- «Разведка» (грунтовки в радиусе)
|
||||
- Рельеф (гипсометрия + hillshade, SRTM 30м)
|
||||
- TRI (Terrain Ruggedness Index)
|
||||
- Линейка, метки, GPX экспорт
|
||||
- Тёмная/светлая тема (авто по SunCalc)
|
||||
- Мобильный UI (bottom sheets, toolbar, touch)
|
||||
- Поиск (Nominatim geocoding)
|
||||
|
||||
### Вне скоупа (v1)
|
||||
- PWA офлайн режим
|
||||
- GPS-трекинг в реальном времени
|
||||
- Народные треки (Wikiloc, Komoot)
|
||||
- Профиль высот на маршруте
|
||||
- Мультирегион (вся Россия)
|
||||
- Нативное мобильное приложение
|
||||
|
||||
## 4. Архитектура
|
||||
|
||||
### Компоненты
|
||||
- **Frontend** — MapLibre GL JS, vanilla JS (ES modules), CSS custom properties
|
||||
- **Backend API** — FastAPI (Python 3.12), uvicorn (4 workers)
|
||||
- **Tile Server** — статические raster tiles через nginx
|
||||
- **Vector Tiles** — MVT из SQLite (self-hosted, FastAPI)
|
||||
- **Routing Engine** — OSRM с кастомным профилем `enduro.lua`
|
||||
- **Database** — SQLite + Spatialite (431 MB)
|
||||
- **Reverse Proxy** — nginx (`/enduro/` → контейнер)
|
||||
|
||||
### Инфраструктура
|
||||
- Один сервер: mva154 (82.22.50.71)
|
||||
- Docker Compose
|
||||
- Gitea (git + CI)
|
||||
- Plane (управление задачами)
|
||||
|
||||
### Данные
|
||||
- OSM PBF (Geofabrik, ЦФО + Чувашия)
|
||||
- SRTM 30м (NASA, public domain)
|
||||
- OSRM граф (~5.2 GB)
|
||||
|
||||
## 5. Реализованные фазы
|
||||
|
||||
| Фаза | Описание | Статус | Дата |
|
||||
|------|----------|--------|------|
|
||||
| 1 | MVP: карта + MVT тайлы | ✅ | 02.05.2026 |
|
||||
| 2 | Роутинг + базовый UI | ✅ | 03.05.2026 |
|
||||
| 3 | Умный маршрут (альтернативы, статистика, GPX) | ✅ | 04.05.2026 |
|
||||
| 4 | Продвинутый роутинг (красивый, связка, разведка) | ✅ | 04.05.2026 |
|
||||
| 5 | Редизайн (тёмная тема, mobile UI, UX) | ✅ | 05-06.05.2026 |
|
||||
| 5.4 | Рельеф (hillshade + гипсометрия + TRI) | ✅ | 12-14.05.2026 |
|
||||
|
||||
## 6. Бэклог
|
||||
|
||||
| Фаза | Описание | Приоритет |
|
||||
|------|----------|-----------|
|
||||
| 3.1 | Улучшение роутинга (шлагбаумы, тротуары, слой препятствий) | Высокий |
|
||||
| 6 | SRTM продвинутый (профиль высот, «Горка») | Средний |
|
||||
| 7 | PWA + офлайн | Средний |
|
||||
| 8 | Народные треки | Низкий |
|
||||
|
||||
## 7. Ключевые решения
|
||||
|
||||
| Решение | Причина |
|
||||
|---------|---------|
|
||||
| MapLibre GL JS (не Leaflet) | WebGL, производительность, vector tiles |
|
||||
| Vanilla JS (не React) | Простота, нет build step, быстрый старт |
|
||||
| FastAPI (не Django) | Лёгкий, async, минимум зависимостей |
|
||||
| SQLite/Spatialite (не PostGIS) | Портативность, zero-config, достаточно для 1 региона |
|
||||
| OSRM (не GraphHopper) | Быстрый, проверенный, кастомный lua-профиль |
|
||||
| Self-hosted MVT (не TileServer GL) | Меньше зависимостей, контроль над фильтрацией |
|
||||
| Raster tiles для terrain (не Mapbox Terrain RGB) | Простота генерации, nginx отдаёт статику |
|
||||
| Docker Compose | Один файл — весь стек |
|
||||
|
||||
## 8. Риски
|
||||
|
||||
| Риск | Митигация |
|
||||
|------|-----------|
|
||||
| OSRM граф большой (5.2 GB RAM) | Swap 6 GB настроен |
|
||||
| SQLite не масштабируется | Миграция на PostGIS при необходимости |
|
||||
| Один сервер — single point of failure | Бэкапы, Docker restart policy |
|
||||
| SRTM 30м недостаточно для крутых склонов | Достаточно для ЦФО (равнина) |
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
type: phase-plan
|
||||
phase_id: pilot
|
||||
title: "План пилотной фазы"
|
||||
version: 1
|
||||
status: active
|
||||
---
|
||||
|
||||
# План пилотной фазы — Enduro Trails
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Проект в стадии перехода от прототипа к управляемой мультиагентной разработке.
|
||||
|
||||
### Выполнено (прототип, 02-14.05.2026)
|
||||
- Фазы 1-5.4 реализованы вручную (Стрим + Dev-агент)
|
||||
- Все основные фичи работают
|
||||
- Приложение доступно по URL
|
||||
|
||||
### В процессе (мультиагентная инфраструктура, 15.05.2026)
|
||||
- ✅ Репо в Gitea с канонической структурой
|
||||
- ✅ Claude Code CLI авторизован
|
||||
- ✅ Service account claude-bot
|
||||
- ✅ Branch protection
|
||||
- ✅ CI pipeline
|
||||
- ✅ System prompts агентов
|
||||
- 🔄 Миграция прототипа в репо
|
||||
|
||||
### Следующие шаги
|
||||
1. Merge миграции в main
|
||||
2. Первая задача через полный агентный конвейер (Фаза 1 мультиагентного BRD)
|
||||
3. Orchestrator MVP (Фаза 2 мультиагентного BRD)
|
||||
|
||||
## Приоритет фич (бэклог)
|
||||
|
||||
1. **F-07 + F-08** — Исключить шлагбаумы и тротуары из OSRM (пересборка графа)
|
||||
2. **F-10** — Слой препятствий на карте
|
||||
3. **F-09** — Больше альтернатив (penalized re-query)
|
||||
4. Профиль высот на маршруте
|
||||
5. PWA офлайн
|
||||
20
docs/work-items/ET-002/00-business-request.md
Normal file
20
docs/work-items/ET-002/00-business-request.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
type: business-request
|
||||
work_item_id: ET-002
|
||||
title: "Чекбокс показа/скрытия POI на карте"
|
||||
created_at: 2026-05-20
|
||||
source: telegram
|
||||
requester: Слава
|
||||
---
|
||||
|
||||
# Исходный запрос — ET-002
|
||||
|
||||
## Формулировка заказчика
|
||||
|
||||
> На карте сейчас всегда отражаются маркеры POI. Нужен в кнопке рельефа добавить чекбокс показывать/не показывать POI.
|
||||
|
||||
## Контекст
|
||||
|
||||
- Канал: Telegram
|
||||
- Дата: 2026-05-19
|
||||
- Приоритет: не указан (обычный)
|
||||
61
docs/work-items/ET-002/01-brd.md
Normal file
61
docs/work-items/ET-002/01-brd.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
type: brd
|
||||
work_item_id: ET-002
|
||||
title: "BRD: Чекбокс показа/скрытия POI в попапе рельефа"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-05-20
|
||||
authors:
|
||||
- "agent:analyst"
|
||||
---
|
||||
|
||||
# BRD — ET-002: Чекбокс показа/скрытия POI в попапе рельефа
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Дать пользователю возможность скрывать маркеры POI на карте, чтобы они не загромождали обзор при планировании маршрута. Управление — через чекбокс в существующем попапе кнопки «Рельеф».
|
||||
|
||||
## 2. Контекст
|
||||
|
||||
- Сейчас POI (слои `poi-circles`, `poi-labels`) отображаются всегда при загрузке карты.
|
||||
- Попап кнопки «Рельеф» (`terrain-popup`) уже содержит чекбоксы: Тени рельефа, Перепады, Грунтовки, Тропы.
|
||||
- Механизм `toggleLayer('poi')` уже реализован в коде, но не привязан к UI в попапе.
|
||||
|
||||
## 3. Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Добавить чекбокс «POI» в попап кнопки «Рельеф» (после секции «Тропы»)
|
||||
- POI включены по умолчанию (checked)
|
||||
- Состояние чекбокса сохраняется в localStorage (ключ `poi-visible`)
|
||||
- При загрузке страницы — восстанавливать состояние из localStorage
|
||||
- Скрытие/показ слоёв `poi-circles` и `poi-labels` через MapLibre `setLayoutProperty`
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Фильтрация POI по типу (кафе, заправки, и т.д.)
|
||||
- Отдельная кнопка для POI вне попапа рельефа
|
||||
- Изменение иконок или стилей POI
|
||||
- Backend-изменения
|
||||
|
||||
## 4. Метрики успеха
|
||||
|
||||
| Метрика | Критерий |
|
||||
|---------|----------|
|
||||
| Чекбокс отображается в попапе | Визуально присутствует после «Тропы» |
|
||||
| Снятие чекбокса скрывает POI | Слои `poi-circles` и `poi-labels` получают visibility: none |
|
||||
| Установка чекбокса показывает POI | Слои получают visibility: visible |
|
||||
| Состояние сохраняется | После перезагрузки страницы чекбокс и видимость POI соответствуют последнему выбору |
|
||||
| Не ломает существующий функционал | Грунтовки, тропы, рельеф, роутинг работают как прежде |
|
||||
|
||||
## 5. Риски
|
||||
|
||||
| Риск | Вероятность | Митигация |
|
||||
|------|-------------|-----------|
|
||||
| Конфликт с существующим toggleLayer('poi') | Низкая | Использовать тот же механизм layerState, не дублировать логику |
|
||||
| Попап становится слишком длинным на мобильных | Низкая | Один чекбокс — минимальное увеличение высоты |
|
||||
|
||||
## 6. Зависимости
|
||||
|
||||
- Нет внешних зависимостей
|
||||
- Только фронтенд (vanilla JS + HTML)
|
||||
98
docs/work-items/ET-002/02-trz.md
Normal file
98
docs/work-items/ET-002/02-trz.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
type: trz
|
||||
work_item_id: ET-002
|
||||
title: "ТЗ: Чекбокс показа/скрытия POI в попапе рельефа"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-05-20
|
||||
authors:
|
||||
- "agent:analyst"
|
||||
---
|
||||
|
||||
# ТЗ — ET-002: Чекбокс показа/скрытия POI в попапе рельефа
|
||||
|
||||
## 1. Функциональные требования
|
||||
|
||||
### REQ-F-01: Чекбокс POI в попапе рельефа
|
||||
|
||||
Система должна отображать чекбокс «POI» в попапе кнопки «Рельеф» (`terrain-popup`), расположенный после чекбокса «Тропы» (`trails-path-cb`), отделённый горизонтальной линией (`<hr>`).
|
||||
|
||||
### REQ-F-02: Состояние по умолчанию
|
||||
|
||||
При первом посещении (отсутствие ключа в localStorage) чекбокс POI должен быть включён (checked), маркеры POI видимы.
|
||||
|
||||
### REQ-F-03: Скрытие POI
|
||||
|
||||
При снятии чекбокса система должна установить `visibility: 'none'` для слоёв `poi-circles` и `poi-labels` через `map.setLayoutProperty()`.
|
||||
|
||||
### REQ-F-04: Показ POI
|
||||
|
||||
При установке чекбокса система должна установить `visibility: 'visible'` для слоёв `poi-circles` и `poi-labels`.
|
||||
|
||||
### REQ-F-05: Сохранение состояния
|
||||
|
||||
Система должна сохранять состояние чекбокса в `localStorage` под ключом `poi-visible` (значения: `'1'` — показывать, `'0'` — скрывать).
|
||||
|
||||
### REQ-F-06: Восстановление состояния при загрузке
|
||||
|
||||
При загрузке страницы система должна:
|
||||
1. Прочитать значение `localStorage.getItem('poi-visible')`
|
||||
2. Если значение `'0'` — снять чекбокс и скрыть слои POI
|
||||
3. Если значение `'1'` или отсутствует (null) — установить чекбокс и показать слои POI
|
||||
|
||||
### REQ-F-07: Синхронизация с layerState
|
||||
|
||||
Изменение чекбокса должно обновлять `layerState.poi` (true/false), чтобы состояние было консистентно с внутренней моделью приложения.
|
||||
|
||||
## 2. Нефункциональные требования
|
||||
|
||||
### REQ-NF-01: Производительность
|
||||
|
||||
Переключение видимости POI должно происходить мгновенно (< 50 мс), без перезагрузки тайлов.
|
||||
|
||||
### REQ-NF-02: Совместимость
|
||||
|
||||
Решение должно работать во всех браузерах, поддерживающих MapLibre GL JS (Chrome 80+, Firefox 78+, Safari 14+, Edge 80+).
|
||||
|
||||
### REQ-NF-03: Мобильная адаптация
|
||||
|
||||
Чекбокс должен быть доступен и нажимаем на мобильных устройствах (touch target ≥ 44px).
|
||||
|
||||
### REQ-NF-04: Отсутствие регрессий
|
||||
|
||||
Существующие чекбоксы (Тени рельефа, Перепады, Грунтовки, Тропы) должны продолжать работать без изменений.
|
||||
|
||||
## 3. UI-спецификация
|
||||
|
||||
### Расположение в HTML
|
||||
|
||||
```html
|
||||
<!-- Внутри #terrain-popup, после trails-path-cb -->
|
||||
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
|
||||
<label class="terrain-checkbox">
|
||||
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
|
||||
<span>POI</span>
|
||||
</label>
|
||||
```
|
||||
|
||||
### Стилизация
|
||||
|
||||
Использовать существующий класс `terrain-checkbox` — единообразие с остальными чекбоксами в попапе.
|
||||
|
||||
## 4. Данные
|
||||
|
||||
- localStorage ключ: `poi-visible`
|
||||
- Значения: `'1'` (показывать) | `'0'` (скрывать)
|
||||
- MapLibre слои: `poi-circles`, `poi-labels`
|
||||
- Состояние в JS: `layerState.poi`
|
||||
|
||||
## 5. API
|
||||
|
||||
Нет изменений в backend API. Вся логика — клиентская.
|
||||
|
||||
## 6. Затрагиваемые файлы
|
||||
|
||||
| Файл | Изменение |
|
||||
|------|-----------|
|
||||
| `src/web/index.html` | Добавить чекбокс в `terrain-popup` |
|
||||
| `src/web/app.js` | Добавить функцию `onPoiCheckbox()`, логику восстановления состояния при загрузке |
|
||||
85
docs/work-items/ET-002/03-acceptance-criteria.md
Normal file
85
docs/work-items/ET-002/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
type: acceptance-criteria
|
||||
work_item_id: ET-002
|
||||
title: "Acceptance Criteria: Чекбокс POI"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-05-20
|
||||
authors:
|
||||
- "agent:analyst"
|
||||
---
|
||||
|
||||
# Acceptance Criteria — ET-002
|
||||
|
||||
## AC-01: Чекбокс отображается в попапе
|
||||
|
||||
```gherkin
|
||||
Given пользователь открыл карту
|
||||
When пользователь нажимает кнопку «Рельеф»
|
||||
Then в попапе отображается чекбокс «POI» после секции «Тропы»
|
||||
And чекбокс отделён горизонтальной линией от секции выше
|
||||
```
|
||||
|
||||
## AC-02: POI включены по умолчанию
|
||||
|
||||
```gherkin
|
||||
Given пользователь открыл карту впервые (нет ключа poi-visible в localStorage)
|
||||
When попап рельефа открыт
|
||||
Then чекбокс «POI» установлен (checked)
|
||||
And маркеры POI видны на карте
|
||||
```
|
||||
|
||||
## AC-03: Скрытие POI
|
||||
|
||||
```gherkin
|
||||
Given чекбокс «POI» установлен
|
||||
And маркеры POI видны на карте
|
||||
When пользователь снимает чекбокс «POI»
|
||||
Then маркеры POI исчезают с карты
|
||||
And слои poi-circles и poi-labels имеют visibility: none
|
||||
```
|
||||
|
||||
## AC-04: Показ POI
|
||||
|
||||
```gherkin
|
||||
Given чекбокс «POI» снят
|
||||
And маркеры POI скрыты
|
||||
When пользователь устанавливает чекбокс «POI»
|
||||
Then маркеры POI появляются на карте
|
||||
And слои poi-circles и poi-labels имеют visibility: visible
|
||||
```
|
||||
|
||||
## AC-05: Состояние сохраняется после перезагрузки
|
||||
|
||||
```gherkin
|
||||
Given пользователь снял чекбокс «POI»
|
||||
When пользователь перезагружает страницу
|
||||
Then чекбокс «POI» снят
|
||||
And маркеры POI скрыты
|
||||
```
|
||||
|
||||
## AC-06: Восстановление включённого состояния
|
||||
|
||||
```gherkin
|
||||
Given пользователь установил чекбокс «POI»
|
||||
When пользователь перезагружает страницу
|
||||
Then чекбокс «POI» установлен
|
||||
And маркеры POI видны
|
||||
```
|
||||
|
||||
## AC-07: Не ломает существующие чекбоксы
|
||||
|
||||
```gherkin
|
||||
Given попап рельефа открыт
|
||||
When пользователь переключает чекбоксы «Тени рельефа», «Перепады», «Грунтовки», «Тропы»
|
||||
Then каждый чекбокс работает как прежде
|
||||
And чекбокс «POI» не влияет на их поведение
|
||||
```
|
||||
|
||||
## AC-08: Синхронизация с layerState
|
||||
|
||||
```gherkin
|
||||
Given чекбокс «POI» снят
|
||||
When внешний код читает layerState.poi
|
||||
Then значение равно false
|
||||
```
|
||||
116
docs/work-items/ET-002/04-test-plan.yaml
Normal file
116
docs/work-items/ET-002/04-test-plan.yaml
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
type: test-plan
|
||||
work_item_id: ET-002
|
||||
title: "Test Plan: Чекбокс POI"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-05-20
|
||||
authors:
|
||||
- "agent:analyst"
|
||||
---
|
||||
|
||||
tests:
|
||||
- id: TP-01
|
||||
type: unit
|
||||
description: "onPoiCheckbox() устанавливает visibility слоёв"
|
||||
steps:
|
||||
- "Mock map.getLayer() → true"
|
||||
- "Mock map.setLayoutProperty()"
|
||||
- "Установить checkbox.checked = false"
|
||||
- "Вызвать onPoiCheckbox()"
|
||||
expected:
|
||||
- "setLayoutProperty('poi-circles', 'visibility', 'none') вызван"
|
||||
- "setLayoutProperty('poi-labels', 'visibility', 'none') вызван"
|
||||
- "localStorage.setItem('poi-visible', '0') вызван"
|
||||
- "layerState.poi === false"
|
||||
|
||||
- id: TP-02
|
||||
type: unit
|
||||
description: "onPoiCheckbox() показывает слои при checked=true"
|
||||
steps:
|
||||
- "Mock map.getLayer() → true"
|
||||
- "Установить checkbox.checked = true"
|
||||
- "Вызвать onPoiCheckbox()"
|
||||
expected:
|
||||
- "setLayoutProperty('poi-circles', 'visibility', 'visible') вызван"
|
||||
- "setLayoutProperty('poi-labels', 'visibility', 'visible') вызван"
|
||||
- "localStorage.setItem('poi-visible', '1') вызван"
|
||||
- "layerState.poi === true"
|
||||
|
||||
- id: TP-03
|
||||
type: unit
|
||||
description: "Восстановление состояния при загрузке — POI скрыты"
|
||||
steps:
|
||||
- "localStorage.setItem('poi-visible', '0')"
|
||||
- "Вызвать функцию инициализации POI"
|
||||
expected:
|
||||
- "checkbox.checked === false"
|
||||
- "layerState.poi === false"
|
||||
- "Слои скрыты"
|
||||
|
||||
- id: TP-04
|
||||
type: unit
|
||||
description: "Восстановление состояния при загрузке — POI видны (default)"
|
||||
steps:
|
||||
- "localStorage не содержит ключ poi-visible"
|
||||
- "Вызвать функцию инициализации POI"
|
||||
expected:
|
||||
- "checkbox.checked === true"
|
||||
- "layerState.poi === true"
|
||||
|
||||
- id: TP-05
|
||||
type: e2e
|
||||
description: "Чекбокс POI виден в попапе рельефа"
|
||||
steps:
|
||||
- "Открыть карту в браузере"
|
||||
- "Нажать кнопку «Рельеф»"
|
||||
expected:
|
||||
- "В попапе виден чекбокс с текстом «POI»"
|
||||
- "Чекбокс расположен после «Тропы», отделён линией"
|
||||
|
||||
- id: TP-06
|
||||
type: e2e
|
||||
description: "Переключение POI скрывает/показывает маркеры"
|
||||
steps:
|
||||
- "Открыть карту, дождаться загрузки POI"
|
||||
- "Открыть попап рельефа"
|
||||
- "Снять чекбокс POI"
|
||||
- "Убедиться что маркеры исчезли"
|
||||
- "Установить чекбокс POI"
|
||||
- "Убедиться что маркеры появились"
|
||||
expected:
|
||||
- "Маркеры POI скрываются и появляются в соответствии с чекбоксом"
|
||||
|
||||
- id: TP-07
|
||||
type: e2e
|
||||
description: "Состояние POI сохраняется после перезагрузки"
|
||||
steps:
|
||||
- "Открыть карту"
|
||||
- "Снять чекбокс POI"
|
||||
- "Перезагрузить страницу"
|
||||
- "Открыть попап рельефа"
|
||||
expected:
|
||||
- "Чекбокс POI снят"
|
||||
- "Маркеры POI не отображаются"
|
||||
|
||||
- id: TP-08
|
||||
type: integration
|
||||
description: "Чекбокс POI не влияет на другие слои"
|
||||
steps:
|
||||
- "Включить Тени рельефа и Грунтовки"
|
||||
- "Снять чекбокс POI"
|
||||
expected:
|
||||
- "Тени рельефа остаются видимыми"
|
||||
- "Грунтовки остаются видимыми"
|
||||
- "Только POI скрыты"
|
||||
|
||||
- id: TP-09
|
||||
type: e2e
|
||||
description: "Мобильная доступность чекбокса"
|
||||
steps:
|
||||
- "Открыть карту на мобильном viewport (375px)"
|
||||
- "Открыть попап рельефа"
|
||||
- "Тапнуть чекбокс POI"
|
||||
expected:
|
||||
- "Чекбокс нажимается без проблем"
|
||||
- "Touch target достаточный (≥ 44px)"
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-002
|
||||
adr_id: adr-0001
|
||||
title: "ADR-0001: Управление видимостью POI — клиентское решение на localStorage"
|
||||
status: accepted
|
||||
created_at: 2026-05-21
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels: []
|
||||
---
|
||||
|
||||
# ADR-0001 — Управление видимостью POI: клиентское решение на localStorage
|
||||
|
||||
## Статус
|
||||
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
ET-002 добавляет в попап кнопки «Рельеф» (`terrain-popup`) чекбокс «POI»,
|
||||
позволяющий пользователю скрывать маркеры POI (слои `poi-circles`,
|
||||
`poi-labels`), чтобы не загромождать карту при планировании маршрута.
|
||||
|
||||
Существующее состояние кодовой базы (`src/web/app.js`):
|
||||
|
||||
- объект `layerState = { tracks, paths, poi, basemap }` — внутренняя модель
|
||||
видимости слоёв (строка 377), `poi: true` по умолчанию;
|
||||
- карта групп слоёв `poi: ['poi-circles', 'poi-labels']` (строка 381);
|
||||
- функция `toggleLayer(group)` уже переключает видимость через
|
||||
`map.setLayoutProperty(..., 'visibility', ...)` (строки 385–389), но
|
||||
завязана на кнопку-тулбар (`btn.classList.toggle('active', ...)`), а не на
|
||||
чекбокс попапа;
|
||||
- сложился устойчивый паттерн персистентности в `localStorage` со
|
||||
значениями `'1'`/`'0'`: ключи `terrain-hillshade`, `terrain-tri`,
|
||||
`trails-track`, `trails-path`, `enduro-theme-mode`, `MARKERS_KEY`.
|
||||
|
||||
Backend (FastAPI) и БД (SQLite/Spatialite) к видимости POI отношения не
|
||||
имеют — POI отдаются как часть тайлов/источника, видимость регулируется
|
||||
исключительно на уровне рендера MapLibre.
|
||||
|
||||
## Рассматриваемые варианты
|
||||
|
||||
### Вариант A — Клиентское решение: `setLayoutProperty` + `localStorage` (выбран)
|
||||
|
||||
Видимость переключается через `map.setLayoutProperty()` для слоёв
|
||||
`poi-circles` и `poi-labels`; состояние хранится в `localStorage` под
|
||||
ключом `poi-visible`; `layerState.poi` остаётся единственным источником
|
||||
истины в рантайме.
|
||||
|
||||
- **Плюсы:** нулевые изменения backend/БД/инфраструктуры; переключение
|
||||
мгновенное (тайлы не перезагружаются — слой остаётся в источнике,
|
||||
меняется только `layout.visibility`); полностью консистентно с уже
|
||||
существующими чекбоксами попапа и принципом «минимум зависимостей».
|
||||
- **Минусы:** настройка не синхронизируется между устройствами/браузерами
|
||||
(для данной фичи это приемлемо и зафиксировано как Out of scope в BRD).
|
||||
|
||||
### Вариант B — Хранение настройки на backend (профиль пользователя)
|
||||
|
||||
Видимость POI сохраняется через новый API-эндпоинт в SQLite.
|
||||
|
||||
- **Плюсы:** синхронизация между устройствами.
|
||||
- **Минусы:** требует модели пользователя/сессий (в проекте отсутствует),
|
||||
новый эндпоинт, миграцию БД, сетевой round-trip — несопоставимо
|
||||
избыточно для одного UI-флага. Противоречит принципам BRD (минимум
|
||||
зависимостей, нет необходимости в server-state).
|
||||
|
||||
### Вариант C — Удаление/повторное добавление слоёв POI
|
||||
|
||||
Скрытие через `map.removeLayer()`, показ через `map.addLayer()`.
|
||||
|
||||
- **Плюсы:** нет «невидимых» слоёв в стиле.
|
||||
- **Минусы:** дороже по производительности, риск рассинхрона порядка
|
||||
слоёв и обработчиков событий (`map.on('click', 'poi-circles', ...)`,
|
||||
строка 1515), усложняет код. `setLayoutProperty` — штатный механизм
|
||||
MapLibre именно для этого сценария.
|
||||
|
||||
## Решение
|
||||
|
||||
Принимается **Вариант A**.
|
||||
|
||||
1. **Видимость** слоёв `poi-circles` и `poi-labels` переключается через
|
||||
`map.setLayoutProperty(layerId, 'visibility', 'visible' | 'none')`.
|
||||
2. **Персистентность** — `localStorage`, ключ `poi-visible`, значения
|
||||
`'1'` / `'0'`, в соответствии с уже сложившейся конвенцией проекта.
|
||||
3. **Источник истины в рантайме** — `layerState.poi`. Обработчик
|
||||
`onPoiCheckbox()` обязан синхронно обновлять `layerState.poi`,
|
||||
`localStorage` и `layout.visibility` обоих слоёв.
|
||||
4. **Без дублирования логики.** Логика установки видимости группы POI
|
||||
должна переиспользовать существующую карту групп слоёв
|
||||
(`poi: ['poi-circles','poi-labels']`). Допускается выделение общего
|
||||
приватного хелпера (например `applyPoiVisibility(visible)`),
|
||||
вызываемого как из `onPoiCheckbox()`, так и из восстановления
|
||||
состояния при загрузке. `toggleLayer('poi')` (тулбар-кнопка) и
|
||||
`onPoiCheckbox()` (чекбокс попапа) должны оставаться консистентны
|
||||
через общий `layerState.poi` — недопустимо, чтобы кнопка и чекбокс
|
||||
показывали разное состояние.
|
||||
5. **Backend, БД, API, инфраструктура — без изменений.** Состав
|
||||
компонентов C4 не меняется, обновление C4-диаграмм не требуется.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- Изменение полностью клиентское: deploy без миграций и без изменения
|
||||
состава контейнеров.
|
||||
- Переключение < 50 мс (REQ-NF-01) — тайлы не перезагружаются.
|
||||
- Конвенция `localStorage` остаётся единообразной — ниже когнитивная
|
||||
нагрузка при поддержке.
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- Настройка локальна для браузера: при очистке `localStorage` или смене
|
||||
устройства сбрасывается на дефолт (POI включены). Принято осознанно.
|
||||
- В стиле карты остаются слои с `visibility: none` — это штатно для
|
||||
MapLibre и не влияет на производительность.
|
||||
|
||||
### Технический долг
|
||||
|
||||
- `toggleLayer()` и `onPoiCheckbox()` частично дублируют намерение
|
||||
«переключить группу слоёв». Если в будущих фазах появятся ещё
|
||||
popup-чекбоксы для слоёв, стоит унифицировать тулбар-кнопки и
|
||||
popup-чекбоксы в единый контроллер слоёв. На ET-002 — вне scope.
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
Minor change. Новые сервисы/БД/эндпоинты не вводятся, состав компонентов
|
||||
не меняется. Лейбл `arch:major-change` **не требуется**. Обязательного
|
||||
архитектурного approve не требуется.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-002/01-brd.md`
|
||||
- `docs/work-items/ET-002/02-trz.md`
|
||||
- `docs/work-items/ET-002/03-acceptance-criteria.md`
|
||||
- `docs/work-items/ET-002/07-infra-requirements.md`
|
||||
- `docs/architecture/README.md`
|
||||
94
docs/work-items/ET-002/07-infra-requirements.md
Normal file
94
docs/work-items/ET-002/07-infra-requirements.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: infra-requirements
|
||||
work_item_id: ET-002
|
||||
title: "Инфраструктурные требования — ET-002: Чекбокс показа/скрытия POI"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-05-21
|
||||
authors:
|
||||
- "agent:architect"
|
||||
---
|
||||
|
||||
# Инфраструктурные требования — ET-002
|
||||
|
||||
## 1. Резюме
|
||||
|
||||
ET-002 — изменение **исключительно фронтенда** (`src/web/index.html`,
|
||||
`src/web/app.js`). Новой инфраструктуры не требуется. Документ
|
||||
зафиксирован для полноты work-item и явно подтверждает отсутствие
|
||||
инфра-воздействия (см. ADR-0001).
|
||||
|
||||
## 2. Контейнеры и сервисы
|
||||
|
||||
| Аспект | Требование |
|
||||
|--------|------------|
|
||||
| Новые контейнеры | Нет |
|
||||
| Изменения существующих сервисов (api, osrm, nginx) | Нет |
|
||||
| Изменения `docker-compose.yml` | Нет |
|
||||
| Изменения `Dockerfile` | Нет |
|
||||
|
||||
## 3. Сеть
|
||||
|
||||
| Аспект | Требование |
|
||||
|--------|------------|
|
||||
| Новые порты | Нет |
|
||||
| Изменения reverse proxy (nginx, `/enduro/`) | Нет |
|
||||
| Новые внешние домены / DNS | Нет |
|
||||
| Исходящие сетевые вызовы из фронтенда | Нет (вся логика локальная) |
|
||||
|
||||
## 4. Хранилища данных
|
||||
|
||||
| Аспект | Требование |
|
||||
|--------|------------|
|
||||
| Изменения схемы SQLite/Spatialite | Нет |
|
||||
| Миграции БД (`migrations/`) | Нет |
|
||||
| Серверное хранилище состояния | Нет |
|
||||
| Клиентское хранилище | `localStorage`, ключ `poi-visible` (`'1'`/`'0'`), ≈ 1 байт полезной нагрузки на браузер |
|
||||
|
||||
## 5. Конфигурация и секреты
|
||||
|
||||
| Аспект | Требование |
|
||||
|--------|------------|
|
||||
| Новые переменные окружения | Нет |
|
||||
| Новые секреты | Нет |
|
||||
| Изменения конфигурации FastAPI / uvicorn | Нет |
|
||||
|
||||
## 6. Зависимости
|
||||
|
||||
| Аспект | Требование |
|
||||
|--------|------------|
|
||||
| Новые npm/Python пакеты | Нет |
|
||||
| Новые внешние сервисы | Нет |
|
||||
| Версия MapLibre GL JS | Без изменений (`setLayoutProperty` — штатный API) |
|
||||
|
||||
## 7. Сборка и деплой
|
||||
|
||||
- **Pipeline:** существующий Gitea Actions без изменений (`lint`, `test`,
|
||||
`build`).
|
||||
- **Артефакт:** статические ассеты фронтенда (`src/web/`). Деплой —
|
||||
штатная пересборка/перевыкладка и `docker compose up -d` на mva154.
|
||||
- **Простой (downtime):** нет — изменение только в статике фронтенда.
|
||||
- **План отката:** обратный коммит (revert) и повторный деплой;
|
||||
миграций/состояния, требующих отдельного отката, нет.
|
||||
|
||||
## 8. Ресурсы (CPU / RAM / диск)
|
||||
|
||||
Воздействие отсутствует. Переключение `layout.visibility` слоёв
|
||||
выполняется в браузере клиента; тайлы не перезапрашиваются (REQ-NF-01).
|
||||
|
||||
## 9. Наблюдаемость
|
||||
|
||||
Новые метрики, логи и алерты не требуются. Поведение проверяется
|
||||
e2e-тестами фронтенда согласно `04-test-plan.yaml`.
|
||||
|
||||
## 10. Влияние на C4
|
||||
|
||||
Состав компонентов системы не меняется. Обновление
|
||||
`docs/architecture/c4-*.mmd` не требуется (диаграммы C4 в репозитории
|
||||
на данный момент отсутствуют — только `docs/architecture/README.md`).
|
||||
|
||||
## 11. Вывод
|
||||
|
||||
Инфраструктурных, сетевых, конфигурационных изменений и изменений БД
|
||||
**нет**. ET-002 безопасен для деплоя в рамках обычного релизного цикла
|
||||
фронтенда. Эскалация `arch:major-change` не требуется.
|
||||
144
docs/work-items/ET-002/09-review.md
Normal file
144
docs/work-items/ET-002/09-review.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ET-002
|
||||
title: "Code Review: Чекбокс показа/скрытия POI в попапе рельефа"
|
||||
version: 2
|
||||
status: approved
|
||||
verdict: APPROVED
|
||||
created_at: 2026-05-21
|
||||
authors:
|
||||
- "agent:reviewer"
|
||||
---
|
||||
|
||||
# Code Review — ET-002
|
||||
|
||||
## Вердикт
|
||||
|
||||
**APPROVED** — найдены только замечания уровня P3 (nice-to-have).
|
||||
Блокеров (P0) и must-fix (P1) нет.
|
||||
|
||||
## Объём ревью
|
||||
|
||||
Проверены изменения в `src/web/app.js` и `src/web/index.html` против:
|
||||
|
||||
- `docs/work-items/ET-002/02-trz.md` (ТЗ, v1, approved)
|
||||
- `docs/work-items/ET-002/03-acceptance-criteria.md` (AC, v1, approved)
|
||||
- `docs/work-items/ET-002/06-adr/adr-0001-poi-visibility-client-side.md`
|
||||
- `docs/work-items/ET-002/04-test-plan.yaml`
|
||||
- `CLAUDE.md`
|
||||
|
||||
Дополнительно учтён юнит-тест `tests/unit/poi_toggle.test.js`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Соответствие ТЗ
|
||||
|
||||
| Требование | Статус | Где реализовано |
|
||||
|------------|--------|-----------------|
|
||||
| REQ-F-01 — чекбокс «POI» в `terrain-popup` после `trails-path-cb`, отделён `<hr>` | ✅ | `index.html:56-60` |
|
||||
| REQ-F-02 — состояние по умолчанию: checked, POI видимы | ✅ | `index.html:58` (атрибут `checked`); `app.js:2848-2852` (`stored === null → poiOn = true`) |
|
||||
| REQ-F-03 — снятие чекбокса → `visibility: 'none'` для `poi-circles`, `poi-labels` | ✅ | `app.js:2833-2837` → `applyPoiVisibility(false)` → `app.js:2815-2825` |
|
||||
| REQ-F-04 — установка чекбокса → `visibility: 'visible'` | ✅ | те же функции, ветка `visible` |
|
||||
| REQ-F-05 — сохранение в `localStorage['poi-visible']` (`'1'`/`'0'`) | ✅ | `app.js:2835` |
|
||||
| REQ-F-06 — восстановление при загрузке (`'0'` скрыть / `'1'`\|null показать) | ✅ | `app.js:2847-2853` (`restorePoiState`) |
|
||||
| REQ-F-07 — синхронизация `layerState.poi` | ✅ | `app.js:2816` (`layerState.poi = visible` в общем хелпере) |
|
||||
| REQ-NF-01 — переключение < 50 мс, без перезагрузки тайлов | ✅ | используется `setLayoutProperty`, источник слоёв не трогается |
|
||||
| REQ-NF-02 — совместимость с MapLibre GL JS | ✅ | штатные API MapLibre, без экзотики |
|
||||
| REQ-NF-03 — мобильный touch target ≥ 44px | ⚠️ см. P3-2 | переиспользован класс `terrain-checkbox` (как предписано ТЗ §3) |
|
||||
| REQ-NF-04 — отсутствие регрессий | ✅ | существующие чекбоксы и их обработчики не затронуты |
|
||||
|
||||
UI-спецификация ТЗ §3 (разметка, `id="poi-visible-cb"`, `onchange="onPoiCheckbox()"`,
|
||||
класс `terrain-checkbox`) воспроизведена в `index.html` дословно.
|
||||
|
||||
**Вывод:** все функциональные требования выполнены.
|
||||
|
||||
## 2. Соответствие ADR (adr-0001)
|
||||
|
||||
| Пункт решения | Статус | Комментарий |
|
||||
|---------------|--------|-------------|
|
||||
| 1. Видимость через `map.setLayoutProperty(... 'visibility' ...)` | ✅ | `app.js:2822` |
|
||||
| 2. Персистентность — `localStorage['poi-visible']`, `'1'`/`'0'` | ✅ | соответствует конвенции проекта (`trails-track`, `terrain-*`) |
|
||||
| 3. Источник истины — `layerState.poi`; `onPoiCheckbox()` синхронно обновляет `layerState`, `localStorage`, `layout.visibility` | ✅ | `onPoiCheckbox` → `setItem` + `applyPoiVisibility` (правит `layerState` и оба слоя) |
|
||||
| 4. Без дублирования: общий приватный хелпер, переиспользование `layerGroups.poi` | ✅ | выделен `applyPoiVisibility(visible)`, итерирует `layerGroups.poi`; используется и `onPoiCheckbox`, и `restorePoiState` — ровно как предлагает ADR |
|
||||
| 5. Backend/БД/API/инфраструктура без изменений | ✅ | изменения чисто клиентские |
|
||||
|
||||
Выбран и реализован Вариант A. Решение полностью соответствует ADR.
|
||||
Замечание по консистентности с `toggleLayer('poi')` — см. P3-1.
|
||||
|
||||
## 3. Качество кода
|
||||
|
||||
Сильные стороны:
|
||||
|
||||
- Изменения локализованы в явно размеченном блоке
|
||||
`>>> ET-002 POI visibility block ... <<<` (`app.js:2800-2854`) — удобно
|
||||
для ревью и для тест-харнеса.
|
||||
- JSDoc на всех трёх функциях, есть ссылка на ADR в шапке блока.
|
||||
- `applyPoiVisibility` имеет guard `if (!map) return` и проверку
|
||||
`map.getLayer(id)` перед `setLayoutProperty` — устойчиво к раннему вызову
|
||||
и к смене стиля.
|
||||
- `restorePoiState` корректно интегрирован в существующую цепочку
|
||||
восстановления: `style.load → onMapStyleLoad → rebuildMapOverlays`
|
||||
(`app.js:131`) — POI восстанавливается и при первой загрузке, и при
|
||||
переключении темы. Паттерн идентичен `restoreTrailsState`.
|
||||
- `restorePoiState` не пишет в `localStorage` (восстановление не должно
|
||||
иметь побочных эффектов) — поведение задокументировано в JSDoc и
|
||||
покрыто тестом TP-03.
|
||||
- Разделение ответственности: персистентность — только в `onPoiCheckbox`,
|
||||
применение видимости — в общем хелпере.
|
||||
|
||||
Замечаний P0/P1/P2 нет.
|
||||
|
||||
## 4. Качество тестов
|
||||
|
||||
`tests/unit/poi_toggle.test.js` исполняет **реальный** код из `app.js`
|
||||
(блок извлекается по маркерам ET-002 и оборачивается через `new Function`
|
||||
с мок-зависимостями) — это покрывает риск рассинхрона тестов и продакшн-кода.
|
||||
|
||||
- TP-01 — снятие чекбокса: скрытие слоёв + `setItem('poi-visible','0')` + `layerState.poi=false` ✅
|
||||
- TP-02 — установка чекбокса: показ слоёв + `setItem('1')` + `layerState.poi=true` ✅
|
||||
- TP-03 — `restorePoiState()` при `'0'`: скрытие + чекбокс снят + без записи в `localStorage` ✅
|
||||
- TP-04 — `restorePoiState()` без ключа: дефолт «видимы» ✅
|
||||
- Доп.: значение `'1'`, изоляция чужих слоёв (дух TP-08), синхронизация
|
||||
`layerState` без слоёв на карте.
|
||||
|
||||
TP-05..TP-07, TP-09 (e2e) и TP-08 (integration) по своей природе не
|
||||
покрываются юнит-тестом — это ожидаемо и не является замечанием к данному PR.
|
||||
|
||||
## Findings
|
||||
|
||||
### P3-1 (nice-to-have) — рассинхрон `toggleLayer('poi')` с чекбоксом
|
||||
|
||||
`toggleLayer(group)` (`app.js:386-396`) меняет `layerState.poi`, но не
|
||||
обновляет ни чекбокс `poi-visible-cb`, ни `localStorage`. ADR-0001 п.4
|
||||
указывает, что состояние кнопки и чекбокса не должно расходиться.
|
||||
|
||||
Смягчающие факты: `toggleLayer` не имеет ни одного вызова в кодовой базе,
|
||||
элемента `btn-poi` в `index.html` нет (вызов `toggleLayer('poi')` упал бы
|
||||
на `btn.classList`). Фактически это недостижимый код, расхождение
|
||||
пользователю не наблюдаемо. ADR сам относит унификацию тулбар-кнопок и
|
||||
popup-чекбоксов к будущему техдолгу (раздел «Технический долг»).
|
||||
|
||||
Рекомендация (вне scope ET-002): удалить мёртвый `toggleLayer` либо
|
||||
оформить unified-контроллер слоёв отдельной задачей.
|
||||
|
||||
### P3-2 (nice-to-have) — REQ-NF-03 не проверяется по диффу
|
||||
|
||||
Touch target ≥ 44px зависит от CSS-класса `terrain-checkbox` в `app.css`,
|
||||
который в рамках ET-002 не менялся (ТЗ §3 предписывает переиспользовать
|
||||
существующий класс). Замечания к коду нет — отметка для приёмки:
|
||||
подтвердить REQ-NF-03 прогоном e2e-теста TP-09.
|
||||
|
||||
### P3-3 (nice-to-have) — неровный отступ в IIFE `initTerrain`
|
||||
|
||||
В `app.js:2949-2950` и `2962-2963` вызовы `restoreTrailsState()` /
|
||||
`restorePoiState()` имеют отступ, не совпадающий с окружающим блоком.
|
||||
Это предсуществующая стилевая мелочь, которую ET-002 лишь продолжил.
|
||||
Косметика; рекомендуется выровнять при ближайшем касании файла.
|
||||
|
||||
## Итог
|
||||
|
||||
Реализация полностью соответствует ТЗ и ADR-0001, код аккуратен, юнит-тесты
|
||||
исполняют реальный код и покрывают TP-01..TP-04. Найдены только три
|
||||
замечания уровня P3, ни одно из которых не блокирует мерж.
|
||||
|
||||
**Вердикт: APPROVED.**
|
||||
168
docs/work-items/ET-002/12-review.md
Normal file
168
docs/work-items/ET-002/12-review.md
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ET-002
|
||||
title: "Code Review: Чекбокс показа/скрытия POI в попапе рельефа"
|
||||
version: 1
|
||||
status: approved
|
||||
verdict: APPROVED
|
||||
created_at: 2026-05-21
|
||||
authors:
|
||||
- "agent:reviewer"
|
||||
---
|
||||
|
||||
# Code Review — ET-002
|
||||
|
||||
## Вердикт
|
||||
|
||||
**APPROVED** (с комментариями).
|
||||
|
||||
P0/P1-findings нет. Зафиксировано 1×P2 и 3×P3 — все некритичные, не
|
||||
блокируют merge. P2 относится к рассогласованию апстрим-артефактов
|
||||
(test-plan vs infra-requirements), а не к дефекту реализации.
|
||||
|
||||
## Объект ревью
|
||||
|
||||
- Ветка: `feature/ET-002-poi-toggle`
|
||||
- Базовая точка: `main` (832099c3) — ветка на 4 коммита впереди
|
||||
- Код-коммит: `8c17a4f` `feat(web): add POI visibility checkbox to terrain popup`
|
||||
- Изменённые файлы кода:
|
||||
- `src/web/index.html` (+5 строк)
|
||||
- `src/web/app.js` (+58 строк)
|
||||
- `tests/unit/poi_toggle.test.js` (новый, 167 строк)
|
||||
- `tests/unit/test_poi_toggle.py` (новый, 162 строки)
|
||||
- Прочитано: `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`,
|
||||
`06-adr/adr-0001-poi-visibility-client-side.md`, `07-infra-requirements.md`,
|
||||
`CLAUDE.md`.
|
||||
|
||||
## 1. Соответствие ТЗ
|
||||
|
||||
| Требование | Статус | Комментарий |
|
||||
|------------|--------|-------------|
|
||||
| REQ-F-01 Чекбокс в попапе после «Тропы», отделён `<hr>` | ✅ | `index.html:56-60` — `<hr>` + `label.terrain-checkbox`, идёт сразу после `trails-path-cb` |
|
||||
| REQ-F-02 Состояние по умолчанию — checked | ✅ | Атрибут `checked` в HTML; `restorePoiState()` при отсутствии ключа даёт `poiOn = true` |
|
||||
| REQ-F-03 Скрытие → `visibility: 'none'` обоих слоёв | ✅ | `applyPoiVisibility(false)` → `setLayoutProperty` для `layerGroups.poi` |
|
||||
| REQ-F-04 Показ → `visibility: 'visible'` | ✅ | `applyPoiVisibility(true)` |
|
||||
| REQ-F-05 Сохранение в `localStorage` `poi-visible` `'1'`/`'0'` | ✅ | `onPoiCheckbox()` → `localStorage.setItem('poi-visible', …)` |
|
||||
| REQ-F-06 Восстановление при загрузке | ⚠️ | Реализовано; нештатные значения ключа трактуются как «скрыть» — см. R-02 (P3) |
|
||||
| REQ-F-07 Синхронизация `layerState.poi` | ✅ | `applyPoiVisibility()` пишет `layerState.poi` первой строкой |
|
||||
| REQ-NF-01 Производительность < 50 мс | ✅ | Только `setLayoutProperty`, тайлы не перезапрашиваются |
|
||||
| REQ-NF-02 Совместимость браузеров | ✅ | Стандартные API (`localStorage`, `setLayoutProperty`) |
|
||||
| REQ-NF-03 Мобильная доступность ≥ 44px | ✅ | Переиспользован общий класс `terrain-checkbox` (как у соседних чекбоксов) |
|
||||
| REQ-NF-04 Отсутствие регрессий | ✅ | Изменения аддитивные; точки восстановления зеркалят `restoreTrailsState()` |
|
||||
|
||||
Все функциональные и нефункциональные требования ТЗ выполнены.
|
||||
|
||||
## 2. Соответствие ADR-0001
|
||||
|
||||
| Пункт решения ADR | Статус | Комментарий |
|
||||
|-------------------|--------|-------------|
|
||||
| п.1 Видимость через `setLayoutProperty` | ✅ | Подтверждено; `removeLayer`/`addLayer` не используются (Вариант C отвергнут корректно) |
|
||||
| п.2 Персистентность `localStorage` `poi-visible` `'1'`/`'0'` | ✅ | Соответствует конвенции проекта |
|
||||
| п.3 Источник истины — `layerState.poi`; `onPoiCheckbox()` синхронно обновляет state + storage + visibility | ✅ | Выполнено |
|
||||
| п.4 Без дублирования; переиспользование `layerGroups.poi`; общий хелпер | ✅ | Создан `applyPoiVisibility(visible)` — буквально имя, предложенное в ADR; используется и `onPoiCheckbox()`, и `restorePoiState()` |
|
||||
| п.4 Консистентность `toggleLayer('poi')` ↔ `onPoiCheckbox()` через `layerState.poi` | ✅ | Обе функции читают/пишут `layerState.poi`. POI-кнопки в тулбаре нет (`btn-poi` отсутствует, `toggleLayer` нигде не вызывается) — визуальный рассинхрон невозможен |
|
||||
| п.5 Backend/БД/API/инфраструктура без изменений | ✅ | Затронут только `src/web/` |
|
||||
|
||||
Реализация полностью соответствует принятому ADR. Отдельно отмечается
|
||||
точное следование п.4 — выделен ровно тот приватный хелпер, который ADR
|
||||
описывал как рекомендуемый.
|
||||
|
||||
## 3. Acceptance Criteria
|
||||
|
||||
| AC | Покрытие |
|
||||
|----|----------|
|
||||
| AC-01 Чекбокс в попапе после «Тропы», отделён линией | ✅ `test_poi_checkbox_placed_after_trails_separated_by_hr` |
|
||||
| AC-02 POI включены по умолчанию | ✅ `test_poi_checkbox_checked_by_default`, JS `TP-04` |
|
||||
| AC-03 Скрытие POI | ✅ JS `TP-01` |
|
||||
| AC-04 Показ POI | ✅ JS `TP-02` |
|
||||
| AC-05 Состояние сохраняется после перезагрузки | ✅ JS `TP-03` (restore при `poi-visible=0`) |
|
||||
| AC-06 Восстановление включённого состояния | ✅ JS `restorePoiState() при poi-visible=1` |
|
||||
| AC-07 Не ломает существующие чекбоксы | ✅ JS `onPoiCheckbox() меняет только poi-circles/poi-labels`; изменения HTML аддитивны |
|
||||
| AC-08 Синхронизация с `layerState` | ✅ JS `TP-01`/`TP-02` (`layerState.poi`), `applyPoiVisibility()` без слоёв |
|
||||
|
||||
Все 8 критериев имеют поведенческое покрытие.
|
||||
|
||||
## 4. Качество кода
|
||||
|
||||
Сильные стороны:
|
||||
|
||||
- **Нет дублирования.** Логика видимости группы вынесена в единый
|
||||
`applyPoiVisibility()`; карта слоёв `layerGroups.poi` не продублирована.
|
||||
- **Консистентность с кодовой базой.** `restorePoiState()` подключён в
|
||||
тех же трёх точках, что и `restoreTrailsState()` (`rebuildMapOverlays`
|
||||
+ обе ветки `initTerrain`), используется `window._map`, паттерн
|
||||
`localStorage` `'1'`/`'0'` — всё единообразно с существующим кодом.
|
||||
- **Защитное программирование.** Проверки `if (!map)`, `if (map.getLayer(id))`,
|
||||
`if (cb)`; `layerState.poi` обновляется даже когда карта/слои ещё не
|
||||
готовы (порядок инициализации безопасен).
|
||||
- **Документированность.** JSDoc на всех трёх функциях, блок-маркеры с
|
||||
ссылкой на ADR, запись в `CHANGELOG.md`.
|
||||
- **Коммиты.** Conventional Commits соблюдён (`feat(web):`, `docs(ET-002):`).
|
||||
|
||||
Замечания — см. findings R-02, R-03 (P3).
|
||||
|
||||
## 5. Качество тестов
|
||||
|
||||
- **Unit (`poi_toggle.test.js`)** — высокое качество: тесты исполняют
|
||||
**реальный** код из `app.js` (блок извлекается по маркерам и
|
||||
оборачивается через `new Function` с инъекцией моков), а не его копию.
|
||||
Покрыты TP-01..TP-04 + 3 дополнительных кейса (значение `'1'`,
|
||||
изоляция чужих слоёв, синхронизация state без слоёв на карте).
|
||||
- **Python (`test_poi_toggle.py`)** — статические проверки структуры
|
||||
HTML/JS (REQ-F-01, REQ-F-02, ADR-0001) + запуск JS-раннера через
|
||||
`node --test`, со `skip` при отсутствии `node` (по аналогии с
|
||||
существующим `test_lua_syntax`/`luac`).
|
||||
- Браузерные e2e TP-05..TP-09 не реализованы — см. R-01 (P2).
|
||||
|
||||
## Findings
|
||||
|
||||
### R-01 — Отклонение от test-plan: e2e TP-05..TP-09 не реализованы (P2)
|
||||
|
||||
`04-test-plan.yaml` определяет TP-05..TP-09 как `type: e2e`, однако
|
||||
`07-infra-requirements.md` §6 явно запрещает новые npm-пакеты, а
|
||||
Playwright-инфраструктуры в репозитории нет. Это конфликт между двумя
|
||||
**approved**-артефактами, а не дефект разработчика: реализовать e2e «как
|
||||
написано» означало бы нарушить инфра-требования. Поведение всех
|
||||
сценариев покрыто статическими + unit-тестами, отклонение подробно
|
||||
задокументировано в шапке `test_poi_toggle.py`.
|
||||
**Рекомендация:** Analyst — согласовать `04-test-plan.yaml` с
|
||||
`07-infra-requirements.md` (пометить TP-05..09 как покрытые альтернативно
|
||||
либо завести отдельную инфра-задачу на Playwright). На merge ET-002 не
|
||||
влияет.
|
||||
|
||||
### R-02 — `restorePoiState()`: нештатное значение ключа скрывает POI (P3)
|
||||
|
||||
`const poiOn = stored === null || stored === '1';` — любое значение,
|
||||
отличное от `'1'`/`null` (например мусор в `localStorage`), трактуется
|
||||
как «скрыть». REQ-F-06 описывает только `'0'` и `'1'`/`null`, а дефолт
|
||||
фичи (REQ-F-02) — POI включены. Надёжнее: `const poiOn = stored !== '0';`
|
||||
— тогда повреждённое значение деградирует к дефолту «показать».
|
||||
Крайний кейс (ключ пишет только это приложение), отсюда P3.
|
||||
|
||||
### R-03 — Непоследовательные отступы в `initTerrain` (P3)
|
||||
|
||||
`app.js:2948-2950` и `2961-2963`: `restoreTrailsState()`/`restorePoiState()`
|
||||
смещены относительно `restoreTerrainState()`. Дефект унаследован из
|
||||
существующего кода — новые строки повторяют локальный (сломанный) отступ.
|
||||
Косметика; при желании выровнять блок целиком.
|
||||
|
||||
### R-04 — Хрупкая связанность тестов с исходником (P3)
|
||||
|
||||
Тесты зависят от комментариев-маркеров `>>> ET-002 POI visibility block`
|
||||
в `app.js` и от точного совпадения пробелов в
|
||||
`test_poi_logic_reuses_layer_state_and_groups`
|
||||
(`"poi: ['poi-circles', 'poi-labels']"`). Для монолитного non-module
|
||||
`app.js` это прагматичное и задокументированное решение, но хрупкое при
|
||||
рефакторинге/прогоне линтера. Технический долг — учесть при будущей
|
||||
модуляризации фронтенда.
|
||||
|
||||
## Заключение
|
||||
|
||||
Реализация ET-002 корректна, полна и точно следует ТЗ и ADR-0001.
|
||||
Дублирования нет, регрессионные риски закрыты повторением проверенного
|
||||
паттерна `restoreTrailsState()`. Тесты исполняют реальный код и
|
||||
покрывают все acceptance-критерии. Блокирующих замечаний нет.
|
||||
|
||||
**Вердикт: APPROVED.** R-02/R-03/R-04 — на усмотрение разработчика
|
||||
(можно поправить в этом же PR или вынести в техдолг). R-01 — действие на
|
||||
стороне Analyst, merge не блокирует.
|
||||
227
docs/work-items/ET-002/13-test-report.md
Normal file
227
docs/work-items/ET-002/13-test-report.md
Normal file
@@ -0,0 +1,227 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ET-002
|
||||
version: 1
|
||||
status: pass
|
||||
tester: "agent:tester"
|
||||
date: 2026-05-21
|
||||
commit_tested: 8c17a4f
|
||||
verdict: PASS
|
||||
---
|
||||
|
||||
# Test Report — ET-002
|
||||
|
||||
## Verdict: **PASS** → `stage:ready-to-deploy`
|
||||
|
||||
Полный регресс зелёный: **14 passed, 4 skipped, 0 failed**, lint чистый,
|
||||
JS-юнит-тесты POI 7/7 pass, тест-окружение отвечает 200. Блокирующих
|
||||
багов (P0/P1) не найдено.
|
||||
|
||||
Тест-кейсы TP-01..TP-04 и TP-08 из `04-test-plan.yaml` исполнены и
|
||||
прошли. e2e-кейсы TP-05..TP-07, TP-09 **не исполнялись** — в репозитории
|
||||
нет Playwright-инфраструктуры, а `07-infra-requirements.md §6` запрещает
|
||||
новые npm-пакеты (конфликт двух approved-артефактов, зафиксирован
|
||||
reviewer'ом как R-01/P2). Поведенческая суть этих сценариев покрыта
|
||||
unit- и статическими тестами. На merge/деплой ET-002 не влияет.
|
||||
|
||||
## Окружение
|
||||
|
||||
- **Дата прогона:** 2026-05-21
|
||||
- **Ветка:** `feature/ET-002-poi-toggle`
|
||||
- **Коммит:** `8c17a4f` (`feat(web): add POI visibility checkbox to terrain popup`;
|
||||
взят из `12-review.md` — `git` в окружении тестера недоступен)
|
||||
- **Python:** 3.12.13
|
||||
- **pytest:** 8.3.3 (plugins: asyncio-1.3.0, anyio-4.13.0)
|
||||
- **Node:** v22.22.2 (`node --test`)
|
||||
- **ruff:** 0.15.14
|
||||
- **test-env:** https://openclaw.mva154.duckdns.org/enduro/ → HTTP 200
|
||||
|
||||
## Healthcheck
|
||||
|
||||
| Среда | URL | Код |
|
||||
|---|---|---|
|
||||
| local dev | http://localhost:5556/health | connection refused (dev не поднят — ОК, прогон оффлайн) |
|
||||
| test | https://openclaw.mva154.duckdns.org/enduro/ | 200 |
|
||||
| test API | https://openclaw.mva154.duckdns.org/enduro/api/health | 200 `{"status":"ok","db_exists":true}` |
|
||||
|
||||
ET-002 — фронтенд-изменение; в test задеплоен `main`, поэтому healthcheck
|
||||
подтверждает только живость окружения, а не наличие фичи (фича попадёт
|
||||
в test штатной перевыкладкой после merge).
|
||||
|
||||
## Команды запуска
|
||||
|
||||
```bash
|
||||
# Unit + integration (эквивалент make test)
|
||||
python -m pytest tests/ -v
|
||||
|
||||
# JS behavioral unit-тесты POI (TP-01..TP-04)
|
||||
node --test tests/unit/poi_toggle.test.js
|
||||
|
||||
# Lint
|
||||
ruff check src/
|
||||
ruff check tests/
|
||||
```
|
||||
|
||||
## Результаты pytest
|
||||
|
||||
`python -m pytest tests/ -v` → **14 passed, 4 skipped, 1 warning in 0.51s**
|
||||
|
||||
| # | Тест | Тип | Результат |
|
||||
|---|---|---|---|
|
||||
| 1 | `test_routing_barriers.py::test_lua_syntax` | unit (структура lua) | **PASS** |
|
||||
| 2 | `test_routing_barriers.py::test_blocked_barriers_match_trz` | static AC | **PASS** |
|
||||
| 3 | `test_routing_barriers.py::test_excluded_highways_match_trz` | static AC | **PASS** |
|
||||
| 4 | `test_routing_barriers.py::test_route_avoids_barrier` | integration | **SKIP** (OSRM недоступен) |
|
||||
| 5 | `test_routing_barriers.py::test_route_no_footway` | integration | **SKIP** (OSRM недоступен) |
|
||||
| 6 | `test_routing_barriers.py::test_route_allows_cattle_grid` | integration | **SKIP** (OSRM недоступен) |
|
||||
| 7 | `test_routing_barriers.py::test_existing_route_works` | regression | **SKIP** (OSRM недоступен) |
|
||||
| 8 | `test_health.py::test_health_endpoint` | unit (async) | **PASS** |
|
||||
| 9 | `test_poi_toggle.py::test_poi_checkbox_present_in_html` | static (REQ-F-01) | **PASS** |
|
||||
| 10 | `test_poi_toggle.py::test_poi_checkbox_checked_by_default` | static (REQ-F-02) | **PASS** |
|
||||
| 11 | `test_poi_toggle.py::test_poi_checkbox_placed_after_trails_separated_by_hr` | static (REQ-F-01) | **PASS** |
|
||||
| 12 | `test_poi_toggle.py::test_poi_checkbox_uses_shared_style_class` | static (UI-спец) | **PASS** |
|
||||
| 13 | `test_poi_toggle.py::test_poi_functions_defined` | static (ADR-0001) | **PASS** |
|
||||
| 14 | `test_poi_toggle.py::test_poi_logic_uses_localstorage_key` | static (REQ-F-05) | **PASS** |
|
||||
| 15 | `test_poi_toggle.py::test_poi_logic_reuses_layer_state_and_groups` | static (ADR п.3-4) | **PASS** |
|
||||
| 16 | `test_poi_toggle.py::test_restore_poi_state_wired_into_init` | static (REQ-F-06) | **PASS** |
|
||||
| 17 | `test_poi_toggle.py::test_poi_visibility_toggled_via_set_layout_property` | static (ADR п.1) | **PASS** |
|
||||
| 18 | `test_poi_toggle.py::test_js_unit_tests_pass` | behavioral (Node-раннер) | **PASS** |
|
||||
|
||||
**4 SKIP** — интеграционные тесты роутинга ET-001; требуют поднятого OSRM
|
||||
(`OSRM_URL`, по умолчанию `http://172.22.0.1:5559`), который в окружении
|
||||
тестера недоступен. Это штатное поведение (`test_routing_barriers.py`
|
||||
помечает их `skip`, чтобы CI без инфраструктуры не падал). ET-002 —
|
||||
фронтенд-изменение, на роутинг влиять не может; к регрессу не относится.
|
||||
|
||||
Предупреждение `PendingDeprecationWarning` из `starlette/formparsers.py` —
|
||||
внешняя зависимость, к ET-002 отношения не имеет, не блокирует.
|
||||
|
||||
## Результаты JS unit-тестов (TP-01..TP-04)
|
||||
|
||||
`node --test tests/unit/poi_toggle.test.js` → **# pass 7, # fail 0**
|
||||
|
||||
Тесты исполняют **реальный** код POI-блока из `src/web/app.js` (блок
|
||||
извлекается по маркерам `ET-002 POI visibility block` и оборачивается
|
||||
через `new Function` с инъекцией моков).
|
||||
|
||||
| Тест | TC | Результат |
|
||||
|---|---|---|
|
||||
| снятый чекбокс скрывает слои POI и сохраняет `0` | TP-01 | **PASS** |
|
||||
| установленный чекбокс показывает слои POI и сохраняет `1` | TP-02 | **PASS** |
|
||||
| `restorePoiState()` при `poi-visible=0` скрывает POI | TP-03 | **PASS** |
|
||||
| `restorePoiState()` без ключа включает POI по умолчанию | TP-04 | **PASS** |
|
||||
| `restorePoiState()` при `poi-visible=1` показывает POI | доп. | **PASS** |
|
||||
| `onPoiCheckbox()` меняет только `poi-circles`/`poi-labels` | доп. (дух TP-08) | **PASS** |
|
||||
| `applyPoiVisibility()` синхронизирует `layerState` без слоёв | доп. | **PASS** |
|
||||
|
||||
## Результаты lint
|
||||
|
||||
| Команда | Результат |
|
||||
|---|---|
|
||||
| `ruff check src/` | **All checks passed!** |
|
||||
| `ruff check tests/` | **All checks passed!** |
|
||||
|
||||
## Покрытие тест-плана (04-test-plan.yaml)
|
||||
|
||||
| TC | Тип | Исполнение | Статус |
|
||||
|---|---|---|---|
|
||||
| **TP-01** | unit | JS-тест «TP-01» (через `test_js_unit_tests_pass`) | **PASS** |
|
||||
| **TP-02** | unit | JS-тест «TP-02» | **PASS** |
|
||||
| **TP-03** | unit | JS-тест «TP-03» | **PASS** |
|
||||
| **TP-04** | unit | JS-тест «TP-04» | **PASS** |
|
||||
| **TP-05** | e2e | **BLOCKED** (нет Playwright). Покрыто статически: `test_poi_checkbox_present_in_html`, `test_poi_checkbox_placed_after_trails_separated_by_hr` | COVERED (alt) |
|
||||
| **TP-06** | e2e | **BLOCKED** (нет Playwright). Покрыто поведенчески: JS «TP-01»/«TP-02», «меняет только poi-circles/poi-labels» | COVERED (alt) |
|
||||
| **TP-07** | e2e | **BLOCKED** (нет Playwright). Покрыто поведенчески: JS «TP-03» (`restorePoiState` при `poi-visible=0`) | COVERED (alt) |
|
||||
| **TP-08** | integration | JS «onPoiCheckbox() меняет только слои poi-circles и poi-labels» — изоляция чужих слоёв подтверждена | **PASS** (alt) |
|
||||
| **TP-09** | e2e | **BLOCKED** (нет Playwright). Класс `terrain-checkbox` подтверждён статически; по touch-target см. P3-2 | PARTIAL |
|
||||
|
||||
**Исполнено и пройдено: TP-01..TP-04, TP-08 (5/9).**
|
||||
**e2e TP-05..TP-07, TP-09 (4/9) — заблокированы инфраструктурой**, их
|
||||
поведение покрыто unit/статикой. Прямой инструментальной проверки в
|
||||
реальном браузере не было.
|
||||
|
||||
## Соответствие Acceptance Criteria
|
||||
|
||||
| AC | Описание | Источник проверки | Статус |
|
||||
|---|---|---|---|
|
||||
| AC-01 | Чекбокс в попапе после «Тропы», отделён `<hr>` | `test_poi_checkbox_placed_after_trails_separated_by_hr` | **PASS** |
|
||||
| AC-02 | POI включены по умолчанию | `test_poi_checkbox_checked_by_default` + JS «TP-04» | **PASS** |
|
||||
| AC-03 | Скрытие POI → `visibility: none` | JS «TP-01» | **PASS** |
|
||||
| AC-04 | Показ POI → `visibility: visible` | JS «TP-02» | **PASS** |
|
||||
| AC-05 | Состояние сохраняется после перезагрузки | JS «TP-03» (`restorePoiState` при `poi-visible=0`) | **PASS** |
|
||||
| AC-06 | Восстановление включённого состояния | JS «restorePoiState при poi-visible=1» | **PASS** |
|
||||
| AC-07 | Не ломает существующие чекбоксы | JS «меняет только poi-circles/poi-labels»; HTML-изменения аддитивны | **PASS** |
|
||||
| AC-08 | Синхронизация с `layerState.poi` | JS «TP-01»/«TP-02» + «applyPoiVisibility без слоёв» | **PASS** |
|
||||
|
||||
Все 8 критериев имеют поведенческое покрытие; ни один не нарушен.
|
||||
Браузерная (e2e) проверка AC-01/03/04/05 ограничена отсутствием
|
||||
Playwright — поведение подтверждено на уровне реального кода POI-блока.
|
||||
|
||||
## Найденные баги
|
||||
|
||||
### P0 (блокирующие)
|
||||
Нет.
|
||||
|
||||
### P1 (критические)
|
||||
Нет.
|
||||
|
||||
### P2 (важные)
|
||||
|
||||
**T-01 (= R-01 из `12-review.md`) — e2e TP-05..TP-09 не исполнимы.**
|
||||
`04-test-plan.yaml` определяет TP-05..TP-09 как `type: e2e`, но
|
||||
`07-infra-requirements.md §6` запрещает новые npm-пакеты, а
|
||||
Playwright-инфраструктуры в репозитории нет (проверено: нет
|
||||
`package.json`, `node_modules`, бинаря `playwright`, `npx`). Это конфликт
|
||||
двух **approved**-артефактов, а не дефект разработки. Поведение покрыто
|
||||
unit/статикой.
|
||||
**Действие:** Analyst — согласовать `04-test-plan.yaml` с
|
||||
`07-infra-requirements.md` (пометить TP-05..09 как покрытые альтернативно
|
||||
либо завести инфра-задачу на Playwright). **Merge/деплой ET-002 не
|
||||
блокирует.**
|
||||
|
||||
### P3 (косметика / наблюдения)
|
||||
|
||||
1. **(= R-02 из `12-review.md`)** `app.js:2849` —
|
||||
`const poiOn = stored === null || stored === '1';`: нештатное значение
|
||||
ключа `poi-visible` трактуется как «скрыть». Надёжнее
|
||||
`stored !== '0'` (деградация к дефолту «показать», REQ-F-02).
|
||||
Крайний кейс — ключ пишет только это приложение. Не блокирует.
|
||||
2. **REQ-NF-03 (touch target ≥ 44px)** — `.terrain-checkbox`
|
||||
(`app.css:806`) при `padding:8px` и `font-size:15px` даёт высоту
|
||||
строки ≈ 35px, что ниже ориентира 44px. Однако чекбокс POI
|
||||
использует **тот же** класс, что и все соседние чекбоксы попапа
|
||||
(Тени/Перепады/Грунтовки/Тропы) — **регрессии нет**, кликабельна вся
|
||||
строка-`<label>`. Точная проверка на реальном устройстве (TP-09)
|
||||
невозможна без e2e. Если 44px критичны — это пред-существующий
|
||||
UX-вопрос ко всему попапу, отдельной задачей. Не блокирует ET-002.
|
||||
3. **Окружение тестера.** Пакеты `shapely`, `mapbox-vector-tile`,
|
||||
`httpx` (`requirements.txt`) и `pytest-asyncio`, `ruff`
|
||||
(`pyproject.toml [dev]`) не были предустановлены в песочнице — без
|
||||
`shapely` падал сбор `test_health.py` (импорт `src.api.main`).
|
||||
Тестер доустановил их по манифестам проекта, после чего регресс
|
||||
прошёл полностью. Это дефект провижининга окружения, **не дефект
|
||||
ET-002**. CI обязан выполнять `pip install -r requirements.txt` и
|
||||
`.[dev]` перед `make test`.
|
||||
|
||||
## Замечания тестера
|
||||
|
||||
- Прогон выполнен против локального репозитория без поднятого dev-сервера
|
||||
(`make test` оффлайн). Backend не затронут (ET-002 — только `src/web/`).
|
||||
- Ручная сверка реализации: `index.html:56-60` — `<hr>` + чекбокс
|
||||
`poi-visible-cb` (`checked`, класс `terrain-checkbox`) сразу после
|
||||
`trails-path-cb`; `app.js:2800-2854` — POI-блок с `applyPoiVisibility`,
|
||||
`onPoiCheckbox`, `restorePoiState`; `restorePoiState()` подключён в 3
|
||||
точках восстановления (`app.js:131, 2950, 2963`). Соответствует ТЗ,
|
||||
ADR-0001 и выводам `12-review.md`.
|
||||
- Тесты не подгонялись под код; на prom ничего не запускалось.
|
||||
|
||||
## Итог
|
||||
|
||||
**Verdict: PASS** → `stage:ready-to-deploy`.
|
||||
|
||||
Все исполнимые тест-кейсы (TP-01..04, TP-08), все 8 acceptance-критериев
|
||||
и lint — зелёные; блокирующих (P0/P1) багов нет. Незакрытые e2e-кейсы
|
||||
(TP-05..07, TP-09) заблокированы инфраструктурным конфликтом
|
||||
approved-артефактов (T-01/P2) — действие на стороне Analyst, на ET-002
|
||||
не влияет. Готово к штатной перевыкладке фронтенда в test/prom согласно
|
||||
`07-infra-requirements.md`.
|
||||
262
src/web/app.css
262
src/web/app.css
@@ -247,7 +247,8 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
|
||||
#waypoints-list { display: flex; flex-direction: column; margin-bottom: 10px; }
|
||||
.wl-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 0;
|
||||
padding: 8px 4px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
@@ -769,3 +770,262 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Terrain Layer (Phase 5.4)
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* Terrain toggle button active state */
|
||||
#terrain-toggle.active {
|
||||
color: var(--accent, #4CAF50);
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
}
|
||||
|
||||
/* Terrain popup */
|
||||
.terrain-popup {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
background: var(--surface, #1e1e1e);
|
||||
border: 1px solid var(--border, rgba(255,255,255,0.12));
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
min-width: 160px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.terrain-popup-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text2, rgba(255,255,255,0.5));
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.terrain-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 4px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: var(--text, #fff);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.terrain-checkbox span {
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.terrain-checkbox:hover {
|
||||
color: var(--accent, #4CAF50);
|
||||
}
|
||||
|
||||
.terrain-checkbox input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent, #4CAF50);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Light theme overrides */
|
||||
.theme-light .terrain-popup {
|
||||
background: var(--surface, #fff);
|
||||
border-color: var(--border, rgba(0,0,0,0.12));
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.theme-light .terrain-popup-title {
|
||||
color: var(--text2, rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
.theme-light .terrain-checkbox {
|
||||
color: var(--text, #111);
|
||||
}
|
||||
|
||||
|
||||
/* Terrain hillshade hint & disabled state */
|
||||
.terrain-hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--accent, #4CAF50);
|
||||
font-style: italic;
|
||||
padding: 4px 0 2px 28px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.terrain-checkbox.disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.terrain-checkbox.disabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Scale + Zoom bar (one line, top-right) ───────── */
|
||||
#scale-zoom-bar {
|
||||
position: absolute;
|
||||
top: calc(max(env(safe-area-inset-top, 0px), 8px) + 4px);
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.szb-scale {
|
||||
height: 16px;
|
||||
border: 1.5px solid rgba(255,255,255,0.8);
|
||||
border-top: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.szb-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.9);
|
||||
text-shadow: 0 0 3px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
|
||||
white-space: nowrap;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.szb-zoom {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-shadow: 0 0 3px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* ── Search panel ───────────────────────────── */
|
||||
#search-panel {
|
||||
position: fixed;
|
||||
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
||||
left: 0; right: 0;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
z-index: 350;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
.search-panel-inner {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
#standalone-search-input {
|
||||
flex: 1;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 15px;
|
||||
color: var(--text1);
|
||||
outline: none;
|
||||
}
|
||||
#standalone-search-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
#search-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
#standalone-search-results {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#standalone-search-results .search-result-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#standalone-search-results .search-result-item:hover,
|
||||
#standalone-search-results .search-result-item:active {
|
||||
background: var(--surface2);
|
||||
}
|
||||
#standalone-search-results .search-result-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text1);
|
||||
}
|
||||
#standalone-search-results .search-result-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
|
||||
/* ─── Zoom controls ──────────────────────────────────────────────────────── */
|
||||
#zoom-controls {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
z-index: 400;
|
||||
}
|
||||
#zoom-controls .map-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
#zoom-level {
|
||||
background: var(--surface, #1e1e1e);
|
||||
color: var(--text, #fff);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* ─── Scale bar ──────────────────────────────────────────────────────────── */
|
||||
#scale-bar {
|
||||
position: fixed;
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px) + 16px);
|
||||
left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
z-index: 400;
|
||||
pointer-events: none;
|
||||
}
|
||||
#scale-line {
|
||||
height: 4px;
|
||||
width: 100px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0,0,0,0.6);
|
||||
border-top: none;
|
||||
border-left: 2px solid #fff;
|
||||
border-right: 2px solid #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||
}
|
||||
#scale-label {
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.9), 0 0 4px rgba(0,0,0,0.7);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
302
src/web/app.js
302
src/web/app.js
@@ -88,6 +88,7 @@ function switchMapStyle() {
|
||||
if (!map) return;
|
||||
const dark = isDarkTheme();
|
||||
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||||
const tileBase = window.location.origin + basePath;
|
||||
const styleUrl = dark ? basePath + '/style-dark.json' : basePath + '/style.json';
|
||||
|
||||
// Save current position before style change
|
||||
@@ -96,18 +97,22 @@ function switchMapStyle() {
|
||||
const bearing = map.getBearing();
|
||||
const pitch = map.getPitch();
|
||||
|
||||
fetch(styleUrl, { method: 'HEAD' }).then(r => {
|
||||
if (r.ok) {
|
||||
map.setStyle(styleUrl);
|
||||
// Restore position after style loads
|
||||
map.once('style.load', () => {
|
||||
map.jumpTo({ center, zoom, bearing, pitch });
|
||||
});
|
||||
} else {
|
||||
console.log('Map style not available:', styleUrl);
|
||||
fetch(styleUrl).then(r => {
|
||||
if (r.ok) return r.json();
|
||||
throw new Error('Style not available');
|
||||
}).then(style => {
|
||||
// Fix tile URLs to absolute (same as initMap)
|
||||
if (style.sources && style.sources['trails-tiles'] && style.sources['trails-tiles'].tiles) {
|
||||
style.sources['trails-tiles'].tiles = [`${tileBase}/api/tiles/{z}/{x}/{y}.mvt`];
|
||||
}
|
||||
map.setStyle(style);
|
||||
// Restore position and overlays after style loads
|
||||
map.once('idle', () => {
|
||||
map.jumpTo({ center, zoom, bearing, pitch });
|
||||
rebuildMapOverlays();
|
||||
});
|
||||
}).catch(() => {
|
||||
// Network error, don't switch
|
||||
// Network error or style not available, don't switch
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,6 +125,11 @@ function onMapStyleLoad() {
|
||||
}
|
||||
|
||||
function rebuildMapOverlays() {
|
||||
// Re-apply terrain and trails after style change
|
||||
restoreTerrainState();
|
||||
restoreTrailsState();
|
||||
restorePoiState();
|
||||
|
||||
// Re-apply recon circle if active
|
||||
if (reconMode && reconCenter) {
|
||||
doRecon(reconCenter[0], reconCenter[1]);
|
||||
@@ -1283,9 +1293,7 @@ function selectMarkerType(idx, lat, lng) {
|
||||
closeMarkerDialog();
|
||||
const markers = loadMarkers();
|
||||
const icon = MARKER_ICONS[idx] || MARKER_ICONS[0];
|
||||
const name = prompt('Название метки (Enter = автоимя):');
|
||||
if (name === null) return;
|
||||
const autoName = name.trim() || `Метка ${markers.length + 1}`;
|
||||
const autoName = `Метка ${markers.length + 1}`;
|
||||
const marker = { id: Date.now(), name: autoName, icon, lat, lon: lng };
|
||||
markers.push(marker);
|
||||
saveMarkers(markers);
|
||||
@@ -1320,6 +1328,17 @@ function drawNamedMarker(markerData) {
|
||||
}
|
||||
|
||||
function renderMarkers() {
|
||||
// Clear existing marker objects to prevent duplicates
|
||||
Object.keys(namedMarkerObjects).forEach(id => {
|
||||
const obj = namedMarkerObjects[id];
|
||||
if (obj) {
|
||||
const popup = obj.getPopup();
|
||||
if (popup) popup.remove();
|
||||
obj.remove();
|
||||
}
|
||||
});
|
||||
namedMarkerObjects = {};
|
||||
// Re-draw from localStorage
|
||||
const markers = loadMarkers();
|
||||
markers.forEach(m => drawNamedMarker(m));
|
||||
}
|
||||
@@ -1379,8 +1398,52 @@ async function initMap() {
|
||||
window._map = map;
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-left');
|
||||
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right');
|
||||
map.addControl(new maplibregl.FullscreenControl(), 'top-left');
|
||||
// Custom scale + zoom indicator (one line, top-right)
|
||||
const scaleZoomBar = document.createElement('div');
|
||||
scaleZoomBar.id = 'scale-zoom-bar';
|
||||
scaleZoomBar.innerHTML = '<div class="szb-scale"><span class="szb-label">30 km</span></div><div class="szb-zoom">z7</div>';
|
||||
document.getElementById('map').appendChild(scaleZoomBar);
|
||||
|
||||
function updateScaleZoom() {
|
||||
const zoom = Math.round(map.getZoom());
|
||||
const lat = map.getCenter().lat;
|
||||
const metersPerPixel = 156543.03392 * Math.cos(lat * Math.PI / 180) / Math.pow(2, map.getZoom());
|
||||
|
||||
const targetPx = 80;
|
||||
const rawMeters = metersPerPixel * targetPx;
|
||||
|
||||
let distance, unit, niceMeters;
|
||||
if (rawMeters >= 1000) {
|
||||
const km = rawMeters / 1000;
|
||||
distance = km >= 100 ? Math.round(km / 50) * 50 :
|
||||
km >= 10 ? Math.round(km / 5) * 5 :
|
||||
km >= 1 ? Math.round(km) : Math.round(km * 10) / 10;
|
||||
unit = 'km';
|
||||
niceMeters = distance * 1000;
|
||||
} else {
|
||||
distance = rawMeters >= 100 ? Math.round(rawMeters / 50) * 50 :
|
||||
rawMeters >= 10 ? Math.round(rawMeters / 5) * 5 :
|
||||
Math.round(rawMeters);
|
||||
unit = 'm';
|
||||
niceMeters = distance;
|
||||
}
|
||||
|
||||
const actualPx = Math.round(niceMeters / metersPerPixel);
|
||||
const clampedPx = Math.max(40, Math.min(150, actualPx));
|
||||
|
||||
const scaleEl = scaleZoomBar.querySelector('.szb-scale');
|
||||
const labelEl = scaleZoomBar.querySelector('.szb-label');
|
||||
const zoomEl = scaleZoomBar.querySelector('.szb-zoom');
|
||||
|
||||
scaleEl.style.width = clampedPx + 'px';
|
||||
labelEl.textContent = distance + ' ' + unit;
|
||||
zoomEl.textContent = 'z' + zoom;
|
||||
}
|
||||
|
||||
updateScaleZoom();
|
||||
map.on('zoom', updateScaleZoom);
|
||||
map.on('move', updateScaleZoom);
|
||||
|
||||
map.on('load', () => {
|
||||
checkDataAvailability();
|
||||
@@ -2637,16 +2700,25 @@ function toggleTerrainPopup() {
|
||||
|
||||
const isVisible = popup.style.display !== 'none';
|
||||
popup.style.display = isVisible ? 'none' : 'block';
|
||||
btn.classList.toggle('active', !isVisible);
|
||||
|
||||
// Close on outside click
|
||||
// Position popup to the left of the button
|
||||
if (!isVisible) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
popup.style.right = (window.innerWidth - rect.left + 8) + 'px';
|
||||
// Position: align bottom of popup with bottom of button, ensure fits in viewport
|
||||
const popupHeight = popup.offsetHeight;
|
||||
const desiredTop = rect.bottom - popupHeight;
|
||||
const minTop = 8;
|
||||
popup.style.top = Math.max(minTop, desiredTop) + 'px';
|
||||
updateHillshadeAvailability();
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeTerrainOnOutside);
|
||||
}, 10);
|
||||
} else {
|
||||
document.removeEventListener('click', closeTerrainOnOutside);
|
||||
}
|
||||
|
||||
btn.classList.toggle('active', !isVisible);
|
||||
}
|
||||
|
||||
function closeTerrainOnOutside(e) {
|
||||
@@ -2663,22 +2735,124 @@ function onTerrainCheckbox() {
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
|
||||
const hypsoChecked = document.getElementById('terrain-hypso-cb').checked;
|
||||
const hillshadeChecked = document.getElementById('terrain-hillshade-cb').checked;
|
||||
const triChecked = document.getElementById('terrain-tri-cb').checked;
|
||||
|
||||
// Save state
|
||||
localStorage.setItem('terrain-hypso', hypsoChecked ? '1' : '0');
|
||||
localStorage.setItem('terrain-hillshade', hillshadeChecked ? '1' : '0');
|
||||
localStorage.setItem('terrain-tri', triChecked ? '1' : '0');
|
||||
|
||||
// Update button active state
|
||||
const btn = document.getElementById('terrain-toggle');
|
||||
btn.classList.toggle('active', hypsoChecked || hillshadeChecked);
|
||||
btn.classList.toggle('active', hillshadeChecked || triChecked);
|
||||
|
||||
// Apply layers
|
||||
applyTerrainLayer('terrain-hypso', TERRAIN_BASE_URL + '/hypso/{z}/{x}/{y}.png', hypsoChecked, 0.55, 5, 15);
|
||||
applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, 0.40, 10, 15);
|
||||
applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', triChecked, 0.70, 5, 15);
|
||||
}
|
||||
|
||||
|
||||
function onTrailsCheckbox() {
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
|
||||
const trackChecked = document.getElementById('trails-track-cb').checked;
|
||||
const pathChecked = document.getElementById('trails-path-cb').checked;
|
||||
|
||||
// Save state
|
||||
localStorage.setItem('trails-track', trackChecked ? '1' : '0');
|
||||
localStorage.setItem('trails-path', pathChecked ? '1' : '0');
|
||||
|
||||
// Toggle layer visibility
|
||||
if (map.getLayer('trails-track')) {
|
||||
map.setLayoutProperty('trails-track', 'visibility', trackChecked ? 'visible' : 'none');
|
||||
}
|
||||
if (map.getLayer('trails-path-bridleway')) {
|
||||
map.setLayoutProperty('trails-path-bridleway', 'visibility', pathChecked ? 'visible' : 'none');
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTrailsState() {
|
||||
const trackState = localStorage.getItem('trails-track');
|
||||
const pathState = localStorage.getItem('trails-path');
|
||||
|
||||
// Default: both checked (visible)
|
||||
const trackOn = trackState === null || trackState === '1';
|
||||
const pathOn = pathState === null || pathState === '1';
|
||||
|
||||
const trackCb = document.getElementById('trails-track-cb');
|
||||
const pathCb = document.getElementById('trails-path-cb');
|
||||
|
||||
if (trackCb) trackCb.checked = trackOn;
|
||||
if (pathCb) pathCb.checked = pathOn;
|
||||
|
||||
const map = window._map;
|
||||
if (map) {
|
||||
if (map.getLayer('trails-track')) {
|
||||
map.setLayoutProperty('trails-track', 'visibility', trackOn ? 'visible' : 'none');
|
||||
}
|
||||
if (map.getLayer('trails-path-bridleway')) {
|
||||
map.setLayoutProperty('trails-path-bridleway', 'visibility', pathOn ? 'visible' : 'none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// >>> ET-002 POI visibility block (do not remove markers — used by unit tests) >>>
|
||||
// Видимость POI (слои poi-circles, poi-labels) управляется чекбоксом
|
||||
// «POI» в попапе рельефа. Состояние хранится в localStorage под ключом
|
||||
// 'poi-visible' ('1'/'0'). Источник истины в рантайме — layerState.poi.
|
||||
// См. docs/work-items/ET-002/06-adr/adr-0001-poi-visibility-client-side.md
|
||||
/**
|
||||
* Применяет видимость группы слоёв POI и синхронизирует layerState.poi.
|
||||
*
|
||||
* Единый приватный хелпер: переиспользуется чекбоксом попапа
|
||||
* (onPoiCheckbox) и восстановлением состояния при загрузке/смене стиля
|
||||
* (restorePoiState). Не пишет в localStorage — персистентность остаётся
|
||||
* ответственностью обработчика чекбокса.
|
||||
*
|
||||
* @param {boolean} visible - true — показать POI, false — скрыть.
|
||||
*/
|
||||
function applyPoiVisibility(visible) {
|
||||
layerState.poi = visible;
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
const visibility = visible ? 'visible' : 'none';
|
||||
layerGroups.poi.forEach(id => {
|
||||
if (map.getLayer(id)) {
|
||||
map.setLayoutProperty(id, 'visibility', visibility);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик чекбокса «POI» в попапе рельефа (атрибут onchange).
|
||||
*
|
||||
* Сохраняет выбор в localStorage ('poi-visible': '1' видимы | '0' скрыты)
|
||||
* и применяет видимость слоёв POI через applyPoiVisibility().
|
||||
*/
|
||||
function onPoiCheckbox() {
|
||||
const checked = document.getElementById('poi-visible-cb').checked;
|
||||
localStorage.setItem('poi-visible', checked ? '1' : '0');
|
||||
applyPoiVisibility(checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает видимость POI при загрузке страницы и после смены
|
||||
* стиля карты (переключение темы).
|
||||
*
|
||||
* По умолчанию (ключ 'poi-visible' отсутствует или равен '1') POI
|
||||
* видимы; '0' — скрыты. Синхронизирует чекбокс, layerState.poi и
|
||||
* фактическую видимость слоёв.
|
||||
*/
|
||||
function restorePoiState() {
|
||||
const stored = localStorage.getItem('poi-visible');
|
||||
const poiOn = stored === null || stored === '1';
|
||||
const cb = document.getElementById('poi-visible-cb');
|
||||
if (cb) cb.checked = poiOn;
|
||||
applyPoiVisibility(poiOn);
|
||||
}
|
||||
// <<< ET-002 POI visibility block <<<
|
||||
|
||||
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
|
||||
const map = window._map;
|
||||
if (!map) return;
|
||||
@@ -2693,7 +2867,6 @@ function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
|
||||
tiles: [tileUrl],
|
||||
tileSize: 256,
|
||||
scheme: 'tms',
|
||||
bounds: [35, 45, 55, 62],
|
||||
minzoom: minzoom,
|
||||
maxzoom: maxzoom
|
||||
});
|
||||
@@ -2709,7 +2882,8 @@ function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
|
||||
type: 'raster',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'raster-opacity': opacity
|
||||
'raster-opacity': opacity,
|
||||
'raster-resampling': 'linear'
|
||||
},
|
||||
minzoom: minzoom,
|
||||
maxzoom: maxzoom
|
||||
@@ -2743,22 +2917,22 @@ function updateHillshadeAvailability() {
|
||||
}
|
||||
|
||||
function restoreTerrainState() {
|
||||
const hypso = localStorage.getItem('terrain-hypso') === '1';
|
||||
const hillshade = localStorage.getItem('terrain-hillshade') === '1';
|
||||
const tri = localStorage.getItem('terrain-tri') === '1';
|
||||
|
||||
const hypsoCb = document.getElementById('terrain-hypso-cb');
|
||||
const hillshadeCb = document.getElementById('terrain-hillshade-cb');
|
||||
const triCb = document.getElementById('terrain-tri-cb');
|
||||
|
||||
if (hypsoCb) hypsoCb.checked = hypso;
|
||||
if (hillshadeCb) hillshadeCb.checked = hillshade;
|
||||
if (triCb) triCb.checked = tri;
|
||||
|
||||
if (hypso || hillshade) {
|
||||
if (hillshade || tri) {
|
||||
onTerrainCheckbox();
|
||||
}
|
||||
|
||||
// Update button active state
|
||||
const btn = document.getElementById('terrain-toggle');
|
||||
if (btn) btn.classList.toggle('active', hypso || hillshade);
|
||||
if (btn) btn.classList.toggle('active', hillshade || tri);
|
||||
}
|
||||
|
||||
// Hook into map load and zoom changes
|
||||
@@ -2771,8 +2945,9 @@ function restoreTerrainState() {
|
||||
setTimeout(restoreTerrainState, 100);
|
||||
});
|
||||
// Initial state
|
||||
updateHillshadeAvailability();
|
||||
restoreTerrainState();
|
||||
restoreTrailsState();
|
||||
restorePoiState();
|
||||
} else {
|
||||
// Map not ready yet, wait
|
||||
const interval = setInterval(() => {
|
||||
@@ -2784,7 +2959,78 @@ function restoreTerrainState() {
|
||||
});
|
||||
updateHillshadeAvailability();
|
||||
restoreTerrainState();
|
||||
restoreTrailsState();
|
||||
restorePoiState();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
})();
|
||||
|
||||
// ─── Standalone Search Mode ──────────────────────────────────────
|
||||
let searchModeActive = false;
|
||||
let standaloneSearchTimeout = null;
|
||||
|
||||
function toggleSearchMode() {
|
||||
searchModeActive = !searchModeActive;
|
||||
const panel = document.getElementById('search-panel');
|
||||
const btn = document.getElementById('tb-search');
|
||||
|
||||
if (searchModeActive) {
|
||||
panel.style.display = 'block';
|
||||
btn.classList.add('active');
|
||||
const input = document.getElementById('standalone-search-input');
|
||||
input.value = '';
|
||||
document.getElementById('standalone-search-results').innerHTML = '';
|
||||
setTimeout(() => input.focus(), 100);
|
||||
|
||||
input.oninput = () => {
|
||||
clearTimeout(standaloneSearchTimeout);
|
||||
const q = input.value.trim();
|
||||
if (q.length < 2) {
|
||||
document.getElementById('standalone-search-results').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
standaloneSearchTimeout = setTimeout(() => doStandaloneSearch(q), 400);
|
||||
};
|
||||
|
||||
input.onkeydown = (e) => {
|
||||
if (e.key === 'Escape') toggleSearchMode();
|
||||
};
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function doStandaloneSearch(query) {
|
||||
const resultsEl = document.getElementById('standalone-search-results');
|
||||
resultsEl.innerHTML = '<div class="search-result-item"><span style="color:var(--text3)">Поиск...</span></div>';
|
||||
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&countrycodes=ru&accept-language=ru`;
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
|
||||
if (!data.length) {
|
||||
resultsEl.innerHTML = '<div class="search-result-item"><span style="color:var(--text3)">Ничего не найдено</span></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = data.map(item => {
|
||||
const parts = item.display_name.split(',');
|
||||
const name = parts[0].trim();
|
||||
const sub = parts.slice(1, 3).join(',').trim();
|
||||
return `<div class="search-result-item" onclick="standaloneSelectResult(${item.lat}, ${item.lon}, '${name.replace(/'/g, "\\\'")}')">
|
||||
<div class="search-result-name">${name}</div>
|
||||
${sub ? `<div class="search-result-sub">${sub}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
resultsEl.innerHTML = '<div class="search-result-item"><span style="color:var(--red)">Ошибка поиска</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneSelectResult(lat, lon, name) {
|
||||
toggleSearchMode();
|
||||
window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 13, duration: 800 });
|
||||
}
|
||||
|
||||
@@ -32,6 +32,34 @@
|
||||
<!-- ── No data warning ───────────────────── -->
|
||||
<div id="no-data-warning">⚠️ База данных недоступна</div>
|
||||
|
||||
<!-- ── Terrain popup ────────────────────── -->
|
||||
<div id="terrain-popup" class="terrain-popup" style="display:none">
|
||||
<div class="terrain-popup-title">Эндуро</div>
|
||||
<label class="terrain-checkbox">
|
||||
<input type="checkbox" id="terrain-hillshade-cb" onchange="onTerrainCheckbox()">
|
||||
<span>Тени рельефа</span>
|
||||
</label>
|
||||
<span class="terrain-hint" id="terrain-hillshade-hint" style="display:none">Зум 10+</span>
|
||||
<label class="terrain-checkbox">
|
||||
<input type="checkbox" id="terrain-tri-cb" onchange="onTerrainCheckbox()">
|
||||
<span>Перепады</span>
|
||||
</label>
|
||||
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
|
||||
<label class="terrain-checkbox">
|
||||
<input type="checkbox" id="trails-track-cb" onchange="onTrailsCheckbox()" checked>
|
||||
<span>Грунтовки</span>
|
||||
</label>
|
||||
<label class="terrain-checkbox">
|
||||
<input type="checkbox" id="trails-path-cb" onchange="onTrailsCheckbox()" checked>
|
||||
<span>Тропы</span>
|
||||
</label>
|
||||
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
|
||||
<label class="terrain-checkbox">
|
||||
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
|
||||
<span>POI</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- ── Map Buttons (right) ───────────────── -->
|
||||
<div id="map-controls-r">
|
||||
<button class="map-btn" id="btn-compass" onclick="toggleCompass()" title="Компас">
|
||||
@@ -40,6 +68,9 @@
|
||||
<button class="map-btn" onclick="locateMe()" title="Моё местоположение">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>
|
||||
</button>
|
||||
<button class="map-btn" id="terrain-toggle" onclick="toggleTerrainPopup()" title="Рельеф">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg>
|
||||
</button>
|
||||
<button class="map-btn" id="btn-theme" onclick="toggleTheme()" title="Переключить тему">
|
||||
<svg id="theme-icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
|
||||
<svg id="theme-icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
|
||||
@@ -197,6 +228,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search panel -->
|
||||
<div id="search-panel" style="display:none">
|
||||
<div class="search-panel-inner">
|
||||
<input id="standalone-search-input" type="text" placeholder="Поиск места..." autocomplete="off" autocorrect="off">
|
||||
<button id="search-close-btn" onclick="toggleSearchMode()">✕</button>
|
||||
</div>
|
||||
<div id="standalone-search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
BOTTOM TOOLBAR
|
||||
════════════════════════════════════════════ -->
|
||||
@@ -221,6 +261,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/></svg>
|
||||
<span>Линейка</span>
|
||||
</button>
|
||||
<button class="tb-btn" id="tb-search" onclick="toggleSearchMode()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<span>Поиск</span>
|
||||
</button>
|
||||
<button class="tb-btn" id="tb-marker" onclick="toggleMarkerMode()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<span>Метка</span>
|
||||
|
||||
167
tests/unit/poi_toggle.test.js
Normal file
167
tests/unit/poi_toggle.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ET-002 — поведенческие unit-тесты чекбокса видимости POI.
|
||||
*
|
||||
* Покрывают TP-01..TP-04 из docs/work-items/ET-002/04-test-plan.yaml.
|
||||
*
|
||||
* Тесты исполняют РЕАЛЬНЫЙ код из src/web/app.js: блок POI извлекается
|
||||
* по маркерам `>>> ET-002 POI visibility block` и оборачивается в
|
||||
* фабрику через `new Function`, которой передаются мок-зависимости
|
||||
* (window, document, localStorage, layerState, layerGroups). Так
|
||||
* монолитный browser-скрипт проверяется без полной загрузки в Node.
|
||||
*
|
||||
* Запуск: `node --test tests/unit/poi_toggle.test.js`
|
||||
* (в CI оборачивается pytest-тестом tests/unit/test_poi_toggle.py).
|
||||
*/
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'src', 'web', 'app.js');
|
||||
|
||||
/**
|
||||
* Извлекает блок POI-логики из app.js и собирает из него модуль,
|
||||
* подставляя переданные зависимости.
|
||||
*/
|
||||
function loadPoiModule(deps) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const m = src.match(
|
||||
/\/\/ >>> ET-002 POI visibility block[^\n]*\n([\s\S]*?)\/\/ <<< ET-002 POI visibility block/
|
||||
);
|
||||
assert.ok(m, 'POI-блок не найден в app.js (маркеры ET-002 отсутствуют)');
|
||||
const factory = new Function(
|
||||
'layerState', 'layerGroups', 'window', 'document', 'localStorage',
|
||||
m[1] + '\nreturn { applyPoiVisibility, onPoiCheckbox, restorePoiState };'
|
||||
);
|
||||
return factory(
|
||||
deps.layerState, deps.layerGroups, deps.window, deps.document, deps.localStorage
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Готовит изолированное мок-окружение для одного теста.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.stored] - значение ключа poi-visible в localStorage
|
||||
* (если не указан — ключ отсутствует).
|
||||
* @param {boolean} [opts.layerExists] - что возвращает map.getLayer().
|
||||
*/
|
||||
function makeEnv({ stored, layerExists = true } = {}) {
|
||||
const calls = { setLayoutProperty: [], setItem: [] };
|
||||
const store = {};
|
||||
if (stored !== undefined) store['poi-visible'] = stored;
|
||||
|
||||
const localStorage = {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); calls.setItem.push([k, String(v)]); },
|
||||
};
|
||||
const map = {
|
||||
getLayer: () => layerExists,
|
||||
setLayoutProperty: (id, prop, val) => calls.setLayoutProperty.push([id, prop, val]),
|
||||
};
|
||||
const checkbox = { checked: true };
|
||||
const document = {
|
||||
getElementById: (id) => (id === 'poi-visible-cb' ? checkbox : null),
|
||||
};
|
||||
const layerState = { tracks: true, paths: true, poi: true, basemap: true };
|
||||
const layerGroups = { poi: ['poi-circles', 'poi-labels'] };
|
||||
const win = { _map: map };
|
||||
|
||||
const mod = loadPoiModule({
|
||||
layerState, layerGroups, window: win, document, localStorage,
|
||||
});
|
||||
return { mod, calls, checkbox, layerState, store };
|
||||
}
|
||||
|
||||
// ── TP-01: onPoiCheckbox() скрывает слои при checked=false ───────────────
|
||||
test('TP-01: снятый чекбокс скрывает слои POI и сохраняет 0', () => {
|
||||
const env = makeEnv();
|
||||
env.checkbox.checked = false;
|
||||
|
||||
env.mod.onPoiCheckbox();
|
||||
|
||||
assert.deepEqual(env.calls.setLayoutProperty, [
|
||||
['poi-circles', 'visibility', 'none'],
|
||||
['poi-labels', 'visibility', 'none'],
|
||||
]);
|
||||
assert.deepEqual(env.calls.setItem, [['poi-visible', '0']]);
|
||||
assert.equal(env.layerState.poi, false);
|
||||
});
|
||||
|
||||
// ── TP-02: onPoiCheckbox() показывает слои при checked=true ──────────────
|
||||
test('TP-02: установленный чекбокс показывает слои POI и сохраняет 1', () => {
|
||||
const env = makeEnv();
|
||||
env.checkbox.checked = true;
|
||||
|
||||
env.mod.onPoiCheckbox();
|
||||
|
||||
assert.deepEqual(env.calls.setLayoutProperty, [
|
||||
['poi-circles', 'visibility', 'visible'],
|
||||
['poi-labels', 'visibility', 'visible'],
|
||||
]);
|
||||
assert.deepEqual(env.calls.setItem, [['poi-visible', '1']]);
|
||||
assert.equal(env.layerState.poi, true);
|
||||
});
|
||||
|
||||
// ── TP-03: восстановление состояния — POI скрыты ─────────────────────────
|
||||
test('TP-03: restorePoiState() при poi-visible=0 скрывает POI', () => {
|
||||
const env = makeEnv({ stored: '0' });
|
||||
|
||||
env.mod.restorePoiState();
|
||||
|
||||
assert.equal(env.checkbox.checked, false);
|
||||
assert.equal(env.layerState.poi, false);
|
||||
assert.deepEqual(env.calls.setLayoutProperty, [
|
||||
['poi-circles', 'visibility', 'none'],
|
||||
['poi-labels', 'visibility', 'none'],
|
||||
]);
|
||||
// restore не должен переписывать localStorage
|
||||
assert.deepEqual(env.calls.setItem, []);
|
||||
});
|
||||
|
||||
// ── TP-04: восстановление состояния — POI видны (default) ────────────────
|
||||
test('TP-04: restorePoiState() без ключа включает POI по умолчанию', () => {
|
||||
const env = makeEnv();
|
||||
|
||||
env.mod.restorePoiState();
|
||||
|
||||
assert.equal(env.checkbox.checked, true);
|
||||
assert.equal(env.layerState.poi, true);
|
||||
assert.deepEqual(env.calls.setLayoutProperty, [
|
||||
['poi-circles', 'visibility', 'visible'],
|
||||
['poi-labels', 'visibility', 'visible'],
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Доп.: значение '1' восстанавливает видимость явно ────────────────────
|
||||
test('restorePoiState() при poi-visible=1 показывает POI', () => {
|
||||
const env = makeEnv({ stored: '1' });
|
||||
|
||||
env.mod.restorePoiState();
|
||||
|
||||
assert.equal(env.checkbox.checked, true);
|
||||
assert.equal(env.layerState.poi, true);
|
||||
});
|
||||
|
||||
// ── Доп.: POI-логика не трогает чужие слои (дух TP-08) ───────────────────
|
||||
test('onPoiCheckbox() меняет только слои poi-circles и poi-labels', () => {
|
||||
const env = makeEnv();
|
||||
env.checkbox.checked = false;
|
||||
|
||||
env.mod.onPoiCheckbox();
|
||||
|
||||
const touched = env.calls.setLayoutProperty.map((c) => c[0]);
|
||||
assert.deepEqual([...touched].sort(), ['poi-circles', 'poi-labels']);
|
||||
});
|
||||
|
||||
// ── Доп.: layerState синхронизируется даже без слоёв на карте ────────────
|
||||
test('applyPoiVisibility() обновляет layerState даже если слой ещё не добавлен', () => {
|
||||
const env = makeEnv({ layerExists: false });
|
||||
|
||||
env.mod.applyPoiVisibility(false);
|
||||
|
||||
assert.equal(env.layerState.poi, false);
|
||||
assert.deepEqual(env.calls.setLayoutProperty, []);
|
||||
});
|
||||
162
tests/unit/test_poi_toggle.py
Normal file
162
tests/unit/test_poi_toggle.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""ET-002 — тесты чекбокса показа/скрытия POI в попапе рельефа.
|
||||
|
||||
Изменение ET-002 — исключительно фронтендовое (`src/web/index.html`,
|
||||
`src/web/app.js`). В CI исполняется только `pytest tests/`, поэтому файл
|
||||
покрывает фичу двумя способами:
|
||||
|
||||
1. Статические проверки структуры `index.html` и `app.js` — выполняются
|
||||
всегда, без внешних зависимостей.
|
||||
2. Поведенческие JS unit-тесты (TP-01..TP-04 из `04-test-plan.yaml`) —
|
||||
запускаются через встроенный тест-раннер Node (`node --test`). Если
|
||||
`node` в системе отсутствует — эта часть помечается `skip` (по аналогии
|
||||
с `tests/integration/test_routing_barriers.py::test_lua_syntax` и
|
||||
`luac`).
|
||||
|
||||
Браузерные e2e-сценарии (TP-05..TP-09) требуют Playwright-инфраструктуры,
|
||||
которой в репозитории нет; добавление новых npm-пакетов запрещено
|
||||
`07-infra-requirements.md`. Их поведенческая суть покрыта JS unit-тестами
|
||||
и статическими проверками ниже.
|
||||
"""
|
||||
|
||||
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"
|
||||
JS_TEST = REPO_ROOT / "tests" / "unit" / "poi_toggle.test.js"
|
||||
|
||||
|
||||
def _index_html() -> str:
|
||||
assert INDEX_HTML.is_file(), f"не найден {INDEX_HTML}"
|
||||
return INDEX_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _app_js() -> str:
|
||||
assert APP_JS.is_file(), f"не найден {APP_JS}"
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Статические проверки index.html (REQ-F-01, UI-спецификация)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_poi_checkbox_present_in_html():
|
||||
"""REQ-F-01: чекбокс POI присутствует в попапе с корректными атрибутами."""
|
||||
html = _index_html()
|
||||
assert 'id="poi-visible-cb"' in html, "нет чекбокса poi-visible-cb"
|
||||
assert 'onchange="onPoiCheckbox()"' in html, "чекбокс не привязан к onPoiCheckbox()"
|
||||
assert "<span>POI</span>" in html, "нет подписи «POI»"
|
||||
|
||||
|
||||
def test_poi_checkbox_checked_by_default():
|
||||
"""REQ-F-02: чекбокс POI отрисован как checked (POI видны по умолчанию)."""
|
||||
html = _index_html()
|
||||
# Атрибут checked должен стоять именно на инпуте poi-visible-cb.
|
||||
start = html.index('id="poi-visible-cb"')
|
||||
tag_end = html.index(">", start)
|
||||
assert "checked" in html[start:tag_end], "у чекбокса POI нет атрибута checked"
|
||||
|
||||
|
||||
def test_poi_checkbox_placed_after_trails_separated_by_hr():
|
||||
"""REQ-F-01: чекбокс POI стоит после «Тропы» и отделён <hr>."""
|
||||
html = _index_html()
|
||||
trails_pos = html.index('id="trails-path-cb"')
|
||||
poi_pos = html.index('id="poi-visible-cb"')
|
||||
assert poi_pos > trails_pos, "POI должен идти после чекбокса «Тропы»"
|
||||
between = html[trails_pos:poi_pos]
|
||||
assert "<hr" in between, "POI не отделён горизонтальной линией <hr>"
|
||||
|
||||
|
||||
def test_poi_checkbox_uses_shared_style_class():
|
||||
"""UI-спецификация: чекбокс использует общий класс terrain-checkbox."""
|
||||
html = _index_html()
|
||||
start = html.index('id="poi-visible-cb"')
|
||||
label_start = html.rfind("<label", 0, start)
|
||||
label_open_end = html.index(">", label_start)
|
||||
assert 'class="terrain-checkbox"' in html[label_start:label_open_end], (
|
||||
"чекбокс POI должен быть в label с классом terrain-checkbox"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Статические проверки app.js (ADR-0001)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_poi_functions_defined():
|
||||
"""ADR-0001: определены хелпер и обработчики POI."""
|
||||
js = _app_js()
|
||||
for fn in ("applyPoiVisibility", "onPoiCheckbox", "restorePoiState"):
|
||||
assert f"function {fn}(" in js, f"не определена функция {fn}()"
|
||||
|
||||
|
||||
def test_poi_logic_uses_localstorage_key():
|
||||
"""REQ-F-05: персистентность через localStorage ключ poi-visible."""
|
||||
js = _app_js()
|
||||
assert "localStorage.setItem('poi-visible'" in js, "состояние POI не сохраняется"
|
||||
assert "localStorage.getItem('poi-visible')" in js, "состояние POI не читается"
|
||||
|
||||
|
||||
def test_poi_logic_reuses_layer_state_and_groups():
|
||||
"""ADR-0001 п.3-4: источник истины — layerState.poi, группа слоёв не дублируется."""
|
||||
js = _app_js()
|
||||
assert "layerState.poi" in js, "POI-логика не синхронизирует layerState.poi"
|
||||
assert "layerGroups.poi" in js, "POI-логика должна переиспользовать layerGroups.poi"
|
||||
# poi-группа объявлена ровно один раз — в общей карте layerGroups.
|
||||
assert js.count("poi: ['poi-circles', 'poi-labels']") == 1, (
|
||||
"карта групп слоёв POI задублирована"
|
||||
)
|
||||
|
||||
|
||||
def test_restore_poi_state_wired_into_init():
|
||||
"""REQ-F-06: restorePoiState() вызывается при инициализации/смене стиля."""
|
||||
js = _app_js()
|
||||
# Один def + минимум 3 вызова (rebuildMapOverlays + 2 ветки initTerrain).
|
||||
assert js.count("restorePoiState()") >= 4, (
|
||||
"restorePoiState() не подключён ко всем точкам восстановления"
|
||||
)
|
||||
|
||||
|
||||
def test_poi_visibility_toggled_via_set_layout_property():
|
||||
"""ADR-0001 п.1: видимость переключается через setLayoutProperty."""
|
||||
js = _app_js()
|
||||
marker = "// >>> ET-002 POI visibility block"
|
||||
end = "// <<< ET-002 POI visibility block"
|
||||
assert marker in js and end in js, "маркеры POI-блока отсутствуют"
|
||||
block = js[js.index(marker):js.index(end)]
|
||||
assert "setLayoutProperty" in block, "POI-блок не использует setLayoutProperty"
|
||||
assert "removeLayer" not in block, (
|
||||
"POI-блок не должен удалять слои (ADR-0001 отвергает вариант C)"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Поведенческие JS unit-тесты через Node (TP-01..TP-04)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
node_required = pytest.mark.skipif(
|
||||
which("node") is None,
|
||||
reason="node не установлен — поведенческие JS unit-тесты пропущены",
|
||||
)
|
||||
|
||||
|
||||
@node_required
|
||||
def test_js_unit_tests_pass():
|
||||
"""TP-01..TP-04: запускает behavioral JS-тесты POI через `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-тесты POI упали (код {result.returncode}):\n"
|
||||
f"{result.stdout}\n{result.stderr}"
|
||||
)
|
||||
Reference in New Issue
Block a user