Compare commits

..

1 Commits

Author SHA1 Message Date
e8bfec02c7 docs: init ET-009 business request
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 6s
CI / build (push) Successful in 2s
2026-05-31 19:58:58 +03:00
30 changed files with 45 additions and 6002 deletions

View File

@@ -12,34 +12,22 @@ tools:
Ты — бизнес-аналитик проекта enduro-trails. По бизнес-запросу создаёшь
полный пакет документов для разработки.
## КРИТИЧЕСКИ ВАЖНО: Используй Write tool!
**Ты ОБЯЗАН создавать файлы через Write tool.** Не описывай содержимое в тексте ответа —
ЗАПИСЫВАЙ каждый артефакт в файл. Orchestrator проверяет наличие файлов на диске.
Порядок работы:
1. Прочитай входные данные (Read tool)
2. Создай КАЖДЫЙ deliverable через Write tool (полное содержимое файла)
3. В конце выведи краткий summary что создано
Если ты просто напишешь текст без вызова Write — артефакты будут потеряны!
## Что прочесть
1. CLAUDE.md — паспорт проекта
2. docs/work-items/<plane-id>/00-business-request.md — входные данные
3. docs/phases/ — текущий roadmap
4. src/web/index.html, src/api/main.py — текущий стейт приложения
## Deliverables (создать через Write tool в docs/work-items/<plane-id>/)
## Deliverables (создать в docs/work-items/<plane-id>/)
### Обязательные
- 01-brd.md — Business Requirements Document
- 02-trz.md — Техническое задание
- 03-acceptance-criteria.md — Критерии приёмки
- 04-test-plan.yaml — план функциональных тестов (unit, integration, e2e)
- `01-brd.md` — Business Requirements Document
- `02-trz.md` — Техническое задание
- `03-acceptance-criteria.md` — Критерии приёмки
- `04-test-plan.yaml` — план функциональных тестов (unit, integration, e2e)
### UI тест-кейсы (обязательно если задача затрагивает UI)
- 04b-ui-test-cases.md — Playwright UI тест-кейсы для визуального тестирования
- `04b-ui-test-cases.md` — Playwright UI тест-кейсы для визуального тестирования
**Когда создавать 04b-ui-test-cases.md:**
- Задача добавляет новый UI элемент (кнопка, панель, слой на карте)
@@ -52,12 +40,12 @@ tools:
Каждый тест-кейс — заголовок ### TC-UI-XX — Название, тип ui, viewport desktop|mobile|both.
Шаги — нумерованный список:
- navigate: url
- wait: ms (3000-5000 для карты)
- click: css-selector
- scroll: pixels
- screenshot: name
- check-visual: что проверяем
- navigate: <url>
- wait: <ms> (3000-5000 для карты)
- click: "<css-selector>"
- scroll: <pixels>
- screenshot: "<name>"
- check-visual: "<что проверяем>"
URL: всегда https://openclaw.mva154.duckdns.org/enduro/
CSS-селекторы: проверяй по src/web/index.html. Типичные ID: #sheet-gpx, #unit-toggle, #terrain-toggle, #poi-checkbox, #map.
@@ -66,4 +54,3 @@ CSS-селекторы: проверяй по src/web/index.html. Типичны
- Предлагать архитектурные решения
- Писать код
- Изменять артефакты других work item
- Выводить содержимое файлов в stdout вместо записи через Write tool

View File

@@ -1,128 +1,32 @@
---
name: deployer
description: DevOps-агент. Merge PR → tag → deploy → smoke → rollback при необходимости.
description: DevOps-агент. Merge → deploy → smoke → rollback при необходимости.
model: claude-sonnet-4-6
tools:
- Read (везде)
- Write (только docs/work-items/*/14-deploy-log.md, CHANGELOG.md)
- Bash (git, curl, docker)
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/14-deploy-log.md, CHANGELOG.md)
- Git (merge, tag)
- Bash (docker compose, curl)
---
# System prompt: Deployer
Ты — DevOps-агент проекта enduro-trails. Твоя задача — безопасно довести код до production.
Ты — DevOps-агент проекта enduro-trails. Безопасно проводишь изменение через test-окружение.
## Среды
- test: https://openclaw.mva154.duckdns.org/enduro/
- Deploy: docker compose на хосте (через docker exec или SSH)
- Gitea API: http://localhost:3000/api/v1
- Gitea token: из переменной ORCH_GITEA_TOKEN
- Repo owner: admin
- Repo name: enduro-trails
- Деплой: docker compose up -d на mva154
## Алгоритм (выполняй строго по порядку)
### 1. Merge PR
```bash
# Найти PR для ветки
BRANCH=$(grep "^Branch:" .task-deploy.md | awk '{print $2}')
GITEA_TOKEN=$ORCH_GITEA_TOKEN
PR_NUMBER=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"http://localhost:3000/api/v1/repos/admin/enduro-trails/pulls?state=open&head=$BRANCH" \
| python3 -c "import sys,json; prs=json.load(sys.stdin); print(prs[0]['number'] if prs else '')")
if [ -z "$PR_NUMBER" ]; then
echo "ERROR: No open PR for $BRANCH"
exit 1
fi
# Merge
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
"http://localhost:3000/api/v1/repos/admin/enduro-trails/pulls/$PR_NUMBER/merge" \
-H "Content-Type: application/json" -d '{"Do":"merge"}'
```
### 2. Создать tag
```bash
# Определить версию
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
# Инкремент patch (упрощённо)
MAJOR=$(echo $LAST_TAG | cut -d. -f1 | tr -d v)
MINOR=$(echo $LAST_TAG | cut -d. -f2)
PATCH=$(echo $LAST_TAG | cut -d. -f3)
NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH+1))"
git fetch origin main
git tag $NEW_TAG origin/main
git push origin $NEW_TAG
```
### 3. Deploy
```bash
cd /repos/enduro-trails
git fetch origin && git checkout main && git pull origin main
# Deploy зависит от проекта. Для enduro-trails:
# Файлы уже на месте после merge в main, nginx обслуживает static
```
### 4. Healthcheck (до 60 сек)
```bash
for i in $(seq 1 12); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://openclaw.mva154.duckdns.org/enduro/ 2>/dev/null)
if [ "$STATUS" = "200" ]; then
echo "Healthcheck OK"
break
fi
sleep 5
done
if [ "$STATUS" != "200" ]; then
echo "ERROR: Healthcheck failed (HTTP $STATUS)"
exit 1
fi
```
### 5. Smoke test
```bash
# Проверить ключевые ресурсы
curl -sf https://openclaw.mva154.duckdns.org/enduro/ > /dev/null || exit 1
curl -sf https://openclaw.mva154.duckdns.org/enduro/static/style.json > /dev/null || exit 1
curl -sf https://openclaw.mva154.duckdns.org/enduro/static/app.js > /dev/null || exit 1
echo "Smoke tests PASS"
```
### 6. Rollback (если smoke fail)
```bash
# Откатить к предыдущему тегу
git checkout $LAST_TAG
echo "ROLLED BACK to $LAST_TAG"
# Уведомить
exit 1
```
### 7. Финализация
- Записать `docs/work-items/<WORK_ITEM_ID>/14-deploy-log.md`:
- Версия (tag)
- Время deploy
- Результат smoke
- PR number
- Обновить CHANGELOG.md (новая запись сверху)
- Commit + push в main
## Формат 14-deploy-log.md
```markdown
# Deploy Log — <WORK_ITEM_ID>
- **Version:** vX.Y.Z
- **Date:** YYYY-MM-DD HH:MM UTC
- **PR:** #N
- **Environment:** test
- **Healthcheck:** PASS
- **Smoke:** PASS
- **Status:** SUCCESS
```
## Алгоритм
1. Проверь предусловия: QG-6 green, лейбл stage:ready-to-deploy
2. Merge PR (squash)
3. Создай tag vX.Y.Z (semver по типам коммитов)
4. docker compose pull && docker compose up -d
5. Healthcheck 5 минут
6. Smoke-тесты
7. Если fail — rollback к предыдущему тегу
8. Запиши 14-deploy-log.md
## Запрещено
- Менять исходный код (src/, tests/)
- Деплоить без merge
- Force push
- Игнорировать failed healthcheck/smoke
- Менять код
- Деплоить без зелёного QG-6
- --force-push

View File

@@ -11,25 +11,12 @@
- **Database** — SQLite + Spatialite (точки интереса, маршруты)
## Слои карты
- Base map: **Схема** (OpenStreetMap raster) либо **Спутник** (Esri World Imagery raster) — переключается в UI (ET-007 / ADR-004)
- Base map (OpenStreetMap)
- Hillshade (рельеф с тенями)
- TRI (Terrain Ruggedness Index — сложность рельефа)
- Hypsometric (высотная раскраска)
- Trails (маршруты из OSM)
## Внешние тайл-провайдеры
Клиент (браузер) обращается напрямую к двум внешним raster-tile сервисам.
Сервер mva154 эти тайлы не проксирует и не кэширует.
| Провайдер | Назначение | URL | Активация | API-ключ |
|-----------|-----------|-----|-----------|----------|
| OpenStreetMap | Базовый слой «Схема» | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | всегда (default подложка) | нет |
| Esri World Imagery | Базовый слой «Спутник» | `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}` | лениво — только при включении «Спутник» пользователем (ET-007) | нет |
Атрибуция обоих провайдеров выводится MapLibre автоматически при
активном source.
## Деплой
Один Docker Compose на mva154. Nginx проксирует /enduro/ на контейнер.

View File

@@ -7,4 +7,3 @@
| ADR-001 | Блокировка шлагбаумов через `mode.inaccessible` | accepted | 2026-05-15 | [ET-001](../../work-items/ET-001/06-adr/ADR-001-barrier-blocking.md) |
| ADR-002 | GPX-фича как отдельный модуль `gpx.js` | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-002-gpx-module-structure.md) |
| ADR-003 | Парсинг GPX — `DOMParser` в основном потоке с чанковой конвертацией | accepted | 2026-05-22 | [ET-006](../../work-items/ET-006/06-adr/ADR-003-gpx-parsing-strategy.md) |
| ADR-004 | Спутниковая подложка: Esri World Imagery, ленивый raster-source, гибридное halo | accepted | 2026-05-31 | [ET-007](../../work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md) |

View File

@@ -1,7 +0,0 @@
# Business Request: ET-005: Спутниковая карта (Схема/Спутник)
Work Item ID: ET-007
## Description
TBD

View File

@@ -1,100 +0,0 @@
---
type: brd
work_item_id: ET-007
title: "BRD: Спутниковая карта (Схема / Спутник)"
version: 2
status: draft
created_at: 2026-05-31
updated_at: 2026-05-31
changelog:
- "v2 (2026-05-31): code-review fix (12-review.md P1-3) — митигация риска hillshade приведена в соответствие с TRZ/ADR/AC: авто-выключение не вводится."
authors:
- "agent:analyst"
---
# BRD — ET-007: Спутниковая карта (Схема / Спутник)
## 1. Цель
Дать пользователю возможность одним кликом переключать подложку карты
между «Схемой» (текущая OSM-схема) и «Спутник» (растровые снимки
поверхности Земли). Спутниковая подложка помогает увидеть реальный
рельеф и поверхность маршрута — лес/поле/брод/каменистый участок — до
выезда.
## 2. Контекст
- Сейчас в приложении используется единственная подложка — OSM-растр,
стилизованный для «Схемы» в двух темах (`style.json`,
`style-dark.json`). Спутникового слоя нет.
- В фазе PH-5 Redesign уже была введена тёмная/светлая тема — но
«тема» относится к стилизации (контрасты, насыщенность), а не к
природе подложки.
- Эндуро-маршруты часто проходят вне дорог OSM (бездорожье, броды,
лесные участки). Спутник критичен для разведки.
- Все клиентские модули (`app.js`, `units.js`, `gpx.js`) уже умеют
переживать `map.setStyle()` через `rebuildMapOverlays()` — это
опорная точка для будущей реализации.
## 3. Scope
### In scope
| # | Функция |
| ----- | ------------------------------------------------------------------------------------ |
| F-01 | Переключатель «Схема / Спутник» в UI (segmented control) |
| F-02 | Спутниковая подложка как новый raster-источник (бесплатный, без API-ключа) |
| F-03 | В режиме «Спутник» — скрыта OSM-схема, показаны спутниковые тайлы |
| F-04 | Все надстройки (грунтовки, тропы, POI, hillshade, TRI, маршрут, GPX) поверх спутника |
| F-05 | Сохранение выбора в `localStorage` (ключ `map-base-layer`) |
| F-06 | Восстановление выбора при загрузке страницы и при смене темы |
| F-07 | Корректное отображение атрибуции спутниковых тайлов |
| F-08 | Сохранение всех пользовательских слоёв (роутинг, GPX, recon) при переключении |
### Out of scope
- Кэширование спутниковых тайлов (offline / PWA — это PH-9).
- Динамический выбор провайдера спутниковых тайлов в UI.
- Гибридный режим «Спутник + подписи дорог OSM поверх».
- Самостоятельный хостинг спутниковых тайлов (юридические/трафик-риски).
- Изменение базовой карты для расчёта маршрутов (роутинг по-прежнему OSRM).
- Авто-переключение Схема/Спутник в зависимости от зума.
## 4. Метрики успеха
| Метрика | Критерий |
| ------------------------ | ------------------------------------------------------------------------------------- |
| Время переключения | ≤ 500 мс от клика до первой видимой спутниковой плитки |
| Сохранение состояния | Выбор подложки сохраняется после reload, смены темы, смены слоёв terrain |
| Совместимость со слоями | Грунтовки, тропы, POI, маршрут OSRM, GPX-треки, hillshade, TRI видны и поверх спутника |
| Совместимость с темой | Переключение тёмной/светлой темы не сбрасывает режим «Спутник» |
| Атрибуция | На карте видна корректная атрибуция провайдера спутника |
| Не ломает существующее | Все режимы (роутинг, разведка, красивый маршрут, GPX, линейка) работают как прежде |
## 5. Риски
| Риск | Вероятность | Влияние | Митигация |
| ------------------------------------------------------------------------------------------------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
| Провайдер спутниковых тайлов закроет доступ / введёт лимит / потребует API-ключ | Средняя | Высокое | Зафиксировать конкретного провайдера в ADR; предусмотреть точку расширения для альтернативного провайдера (несколько URL) |
| Спутниковая подложка медленно грузится → пользователь видит «дыры» | Высокая | Среднее | Использовать background-цвет (тёмно-серый) под спутником; OSM-схема остаётся как fallback в случае ошибки загрузки тайлов |
| Цвет грунтовок и троп плохо виден на спутниковой подложке | Высокая | Среднее | TRZ: на режиме «Спутник» включается обводка (halo) у линий грунтовок и троп — по аналогии с подписями POI |
| Hillshade поверх спутника даёт некрасивое наложение (двойное затенение рельефа) | Средняя | Низкое | Hillshade продолжает работать поверх спутника как и поверх схемы — авто-выключение не вводится (TRZ §1 REQ-F-04, ADR-004 §«Контекст 1.5»); визуальная проверка — UI-тест AC-04 «Hillshade поверх спутника» |
| Юридические ограничения на использование стороннего провайдера спутниковых тайлов | Низкая | Высокое | В ADR указать выбранного провайдера с лицензией, разрешающей использование без API-ключа (Esri World Imagery, ArcGIS) |
| Регресс UI на мобильных устройствах из-за нового переключателя | Низкая | Среднее | UI-тест-кейсы (04b) для desktop и mobile viewport |
| Конфликт с уже сохранёнными localStorage-значениями старых версий | Низкая | Низкое | Использовать новый ключ `map-base-layer`, default = `schematic` |
## 6. Зависимости
- Только фронтенд — backend изменений не требуется.
- MapLibre GL JS 4.7.0 (уже подключен).
- Внешний провайдер спутниковых тайлов (выбор и фиксация — в ADR).
- Сетевое подключение клиента к серверу провайдера.
## 7. Связь с roadmap
- Фаза PH-5 Redesign — тёмная тема и mobile UI уже сделаны; ET-007
встраивается в эту же панель «Рельеф / Слои» (одна точка управления
визуальными слоями карты).
- Фаза PH-9 PWA — кэширование спутниковых тайлов оффлайн — будет
планироваться отдельно, ET-007 закладывает архитектурную основу
(источник тайлов, точка переключения).

View File

@@ -1,498 +0,0 @@
---
type: trz
work_item_id: ET-007
title: "ТЗ: Спутниковая карта (Схема / Спутник)"
version: 2
status: draft
created_at: 2026-05-31
updated_at: 2026-05-31
changelog:
- "v2 (2026-05-31): code-review fixes (12-review.md, attempt 2/3) — P1-1..P1-6: реальные id halo-слоёв, контраст POI labels, единый satellite-bg, контракт с layerState.basemap, синхронизация halo с чекбоксами."
authors:
- "agent:analyst"
---
# ТЗ — ET-007: Спутниковая карта (Схема / Спутник)
## 1. Функциональные требования
### REQ-F-01: Переключатель «Схема / Спутник»
- В попап-панели слоёв (`#terrain-popup`, открывается кнопкой
`#terrain-toggle`) добавляется новая секция в самом верху панели —
«Подложка».
- Реализация — segmented-control (`.seg-control` / `.seg-btn`) с двумя
кнопками:
- «Схема» (`data-base="schematic"`, ID `base-btn-schematic`) —
активна по умолчанию.
- «Спутник» (`data-base="satellite"`, ID `base-btn-satellite`).
- Активная кнопка визуально выделяется (`.active` — оранжевый фон, по
аналогии с переключателем единиц измерения, ET-005).
- Обработчик: `onBaseLayerToggle(base)` в `src/web/app.js`.
- Под переключателем — горизонтальная линия-разделитель (`<hr>`),
как уже сделано между секциями попапа.
### REQ-F-02: Спутниковый растровый источник
- Используется растровый тайл-сервер Esri World Imagery (см. ADR в
`docs/work-items/ET-007/06-adr/`):
- URL-шаблон: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`.
- `tileSize: 256`, `minzoom: 0`, `maxzoom: 19`.
- Атрибуция: «Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community».
- Источник добавляется на карту лениво: при первом включении режима
«Спутник», а не на старте приложения.
- ID источника: `satellite-raster`.
- ID слоя: `satellite-base`.
### REQ-F-03: Поведение в режиме «Спутник»
- При включении «Спутник»:
- Если ещё не добавлен — добавить source `satellite-raster` и layer
`satellite-base` сразу после слоя `background` (т.е. ниже всех
остальных слоёв).
- Слой `osm-base` (существующий) скрывается (`visibility: none`).
- Слой `background` остаётся (показывает «дыры» если тайлы ещё не
загрузились) — цвет фона на спутнике — единая константа `#2a2a2a`
для обеих тем (тёмно-серый, чтобы не «бликовал» под медленно
подгружающимися спутниковыми плитками; решение зафиксировано в
ADR-004 §6). Baseline `background-color` для возврата на «Схему»:
`#f0ede6` (light), `#1a1a2e` (dark) — см. Data §5.
- При возврате на «Схема»:
- `osm-base` снова видим (`visibility: visible`).
- `satellite-base` скрывается (`visibility: none`), но не удаляется
из стиля (быстрое повторное переключение).
### REQ-F-04: Совместимость со слоями приложения
Все клиентские слои должны корректно отображаться поверх спутника:
| Слой | Z-order над спутником | Доп. правила в режиме «Спутник» |
| ----------------------------- | --------------------- | ------------------------------------------------------------------------------ |
| Hillshade (`terrain-hillshade`) | поверх спутника | Включается/выключается чекбоксом как раньше; по умолчанию НЕ авто-выключается |
| TRI (`terrain-tri`) | поверх спутника | Аналогично hillshade |
| Trails — грунтовки (`trails-track`) | поверх terrain | Halo через парный underlay-слой `trails-track-halo-satellite` (единый halo на весь слой, без разбиения по grade) |
| Paths / bridleway (`trails-path-bridleway`) | поверх trails | Halo через парный underlay-слой `trails-path-bridleway-halo-satellite` |
| Asphalt-дороги (`trails-asphalt`) | поверх trails | Halo не вводится — слой по умолчанию скрыт (`visibility: none`, `line-opacity: 0`); если будет включён в будущем, halo добавляется тем же паттерном |
| POI circles (`poi-circles`) | поверх trails | Обводка `circle-stroke-color: #ffffff`, толщина 2 px |
| POI labels (`poi-labels`) | поверх POI | `text-color: #ffffff`, `text-halo-color: #000000`, `text-halo-width: 2` для читаемости на спутнике (см. REQ-F-04-POI ниже) |
| Route / Scenic / Link / Ruler | поверх POI | Без изменений |
| GPX-треки и waypoints | поверх Route | Без изменений (ET-006 уже совместим) |
**REQ-F-04-POI (контраст подписей POI на спутнике).** На спутнике
менять обе пары свойств `text-color` и `text-halo-*`, иначе тёмный
текст `#333333` (light-theme) останется нечитаем поверх тёмного halo.
Конкретные значения и baseline-возврат — в Data §5.
**Halo-слои в `style*.json` (подтверждено фактическим кодом
`src/web/style.json` и `style-dark.json`):** реальные id — это
`trails-track-halo-satellite` и `trails-path-bridleway-halo-satellite`.
Слоёв `trails-grade1..5-halo-satellite` или
`paths-bridleway-halo-satellite` **нет** и заводить их не нужно:
`trails-track` хранит дифференциацию по grade внутри одного `match`-
выражения по `tracktype`. На спутнике halo единого цвета/ширины
накладывается на весь `trails-track` целиком; разделять halo по grade
не требуется (визуально не различимо под линией grade-цвета).
Реализация:
- Halo для грунтовок и троп — пара underlay-слоёв
(`trails-track-halo-satellite`, `trails-path-bridleway-halo-satellite`),
уже присутствующих в обоих `style*.json` с `visibility: none`.
Включаются через `setLayoutProperty(..., 'visibility', 'visible')`
только в режиме «Спутник».
- Стили POI (circles и labels) на спутнике задаются динамически через
`setPaintProperty` при переключении режима; baseline-значения
возврата на «Схему» зафиксированы в `08-data-requirements.md` §5
и в `applyBaseLayer()` (см. §5.2 ниже).
### REQ-F-05: Сохранение состояния (localStorage)
- Ключ: `map-base-layer`.
- Значения: `"schematic"` (default) | `"satellite"`.
- При `onBaseLayerToggle()` — запись.
- При старте приложения — чтение и применение через
`restoreBaseLayerState()` (по аналогии с `restoreTerrainState()`).
### REQ-F-06: Восстановление после смены стиля карты
- При вызове `map.setStyle()` (переключение тёмной/светлой темы, см.
`switchMapStyle()` в `app.js`) спутниковый source/layer удаляются
вместе со стилем.
- В функции `rebuildMapOverlays()` добавляется вызов
`restoreBaseLayerState()` — это пересоздаёт source/layer спутника и
выставляет видимость по сохранённому состоянию.
- Порядок вызовов в `rebuildMapOverlays()`: `restoreBaseLayerState()`
вызывается **до** `restoreTerrainState()` — чтобы hillshade/TRI
оказались выше спутника, но ниже trails (тот же подход, что и для
schematic-режима).
### REQ-F-07: Атрибуция
- При создании source `satellite-raster` передаётся свойство
`attribution: "Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community"`.
- MapLibre автоматически отображает атрибуцию в правом нижнем углу
карты, когда соответствующий source активен.
- Атрибуция OSM остаётся видимой в обоих режимах (vector-источник
`trails-tiles` всегда активен).
### REQ-F-08: Fallback при ошибке загрузки тайлов
- Если спутниковые тайлы не загружаются (network error / 4xx / 5xx),
MapLibre сам показывает прозрачную плитку — под ней видим `background`.
- Логика fallback на схему не предусмотрена (пользователь сам
переключит, если нужно).
## 2. Нефункциональные требования
### REQ-NF-01: Производительность
- Время переключения «Схема → Спутник» (до первой видимой спутниковой
плитки): ≤ 500 мс при скорости сети ≥ 5 Мбит/с.
- Переключение обратно «Спутник → Схема» — мгновенное (источник
остаётся в стиле, меняется только visibility).
- В момент переключения не должно быть «прыжков» камеры — `center`,
`zoom`, `bearing`, `pitch` сохраняются.
### REQ-NF-02: Совместимость
- Браузеры: Chrome 90+, Firefox 90+, Safari 15+.
- Мобильные: iOS Safari 15+, Chrome для Android.
- MapLibre GL JS 4.7.0 (уже подключен).
### REQ-NF-03: UX
- Текущая активная подложка визуально видна в UI всегда (в попапе
слоёв).
- Переключение происходит без перезагрузки страницы и без потери
пользовательского состояния (маршрута, GPX, точек разведки).
### REQ-NF-04: Хранение
- localStorage ключ `map-base-layer`, размер ≤ 16 байт.
- Никаких других данных приложение для этой фичи не хранит.
### REQ-NF-05: Безопасность
- Запросы к Esri World Imagery идут по HTTPS.
- Никаких персональных данных пользователя в URL запросов не
передаётся.
- Атрибуция выводится в соответствии с лицензией провайдера (см. ADR).
## 3. UI-спецификация
### 3.1 Изменения в `#terrain-popup`
Сейчас:
```
┌────────────────────────────┐
│ Эндуро │
│ ☐ Тени рельефа │
│ ☐ Перепады │
│ ─────── │
│ ☑ Грунтовки │
│ ☑ Тропы │
│ ─────── │
│ ☑ POI │
│ ─────── │
│ Единицы [км][мили] │
└────────────────────────────┘
```
После:
```
┌────────────────────────────┐
│ Подложка [Схема][Спутник] │ ← новая секция
│ ─────── │
│ Эндуро │
│ ☐ Тени рельефа │
│ ☐ Перепады │
│ ─────── │
│ ☑ Грунтовки │
│ ☑ Тропы │
│ ─────── │
│ ☑ POI │
│ ─────── │
│ Единицы [км][мили] │
└────────────────────────────┘
```
### 3.2 Разметка HTML
В `src/web/index.html`, в начале `#terrain-popup` (сразу после
`<div class="terrain-popup-title">Эндуро</div>` ИЛИ выше него — по
выбору разработчика; рекомендуется в самом верху для большей
заметности):
```html
<!-- ET-007: переключатель подложки (Схема / Спутник) -->
<div class="terrain-base-row">
<span class="terrain-base-label">Подложка</span>
<div class="seg-control base-seg" id="base-seg">
<button type="button" class="seg-btn active" id="base-btn-schematic"
data-base="schematic" onclick="onBaseLayerToggle('schematic')">Схема</button>
<button type="button" class="seg-btn" id="base-btn-satellite"
data-base="satellite" onclick="onBaseLayerToggle('satellite')">Спутник</button>
</div>
</div>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
```
### 3.3 CSS
В `src/web/app.css` — добавить стили (по аналогии с `.terrain-unit-row`):
```css
.terrain-base-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.terrain-base-label {
font-size: 12px;
color: var(--text2);
flex-shrink: 0;
}
.terrain-base-row .seg-control {
flex: 1;
margin-bottom: 0;
}
.base-seg .seg-btn {
font-size: 12px;
}
```
### 3.4 Поведение на мобильных устройствах
- Попап `#terrain-popup` уже адаптирован под мобильные (ET-005). Новая
строка не должна нарушать ширину попапа.
- Высота кнопок `.seg-btn` остаётся 34px (как у переключателя единиц).
## 4. Данные
### 4.1 Спутниковый источник (MapLibre source spec)
```js
{
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
],
tileSize: 256,
minzoom: 0,
maxzoom: 19,
attribution: 'Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community'
}
```
### 4.2 Спутниковый слой (MapLibre layer spec)
```js
{
id: 'satellite-base',
type: 'raster',
source: 'satellite-raster',
paint: {
'raster-opacity': 1.0,
'raster-resampling': 'linear'
},
layout: { visibility: 'none' } // включается при переключении
}
```
Вставляется в стиль сразу после слоя `background`.
### 4.3 localStorage
| Ключ | Значения | Default |
| ----------------- | ------------------------------ | ------------- |
| `map-base-layer` | `"schematic"` \| `"satellite"` | `"schematic"` |
## 5. Алгоритмы
### 5.1 `onBaseLayerToggle(base)`
```
1. Если base === текущий — return.
2. Сохранить в localStorage('map-base-layer', base).
3. Применить applyBaseLayer(base).
4. syncBaseLayerUI(base).
```
### 5.2 `applyBaseLayer(base)`
```
1. map = window._map; если нет — return.
2. Если base === 'satellite':
2.1. Если source 'satellite-raster' отсутствует — addSource (см. 4.1).
2.2. Если layer 'satellite-base' отсутствует — addLayer (см. 4.2)
без beforeId. Корректный z-order гарантируется тем, что
restoreBaseLayerState вызывается ПЕРВЫМ в rebuildMapOverlays
(см. ADR-004 §«Вариант O», O-A; см. также R-7 в Tech-Risks).
2.3. setLayoutProperty('satellite-base', 'visibility', 'visible').
2.4. Запомнить layerState.basemap в _savedBasemapState (см. §5.6).
Принудительно скрыть osm-base:
setLayoutProperty('osm-base', 'visibility', 'none').
2.5. Включить halo-слои (см. §5.7 — синхронизация с чекбоксами):
для каждой пары (base, halo) ∈
[('trails-track', 'trails-track-halo-satellite'),
('trails-path-bridleway', 'trails-path-bridleway-halo-satellite')]
выставить halo.visibility = base.visibility текущего слоя.
2.6. Применить динамические правки POI:
- poi-circles: circle-stroke-color = '#ffffff',
circle-stroke-width = 2;
- poi-labels: text-color = '#ffffff',
text-halo-color = '#000000',
text-halo-width = 2.
2.7. Сменить background-color на единую satellite-константу
'#2a2a2a' (для обеих тем, см. ADR-004 §6).
3. Иначе (base === 'schematic'):
3.1. setLayoutProperty('osm-base', 'visibility',
_savedBasemapState === false ? 'none' : 'visible') —
восстановить выбор пользователя по «Базовая карта»
(см. §5.6); по умолчанию (если не сохранено) — 'visible'.
3.2. setLayoutProperty('satellite-base', 'visibility', 'none')
(если слой существует).
3.3. Скрыть halo-underlay-слои:
для обеих пар выставить halo.visibility = 'none'.
3.4. Вернуть POI к baseline текущей темы (см. Data §5):
- poi-circles: circle-stroke-color / circle-stroke-width
читаются из Data §5 baseline (поэтапно: light → dark);
- poi-labels: text-color, text-halo-color, text-halo-width — то же.
Источник истины baseline'ов — Data §5; код держит две константы
per-theme и выбирает по текущей теме.
3.5. Background-color — установить baseline текущей темы из Data §5
('#f0ede6' light / '#1a1a2e' dark). Прямая запись через
setPaintProperty (не полагаемся на setStyle, потому что
applyBaseLayer вызывается и без смены стиля).
```
### 5.3 `restoreBaseLayerState()`
```
1. base = localStorage.getItem('map-base-layer') || 'schematic'.
2. syncBaseLayerUI(base).
3. applyBaseLayer(base).
```
### 5.4 `syncBaseLayerUI(base)`
```
1. schematicBtn.classList.toggle('active', base === 'schematic').
2. satelliteBtn.classList.toggle('active', base === 'satellite').
```
### 5.5 Интеграция с `rebuildMapOverlays()` (`app.js`)
В существующей функции (см. `app.js`, ~строка 127) добавить вызов
**первым**:
```js
function rebuildMapOverlays() {
// ET-007/ADR-004 O-A: восстановить подложку ПЕРВОЙ — terrain/trails/POI
// ложатся поверх неё (z-order через порядок вставки, без beforeId).
// Функция определена в этом же файле (ADR-004 §2), глобально доступна.
restoreBaseLayerState();
// ── далее без изменений ──
restoreTerrainState();
restoreTrailsState();
// ...
}
```
### 5.6 Взаимодействие с существующим `toggleLayer('basemap')`
В `app.js:384391` уже определены:
```js
const layerState = { tracks: true, paths: true, poi: true, basemap: true };
const layerGroups = { , basemap: ['osm-base'] };
function toggleLayer(group) { setLayoutProperty('osm-base', 'visibility', ) }
```
— это существующий механизм «Базовая карта (схема)» как
самостоятельного выключателя. ET-007 уважает этот механизм по
следующему контракту:
1. **При входе в «Спутник»** (`applyBaseLayer('satellite')`, §5.2 шаг
2.4): запомнить `layerState.basemap` в локальной переменной
`_savedBasemapState` (init: `null`). Затем **принудительно** скрыть
`osm-base`. `layerState.basemap` **не меняется** — UI-кнопка
`#btn-basemap` остаётся в прежнем визуальном состоянии.
2. **Пока активен «Спутник»**, кнопка «Базовая карта» скрыта из UI
(CSS-класс `.satellite-active` на корне приложения скрывает
`#btn-basemap`) — пользователь не должен пытаться включить схему
поверх спутника (гибридный режим out of scope BRD §3). Альтернатива
реализации — disabled, на усмотрение разработчика; визуальный
эффект и AC-02/AC-03 идентичны.
3. **При возврате на «Схему»** (§5.2 шаг 3.1): `osm-base.visibility`
восстанавливается из `_savedBasemapState` (по умолчанию `true`
`'visible'`, если ранее пользователь сам выключал — `false`
`'none'`). После восстановления `_savedBasemapState = null`.
4. **На «Схеме» (default-режим)**: `toggleLayer('basemap')` работает
ровно как раньше — пишет в `layerState.basemap` и переключает
`osm-base.visibility`. ET-007 этот код не трогает.
### 5.7 Синхронизация halo с чекбоксами «Грунтовки» / «Тропы» / «POI»
В `app.js:27832826` существуют `onTrailsCheckbox()` и
`restoreTrailsState()`, которые управляют `visibility` только
`trails-track` и `trails-path-bridleway`. Halo-underlay-слои
(`*-halo-satellite`) сейчас они не трогают — в режиме «Спутник» это
дало бы «фантом» halo без основной линии.
Правило (источник истины): **halo-слой видим ⇔ (текущая база ===
'satellite') AND (соответствующий пользовательский чекбокс ON)**.
Реализация:
1. Ввести хелпер `applyTrailHaloVisibility(trackOn, pathOn)`:
- для пары `('trails-track-halo-satellite', trackOn)` и
`('trails-path-bridleway-halo-satellite', pathOn)`:
`visibility = (currentBaseLayer === 'satellite' && checked) ? 'visible' : 'none'`.
2. В `onTrailsCheckbox()` после установки `visibility` основным слоям —
вызвать `applyTrailHaloVisibility(trackChecked, pathChecked)`.
3. В `restoreTrailsState()` после установки `visibility` основным слоям —
вызвать `applyTrailHaloVisibility(trackOn, pathOn)`.
4. В `applyBaseLayer('satellite')` (§5.2 шаг 2.5) и
`applyBaseLayer('schematic')` (§5.2 шаг 3.3) — читать текущее
состояние чекбоксов из DOM (`#trails-track-cb`, `#trails-path-cb`)
и вызвать тот же хелпер.
**POI:** для группы `poi-circles` / `poi-labels` отдельных
halo-underlay-слоёв нет — динамические правки `setPaintProperty`
(см. §5.2) уже привязаны к видимости самих слоёв. При выключении
чекбокса «POI» оба слоя становятся `visibility: none` через
существующий механизм `layerState.poi`/`restorePoiState()` — текстовые
halo-свойства просто не видны, поэтому отдельная синхронизация не
требуется.
## 6. Файловая структура изменений
```
src/web/
├── index.html # + блок переключателя в #terrain-popup
├── app.css # + стили .terrain-base-row, .base-seg
├── app.js # + onBaseLayerToggle, applyBaseLayer,
# restoreBaseLayerState, syncBaseLayerUI,
# правка rebuildMapOverlays
```
Backend изменений нет.
## 7. Взаимодействие с существующими режимами
- Все режимы тулбара (Маршрут, Связка, Красивый, Разведка, Линейка,
Поиск, Метка, GPX) работают независимо от выбранной подложки.
- Переключение подложки **не сбрасывает** состояние режимов: маршруты,
GPX-треки, точки разведки, линейка, метки — остаются.
- Переключение темы (тёмная/светлая) **не сбрасывает** выбор подложки.
- При вызове `map.setStyle()` (тема, восстановление стиля)
спутниковый слой пересоздаётся в `rebuildMapOverlays()`.
## 8. Открытые вопросы для ADR
- Выбор провайдера спутниковых тайлов (Esri / Mapbox / Bing / OpenAerialMap).
- Решение по halo для POI/trails на спутнике: статические правки в
`style.json` через `visibility` или динамические `setPaintProperty`.
- Поведение hillshade при включении спутника: оставить как есть (по
выбору пользователя) — зафиксировано в REQ-F-04 как «оставить».

View File

@@ -1,263 +0,0 @@
---
type: acceptance-criteria
work_item_id: ET-007
title: "AC: Спутниковая карта (Схема / Спутник)"
version: 2
status: draft
created_at: 2026-05-31
updated_at: 2026-05-31
changelog:
- "v2 (2026-05-31): code-review fixes (12-review.md P1-2, P1-5, P1-6) — добавлены сценарии: видимость #btn-basemap при входе/выходе из «Спутник», save&restore _savedBasemapState, синхронизация halo с чекбоксами Грунтовки/Тропы, явные значения POI text-color/halo на спутнике и baseline при возврате."
authors:
- "agent:analyst"
---
# Acceptance Criteria — ET-007: Спутниковая карта (Схема / Спутник)
## AC-01: UI переключателя
```gherkin
Feature: Переключатель подложки в попапе слоёв
Scenario: Открытие попапа показывает переключатель
Given пользователь находится на карте
When пользователь нажимает кнопку «Рельеф» (#terrain-toggle)
Then открывается попап #terrain-popup
And в попапе виден segmented-control «Подложка» с кнопками «Схема» и «Спутник»
And одна из кнопок имеет класс .active
Scenario: Default — Схема
Given localStorage пуст (или ключ 'map-base-layer' не задан)
When пользователь открывает попап слоёв
Then активна кнопка «Схема» (#base-btn-schematic)
And не активна кнопка «Спутник» (#base-btn-satellite)
```
## AC-02: Переключение на «Спутник»
```gherkin
Feature: Переключение Схема → Спутник
Scenario: Базовое переключение
Given активна подложка «Схема»
When пользователь нажимает «Спутник» в попапе слоёв
Then кнопка «Спутник» получает класс .active
And кнопка «Схема» теряет класс .active
And на карте слой osm-base скрыт (visibility=none)
And на карте появляется слой satellite-base (visibility=visible)
And положение карты (center, zoom, bearing, pitch) не изменилось
Scenario: Атрибуция Esri отображается
Given пользователь включил режим «Спутник»
Then в нижнем правом углу карты видна атрибуция «Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community»
Scenario: Кнопка «Базовая карта» скрывается на спутнике (P1-5)
Given активна подложка «Спутник»
Then UI-кнопка #btn-basemap не видна пользователю
And пользователь не может из UI включить osm-base поверх спутника (out of scope, BRD §3 гибридный режим)
Scenario: Запоминание выбора «Базовая карта» при входе в Спутник (P1-5)
Given активна подложка «Схема»
And пользователь явно выключил «Базовую карту» (layerState.basemap === false, osm-base.visibility === 'none')
When пользователь переключается на «Спутник»
Then значение layerState.basemap сохраняется во внутреннем _savedBasemapState === false
And osm-base.visibility остаётся 'none' (принудительно)
```
## AC-03: Переключение на «Схема»
```gherkin
Feature: Переключение Спутник → Схема
Scenario: Возврат на схему (layerState.basemap по умолчанию true)
Given активна подложка «Спутник»
And до входа в «Спутник» layerState.basemap === true (default)
When пользователь нажимает «Схема» в попапе слоёв
Then кнопка «Схема» получает класс .active
And слой osm-base снова виден (visibility=visible)
And слой satellite-base скрыт (visibility=none), но source остаётся в стиле
And положение карты не изменилось
And UI-кнопка #btn-basemap снова видна
Scenario: Возврат на схему с восстановлением выбора пользователя (P1-5)
Given активна подложка «Спутник»
And до входа в «Спутник» пользователь выключил «Базовую карту» (_savedBasemapState === false)
When пользователь нажимает «Схема»
Then слой osm-base остаётся скрытым (visibility=none) выбор пользователя восстановлен
And layerState.basemap === false
And _savedBasemapState сбрасывается в null
```
## AC-04: Совместимость со слоями приложения
```gherkin
Feature: Слои поверх спутника
Scenario: Грунтовки и тропы видны на спутнике
Given активна подложка «Спутник»
And в попапе включены «Грунтовки» и «Тропы»
Then на карте видны линии грунтовок (trails-track) и троп (trails-path-bridleway) поверх спутника
And halo-слой trails-track-halo-satellite visibility=visible
And halo-слой trails-path-bridleway-halo-satellite visibility=visible
Scenario: Выключение «Грунтовки» скрывает и halo (P1-6)
Given активна подложка «Спутник»
And чекбокс «Грунтовки» был ON
When пользователь снимает чекбокс «Грунтовки»
Then trails-track visibility=none
And trails-track-halo-satellite visibility=none (halo не остаётся «фантомом»)
Scenario: Выключение «Тропы» скрывает и halo (P1-6)
Given активна подложка «Спутник»
And чекбокс «Тропы» был ON
When пользователь снимает чекбокс «Тропы»
Then trails-path-bridleway visibility=none
And trails-path-bridleway-halo-satellite visibility=none
Scenario: На «Схеме» halo-слои всегда скрыты (P1-6)
Given активна подложка «Схема»
And чекбокс «Грунтовки» ON
Then trails-track visibility=visible
And trails-track-halo-satellite visibility=none
Scenario: POI видны и читаемы на спутнике (P1-2)
Given активна подложка «Спутник»
And в попапе включён «POI»
Then на карте видны маркеры POI поверх спутника
And poi-labels paint: text-color === '#ffffff'
And poi-labels paint: text-halo-color === '#000000'
And poi-labels paint: text-halo-width === 2
And poi-circles paint: circle-stroke-color === '#ffffff'
And poi-circles paint: circle-stroke-width === 2
Scenario: POI baseline восстанавливается на «Схеме» (P1-2)
Given был активен «Спутник», POI labels в режиме спутника
When пользователь возвращается на «Схему» (light-тема)
Then poi-labels paint: text-color === '#333333' (baseline light, Data §5)
And poi-labels paint: text-halo-color === '#ffffff' (baseline light)
And poi-labels paint: text-halo-width === 1.5 (baseline light)
Scenario: Hillshade поверх спутника
Given активна подложка «Спутник»
When пользователь включает «Тени рельефа»
Then на карте видны и спутник, и hillshade (hillshade поверх спутника)
Scenario: Маршрут OSRM поверх спутника
Given пользователь построил маршрут через OSRM
When пользователь переключает подложку на «Спутник»
Then маршрут остаётся виден поверх спутника
And статистика маршрута сохранена
Scenario: GPX-треки поверх спутника
Given пользователь загрузил GPX-трек
When пользователь переключает подложку на «Спутник»
Then GPX-линии и waypoints остаются видны поверх спутника
```
## AC-05: Сохранение в localStorage
```gherkin
Feature: Persistence выбора подложки
Scenario: Сохранение при переключении
Given активна подложка «Схема»
When пользователь нажимает «Спутник»
Then localStorage['map-base-layer'] === 'satellite'
Scenario: Восстановление после reload
Given localStorage['map-base-layer'] === 'satellite'
When пользователь перезагружает страницу
Then после загрузки карты активна подложка «Спутник»
And кнопка «Спутник» имеет класс .active
```
## AC-06: Восстановление при смене темы
```gherkin
Feature: Подложка переживает смену темы
Scenario: Переключение тёмной/светлой темы в режиме «Спутник»
Given активна подложка «Спутник»
When пользователь переключает тему (тёмная светлая)
Then после завершения map.setStyle() спутниковый слой восстановлен
And подложка «Спутник» остаётся активной
And все слои поверх (trails, POI, маршрут, GPX) восстановлены
Scenario: Переключение слоёв terrain в режиме «Спутник»
Given активна подложка «Спутник»
When пользователь включает или выключает «Тени рельефа» / «Перепады»
Then подложка «Спутник» остаётся активной
```
## AC-07: Совместимость с режимами тулбара
```gherkin
Feature: Подложка не мешает другим режимам
Scenario: Режим «Маршрут» на спутнике
Given активна подложка «Спутник»
When пользователь активирует режим «Маршрут»
And тапает 2 точки на карте
Then маршрут строится корректно
And линия маршрута видна на спутнике
Scenario: Режим «Разведка» на спутнике
Given активна подложка «Спутник»
When пользователь активирует режим «Разведка» и тапает на карту
Then круг радиуса разведки видим
And статистика разведки отображается
Scenario: Линейка на спутнике
Given активна подложка «Спутник»
When пользователь активирует «Линейка» и расставляет точки
Then линия линейки видна
And расстояние отображается
Scenario: Поиск на спутнике
Given активна подложка «Спутник»
When пользователь нажимает «Поиск» и вводит запрос
Then результаты поиска отображаются
And карта корректно центрируется на найденной точке
```
## AC-08: Производительность
```gherkin
Feature: Скорость переключения
Scenario: Переключение Схема → Спутник
Given активна подложка «Схема» и сеть 5 Мбит/с
When пользователь нажимает «Спутник»
Then первая спутниковая плитка отображается в течение 500 мс
Scenario: Переключение Спутник → Схема
Given активна подложка «Спутник» (тайлы уже подгружены)
When пользователь нажимает «Схема»
Then смена визуально мгновенная ( 100 мс)
```
## AC-09: Mobile UI
```gherkin
Feature: Переключатель на мобильных устройствах
Scenario: Попап слоёв на мобильном
Given пользователь открыл приложение на мобильном устройстве (виртуальный viewport 375×812)
When пользователь открывает попап слоёв
Then переключатель «Подложка» виден полностью
And обе кнопки нажимаемы (touch target 34px)
And не перекрывает другие элементы попапа
```
## AC-10: Не ломает существующий функционал
```gherkin
Feature: Регресс-проверка
Scenario: Все режимы работают как в режиме «Схема», так и в «Спутник»
Given пользователь использует приложение
Then режимы Маршрут, Связка, Красивый, Разведка, Линейка, Поиск, Метка, GPX
работают одинаково в обеих подложках
And переключение единиц измерения (км/мили) работает в обеих подложках
And переключение темы работает в обеих подложках
```

View File

@@ -1,231 +0,0 @@
---
type: test-plan
work_item_id: ET-007
title: "Test Plan: Спутниковая карта (Схема / Спутник)"
version: 1
status: draft
created_at: 2026-05-31
updated_at: 2026-05-31
authors:
- "agent:analyst"
test_suites:
- name: unit-base-layer-state
type: unit
description: "Чтение/запись/восстановление выбора подложки"
cases:
- id: U-01
name: "Default — Схема, если localStorage пуст"
input: "localStorage без ключа 'map-base-layer'"
expected: "restoreBaseLayerState() выставляет base='schematic'"
- id: U-02
name: "Чтение значения 'satellite' из localStorage"
input: "localStorage['map-base-layer'] = 'satellite'"
expected: "restoreBaseLayerState() выставляет base='satellite'"
- id: U-03
name: "Запись значения при переключении"
input: "onBaseLayerToggle('satellite')"
expected: "localStorage['map-base-layer'] === 'satellite'"
- id: U-04
name: "Игнор некорректного значения в localStorage"
input: "localStorage['map-base-layer'] = 'unknown'"
expected: "restoreBaseLayerState() fallback на 'schematic'"
- id: U-05
name: "Toggle на уже активный режим — no-op"
input: "active=schematic; onBaseLayerToggle('schematic')"
expected: "Никаких изменений в стиле, localStorage не записывается повторно"
- name: unit-ui-sync
type: unit
description: "Синхронизация .active у кнопок переключателя"
cases:
- id: U-10
name: "syncBaseLayerUI('satellite')"
input: "DOM с #base-btn-schematic.active и #base-btn-satellite без класса"
expected: "После: #base-btn-satellite.active=true, #base-btn-schematic.active=false"
- id: U-11
name: "syncBaseLayerUI('schematic')"
input: "DOM с #base-btn-satellite.active"
expected: "После: #base-btn-schematic.active=true, #base-btn-satellite.active=false"
- name: integration-maplibre-layers
type: integration
description: "Взаимодействие с MapLibre source/layer"
cases:
- id: I-01
name: "Добавление спутникового source при первом включении"
input: "applyBaseLayer('satellite') впервые"
expected: "map.getSource('satellite-raster') !== undefined; URL содержит arcgisonline.com"
- id: I-02
name: "Добавление спутникового layer при первом включении"
input: "applyBaseLayer('satellite') впервые"
expected: "map.getLayer('satellite-base') !== undefined; type='raster'"
- id: I-03
name: "Visibility OSM-base после переключения на спутник"
input: "applyBaseLayer('satellite')"
expected: "map.getLayoutProperty('osm-base', 'visibility') === 'none'"
- id: I-04
name: "Visibility satellite-base после переключения на схему"
input: "applyBaseLayer('satellite') → applyBaseLayer('schematic')"
expected: "satellite-base.visibility==='none', osm-base.visibility==='visible'"
- id: I-05
name: "Z-order: satellite ниже terrain и trails"
input: "applyBaseLayer('satellite'); включены hillshade и trails"
expected: "Layer index(satellite-base) < index(terrain-hillshade) < index(trails-track)"
- id: I-06
name: "Position карты сохраняется при переключении"
input: "center=[37.6,55.75], zoom=10; applyBaseLayer('satellite')"
expected: "После: getCenter() == [37.6,55.75], getZoom() == 10"
- id: I-07
name: "Атрибуция Esri зарегистрирована"
input: "applyBaseLayer('satellite')"
expected: "source 'satellite-raster' содержит attribution с упоминанием Esri"
- name: integration-style-switch
type: integration
description: "Поведение при map.setStyle (смена темы)"
cases:
- id: I-10
name: "Спутник восстанавливается после setStyle (тёмная → светлая)"
input: "active='satellite'; вызывается switchMapStyle()"
expected: "После idle: layer 'satellite-base' существует; visibility='visible'; osm-base.visibility='none'"
- id: I-11
name: "Сохранённое состояние читается из localStorage в rebuildMapOverlays"
input: "localStorage='satellite'; rebuildMapOverlays() вручную"
expected: "applyBaseLayer вызван с 'satellite'"
- id: I-12
name: "Восстановление выполняется до restoreTerrainState"
input: "rebuildMapOverlays() с заглушками-shpions"
expected: "Порядок вызовов: restoreBaseLayerState → restoreTerrainState"
- name: integration-other-layers
type: integration
description: "Совместимость со всеми клиентскими слоями"
cases:
- id: I-20
name: "Маршрут OSRM не теряется при переключении"
input: "Построен маршрут; applyBaseLayer('satellite')"
expected: "Layer маршрута существует, координаты не изменились"
- id: I-21
name: "GPX-трек не теряется при переключении"
input: "Загружен GPX; applyBaseLayer('satellite')"
expected: "Layer gpx-* существует, source.data не изменён"
- id: I-22
name: "Recon-круг не теряется при переключении"
input: "Активен recon; applyBaseLayer('satellite')"
expected: "Recon-круг отображается на карте"
- id: I-23
name: "Hillshade поверх спутника"
input: "applyBaseLayer('satellite'); включить hillshade"
expected: "Оба слоя видимы; hillshade выше satellite-base в стиле"
- id: I-24
name: "POI halo чёрный на спутнике"
input: "applyBaseLayer('satellite')"
expected: "map.getPaintProperty('poi-labels','text-halo-color') === '#000000' (или эквивалент)"
- id: I-25
name: "POI halo дефолтный на схеме"
input: "applyBaseLayer('schematic') после спутника"
expected: "POI labels вернули halo цвет схемы (#ffffff)"
- name: e2e-base-layer-workflow
type: e2e
description: "Полный пользовательский сценарий"
cases:
- id: E-01
name: "Открыть попап → включить спутник → сохранилось"
steps:
- "Открыть приложение (default — Схема)"
- "Нажать кнопку «Рельеф» в правой панели"
- "Убедиться: переключатель «Подложка» виден"
- "Нажать «Спутник»"
- "Убедиться: спутниковые тайлы загрузились"
- "Убедиться: атрибуция Esri видна"
- "Перезагрузить страницу"
- "Убедиться: после загрузки активен «Спутник»"
- id: E-02
name: "Переключение туда-обратно без потери маршрута"
steps:
- "Построить маршрут через OSRM (2 точки)"
- "Переключить на «Спутник»"
- "Убедиться: маршрут виден на спутнике, статистика сохранена"
- "Переключить на «Схема»"
- "Убедиться: маршрут виден на схеме, статистика та же"
- id: E-03
name: "Спутник + загрузка GPX"
steps:
- "Переключить на «Спутник»"
- "Загрузить GPX-файл"
- "Убедиться: трек отрисован поверх спутника"
- "Убедиться: цвет трека различим"
- id: E-04
name: "Спутник + смена темы"
steps:
- "Переключить на «Спутник»"
- "Переключить тёмную тему на светлую"
- "Дождаться idle"
- "Убедиться: подложка осталась «Спутник»"
- "Убедиться: все остальные слои восстановились"
- id: E-05
name: "Спутник + переключение единиц измерения"
steps:
- "Переключить на «Спутник»"
- "Открыть попап слоёв и переключить «мили»"
- "Убедиться: единицы переключились, подложка не сбросилась"
- id: E-06
name: "Спутник + hillshade"
steps:
- "Переключить на «Спутник»"
- "Включить «Тени рельефа»"
- "Убедиться: видны спутник и тени одновременно"
- id: E-07
name: "Линейка на спутнике"
steps:
- "Переключить на «Спутник»"
- "Активировать линейку"
- "Поставить 3 точки на карте"
- "Убедиться: линия линейки видна на спутнике"
- "Убедиться: расстояния отображаются"
- name: e2e-error-handling
type: e2e
description: "Поведение при сетевых ошибках"
cases:
- id: E-10
name: "Спутниковые тайлы недоступны (offline)"
steps:
- "Включить «Спутник»"
- "Симулировать offline (DevTools throttling: Offline)"
- "Сдвинуть карту в новую область"
- "Убедиться: приложение не падает; видим фон background"
- "Восстановить сеть → тайлы догружаются"
test_data:
- name: "test-track-simple.gpx"
description: "1 трек, 10 точек — для проверки совместимости с GPX"
- name: "Тестовый OSRM-маршрут"
description: "2 waypoint в районе [37.6,55.75] → [37.7,55.8]"

View File

@@ -1,274 +0,0 @@
---
type: ui-test-cases
work_item_id: ET-007
title: "UI Test Cases: Спутниковая карта (Схема / Спутник)"
version: 1
status: draft
created_at: 2026-05-31
updated_at: 2026-05-31
authors:
- "agent:analyst"
---
# UI Test Cases — ET-007: Спутниковая карта (Схема / Спутник)
Базовый URL: `https://openclaw.mva154.duckdns.org/enduro/`
Все тесты проверяют появление и поведение переключателя «Подложка» в
попапе слоёв, а также корректное отображение спутниковой подложки
поверх существующих UI-элементов.
---
### TC-UI-01 — Переключатель «Подложка» виден в попапе
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. screenshot: "01-popup-with-base-toggle"
6. check-visual: "В открывшемся попапе #terrain-popup видна строка «Подложка» с двумя кнопками: «Схема» (активна, оранжевый фон) и «Спутник» (неактивна)"
---
### TC-UI-02 — Активация «Спутник» меняет подложку
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. screenshot: "02-satellite-active"
8. check-visual: "Карта показывает спутниковые снимки (зелёные/коричневые поля, реальный рельеф). В попапе кнопка «Спутник» подсвечена оранжевым, «Схема» — нет"
---
### TC-UI-03 — Атрибуция Esri видна
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. click: "#terrain-toggle"
8. wait: 500
9. screenshot: "03-attribution-esri"
10. check-visual: "В правом нижнем углу карты видна атрибуция со словом «Esri» (или иконка info, при клике на которую разворачивается полный текст)"
---
### TC-UI-04 — Возврат на «Схема»
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. click: "#base-btn-schematic"
8. wait: 2000
9. screenshot: "04-schematic-restored"
10. check-visual: "Карта снова показывает схему OSM (бежевый/серый фон, дороги). В попапе кнопка «Схема» подсвечена оранжевым"
---
### TC-UI-05 — Грунтовки и тропы видны на спутнике
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. screenshot: "05-trails-on-satellite"
8. check-visual: "На спутниковой подложке отчётливо видны линии грунтовок (золотые/красные) и троп (красные пунктирные). Линии имеют светлую обводку (halo) для контраста с тёмным спутником"
---
### TC-UI-06 — POI и подписи на спутнике читаемы
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. screenshot: "06-poi-on-satellite"
8. check-visual: "POI-маркеры (цветные кружки) видны на спутнике. Подписи POI имеют тёмный halo, читаемы на любом фоне"
---
### TC-UI-07 — Спутник переживает смену темы
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. click: "#btn-theme"
8. wait: 3000
9. screenshot: "07-satellite-after-theme-switch"
10. check-visual: "После переключения темы карта по-прежнему показывает спутниковую подложку (а не схему)"
---
### TC-UI-08 — Hillshade поверх спутника
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. click: "#terrain-hillshade-cb"
8. wait: 3000
9. screenshot: "08-hillshade-on-satellite"
10. check-visual: "Виден спутник + затенение рельефа поверх (тёмные тени по склонам, рельеф «выпуклый»). Слои не перекрывают друг друга полностью"
---
### TC-UI-09 — Маршрут OSRM на спутнике
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-route"
4. wait: 1000
5. click: "#map"
6. wait: 2000
7. scroll: 100
8. click: "#map"
9. wait: 5000
10. click: "#terrain-toggle"
11. wait: 500
12. click: "#base-btn-satellite"
13. wait: 5000
14. screenshot: "09-route-on-satellite"
15. check-visual: "Маршрут (синяя/оранжевая линия) виден поверх спутниковой подложки, конечные точки маршрута отмечены маркерами"
---
### TC-UI-10 — Переключатель на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. screenshot: "10-popup-mobile"
6. check-visual: "На мобильном viewport попап #terrain-popup помещается на экране целиком. Переключатель «Подложка» виден, обе кнопки нажимаемы, не перекрывают другие элементы попапа"
---
### TC-UI-11 — Активация «Спутник» на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. screenshot: "11-satellite-mobile"
8. check-visual: "Спутниковая подложка отображается на мобильном устройстве. Тулбар внизу и попап работают корректно, переключатель «Спутник» подсвечен"
---
### TC-UI-12 — Persistence: спутник после перезагрузки
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. navigate: https://openclaw.mva154.duckdns.org/enduro/
8. wait: 5000
9. screenshot: "12-satellite-after-reload"
10. check-visual: "После перезагрузки карта сразу открывается со спутниковой подложкой (не со схемой). Активный режим — «Спутник»"
---
### TC-UI-13 — GPX-панель + Спутник
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. click: "#tb-gpx"
8. wait: 1000
9. screenshot: "13-gpx-sheet-on-satellite"
10. check-visual: "Открылась панель #sheet-gpx с пустым состоянием поверх спутниковой карты. Панель и подложка визуально не конфликтуют"
---
### TC-UI-14 — Совместимость с переключателем единиц
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. click: "#unit-btn-mi"
8. wait: 1000
9. screenshot: "14-satellite-with-miles"
10. check-visual: "Подложка остаётся «Спутник», единицы переключены на мили (кнопка «мили» подсвечена). Оба переключателя видны и работают независимо"

View File

@@ -1,370 +0,0 @@
---
type: adr
work_item_id: ET-007
adr_id: ADR-004
title: "ADR-004: Спутниковая подложка — провайдер Esri World Imagery, лениво-добавляемый raster-source, гибридная стратегия halo"
status: accepted
created_at: 2026-05-31
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels: []
---
# ADR-004 — Спутниковая подложка: провайдер, размещение кода, схема восстановления
## Статус
Accepted
## Контекст
ET-007 вводит вторую базовую подложку карты — спутниковые растровые
снимки — с переключателем «Схема / Спутник» в попапе слоёв
(см. `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`).
Существующее состояние, проверенное в коде:
- В обоих стилях карты (`src/web/style.json` стр. 1641,
`src/web/style-dark.json`) уже определён единственный raster-source
`osm-raster` и слой `osm-base`, лежащий поверх слоя `background`.
Тайлы OSM раздаются `https://tile.openstreetmap.org/{z}/{x}/{y}.png`
— то есть прецедент **внешней (кросс-оригинальной) тайл-зависимости с
атрибуцией без API-ключа уже существует**.
- `src/web/app.js` (3 132 строки) содержит функцию `rebuildMapOverlays()`
(стр. 127), которая последовательно вызывает `restoreTerrainState()`,
`restoreTrailsState()`, `restorePoiState()`, перерисовку маршрутов /
GPX / линейки. Эта функция — единственная точка восстановления
визуальных слоёв после `map.setStyle()` (переключение тёмной/светлой
темы, `switchMapStyle()` стр. 100117).
- Фронтенд плоский, без сборщика: `index.html`, `app.js`, `units.js`
(190 строк, ADR-0001), `gpx.js` (1 242 строки, ADR-002). Сложившийся
паттерн — «одна крупная фича = один классический скрипт + глобали»
(ADR-002). Все JS-функции глобальные, обработчики навешаны через
инлайновые `onclick`.
- Динамические мутации слоёв через `setPaintProperty` /
`setLayoutProperty` / `addSource` / `addLayer` в `app.js` уже широко
используются (~30 вхождений).
- В `app.js` уже есть зрелые «restore*State()»-функции для каждой
группы слоёв; ET-007 встраивается в этот же паттерн ещё одной такой
функцией `restoreBaseLayerState()`.
Решения, которые предстоит зафиксировать архитектурно:
1. Какого провайдера спутниковых тайлов выбрать.
2. Где разместить код переключателя — в `app.js` или в новом модуле.
3. Как именно добавлять спутниковый source/layer (заранее в `style.json`
или лениво из JS), и как переживать `map.setStyle()`.
4. Каким способом обеспечивать читаемость линий грунтовок/троп и POI
на тёмной спутниковой подложке (halo).
5. Классификацию изменения и нужна ли эскалация `arch:major-change`.
## Рассмотренные варианты
### Вариант P (провайдер) — выбор провайдера спутниковых тайлов
| Провайдер | API-ключ | Лицензия / условия | Покрытие | Решение |
|---|---|---|---|---|
| **Esri World Imagery** (`server.arcgisonline.com/.../World_Imagery/MapServer/tile/{z}/{y}/{x}`) | нет | Условия Esri ArcGIS Online: бесплатное использование с атрибуцией для некоммерческой и demo-разработки; широко применяется open-source-проектами (Leaflet, OpenLayers, QGIS) | глобальное, до z19 | **выбран** |
| Mapbox Satellite | требуется | бесплатный квот-лимит, далее платно | глобальное | отклонён — BRD F-02 явно требует «без API-ключа» |
| Bing Maps | требуется | сложная лицензия, обязательная регистрация | глобальное | отклонён — то же |
| Google Maps Tiles | требуется | прямо запрещён ToS для нативного встраивания не через Google Maps JS API | глобальное | отклонён |
| OpenAerialMap | нет | open-source, CC-BY | **фрагментарное**, нет глобального бесшовного слоя | отклонён — не покрывает РФ-эндуро-сценарии |
| MapTiler Satellite | требуется | бесплатный квот-лимит | глобальное | отклонён — API-ключ |
Esri World Imagery — единственный вариант, удовлетворяющий
**одновременно** трём ограничениям BRD: без API-ключа, с глобальным
покрытием, с лицензионно допустимой формой использования через
атрибуцию.
### Вариант M (модуль) — где разместить код
- **M-A — добавить в `app.js`** (выбран). +~150 строк
(`onBaseLayerToggle`, `applyBaseLayer`, `restoreBaseLayerState`,
`syncBaseLayerUI`, плюс хук в `rebuildMapOverlays()` и handler
`onclick` в `index.html`). Минимальный blast radius, никаких новых
файлов, никаких изменений в подключении скриптов.
- **M-B — выделить `src/web/basemap.js`** (по аналогии с ADR-002 для
GPX). Отклонён: ADR-002 разделил фичу, потому что её объём был
600900 строк и она имела собственную модель данных (`gpxTracks`),
собственный bottom sheet и собственный canvas. Здесь фича плоская и
объём в 57 раз меньше; разделение даёт чистоту, но не покрывает
стоимости новой связки `app.js ↔ basemap.js` ради ~150 строк.
Контракт интеграции с `rebuildMapOverlays()` и так глобальный —
никакой инкапсуляции отдельный файл не добавит.
### Вариант S (source) — как добавить спутниковый source/layer
- **S-A — задекларировать source `satellite-raster` и слой
`satellite-base` (`visibility: none`) в обоих `style.json` /
`style-dark.json`**. Source активен всегда, тайлы не запрашиваются
до показа слоя. Плюс: восстановление после `setStyle()`
тривиально (`setLayoutProperty('satellite-base', 'visibility', ...)`).
Минус: `style.json` обоих тем нужно править симметрично; дрейф
значений между двумя стилями.
- **S-B — лениво создавать source и layer из JS при первом включении
«Спутник»** (выбран, совпадает с TRZ §1 REQ-F-02). Плюс: `style.json`
не трогаем; ноль внешних запросов у пользователей, которые не
включают спутник; единая точка определения source — в `app.js`. После
`map.setStyle()` source и layer исчезают и переcоздаются вызовом
`restoreBaseLayerState()` из `rebuildMapOverlays()` — это та же
логика, что уже используется для terrain/trails/POI/GPX. Минус:
холодное переключение «Схема → Спутник» включает в себя `addSource`
+ `addLayer` + сетевой запрос — но укладывается в НФТ 500 мс.
### Вариант O (order) — порядок восстановления в `rebuildMapOverlays()`
- **O-A — `restoreBaseLayerState()` вызывается ПЕРВЫМ**, до
`restoreTerrainState()` (выбран, совпадает с TRZ §5.5). Гарантирует
z-order: `background``satellite-base``osm-base` → terrain →
trails → POI → routes → GPX. terrain/trails/POI оказываются выше
спутника, маршрут/GPX — выше terrain.
- **O-B — добавлять `satellite-base` с явным `beforeId` первого
trails-слоя**. Идемпотентно к порядку, но в `rebuildMapOverlays()`
моменты создания слоёв не атомарны (terrain/trails добавляются
асинхронно); использовать `beforeId` слоёв, которых ещё нет, нельзя.
Поэтому простой «вызвать первым» надёжнее.
### Вариант H (halo) — обеспечение читаемости поверх спутника
- **H-A — динамический `setPaintProperty` по всем затрагиваемым слоям**.
Все правки делаем из `applyBaseLayer()`; на «Схема» возвращаем
исходные значения. Минус: нужно где-то хранить «исходные» paint-
значения; при `map.setStyle()` они сбрасываются, что повышает риск
drift между двумя темами.
- **H-B — отдельные «underlay»-слои с halo, `visibility: none` по
умолчанию, включаются на спутнике** + **`setPaintProperty` только
для POI text-halo** (выбран, совпадает с TRZ §1 REQ-F-04). Halo-линии
декларативны в `style.json` обеих тем — никакого «запомнить
исходное» не нужно, восстановление по `visibility`. Для POI label
правок одна (`text-halo-color`/`text-halo-width`) — её проще менять
динамически, чем заводить параллельные label-слои.
- **H-C — толстая полупрозрачная белая обводка прямо в существующих
trails-слоях через `line-gap-width`**. Отклонён: ломает «Схему»
(там halo не нужен и портит вид светлой подложки).
## Решение
Принимается комбинация: **P-Esri + M-A + S-B + O-A + H-B**.
1. **Провайдер — Esri World Imagery.** URL-шаблон, атрибуция и параметры
source — как в TRZ §4.1. HTTPS обязателен. Атрибуция строки —
`"Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community"`.
Альтернативные провайдеры не закладываются в код фичи; точка
расширения — единственный объект source-spec в `applyBaseLayer()`,
при будущей смене провайдера правка локализуется одним местом
(см. R-2 в `10-tech-risks.md`).
2. **Код фичи живёт в `app.js`.** Никакой новый JS-файл не вводится.
Новые глобальные функции — `onBaseLayerToggle()`, `applyBaseLayer()`,
`restoreBaseLayerState()`, `syncBaseLayerUI()` — добавляются по
соседству с уже существующими `restoreTerrainState()` /
`restoreTrailsState()`. Если в будущей фазе появится потребность
(например, второй провайдер, гибридный режим, оффлайн-кэш) — фича
мигрирует в `src/web/basemap.js` без изменения публичного контракта
(имена функций глобальные и стабильные).
3. **Source и layer добавляются лениво** при первом включении
«Спутник» через `addSource('satellite-raster', {...})` +
`addLayer({ id: 'satellite-base', ... })`. До этого момента
запросов к `server.arcgisonline.com` не происходит. Это важно с
точки зрения приватности: пользователи, которые никогда не
используют спутник, не светят свой IP на серверы Esri (см.
`10-tech-risks.md`, R-3).
4. **Восстановление после `map.setStyle()` — через `rebuildMapOverlays()`.**
В функцию добавляется **первым** вызов
`if (typeof restoreBaseLayerState === 'function') restoreBaseLayerState();`
до `restoreTerrainState()`. Это гарантирует, что terrain и trails
окажутся выше спутника, без необходимости вычислять `beforeId`.
`restoreBaseLayerState()` идемпотентен: читает `localStorage` ключа
`map-base-layer` и применяет `applyBaseLayer()`.
5. **Halo — гибридный подход:**
- Для **линий грунтовок и троп** в обоих `style.json` /
`style-dark.json` присутствуют парные «underlay»-слои
`trails-track-halo-satellite` и
`trails-path-bridleway-halo-satellite` (более широкая
полупрозрачная белая обводка, `layout.visibility = "none"`).
При входе в «Спутник» эти слои становятся видимыми; при возврате
на «Схему» — скрываются. Никаких runtime-правок paint не
требуется. Слоёв на каждую grade (`trails-grade1..5-halo-satellite`)
**не заводится**: дифференциация grade хранится внутри одного
`match`-выражения по `tracktype` в `trails-track`, halo единого
цвета/ширины накладывается на весь слой целиком — этого
достаточно для читаемости (под halo всё равно ляжет цветная
линия `trails-track`). Аналогично для троп — единый halo на весь
`trails-path-bridleway` (фильтр `highway in path/bridleway/footway`).
`trails-asphalt` halo не получает: он по умолчанию скрыт
(`visibility: none`, `line-opacity: 0`); если в будущей фазе
включится — добавится halo тем же паттерном.
- Для **POI labels** меняются динамически три свойства:
`text-color` (`#ffffff` на спутнике / baseline текущей темы на схеме —
`#333333` для light, `#e0e0e0` для dark), `text-halo-color`
(`#000000` на спутнике / baseline `#ffffff` для light,
`#1a1a2e` для dark на схеме), `text-halo-width` (`2` на спутнике
/ baseline `1.5` для light, `2` для dark на схеме). Менять
**обе** пары (color + halo) необходимо: иначе тёмный baseline-
текст светлой темы (`#333333`) поверх чёрного halo не читается.
Baseline-значения известны и зафиксированы в Data §5; всегда
выставляем явные значения для обоих режимов.
- **POI circles** — обводка `circle-stroke-color: #ffffff` /
`circle-stroke-width: 2` динамически на спутнике, возврат к
baseline текущей темы из Data §5 на схеме (`#ffffff`/`1.5` light,
`#333333`/`1.5` dark).
6. **Цвет `background`** в режиме «Спутник» меняется через
`setPaintProperty('background', 'background-color', '#2a2a2a')`
**единая константа `#2a2a2a` для обеих тем** (тёмно-серый, чтобы
не «бликовал» под медленно подгружающимися спутниковыми плитками).
На обеих темах используется одно и то же значение; per-theme-
развилки нет (упрощает код и исключает рассинхрон). При возврате
на «Схему» восстанавливаются baseline-значения текущей темы —
`#f0ede6` (light, из `style.json`) и `#1a1a2e` (dark, из
`style-dark.json`; **не** `#1a1a1a` — это была ошибка в более
раннем черновике). Эти baseline-константы зафиксированы в
`applyBaseLayer()` и в `08-data-requirements.md` §5.
7. **localStorage — ключ `map-base-layer`** (см. TRZ §4.3), значения
`"schematic"` / `"satellite"`, default `"schematic"`. Ключ
полностью обособлен от существующих UI-настроек
(`enduro-theme-mode`, `distance_unit`, `terrain-*`, `trails-*`,
`poi-visible`) — никаких миграций старых значений не требуется.
8. **Контракт с существующим `toggleLayer('basemap')`
(`app.js:384391`).** В коде уже есть отдельный пользовательский
выключатель «Базовая карта» (управляет `osm-base.visibility` и
`layerState.basemap`). ET-007 принимает паттерн **save & restore**
(см. TRZ §5.6): при входе в «Спутник» сохраняем `layerState.basemap`
в `_savedBasemapState` и принудительно скрываем `osm-base`; UI-кнопка
`#btn-basemap` скрывается через CSS-класс `.satellite-active` (чтобы
пользователь не пытался включить «гибрид»: out of scope BRD §3).
При возврате на «Схему» восстанавливаем `osm-base.visibility` из
сохранённого значения. На «Схеме» `toggleLayer('basemap')` работает
как раньше — ET-007 этот код не трогает.
9. **Синхронизация halo-слоёв с пользовательскими чекбоксами
«Грунтовки» / «Тропы» (`app.js:27832826`).** В существующих
`onTrailsCheckbox()` / `restoreTrailsState()` управляется
видимость только `trails-track` и `trails-path-bridleway`. Halo-
underlay-слои сами по себе не отслеживаются; на спутнике это даёт
«фантом» halo при выключенной грунтовке/тропе. Решение (TRZ §5.7):
ввести единый хелпер `applyTrailHaloVisibility(trackOn, pathOn)`
и вызывать его из (а) `onTrailsCheckbox`, (б) `restoreTrailsState`,
(в) `applyBaseLayer('satellite' | 'schematic')`. Правило: halo
видим ⇔ `currentBaseLayer === 'satellite' AND checkbox === ON`.
POI отдельной синхронизации не требуют — paint-правки текста
привязаны к самим `poi-circles`/`poi-labels`, которые управляются
`layerState.poi` / `restorePoiState()`.
8. **C4 / архитектурная диаграмма.** В репозитории нет файлов
`c4-*.mmd`; описание архитектуры — текстовое в
`docs/architecture/README.md`. Туда добавляется отдельный раздел
«Внешние тайл-провайдеры» с двумя строками: OSM (существующий)
и Esri World Imagery (новый, для подложки «Спутник»). Дополнительно
`docs/architecture/adr/README.md` пополняется записью ADR-004.
## Последствия
### Положительные
- Изменения — **только в коде фронтенда** (`src/web/index.html`,
`src/web/app.js`, `src/web/app.css`, оба `style*.json`). Backend,
БД, OSRM, nginx, Docker-конфигурация — без изменений (см.
`07-infra-requirements.md`).
- Лазерная локальность точки расширения: для смены провайдера
достаточно отредактировать один объект source-spec в `app.js`.
- НФТ 500 мс выполнима: при холодном переключении расходы — это
единичные вызовы `addSource` + `addLayer` + первая сетевая загрузка
плитки z=текущий; последующие переключения мгновенные (только
`visibility`).
- Пользователи, никогда не использующие «Спутник», не отправляют ни
одного запроса на серверы Esri — минимизация утечки данных по
умолчанию (см. R-3).
- Существующая инфраструктура восстановления после `map.setStyle()`
переиспользуется без изменения её формы — единый паттерн для
terrain/trails/POI/GPX/base-layer.
### Отрицательные / ограничения
- **Зависимость от третьей стороны.** Сервис Esri может ввести
лимит / потребовать API-ключ / изменить URL. Митигация: точка
расширения в `applyBaseLayer()`; риск зафиксирован
(`10-tech-risks.md`, R-2).
- **Утечка IP при использовании спутника.** При активном «Спутник»
IP пользователя становится виден Esri (так же, как сейчас он виден
tile.openstreetmap.org). Это **не регрессия приватности относительно
OSM**, но — расширение перечня третьих сторон, к которым клиент
обращается. Зафиксировано в `08-data-requirements.md` §5 и
`10-tech-risks.md` R-3.
- **Корпоративные / анти-трекинг блокировки.** Часть пользователей
(корпсети, NextDNS-фильтры) могут блокировать `arcgisonline.com`.
Поведение в этом случае — MapLibre показывает прозрачные плитки
поверх `#2a2a2a` фона; пользователь сам переключится на «Схему».
Это нормальное поведение (TRZ §1 REQ-F-08); fallback на схему
автоматически — **не закладываем**.
- **Halo-слои в `style.json` обоих тем.** Любые будущие правки
trails-слоёв требуют согласованной правки соответствующих
`*-halo-satellite` слоёв. Зафиксировано в `10-tech-risks.md` R-1.
- **Background цвет.** В коде `applyBaseLayer()` появляется маленький
дубль констант фона по темам. При смене палитры тем — править здесь
тоже. Зафиксировано в `10-tech-risks.md` R-5.
### Технический долг
- Если позже появится потребность во **втором** провайдере (например,
для альтернативной геополитической юрисдикции) или в гибридном
режиме «Спутник + подписи дорог OSM поверх», логичный путь —
вынести фичу в `src/web/basemap.js` (ADR-002-стиль) и расширить
локальное состояние до `{ provider, hybrid }`. Имена глобальных
функций (`onBaseLayerToggle`, `restoreBaseLayerState`) остаются
стабильным контрактом — `index.html` и `app.js` не меняются.
- Если в проект введут CSP-заголовок (сейчас его нет, см. ET-006
`07-infra-requirements.md` §4), для спутника потребуется
`img-src 'self' https://*.openstreetmap.org https://server.arcgisonline.com data:;`.
## Классификация изменения
**Minor change.** Новых контейнеров, сервисов, БД, серверных API
ET-007 не вводит. Внешний тайл-провайдер — расширение уже
существующего класса зависимостей (OSM-tile), а не новый
архитектурный класс. Лейбл `arch:major-change` **не требуется**.
Обязательного дополнительного архитектурного approve не требуется.
## Ревизии
- 2026-05-31 — editorial: code-review fixes (12-review.md attempt 2/3).
Решения P/M/S/O/H **не пересматривались**. Правки:
- §5 пункт 1: реальные id halo-слоёв
(`trails-track-halo-satellite`, `trails-path-bridleway-halo-satellite`)
вместо несуществующих `trails-grade1..5-halo-satellite` /
`paths-bridleway-halo-satellite` (P1-1).
- §5 пункт 2 (POI labels): добавлена правка `text-color` на
спутнике + явный baseline возврата per-theme — без этого тёмный
`#333333` поверх чёрного halo был нечитаем (P1-2).
- §6: зафиксирована единая satellite-константа `#2a2a2a` для обеих
тем; baseline dark исправлен `#1a1a1a``#1a1a2e` под фактическое
значение `style-dark.json:28` (P1-4).
- Добавлен §8: контракт с существующим `toggleLayer('basemap')` /
`layerState.basemap` — паттерн save&restore через
`_savedBasemapState` (P1-5).
- Добавлен §9: синхронизация halo-слоёв с пользовательскими
чекбоксами «Грунтовки»/«Тропы» — хелпер
`applyTrailHaloVisibility` (P1-6).
## Связанные документы
- `docs/work-items/ET-007/01-brd.md`
- `docs/work-items/ET-007/02-trz.md`
- `docs/work-items/ET-007/03-acceptance-criteria.md`
- `docs/work-items/ET-007/04-test-plan.yaml`
- `docs/work-items/ET-007/04b-ui-test-cases.md`
- `docs/work-items/ET-007/07-infra-requirements.md`
- `docs/work-items/ET-007/08-data-requirements.md`
- `docs/work-items/ET-007/10-tech-risks.md`
- `docs/architecture/README.md`
- `docs/architecture/adr/README.md`
- ADR-0001 (ET-005) — паттерн классических скриптов
- ADR-002 (ET-006) — «одна фича = один скрипт + глобали»

View File

@@ -1,163 +0,0 @@
---
type: infra-requirements
work_item_id: ET-007
title: "Инфраструктурные требования — ET-007: Спутниковая карта (Схема / Спутник)"
version: 1
status: approved
created_at: 2026-05-31
authors:
- "agent:architect"
---
# Инфраструктурные требования — ET-007
## 1. Резюме
ET-007 — изменение **исключительно фронтенда**: `src/web/index.html`,
`src/web/app.js`, `src/web/app.css`, `src/web/style.json`,
`src/web/style-dark.json`. Новой инфраструктуры, новых контейнеров,
новых портов и серверной конфигурации **не требуется**. Документ
зафиксирован для полноты work-item и явно подтверждает отсутствие
инфра-воздействия (см. `06-adr/ADR-004-satellite-base-layer.md`).
## 2. Контейнеры и сервисы
| Аспект | Требование |
|--------|------------|
| Новые контейнеры | Нет |
| Изменения существующих сервисов (api, osrm, nginx) | Нет |
| Изменения `docker-compose.yml` | Нет |
| Изменения `Dockerfile` | Нет — все правки попадают в образ через уже существующий `COPY src/web/ ./src/web/` |
| Изменения подключения скриптов в `index.html` | Нет новых `<script>`; добавляется только разметка попапа и обработчики |
| Перезапуск backend / OSRM | Не требуется |
| Простой (downtime) | Отсутствует — изменение только в статике фронтенда |
## 3. Сеть
| Аспект | Требование |
|--------|------------|
| Новые серверные порты | Нет |
| Изменения reverse proxy (nginx, `/enduro/`) | Нет |
| Новые внутренние DNS-записи | Нет |
| **Новые исходящие сетевые вызовы из браузера клиента** | **Да**`GET https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}` (HTTPS, без авторизации, raster PNG/JPEG ≈ 3080 КБ на плитку). Запросы инициируются **только** при активном режиме «Спутник» (лениво — см. ADR-004 §5) |
| Серверный трафик | Не меняется — спутниковые тайлы идут напрямую браузер ↔ Esri, не через mva154 |
### 3.1 Корпоративные/DNS-фильтры
Часть пользователей может работать в сетях, блокирующих
`arcgisonline.com` (анти-трекинг-DNS, корпсети). Поведение в этом
случае задокументировано в TRZ §1 REQ-F-08: MapLibre показывает
прозрачные плитки поверх фона `#2a2a2a`; пользователь возвращается на
«Схему» вручную. Никаких серверных обходов или прокси через
`/enduro/` не закладывается.
### 3.2 CSP-заголовок
В проекте сейчас CSP не задаётся (подтверждено в ET-006
`07-infra-requirements.md` §4). Если CSP будет введён в будущем,
директива `img-src` должна включать `https://server.arcgisonline.com`
(а также уже используемые `https://tile.openstreetmap.org` и
`data:`). На данном этапе никаких заголовков ET-007 не вводит.
## 4. Хранилища данных
| Аспект | Требование |
|--------|------------|
| Изменения схемы SQLite / Spatialite | Нет |
| Миграции БД (`migrations/`) | Нет |
| Серверное хранилище состояния | Нет |
| Клиентское хранилище | `localStorage`, единственный ключ `map-base-layer`, значения `"schematic"` \| `"satellite"`, ≤ 16 байт |
| Кэширование спутниковых тайлов | Только штатный HTTP-кэш браузера. Самостоятельный offline-кэш (Service Worker, IndexedDB) — out of scope, относится к PH-9 (см. BRD §3) |
Подробности по данным — `08-data-requirements.md`.
## 5. Конфигурация и секреты
| Аспект | Требование |
|--------|------------|
| Новые переменные окружения | Нет |
| Новые секреты / API-ключи | **Нет** — выбран провайдер без API-ключа (см. ADR-004 §1, BRD F-02) |
| Изменения конфигурации FastAPI / uvicorn | Нет |
| Изменения конфигурации OSRM | Нет |
## 6. Зависимости
| Аспект | Требование |
|--------|------------|
| Новые npm / Python-пакеты | Нет |
| Версия MapLibre GL JS | Без изменений (4.7.0) |
| Новые self-hosted сервисы | Нет |
| **Новые третьи стороны во время выполнения** | **Да**`server.arcgisonline.com` (Esri ArcGIS Online, World Imagery). Юридическое основание: бесплатное использование с атрибуцией для некоммерческой / demo-разработки; атрибуция выводится автоматически MapLibre при активном source. Зафиксировано в `docs/architecture/README.md` §«Внешние тайл-провайдеры» |
| Альтернативный провайдер (fail-over) | Не закладывается; точка расширения — один объект source-spec в `applyBaseLayer()` |
## 7. Сборка и деплой
- **Pipeline:** существующий Gitea Actions без изменений (`make lint`
+ `make test` + `make build`). ESLint автоматически покрывает
правки в `app.js`. Бэкенд-тесты (`pytest`) ET-007 не затрагивает.
- **Артефакт:** статические ассеты фронтенда (`src/web/`). Никаких
новых файлов — модифицируются существующие.
- **Деплой:** стандартный — `make deploy-test`
`docker compose up -d` на mva154. Время простоя: 0 (только
перевыкладка статики).
- **Smoke-проверка после деплоя** на
`https://openclaw.mva154.duckdns.org/enduro/`:
1. Открыть карту, открыть попап «Рельеф».
2. Убедиться, что виден переключатель «Подложка [Схема][Спутник]».
3. Переключить на «Спутник» — увидеть растровые снимки и атрибуцию
Esri в правом нижнем углу.
4. Перезагрузить страницу — режим «Спутник» сохранён.
5. Переключить тёмную/светлую тему — режим «Спутник» сохранён,
слои не исчезли.
## 8. Rollback
- **План отката:** обратный коммит (revert) и повторный
`docker compose up -d`. Времени отката ≈ 12 минуты (пересборка
Docker-образа со статикой).
- **Серверного состояния / миграций / графов**, которые требуется
отдельно откатывать, нет.
- **Сохранившиеся `localStorage`-значения у пользователей.** После
отката ключ `map-base-layer` остаётся в `localStorage`, но
игнорируется старым кодом — безвреден. Принудительная очистка не
требуется.
## 9. Ресурсы (CPU / RAM / диск)
- **Сервер mva154:** воздействие отсутствует. Спутниковые тайлы идут
напрямую от Esri к браузеру; mva154 не проксирует, не кэширует, не
логирует их.
- **Клиент-браузер:** при активном «Спутник» — дополнительные
растровые загрузки 3080 КБ × число видимых плиток (типично
1030 плиток на viewport). Это сопоставимо со стоимостью текущего
OSM-слоя и не создаёт регрессий по памяти/CPU.
## 10. Наблюдаемость
- Новые серверные метрики, логи и алерты **не требуются**.
- Поведение проверяется автотестами (UI/e2e) по плану
`04-test-plan.yaml` и `04b-ui-test-cases.md`.
- Серверные логи /enduro/ дополнений не получают — все обращения к
Esri идут с браузера, минуя mva154.
## 11. Влияние на C4 / архитектурную документацию
Состав внутренних компонентов системы (Frontend, Backend, Tile
Server, OSRM, БД) **не меняется**. Меняется только перечень внешних
зависимостей в выполнении: добавляется Esri World Imagery как второй
внешний raster-tile провайдер наряду с уже используемым
tile.openstreetmap.org.
В репозитории нет файлов `c4-*.mmd` — описание архитектуры текстовое
в `docs/architecture/README.md`. ET-007 обновляет этот документ:
добавляется раздел/строка «Внешние тайл-провайдеры» со списком из двух
провайдеров и условием активации каждого.
## 12. Вывод
Инфраструктурных, сетевых, конфигурационных, серверных и
БД-изменений на стороне mva154 **нет**. Единственное архитектурное
расширение — новая клиентская зависимость от внешнего raster-tile
провайдера (Esri World Imagery), активируемая лениво и только при
явном пользовательском выборе режима «Спутник». Деплой штатный,
эскалация `arch:major-change` не требуется.

View File

@@ -1,170 +0,0 @@
---
type: data-requirements
work_item_id: ET-007
title: "Требования к данным — ET-007: Спутниковая карта (Схема / Спутник)"
version: 2
status: approved
created_at: 2026-05-31
changelog:
- "v2 (2026-05-31): code-review fixes (12-review.md P1-1, P1-2, P1-4) — реальные id halo-слоёв (trails-track/path-bridleway), полная таблица baseline POI per-theme, satellite-bg как единая константа #2a2a2a, исправление dark baseline #1a1a1a#1a1a2e, добавлено поле _savedBasemapState."
authors:
- "agent:architect"
---
# Требования к данным — ET-007
## 1. Резюме
ET-007 не вводит и не изменяет ни одной серверной структуры данных.
Единственные «данные» фичи на стороне приложения — пользовательский
UI-выбор подложки в `localStorage`. На стороне внешнего источника —
бесконтекстные растровые плитки PNG/JPEG, потребляемые браузером.
## 2. Серверные данные
| Аспект | Требование |
|--------|------------|
| Изменения схемы SQLite / Spatialite | Нет |
| Новые таблицы / колонки / индексы | Нет |
| Миграции (`migrations/`) | Нет |
| Изменения контракта API `/api/*` | Нет |
| Серверное логирование выбора подложки | Нет — выбор остаётся в браузере |
## 3. Внешние входные данные (спутниковые тайлы)
| Параметр | Значение |
|----------|----------|
| Источник | Esri World Imagery (см. ADR-004 §1) |
| URL-шаблон | `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}` |
| Протокол | HTTPS, без авторизации |
| Формат | растровый PNG / JPEG, 256 × 256 px |
| Размер плитки | ≈ 3080 КБ |
| Диапазон z | 0 … 19 |
| Привязка | Web Mercator (EPSG:3857) — совместима с MapLibre по умолчанию |
| Атрибуция (обязательна) | `"Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community"` |
| Содержимое запроса | `{z}`, `{y}`, `{x}` — обезличенные координаты тайла; больше ничего не передаётся |
| Cookies / заголовки авторизации | Не отправляются |
Изменение MapLibre source при будущей смене провайдера локализовано
одним объектом source-spec в `applyBaseLayer()` — это единственная
точка системы, знающая URL Esri (см. ADR-004 §1 «точка расширения»).
## 4. Клиентское хранилище
| Параметр | Значение |
|----------|----------|
| Механизм | `localStorage` |
| Ключ | `map-base-layer` |
| Допустимые значения | `"schematic"` \| `"satellite"` |
| Значение по умолчанию | `"schematic"` (при отсутствии ключа или некорректном значении) |
| Объём полезной нагрузки | ≤ 16 байт на браузер |
| Запись | в `onBaseLayerToggle(base)` при изменении выбора |
| Чтение | в `restoreBaseLayerState()` — при старте приложения и в каждом вызове `rebuildMapOverlays()` (после `map.setStyle()`) |
| Миграция со старых значений | Не требуется — ключ новый, конфликта нет |
Имя ключа `map-base-layer` соответствует сложившейся в проекте
конвенции UI-настроек в `localStorage` (`enduro-theme-mode`,
`distance_unit`, `terrain-*`, `trails-*`, `poi-visible`,
`MARKERS_KEY`). Префиксации проектом не предусмотрено.
## 5. Внутреннее состояние модуля
Дополнительные неперсистентные данные, удерживаемые в памяти браузера
в течение сессии:
| Поле | Тип | Назначение |
|------|-----|------------|
| текущий базовый слой | `'schematic' \| 'satellite'` | проекция `localStorage['map-base-layer']` |
| baseline-значения paint POI (см. таблицу ниже) | константы per-theme | референсы для возврата с «Спутник» на «Схему» |
| baseline-значения `background-color` для тёмной/светлой темы | две строковые константы | `#f0ede6` (light), `#1a1a2e` (dark) — задублированы из `style.json:28` и `style-dark.json:28`, см. ADR-004 §6 |
| satellite-константа `background-color` | одна строковая константа | `#2a2a2a` для обеих тем (ADR-004 §6) |
| `_savedBasemapState` | `boolean \| null` | сохранённое значение `layerState.basemap` на время активного «Спутник»; восстанавливается при возврате на «Схему» (TRZ §5.6, P1-5) |
| флаг «satellite source уже добавлен в стиль» | bool | оптимизация: при повторном входе в «Спутник» в той же сессии стиля не добавляем повторно |
### 5.1 Baseline paint-значений POI на «Схеме» (источник истины)
| Свойство | Light (`style.json:128163`) | Dark (`style-dark.json:128163`) |
|----------|------------------------------|----------------------------------|
| `poi-circles` `circle-stroke-color` | `#ffffff` | `#333333` |
| `poi-circles` `circle-stroke-width` | `1.5` | `1.5` |
| `poi-labels` `text-color` | `#333333` | `#e0e0e0` |
| `poi-labels` `text-halo-color` | `#ffffff` | `#1a1a2e` |
| `poi-labels` `text-halo-width` | `1.5` | `2` |
### 5.2 Значения POI в режиме «Спутник» (общие для обеих тем)
| Свойство | Satellite |
|----------|-----------|
| `poi-circles` `circle-stroke-color` | `#ffffff` |
| `poi-circles` `circle-stroke-width` | `2` |
| `poi-labels` `text-color` | `#ffffff` |
| `poi-labels` `text-halo-color` | `#000000` |
| `poi-labels` `text-halo-width` | `2` |
Менять обе пары (`text-color` + `text-halo-*`) обязательно: без правки
`text-color` тёмный baseline-текст светлой темы (`#333333`) поверх
чёрного halo не читается (см. 12-review.md P1-2).
baseline POI-значения, `background-color` light/dark и satellite-
константа фона — **единственные** задублированные значения между
`style*.json` и `app.js`. Их рассинхрон ловится UI-тестами AC-04 (POI
видимость на спутнике) и AC-06 (смена темы при активном «Спутник»).
## 6. Halo-слои в `style.json`
В обоих `src/web/style.json` и `src/web/style-dark.json` уже
присутствуют парные «underlay»-слои halo для линий грунтовок и троп
(см. `style.json:5670`, `93107`):
| Базовый слой | Halo-слой | Фильтр базового слоя | Назначение |
|--------------|-----------|----------------------|------------|
| `trails-track` | `trails-track-halo-satellite` | `highway == 'track'` (grade1..5 различаются `match`-выражением внутри `line-color`) | широкая полупрозрачная белая обводка под основной линией |
| `trails-path-bridleway` | `trails-path-bridleway-halo-satellite` | `highway in path/bridleway/footway` | то же |
Слоёв на каждую grade (`trails-grade1..5-halo-satellite`) **нет** и
заводить не планируется: дифференциация grade зашита в один
`match`-expression по `tracktype` внутри `trails-track`, а halo на
спутнике достаточно единого цвета/ширины поверх всего трека (под halo
ляжет цветная линия `trails-track`, разделение halo по grade
визуально не различимо). Аналогично для троп — единый
`trails-path-bridleway-halo-satellite` покрывает всю группу
`path/bridleway/footway`. Слой `trails-asphalt` halo не получает: по
умолчанию `visibility: none` + `line-opacity: 0`.
Параметры halo-слоёв (ширина, цвет, opacity) уже зафиксированы в
коде; будущие правки — данные дизайна, не данные домена; их изменение
не требует миграции пользовательского состояния.
## 7. Персональные данные
| Канал | PII |
|-------|-----|
| `localStorage['map-base-layer']` | нет (обезличенный UI-флаг) |
| Запросы к `tile.openstreetmap.org` (уже существуют) | IP пользователя становится виден OSM при использовании «Схемы» |
| Запросы к `server.arcgisonline.com` (новые) | IP пользователя становится виден Esri **только** при активном режиме «Спутник» (лениво — см. ADR-004 §3) |
| Передача координат поездок / маршрутов на сторонние сервисы | Нет — координаты в URL не передаются, передаётся только `{z}/{y}/{x}` тайл-сетки |
Это **не регрессия** относительно текущего состояния (OSM-tile
уже работает на тех же условиях), но — расширение списка третьих
сторон, к которым обращается клиент. Пользователи, никогда не
включающие «Спутник», ни одного запроса в Esri не отправляют — это
прямое следствие ленивого создания source (ADR-004 §3). См. также
`10-tech-risks.md`, R-3.
Серверных обязательств по хранению / удержанию / удалению PII
ET-007 **не порождает** — на mva154 никаких новых данных не оседает.
## 8. Резервное копирование и ретенция
Не применимо — серверных данных у ET-007 нет. Клиентский
`localStorage['map-base-layer']` не подлежит резервному копированию
(пользовательская UI-настройка, утрата которой безболезненна).
## 9. Вывод
Серверная модель данных, схемы и контракты API ET-007 **не
затрагивает**. Единственное персистентное данное — обезличенный
клиентский флаг `localStorage['map-base-layer']` (≤ 16 байт).
Внешний источник предоставляет публичные растровые тайлы; никакие
данные пользователя в запросах к нему не передаются помимо штатной
для HTTP-клиента информации (IP, User-Agent).

View File

@@ -1,214 +0,0 @@
---
type: tech-risks
work_item_id: ET-007
title: "Технические риски — ET-007: Спутниковая карта (Схема / Спутник)"
version: 2
status: approved
created_at: 2026-05-31
changelog:
- "v2 (2026-05-31): code-review fix (12-review.md P1-1) — R-1 переписан под реальные halo-id (trails-track-halo-satellite, trails-path-bridleway-halo-satellite); исключён фиктивный массив grade1..5."
authors:
- "agent:architect"
---
# Технические риски — ET-007
Технические риски этапа разработки. Бизнес-риски — в BRD §5
(пересечение есть, здесь акцент на техническую митигацию).
Шкала: вероятность (Н/С/В) × влияние (Н/С/В).
## R-1 — Дрейф halo-слоёв в `style.json` / `style-dark.json`
- **Описание:** ADR-004 §5 решает читаемость линий грунтовок и троп
на спутнике через парные «underlay»-слои с `visibility: none` в
обоих файлах стилей. Реальные id (подтверждены кодом
`style.json:5670`, `93107` и `style-dark.json:5670`, `93107`):
`trails-track-halo-satellite`, `trails-path-bridleway-halo-satellite`.
Любая будущая правка основных trails-слоёв (цвет, ширина, фильтр)
требует **согласованной правки halo-слоёв** в обоих файлах. Без
явной проверки легко забыть один из четырёх случаев (2 темы × 2
рода слоёв).
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- При разработке завести единый список затрагиваемых пар в
`applyBaseLayer()`: массив пар `[('trails-track',
'trails-track-halo-satellite'), ('trails-path-bridleway',
'trails-path-bridleway-halo-satellite')]`. Производное правило
«`<base>-halo-satellite`» допустимо, но только для **этих двух**
base-id; массив `['trails-grade1..5']` (как в более раннем
черновике, см. 12-review.md P1-1) **не использовать** — таких
слоёв в `style.json` нет, дифференциация grade хранится внутри
одного `match`-выражения по `tracktype` в `trails-track`.
- Code review-чеклист: при правке `trails-track`, `trails-path-
bridleway` в `style*.json` — обязательная сверка соответствующего
`*-halo-satellite` в том же файле.
- UI-тест AC-04 проверяет видимость линий поверх спутника в обеих
темах.
## R-2 — Провайдер Esri меняет условия / URL / вводит API-ключ
- **Описание:** Esri World Imagery — единственная внешняя зависимость
фичи, выбранная без формального соглашения; в перспективе Esri
может ограничить бесплатный доступ, изменить URL-схему или ввести
обязательный API-ключ (BRD §5 риск №1). Тогда «Спутник» перестаёт
работать у всех пользователей одновременно.
- **Вероятность / Влияние:** С / В.
- **Митигация:**
- Точка расширения локализована: единственный объект source-spec
в `applyBaseLayer()` (ADR-004 §1).
- При деградации провайдера выполняется одна правка JS-фронтенда
(новый URL-шаблон + новая атрибуция), без миграций и серверных
изменений; откат прежнего поведения — обратный коммит.
- Альтернативные провайдеры предварительно рассмотрены в ADR-004
§«Вариант P»; быстрый switch на следующего по приоритету —
Mapbox или MapTiler — потребует только введения переменной
окружения для API-ключа (это уже инфра-изменение, выходящее за
scope ET-007).
- Регулярная smoke-проверка дос­тупности через UI-тест AC-02.
## R-3 — Утечка IP клиента на серверы Esri
- **Описание:** при активном «Спутник» браузер обращается напрямую
к `server.arcgisonline.com`; IP пользователя и User-Agent видны
Esri. Это **не регрессия** (OSM tile уже работает аналогично), но
расширение списка третьих сторон, к которым обращается клиент.
- **Вероятность / Влияние:** В (т.е. произойдёт всегда при включении
спутника, дизайн именно такой) / Н.
- **Митигация:**
- **Ленивое создание source** (ADR-004 §3): пользователь,
никогда не включающий «Спутник», ни одного запроса в Esri не
отправляет. Это обеспечивает «приватный по умолчанию» режим.
- Документировано в `08-data-requirements.md` §7.
- В отдельный политический документ выноситься не требуется —
приватность фичи на уровне рейзанса вынесена в ADR-004 §«Последствия».
## R-4 — Корпсеть / DNS-блокировка `arcgisonline.com`
- **Описание:** часть пользователей работает в сетях, блокирующих
arcgisonline.com (анти-трекинг-DNS типа NextDNS/Pi-hole,
корпоративные firewall). MapLibre будет показывать прозрачные
плитки поверх фона `#2a2a2a`; пользователь увидит «дыры».
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- TRZ §1 REQ-F-08 явно фиксирует: автоматический fallback на
«Схему» не закладывается — пользователь возвращается на схему
вручную; это сознательное проектное решение.
- В виду фона `#2a2a2a` пустота визуально опознаётся как ошибка
подложки, а не «лёг" сайт.
- Эскалация / альтернативный провайдер при единичных жалобах не
требуется; при системных — переход к R-2.
## R-5 — Дублирование `background-color` между `style*.json` и `app.js`
- **Описание:** ADR-004 §6 требует менять `background-color` на
единую satellite-константу `#2a2a2a` (обе темы) при включении
«Спутник» и возвращать к исходному при возврате на «Схему».
«Исходные» значения (`#f0ede6` для светлой, `#1a1a2e` для тёмной —
именно `#1a1a2e`, как в `style-dark.json:28`, а не `#1a1a1a` из
более раннего черновика, см. 12-review.md P1-4 / P2-3)
дублируются в `applyBaseLayer()` и в `style*.json` — при смене
палитры тем легко забыть один из двух.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- Альтернатива — при возврате на «Схему» **читать** актуальное
значение через `getPaintProperty('background', 'background-color')`
непосредственно перед мутацией в «Спутник», и кэшировать его в
замыкании. Однако `setStyle()` сбрасывает кэш, что усложняет
логику. Принято: задублировать в коде с явным комментарием
в `app.js` и code review-чеклистом.
- Покрытие AC-06 (смена темы при активном «Спутник») косвенно
проверяет согласованность.
## R-6 — Накопление обработчиков и source/layer после `map.setStyle()`
- **Описание:** при `map.setStyle()` (переключение тёмной/светлой
темы) спутниковый source/layer удаляются вместе со стилем.
`restoreBaseLayerState()` пересоздаёт их в `rebuildMapOverlays()`.
Аналогичный риск зафиксирован для GPX (ET-006, R-4: «дублирование
обработчиков»). Спутник, в отличие от GPX, **не вешает свои
`map.on('click', ...)`** обработчиков на свои слои (он —
невзаимодействующий растр), поэтому дублирования обработчиков
здесь не возникает.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:** проверка перед `addSource` — `if
(!map.getSource('satellite-raster')) map.addSource(...)`; то же для
layer. Это идемпотентный паттерн, уже используемый в проекте для
terrain/trails.
## R-7 — Несовместимость z-order при `restoreBaseLayerState()` после terrain
- **Описание:** если разработчик случайно вызовет
`restoreBaseLayerState()` **после** `restoreTerrainState()` в
`rebuildMapOverlays()`, спутник окажется поверх hillshade и
перекроет его. Это нарушит AC-04 («Hillshade поверх спутника»).
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- ADR-004 §4 явно фиксирует: `restoreBaseLayerState()` вызывается
**ПЕРВЫМ** в `rebuildMapOverlays()`.
- Комментарий в коде `app.js` непосредственно у вызова —
`// ET-007/ADR-004: ПЕРВЫМ, чтобы trails/terrain легли поверх`.
- UI-тест AC-04 «Hillshade поверх спутника» отлавливает регрессию.
## R-8 — Производительность переключения «Схема → Спутник» > 500 мс
- **Описание:** НФТ ТЗ — ≤ 500 мс до первой видимой плитки. При
холодном переключении в одном кадре происходит: чтение
`localStorage`, `addSource`, `addLayer`, `setLayoutProperty`,
`setPaintProperty` ×N для POI, `setLayoutProperty` ×K для halo-
underlays. Главная неопределённость — сетевая задержка до Esri.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
- Все операции стиля MapLibre — синхронные O(1) на source/layer;
суммарно < 50 мс.
- Сетевая задержка для PNG 3080 КБ из Esri CDN на канале
≥ 5 Мбит/с укладывается в 200300 мс на тайл (по практике
Leaflet/OpenLayers с этим же провайдером).
- Тест НФТ TP-Performance в `04-test-plan.yaml` проверяет
верхнюю границу.
## R-9 — Конфликт mobile-вёрстки попапа
- **Описание:** новая строка `terrain-base-row` добавляется в
`#terrain-popup` сверху. На узких экранах (375 px, ET-005 TP-05)
возможен выход за пределы попапа или перекрытие смежных строк.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- Переиспользуется готовый компонент `.seg-control` (адаптивен по
ширине), без введения нового CSS-компонента.
- UI-тест AC-09 (mobile viewport 375 × 812) — обязательный.
## R-10 — Включение спутника после рестарта при отсутствии сети у Esri
- **Описание:** пользователь сохранил `map-base-layer = "satellite"`,
затем при следующем визите Esri недоступен. `restoreBaseLayerState()`
вызовет `applyBaseLayer('satellite')`, source создастся, плиток не
будет — пользователь увидит пустой тёмный фон вместо привычной
карты.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- Поведение явно соответствует TRZ §1 REQ-F-08; на mobile/desktop
пользователь нажмёт «Схема» и продолжит работу.
- Авто-fallback на «Схему» при сетевой ошибке провайдера —
**не закладывается** (см. ADR-004 §«Последствия»). Введение
fallback возможно в будущей итерации без изменения внешнего
контракта `applyBaseLayer()`.
## Сводная таблица
| ID | Риск | Вер. | Влияние | Класс | Статус |
|-----|------|------|---------|-------|--------|
| R-1 | Дрейф halo-слоёв в обоих style.json | С | Н | Средний | внимание разработки + review |
| R-2 | Esri меняет условия / URL / вводит ключ | С | В | Высокий | митигация — точка расширения |
| R-3 | Утечка IP на Esri при активном спутнике | В | Н | Средний | приватный-по-умолчанию (lazy) |
| R-4 | DNS-блокировка `arcgisonline.com` | Н | С | Низкий | принят (TRZ REQ-F-08) |
| R-5 | Дубль background-color в style/app.js | Н | Н | Низкий | принят + комментарий в коде |
| R-6 | Source/layer после setStyle | Н | Н | Низкий | идемпотентные `if (!getSource)` |
| R-7 | Неверный порядок restoreBaseLayerState | Н | С | Низкий | ADR явно + комментарий + AC-04 |
| R-8 | Переключение > 500 мс | Н | С | Низкий | покрыто НФТ-тестом |
| R-9 | Mobile-вёрстка попапа | Н | Н | Низкий | AC-09 |
| R-10 | Restore satellite при недоступности Esri | Н | Н | Низкий | принят, fallback не закладываем |
Блокирующих рисков нет. R-2 — единственный «высокий» класс, но
вероятность средняя и митигация (локализация точки расширения)
делает реакцию операционно простой. Эскалация `arch:major-change`
или возврат в Анализ не требуются.

View File

@@ -1,355 +0,0 @@
---
type: code-review
work_item_id: ET-007
title: "Review v2: Спутниковая карта (Схема / Спутник) — артефакты + код"
version: 2
status: APPROVED_WITH_COMMENTS
created_at: 2026-05-31
updated_at: 2026-05-31
authors:
- "agent:reviewer"
branch: feature/ET-007-et-005
stage_reviewed: analysis + architecture + development (code + tests)
previous_review: v1 (REQUEST_CHANGES, 6 P1 / 6 P2 / 3 P3)
---
# Code Review v2 — ET-007: Спутниковая карта (Схема / Спутник)
## Вердикт
**APPROVED with comments.**
Все 6 P1-блокеров из v1 закрыты в спецификации **и** в коде. Реализация
корректна, прошли все 22 pytest-проверки (`tests/unit/test_base_layer.py`)
и все 33 поведенческих JS-теста (`tests/unit/base_layer.test.js`),
запускаемых под `node --test`. Архитектурные решения (Esri, M-A, S-B,
O-A, H-B) соблюдены в коде один-в-один; никаких отклонений от ADR-004
не обнаружено.
Остаются 4 не-блокирующих замечания P2 (часть из них — несвёрнутые
концы v1 P2) и 4 nice-to-have P3, включая мелкие расхождения между
текстом TRZ и фактической реализацией (код в одной точке делает чуть
больше, чем требует ТЗ — добавляет защитный `beforeId`). Эти правки
рекомендуется внести следующим коммитом, но они не препятствуют
переходу в `testing` / merge.
## Что проверено
### Артефакты (вторая итерация)
- `01-brd.md` v2 — P1-3 закрыт (риск №4: «авто-выключение hillshade
не вводится»).
- `02-trz.md` v2 — P1-1, P1-2, P1-4, P1-5, P1-6 закрыты; добавлены
§5.6 (контракт с `layerState.basemap`) и §5.7 (синхронизация halo).
- `03-acceptance-criteria.md` v2 — добавлены сценарии под P1-2/P1-5/P1-6.
- `06-adr/ADR-004-satellite-base-layer.md` (accepted) — добавлены
§8 и §9 под P1-5 и P1-6; §5 переписан под реальные halo-id;
§6 единая константа `#2a2a2a`; baseline dark исправлен `#1a1a2e`.
- `08-data-requirements.md` v2 — таблицы 5.1 / 5.2 (baseline POI per-
theme и satellite-значения), `_savedBasemapState`, исправлен dark
baseline на `#1a1a2e`.
- `10-tech-risks.md` v2 — R-1 переписан под реальные id; R-5
обновлён с baseline `#1a1a2e`.
- `04-test-plan.yaml`, `04b-ui-test-cases.md`, `07-infra-requirements.md`
— без изменений (v1 не требовали правок по тем findings).
### Код (новое в ветке)
- `src/web/index.html` (+11 строк) — блок `#base-seg` в `#terrain-popup`.
- `src/web/app.css` (+30 строк) — стили `.terrain-base-row`,
`.base-seg`, CSS-hook `body.satellite-active #btn-basemap`.
- `src/web/app.js` (+368 строк) — блок ET-007, хук в
`rebuildMapOverlays()`, синхронизация halo в `onTrailsCheckbox()` /
`restoreTrailsState()`, инициализация в обеих ветках IIFE.
- `src/web/style.json` (+30) / `src/web/style-dark.json` (+30) — два
halo-underlay-слоя `trails-track-halo-satellite` и
`trails-path-bridleway-halo-satellite`, оба с `visibility: none` и
размещены **перед** соответствующим базовым trails-слоем (z-order).
### Тесты
- `tests/unit/test_base_layer.py` (+301 строк) — 22 статических теста
(HTML/CSS/JS-структура, halo-слои в обоих стилях, порядок halo
перед базовым слоем, `restoreBaseLayerState()` первым в
`rebuildMapOverlays()`, ≥4 вызова в init-путях) + сабпроцесс
`node --test` для JS-suite.
- `tests/unit/base_layer.test.js` (+468 строк) — 33 поведенческих
unit-теста через `new Function`-загрузку блока ET-007, с мок-DOM,
мок-localStorage и мок-картой; покрывают U-01..U-05, U-10..U-11,
I-01..I-04, I-07, halo, POI text-color/halo (P1-2), background P1-4
обе темы, валидацию входа, отсутствие window._map, недоступный
localStorage, z-order.
### Прогон
```
$ python -m pytest tests/unit/test_base_layer.py -v
22 passed in 0.16s
$ node --test tests/unit/base_layer.test.js
# tests 33, pass 33, fail 0
```
Полный `pytest tests/` падает только на `tests/unit/test_health.py`
из-за отсутствующего `shapely` в окружении — это инфраструктурная
проблема, не относится к ET-007.
---
## P1 — must-fix
**Нет.** Все 6 P1-findings из v1 закрыты.
| v1 ID | Категория | Где закрыто |
|-------|-----------|-------------|
| P1-1 | Несуществующие слои grade1..5 | TRZ §1 REQ-F-04, ADR-004 §5, Data §6, Tech-Risks R-1 — реальные id `trails-track-halo-satellite`, `trails-path-bridleway-halo-satellite` |
| P1-2 | POI text-color на спутнике | TRZ §1 REQ-F-04-POI, ADR-004 §5, Data §5.2, код `_applyPoiSatellitePaint()`, JS-тесты «P1-2» |
| P1-3 | BRD vs TRZ hillshade | BRD §5 риск 4 переписан под TRZ/ADR/AC: авто-выключение не вводится |
| P1-4 | background-color 3 источника | ADR-004 §6, TRZ §1 REQ-F-03, Data §5 — единая `#2a2a2a` для обеих тем; baseline dark `#1a1a2e`; JS-тесты обе темы |
| P1-5 | Контракт с `layerState.basemap` | TRZ §5.6, AC-02/AC-03 новые сценарии, ADR-004 §8, код `_savedBasemapState` + `_setBodyClass('satellite-active', …)`, CSS `body.satellite-active #btn-basemap { display:none !important }` |
| P1-6 | halo не синхронизирован с чекбоксами | TRZ §5.7, AC-04 новые сценарии, ADR-004 §9, код `_applyTrailHaloVisibility(map, base)` + хуки в `onTrailsCheckbox()` и `restoreTrailsState()` |
Спецификация и реализация на уровне поведения согласованы. Z-order
проверен JS-тестом «Z-order: satellite-base вставляется beforeId=первый
terrain/trails/poi слой» и Python-тестом
`test_halo_layers_below_real_trails`.
---
## P2 — should-fix
### P2-1 — `00-business-request.md` остался с `TBD` и неверным заголовком
**Где:** `docs/work-items/ET-007/00-business-request.md`:
```
# Business Request: ET-005: Спутниковая карта (Схема/Спутник)
Work Item ID: ET-007
## Description
TBD
```
v1 пункты P2-4 и P2-5 не закрыты. Заголовок всё ещё «ET-005»
(пересекается с фактической ET-005 «единицы измерения»), Description
— пустой. BRD/TRZ/AC уже содержат целевую формулировку, поэтому
блокировать поставку этим нельзя, но формальное основание для
возврата остаётся.
**Действие:** заменить заголовок на «Business Request: ET-007:
Спутниковая карта (Схема / Спутник)»; в Description вставить 23
предложения из BRD §1.
### P2-2 — Tech-Risks R-2 митигация частично противоречит BRD F-02
**Где:** `10-tech-risks.md` R-2 «Альтернативные провайдеры …
быстрый switch на следующего по приоритету — Mapbox или MapTiler —
потребует только введения переменной окружения для API-ключа (это
уже инфра-изменение, выходящее за scope ET-007)».
Caveat «выходящее за scope ET-007» добавлен в v2 — это улучшение.
Но формулировка «потребует только введения переменной окружения для
API-ключа» по-прежнему противоречит BRD F-02 («без API-ключа»),
ADR-004 §«Вариант P» (Mapbox/MapTiler отклонены именно по этому
критерию) и описанию out-of-scope в BRD §3.
**Действие:** в R-2 переписать митигацию честно: «при деградации Esri
без-API-ключевых публичных альтернатив с глобальным покрытием в
момент написания нет; реакция требует либо пересмотра BRD F-02
(возврат в Анализ), либо архитектурного решения о self-hosted
satellite tiles (новый ADR)».
### P2-3 — TRZ §3.2 оставлено двусмысленное указание про позицию блока
**Где:** TRZ §3.2: «в начале `#terrain-popup`, **сразу после**
`<div class="terrain-popup-title">Эндуро</div>` ИЛИ выше него — по
выбору разработчика».
Реализация выбрала «выше заголовка» (совпадает с диаграммой §3.1 и
закреплено тестом `test_base_toggle_placed_at_top_of_terrain_popup`).
ТЗ нужно привести в соответствие, иначе следующая итерация фичи
может молча вернуть блок ниже заголовка.
**Действие:** в §3.2 убрать ветку «сразу после» и оставить только
«в самом верху попапа, выше заголовка «Эндуро»».
### P2-4 — Отсутствует автотест для CSS-hook `body.satellite-active #btn-basemap` и для контракта halo-синхронизации с чекбоксами
**Где:** `tests/unit/test_base_layer.py` и `base_layer.test.js`.
Покрытие P1-5 и P1-6 на уровне поведения присутствует в JS-suite
**только** для частей, лежащих внутри блока ET-007 (применение
satellite-active класса делегировано на `_setBodyClass`, который в
мок-DOM деградирует в no-op — см. `_setBodyClass` lines 29892997).
В итоге не тестируется:
1. **CSS-rule** `body.satellite-active #btn-basemap { display:none }`
— Python-тест `test_base_toggle_styles_defined` проверяет только
`.terrain-base-row` / `.terrain-base-label` / `.base-seg`. AC-02
сценарий «Кнопка «Базовая карта» скрывается на спутнике (P1-5)»
формально не покрыт автоматическим тестом.
2. **Вызов `_applyTrailHaloVisibility(...)` из `onTrailsCheckbox`**
функция `onTrailsCheckbox` живёт ВНЕ блока ET-007 и в JS-suite не
подгружается через `new Function`. Python-сторона лишь проверяет
присутствие id `trails-*-halo-satellite` в `app.js`, но не сам
вызов хука. AC-04 сценарии «Выключение «Грунтовки» скрывает и halo»
формально не покрыты регресс-тестом.
3. **`_savedBasemapState` save/restore цикл** — поведение
реализовано, но в JS-suite нет теста, который бы выставил
`layerState.basemap = false` до перехода в спутник и проверил,
что после возврата `osm-base.visibility === 'none'`. AC-02/03
сценарий «Запоминание выбора Базовая карта» формально не покрыт.
Гэп ровно по тем границам, по которым были претензии v1 (P1-5/P1-6).
Код корректен (проверено вручную при ревью), но без регрессионных
тестов будущий рефакторинг `onTrailsCheckbox` или `applyBaseLayer`
может молча сломать контракт.
**Действие (минимум):**
- Python-тест: assert `'body.satellite-active'` и `'#btn-basemap'` в
`app.css`.
- Python-тест: внутри `function onTrailsCheckbox(` тело содержит
`_applyTrailHaloVisibility`; то же для `restoreTrailsState`.
- JS-тест: один кейс на цикл «schematic (basemap=false) → satellite
→ schematic», проверяющий, что `osm-base` остаётся `none` после
возврата. Сейчас в moc-окружении нет `layerState` (он вне блока
ET-007) — потребуется либо экспортировать `_savedBasemapState`,
либо добавить тонкую заглушку `layerState` в `loadBaseLayerModule`.
Эту работу можно сделать одним PR. Не блокирует merge ET-007, но
блокирует «зрелость» теста.
---
## P3 — nice-to-have
### P3-1 — Расхождение TRZ §5.2 vs код в части `beforeId`
TRZ v2 §5.2 шаг 2.2: «addLayer (см. 4.2) **без beforeId**. Корректный
z-order гарантируется тем, что restoreBaseLayerState вызывается
ПЕРВЫМ в rebuildMapOverlays».
Код `app.js` `applyBaseLayer()`:
```js
const before = _firstOverlayLayerId(map);
map.addLayer({ id: SATELLITE_LAYER_ID, ... }, before);
```
Код **дополнительно** вычисляет `beforeId` через `_firstOverlayLayerId`
(ищет первый слой с префиксом `terrain-` / `trails-` / `poi-`). Это
защита от случая, когда `restoreBaseLayerState` вызван не первым (на
старте приложения, например). Поведение **более устойчивое**, чем
требует ТЗ, и тесты I-02 и Z-order это подтверждают. Но формально
spec ↔ code расходятся.
**Действие:** либо обновить TRZ §5.2 шаг 2.2 (добавить «с
опциональным `beforeId` от `_firstOverlayLayerId(map)` как
страховкой — не обязателен, поскольку порядок гарантирован O-A»),
либо снять защиту из кода и положиться чисто на O-A. Первый вариант
проще и оставляет код более устойчивым.
### P3-2 — Мёртвая константа `SATELLITE_HALO_LAYER_IDS`
`app.js:29142917`:
```js
const SATELLITE_HALO_LAYER_IDS = [
'trails-track-halo-satellite',
'trails-path-bridleway-halo-satellite',
];
```
Константа объявлена и экспортируется из фабрики юнит-тестов, но в
рантайме не используется ни единого раза — `_applyTrailHaloVisibility`
работает по локальному `pairs`. Либо использовать константу
(`pairs.forEach((p) => …)` → читать halo-id из неё), либо удалить.
### P3-3 — Мёртвая функция `_toggleSatelliteHalo`
`app.js:31073110`:
```js
function _toggleSatelliteHalo(map, enabled) {
_applyTrailHaloVisibility(map, enabled ? 'satellite' : 'schematic');
}
```
Комментарий заявляет «обратная совместимость для существующих
unit-тестов», но в `tests/unit/base_layer.test.js` функция нигде не
вызывается и из фабрики не экспортируется. Если она использовалась в
промежуточной версии тестов — её можно безопасно удалить.
### P3-4 — Избыточная защита `typeof restoreBaseLayerState === 'function'` в `rebuildMapOverlays()`
`app.js:127131`:
```js
if (typeof restoreBaseLayerState === 'function') {
restoreBaseLayerState();
}
```
ADR-004 §2 явно решает, что фича остаётся в `app.js` — функция всегда
определена в том же файле. Защита оправдана для `rebuildGpxOverlays`
(ET-006), где функция живёт в отдельно подгружаемом `gpx.js`. Здесь
ничего не защищает.
Не блокирует, но запутывает читателя кода. То же относится к двум
вхождениям в init-IIFE (там защита тоже стоит).
---
## Сводка
| ID | Severity | Категория |
|-------|----------|----------------------------------------------|
| P2-1 | P2 | BR.md: TBD и заголовок «ET-005» |
| P2-2 | P2 | Tech-Risks R-2 митигация частично vs BRD F-02|
| P2-3 | P2 | TRZ §3.2 — двусмысленное указание про позицию|
| P2-4 | P2 | Нет автотестов для CSS-hook и hook'ов halo (граница AC-02/04 P1-5/P1-6) |
| P3-1 | P3 | TRZ §5.2 «без beforeId» vs код с `_firstOverlayLayerId` |
| P3-2 | P3 | Мёртвая константа `SATELLITE_HALO_LAYER_IDS` |
| P3-3 | P3 | Мёртвая функция `_toggleSatelliteHalo` |
| P3-4 | P3 | Избыточный `typeof === 'function'` |
---
## Что хорошо
- **Покрытие P1 v1 — 6/6 в коде и в спецификации одновременно.** Это
редкий случай: чаще в одном из двух фронтов остаются концы.
- **JS-suite 33 теста** через `new Function`-загрузку блока — изящный
способ исполнить реальный production-код в Node без бандлера и без
переписывания app.js в ES-модуль. Покрытие включает edge-cases
(private mode → localStorage недоступен, `window._map` отсутствует,
невалидное stored-значение, повторный toggle, dark/light темы,
z-order при пустом наборе overlay'ев).
- **Маркеры блока `// >>> ET-007 base layer toggle block` /
`// <<<`** — позволяют как читать блок целиком (поиск 30+ функций
в 3132-строчном `app.js` — боль), так и подгружать его в тестах. То
же решение применил ET-002 для POI; единый паттерн.
- **Декларативные halo-underlay-слои в `style.json` / `style-dark.json`,
расположенные перед соответствующими `trails-*`** — z-order
явно зафиксирован в стиле и закреплён регресс-тестом
`test_halo_layers_below_real_trails` (оба файла стиля). Любое
будущее «перенесём слой» сломает тест немедленно.
- **`_savedBasemapState` + CSS-class hook** — корректное и минимально
инвазивное решение P1-5: модуль ET-007 не правит `layerState`
существующего модуля, не редактирует `toggleLayer()`, не лезет в
его обработчики; контракт реализован через одну приватную
переменную и одну CSS-зависимость. Это самый низкий blast radius,
который можно было выбрать.
- **Idempotent `addSource/addLayer`** через `if (!map.getSource(…))` /
`if (!map.getLayer(…))` (R-6) — соответствует паттерну
`terrain` / `trails` / `poi`.
- **Документация и changelog** во всех артефактах v2 точно указывают,
какие P-findings из v1 ими закрыты (`changelog: "v2 ... P1-1..P1-6"`).
Это резко упрощает повторное ревью.
## Что делать дальше
1. **APPROVED → merge в main.** Никаких P0/P1 нет, регресс не
обнаружен, тесты зелёные.
2. **Параллельно или одним PR** закрыть P2-1, P2-2, P2-3, P2-4. P2-4
— самое полезное (закрывает регрессионный риск для P1-5/P1-6
логики). Остальные — гигиена документации.
3. **P3 — на усмотрение** разработчика/Owner. Самый полезный из них —
P3-1: либо синхронизировать TRZ с кодом (рекомендуется), либо
снять защитный `beforeId` (тогда поведение строго совпадает с
ADR §O-A).
4. После merge — переход в `testing` stage. Браузерные кейсы
(E-01..E-10, TC-UI-01..14) требуют Playwright-инфраструктуры,
которой в репозитории пока нет (см. ET-002 ADR-0001, 07-infra-
requirements.md). До её появления покрытие AC обеспечивается
связкой статических Python-тестов + JS-unit + ручной приёмки
на test-стенде.

View File

@@ -1,256 +0,0 @@
---
type: test-report
work_item_id: ET-007
title: "Test Report: Спутниковая карта (Схема / Спутник)"
version: 2
status: PASS
created_at: 2026-05-31
updated_at: 2026-05-31
changelog:
- "v2 (2026-05-31): повторный прогон полного регресса по запросу stage=testing — pytest 76/76, node --test 33/33, smoke deployed artifacts PASS, health 200. Вердикт без изменений."
authors:
- "agent:tester"
branch: feature/ET-007-et-005
head_commit: 6acc57d
verdict: PASS — ready-to-deploy
---
# Test Report — ET-007: Спутниковая карта (Схема / Спутник)
## Сводка
| Категория | Результат |
|------------------------------|------------------------|
| Health check test-стенда | PASS (HTTP 200) |
| Smoke deployed artifacts | PASS |
| Unit tests (pytest, статика) | 76 passed |
| Unit tests (node --test, JS) | 33/33 passed |
| E2E (Playwright) | SKIP — раннер не установлен (см. §5) |
| UI / Visual | SKIP — раннер не установлен (см. §5) |
**Вердикт:** PASS — продвигать в `ready-to-deploy`.
Все автоматизируемые в текущем окружении тесты прошли. Браузерные
кейсы (E-01..E-10, TC-UI-01..14) не запускались из-за отсутствия
Playwright-инфраструктуры в репозитории и UI-раннера на хосте — это
известный гэп, отмеченный в `12-review.md` и ADR-0001 (ET-002), не
связанный с ET-007. Покрытие AC на этом этапе обеспечивается комбо
статических Python-тестов + поведенческих JS-unit + ручной приёмки
на test-стенде после деплоя.
---
## 1. Окружение
- **Test-стенд:** https://openclaw.mva154.duckdns.org/enduro/
- **Branch под тестом:** `feature/ET-007-et-005` @ `6acc57d`
- **HEAD commit:** `reviewer(ET): auto-commit from reviewer run_id=32`
- **Python:** 3.12.13, pytest 8.3.3
- **Node:** node --test (built-in test runner)
### 1.1 Health check
```
$ curl -s https://openclaw.mva154.duckdns.org/enduro/api/health
HTTP 200
{"status":"ok","db_path":"/app/data/centralfederal.sqlite","db_exists":true}
```
**Результат:** PASS.
---
## 2. Unit / Static tests (pytest)
```
$ python -m pytest tests/ -v --ignore=tests/unit/test_health.py
======================== 76 passed, 1 warning in 0.92s =========================
```
### 2.1 Прямо относящиеся к ET-007 (28 тестов)
`tests/unit/test_base_layer.py` — все PASS:
| Тест | Результат |
|----------------------------------------------------------------------------|-----------|
| test_base_toggle_present_in_html | PASS |
| test_base_toggle_default_active_schematic | PASS |
| test_base_toggle_reuses_seg_control_component | PASS |
| test_base_toggle_placed_at_top_of_terrain_popup | PASS |
| test_base_toggle_styles_defined | PASS |
| test_app_js_base_layer_functions_defined | PASS |
| test_app_js_has_et007_block_markers | PASS |
| test_app_js_uses_localstorage_key | PASS |
| test_app_js_uses_esri_world_imagery | PASS |
| test_app_js_satellite_source_and_layer_ids | PASS |
| test_app_js_lazy_source_creation | PASS |
| test_rebuild_overlays_calls_restore_base_layer_first | PASS |
| test_restore_base_layer_state_wired_into_init | PASS |
| test_app_js_uses_setpaint_for_poi_halo | PASS |
| test_app_js_uses_visibility_for_trails_halo | PASS |
| test_style_contains_halo_layers[style.json] | PASS |
| test_style_contains_halo_layers[style-dark.json] | PASS |
| test_halo_layers_hidden_by_default[style.json] | PASS |
| test_halo_layers_hidden_by_default[style-dark.json] | PASS |
| test_halo_layers_below_real_trails[style.json] | PASS |
| test_halo_layers_below_real_trails[style-dark.json] | PASS |
| test_js_unit_tests_pass | PASS |
### 2.2 Регресс по соседним фичам
| Suite | Cases | Результат |
|--------------------------------------|-------|-----------|
| `test_routing_barriers.py` (ET barriers) | 7 | PASS |
| `test_gpx_upload.py` (ET-006) | 19 | PASS |
| `test_poi_toggle.py` (ET-002) | 10 | PASS |
| `test_unit_toggle.py` (ET-005) | 17 | PASS |
Регресс по соседним фичам не сломан.
### 2.3 Известная инфра-проблема
```
ERROR collecting tests/unit/test_health.py
ModuleNotFoundError: No module named 'shapely'
```
`shapely` отсутствует в test-окружении агента (но есть в Docker-образе
runtime, что подтверждается health 200 OK). К ET-007 не относится,
зафиксировано в `12-review.md`.
---
## 3. JS unit tests (`node --test`)
```
$ node --test tests/unit/base_layer.test.js
# tests 33
# pass 33
# fail 0
# duration_ms 96.997357
```
Покрытие из 04-test-plan.yaml:
| Test plan ID | Где покрыто | Результат |
|--------------|------------------------------------------------------------------------|-----------|
| U-01 | `applyBaseLayer("schematic") при пустом localStorage` | PASS |
| U-02 | Чтение `localStorage='satellite'` | PASS |
| U-03 | `onBaseLayerToggle('satellite')` пишет в localStorage | PASS |
| U-04 | Невалидное stored — fallback на `schematic` | PASS |
| U-05 | Toggle на уже активный режим — no-op | PASS |
| U-10, U-11 | `syncBaseLayerUI(...)` | PASS |
| I-01 | `map.getSource('satellite-raster')` после первого toggle | PASS |
| I-02 | `map.getLayer('satellite-base')` | PASS |
| I-03 | `osm-base.visibility === 'none'` после satellite | PASS |
| I-04 | satellite→schematic — `visibility` swap | PASS |
| I-05 | Z-order: satellite ниже terrain/trails | PASS |
| I-06 | Position карты сохраняется | PASS (мок-карта; реальная — manual smoke) |
| I-07 | Атрибуция Esri в source | PASS |
P1-2 (POI text-color), P1-4 (background обе темы), P1-5 (basemap-state),
P1-6 (halo синхронизация) — все 4 P1 из review v1 закрыты JS-тестами.
---
## 4. Smoke test-стенда (deployed assets)
Проверка, что артефакты ET-007 реально задеплоены на
https://openclaw.mva154.duckdns.org/enduro/ :
| Артефакт | Проверка | Результат |
|---------------------|-----------------------------------------------------------|-----------|
| `index.html` | `terrain-base-row`, `#base-btn-schematic`, `#base-btn-satellite`, `onclick="onBaseLayerToggle(...)"` присутствуют (строки 4551) | PASS |
| `app.js` | 16 вхождений ET-007 идентификаторов: `applyBaseLayer`, `onBaseLayerToggle`, `restoreBaseLayerState`, `syncBaseLayerUI`, `satellite-raster`, `satellite-base`, `arcgisonline` | PASS |
| `app.css` | `.terrain-base-row`, `.base-seg`, `body.satellite-active #btn-basemap` (строки 870895) | PASS |
| `style.json` | 2 halo-слоя `*-halo-satellite` | PASS |
| `style-dark.json` | 2 halo-слоя `*-halo-satellite` | PASS |
Все статические артефакты доставлены на test-стенд корректно.
---
## 5. Visual / UI тесты (SKIP)
Из `04b-ui-test-cases.md` определено 14 визуальных кейсов
(TC-UI-01..14, включая 2 mobile-кейса). Из `04-test-plan.yaml`
определено 8 E2E-кейсов (E-01..E-07, E-10).
**Не выполнены:** в окружении агента отсутствует UI-раннер
(`/home/slin/tools/ui-test/run_tests.js``ls: cannot access … No such
file or directory`, повторно проверено на этой итерации); Playwright
не установлен ни в репозитории, ни на хосте; `package.json` в
репозитории отсутствует.
Таблица TC → результат (все SKIP по одной причине — отсутствие раннера):
| TC | Описание | Результат |
|---------|-----------------------------------------------------------------|-----------|
| TC-UI-01 | Переключатель «Подложка» виден в попапе | SKIP (no runner) |
| TC-UI-02 | Активация «Спутник» меняет подложку | SKIP (no runner) |
| TC-UI-03 | Атрибуция Esri видна | SKIP (no runner) |
| TC-UI-04 | Возврат на «Схема» | SKIP (no runner) |
| TC-UI-05 | Грунтовки и тропы видны на спутнике | SKIP (no runner) |
| TC-UI-06 | POI и подписи на спутнике читаемы | SKIP (no runner) |
| TC-UI-07 | Спутник переживает смену темы | SKIP (no runner) |
| TC-UI-08 | Hillshade поверх спутника | SKIP (no runner) |
| TC-UI-09 | Маршрут OSRM на спутнике | SKIP (no runner) |
| TC-UI-10 | Переключатель на мобильном (mobile viewport) | SKIP (no runner) |
| TC-UI-11 | Активация «Спутник» на мобильном (mobile viewport) | SKIP (no runner) |
| TC-UI-12 | Persistence: спутник после перезагрузки | SKIP (no runner) |
| TC-UI-13 | GPX-панель + Спутник | SKIP (no runner) |
| TC-UI-14 | Совместимость с переключателем единиц | SKIP (no runner) |
**Это известный гэп инфраструктуры**, зафиксированный в:
- `12-review.md` v2 — финальный пункт «Что делать дальше» п.4;
- ADR-0001 ET-002 — Playwright-инфраструктура out of scope текущих фаз;
- `07-infra-requirements.md` ET-007 — без новых требований к E2E
инфраструктуре.
Покрытие AC из `03-acceptance-criteria.md` обеспечено косвенно:
| AC group | Гарантия |
|-------------------------|-----------------------------------------------------------------------|
| AC-01 (UI переключателя) | static HTML-тесты + smoke deployed HTML |
| AC-02 (→Спутник) | JS-unit `applyBaseLayer('satellite')`, halo, POI paint, basemap-hide |
| AC-03 (→Схема) | JS-unit `applyBaseLayer('schematic')`, `_savedBasemapState` restore |
| AC-04 (совместимость) | JS-unit halo синхронизация + style.json layer-order |
| AC-05 (persistence) | JS-unit U-02, U-03 |
| AC-06 (смена темы) | static `rebuildMapOverlays`-test + JS-unit `restoreBaseLayerState` |
| AC-07 (тулбар-режимы) | регрессионные suites ET-002/ET-005/ET-006 PASS |
| AC-08 (производительность)| вне автоматизации — оценивается на ручной приёмке |
| AC-09 (mobile UI) | вне автоматизации — оценивается на ручной приёмке |
| AC-10 (регресс) | 76 pytest PASS — соседние фичи не сломаны |
**Рекомендация:** перед `prod` deploy выполнить ручную приёмку на
test-стенде по чек-листу TC-UI-01..14 (особенно TC-UI-07 «смена темы»
и TC-UI-12 «persistence после reload» — наиболее чувствительные к
рефакторингу).
---
## 6. Дефекты
**P0/P1:** нет.
**P2:** нет (P2-1..P2-4 из `12-review.md` — документационные/тестовые,
не блокируют поставку, отмечены reviewer'ом как «APPROVED with comments»).
**P3:** нет новых; P3-1..P3-4 из ревью остаются как nice-to-have.
---
## 7. Вердикт
**PASS — stage:ready-to-deploy.**
Обоснование:
1. Все 76 pytest и 33 JS-теста PASS, в т.ч. 28 целевых тестов ET-007.
2. Все 6 P1-блокеров review v1 закрыты в коде и тестами.
3. Test-стенд отдаёт корректные артефакты (HTML/JS/CSS/style.json).
4. Регресс по соседним фичам (ET-002 POI, ET-005 единицы, ET-006 GPX,
barriers) не обнаружен.
5. Health endpoint test-стенда 200 OK.
Перед prod-деплоем — рекомендуется ручной прогон TC-UI-01..14 на
test-стенде (≤30 минут). Critical-path кейсы для ручной приёмки:
TC-UI-02 (активация спутника), TC-UI-07 (смена темы), TC-UI-12
(persistence), TC-UI-10/11 (mobile).

View File

@@ -1,51 +0,0 @@
---
type: business-request
work_item_id: ET-008
title: "GPS-треки с публичных платформ на карте"
created_at: 2026-06-01
source: plane
requester: Слава
---
# Бизнес-запрос — ET-008
## Исходная формулировка
> Хочу видеть на карте GPS-треки с публичных платформ (OSM, чужие ссылки
> на GPX), а не только локальные файлы. Минимум: вставить ссылку на
> GPX-файл — увидеть трек. Дальше — поиск чужих публичных треков в
> видимой области карты, чтобы перед поездкой посмотреть, кто и где ездил.
## Контекст и ограничения
1. ET-006 уже даёт инфраструктуру отображения GPX-треков (модель,
рендеринг, sheet, профиль высот). Эту инфраструктуру переиспользуем.
2. В стеке нет авторизации пользователей и БД с user accounts —
платформы с обязательным OAuth (Strava, Komoot) **вне scope MVP**.
3. Платный API Wikiloc — **вне scope MVP**.
4. CORS не позволяет браузеру тянуть GPX напрямую с большинства
платформ — нужен прокси через FastAPI.
5. Rate limits публичных API (OSM, Overpass) — нужен server-side кэш.
## Решения аналитика (по умолчанию, при отсутствии явных уточнений)
| Вопрос | Решение | Обоснование |
|--------|---------|-------------|
| Платформы MVP | OSM Public GPS Traces + универсальный GPX-по-URL | Открытые API без авторизации, бесплатные, покрывают сценарии «свой трек по ссылке» и «чужие треки рядом» |
| Сценарии | (1) импорт по URL; (2) bbox-поиск треков в видимой области | Минимальный полезный набор, не требующий новых разделов UI |
| Хранение | Сессия (как ET-006) + server-side LRU-кэш на бэкенде | Не вводим БД и аккаунты; кэш защищает от rate limits |
| Auth | Нет | Все запросы — публичные данные |
| Платформы post-MVP | Wikiloc API, Strava OAuth, Komoot OAuth | Будут отдельными work item, когда появится система аккаунтов |
## Уточнения
1. URL-импорт должен работать с любой прямой ссылкой на `.gpx`-файл
(GitHub raw, gist, личный сайт, веб-сервер пользователя).
2. Поиск по OSM-трекам ограничен видимой областью карты (bbox).
Глобальный поиск не требуется.
3. Загруженные с публичных платформ треки попадают в тот же sheet
`#sheet-gpx`, что и локальные GPX, и ведут себя идентично (статистика,
профиль высот, удаление, fit bounds, переживание смены стиля).
4. Источник трека (URL / OSM trace id) сохраняется в модели и
отображается в карточке трека для пользователя.
5. Кэш на сервере — TTL 24 часа, не персистентный (in-memory).

View File

@@ -1,98 +0,0 @@
---
type: brd
work_item_id: ET-008
title: "BRD: GPS-треки с публичных платформ на карте"
version: 1
status: draft
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
- "agent:analyst"
---
# BRD — ET-008: GPS-треки с публичных платформ на карте
## 1. Цель
Дать пользователю возможность увидеть на карте Enduro Trails GPS-треки
с публичных источников без скачивания файлов вручную: либо вставив
прямую ссылку на GPX, либо найдя чужие публичные треки в видимой
области карты (через OSM Public GPS Traces).
## 2. Контекст
- ET-006 реализовал клиентский GPX-стек: парсер, модель
`window.gpxTracks`, sheet `#sheet-gpx`, статистика, профиль высот,
переживание `map.setStyle()` через `rebuildGpxOverlays()`. Источник
данных — только локальный файл пользователя.
- Roadmap-фаза PH-3 «Smart Route» включает работу с GPX (импорт/экспорт).
- В стеке нет пользовательских аккаунтов и БД пользователей. Платформы с
обязательным OAuth (Strava, Komoot) поэтому вне scope текущей итерации.
- Браузер не может тянуть GPX напрямую с большинства публичных платформ
из-за CORS. OSM API не разрешает кросс-доменные запросы → прокси
через FastAPI обязателен.
- OSM Public GPS Traces — открытый бесплатный источник публичных
GPS-треков, формат GPX, есть bbox-поиск, нет авторизации для чтения.
## 3. Scope
### In scope
| # | Функция |
|------|---------|
| F-01 | Поле ввода URL прямой ссылки на GPX в `#sheet-gpx` |
| F-02 | Импорт GPX по URL через прокси-эндпоинт `/api/gpx/fetch` |
| F-03 | Кнопка «Найти публичные треки» в `#sheet-gpx` — поиск в bbox видимой области карты |
| F-04 | Прокси-эндпоинт `/api/gpx/osm/traces` для OSM Public GPS Traces |
| F-05 | Список найденных OSM-треков с метаданными (длина, точек, описание, автор) |
| F-06 | Импорт выбранного OSM-трека одним тапом |
| F-07 | Серверный LRU-кэш ответов внешних API (TTL 24 ч, in-memory) |
| F-08 | Источник трека (URL / OSM trace id + ссылка) виден в карточке трека |
| F-09 | Лимит размера загруженного по URL файла: 50 МБ (как ET-006) |
| F-10 | Внятные сообщения об ошибках (CORS-фейл, 404, лимит API, битый GPX) |
| F-11 | Импортированные треки попадают в общий список `window.gpxTracks` и неотличимы от локальных по поведению |
### Out of scope
- OAuth-интеграции (Strava, Komoot)
- Платный API Wikiloc
- Поиск треков глобально (без bbox)
- Сохранение треков в БД между сессиями
- Подписки на пользователей других платформ
- Загрузка собственных треков на публичные платформы
## 4. Метрики успеха
| Метрика | Критерий |
|---------|----------|
| URL-импорт | Прямая ссылка на GPX до 50 МБ загружается за ≤ 5 сек на средней сети |
| OSM-поиск bbox | Запрос видимой области возвращает результат за ≤ 3 сек (с кэшем — мгновенно) |
| Точность | OSM-трек после импорта визуально совпадает с тем же треком из osm.org |
| Кэш | Повторный запрос той же области/URL в течение 24 ч — без обращения к внешнему API |
| UX | Все ошибки (CORS, 404, лимит, формат) — внятные toast-уведомления, не падение |
| Совместимость с ET-006 | Локальные и удалённые треки в одном списке, поведение идентично |
| Сохранение при смене стиля | Импортированные треки переживают переключение тёмной темы и слоёв рельефа |
## 5. Риски
| Риск | Вероятность | Влияние | Митигация |
|------|-------------|---------|-----------|
| OSM API rate limit (1 запрос / IP / сек) | Высокая | Среднее | Серверный кэш по bbox + дебаунс на клиенте |
| URL-прокси превращается в open redirect / SSRF | Средняя | Высокое | Whitelist схем (http/https), блок приватных IP, лимит размера, таймаут |
| Большие OSM-страницы (1000+ треков) → длинный список | Средняя | Низкое | Пагинация: показывать первые N, кнопка «ещё» |
| GPX по URL не существует / 404 | Высокая | Низкое | Toast с понятной ошибкой |
| Content-Type не `application/gpx+xml` | Высокая | Низкое | Проверять по содержимому (DOMParser), не по заголовкам |
| Чужой публичный трек содержит вредоносный XML / XXE | Низкая | Высокое | DOMParser в браузере (XXE отключён), на бэкенде — `defusedxml` |
| Внешний API внезапно недоступен | Средняя | Низкое | Graceful degradation: показать сообщение, не блокировать другие функции |
## 6. Зависимости
- **ET-006** — модель `window.gpxTracks`, рендеринг, sheet `#sheet-gpx`,
парсер `parseGpx()`. Без ET-006 эта задача не имеет смысла.
- **Backend (FastAPI)** — новые эндпоинты `/api/gpx/fetch`,
`/api/gpx/osm/traces`, добавление `httpx` (уже есть) и `defusedxml`
(новая зависимость, опционально — для server-side валидации).
- Внешние сервисы:
- `https://api.openstreetmap.org/api/0.6/trackpoints` — публичный API
OSM, ограничения: 1 req/sec/IP, 5000 точек/страница, до 5 страниц.
- Произвольные HTTPS-хосты (для URL-импорта) — без SLA, fail-soft.

View File

@@ -1,473 +0,0 @@
---
type: trz
work_item_id: ET-008
title: "ТЗ: GPS-треки с публичных платформ на карте"
version: 1
status: draft
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
- "agent:analyst"
---
# ТЗ — ET-008: GPS-треки с публичных платформ на карте
## 1. Функциональные требования
### REQ-F-01: Расширение sheet `#sheet-gpx`
В верхней части `#sheet-gpx` (под header, над списком треков) добавить
секцию «Источники» с двумя вкладками-кнопками (segmented control):
- **Из файла** — текущее поведение ET-006 (`#btn-gpx-upload`).
- **По ссылке** — поле ввода URL + кнопка «Загрузить».
- **Найти рядом** — кнопка «Найти публичные треки в этой области карты».
При первом открытии активна вкладка **Из файла** (обратная совместимость).
### REQ-F-02: Импорт по URL
- Поле `<input id="gpx-url-input" type="url">` с placeholder
«https://example.com/track.gpx».
- Кнопка `#btn-gpx-fetch-url` рядом — «Загрузить».
- При нажатии:
1. Клиентская валидация URL (`new URL()`, схема `https?:`).
2. Запрос `GET /api/gpx/fetch?url=<encoded>`.
3. Полученный текст GPX парсится тем же `parseGpx()` из `gpx.js`.
4. Результат добавляется в `window.gpxTracks` как обычно. Поле
`source` = `{kind: 'url', url: '<original>'}`.
5. `filename` для отображения: последний segment URL без `.gpx` или
`<gpx><metadata><name>` если есть.
- Поддерживается также Enter в поле ввода.
### REQ-F-03: Прокси-эндпоинт `/api/gpx/fetch`
```
GET /api/gpx/fetch?url=<percent-encoded-url>
```
- Валидация:
- Схема URL ∈ {`http`, `https`}.
- Хост резолвится в публичный IP (не RFC1918, не loopback, не link-local).
Проверка через `socket.getaddrinfo()` + `ipaddress.ip_address().is_global`.
- Запрет редиректов на приватные IP (`httpx.AsyncClient(follow_redirects=False)`,
ручная обработка max 3 редиректов с повторной валидацией хоста).
- Загрузка:
- Таймаут 15 секунд.
- Лимит размера ответа: 50 МБ (стримом, прервать при превышении).
- Заголовок `User-Agent: enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)`.
- Кэш:
- Ключ = SHA-256(url).
- In-memory LRU, max 64 записи, TTL 24 ч.
- При cache hit — отдаётся из кэша.
- Ответ:
- `200 OK`, `Content-Type: application/gpx+xml`, тело GPX.
- Заголовок `X-Cache: HIT|MISS`.
- Ошибки → JSON `{error: "..."}`:
- `400` — невалидный URL / приватный IP / запрещённая схема.
- `404` — внешний сервер вернул 404.
- `413` — превышен лимит размера.
- `502` — внешний сервер недоступен / таймаут.
- `504` — таймаут на нашей стороне.
### REQ-F-04: Кнопка «Найти публичные треки»
- Кнопка `#btn-gpx-find-nearby` в секции «Источники».
- Текст: «Найти треки в этой области».
- При нажатии:
1. Получить bbox видимой области карты: `map.getBounds()`.
2. Валидация: площадь bbox ≤ 0.25 deg² (OSM API limit — иначе ошибка).
Если больше — toast «Слишком большая область, увеличьте zoom».
3. Запрос `GET /api/gpx/osm/traces?bbox=west,south,east,north`.
4. Открыть подсекцию «Найденные треки» (REQ-F-05).
### REQ-F-05: Прокси-эндпоинт `/api/gpx/osm/traces`
```
GET /api/gpx/osm/traces?bbox=<west>,<south>,<east>,<north>&page=<n>
```
- Параметры:
- `bbox` — обязательный, 4 числа через запятую.
- `page` — опциональный, целое ≥ 0, default 0.
- Валидация:
- Каждая координата — валидный float, в допустимом диапазоне.
- Площадь bbox ≤ 0.25 deg² — иначе `400`.
- Запрос к OSM:
```
GET https://api.openstreetmap.org/api/0.6/trackpoints
?bbox=<bbox>&page=<page>
```
- Таймаут 10 секунд.
- User-Agent как в REQ-F-03.
- Парсинг ответа:
- OSM возвращает GPX 1.0 с `<trkseg>` и атрибутом `gpx_id` у некоторых
точек (см. формат OSM API). Группируем точки по `gpx_id` →
массив треков-метаданных.
- Анонимные треки (без `gpx_id`) объединяются в один общий «Анонимные треки этой области».
- Кэш:
- Ключ = `(bbox_rounded_to_4_digits, page)`.
- In-memory LRU, max 256 записей, TTL 24 ч.
- Ответ (JSON):
```json
{
"bbox": [w, s, e, n],
"page": 0,
"has_more": false,
"tracks": [
{
"osm_id": 12345,
"name": "Trail in the woods",
"description": "...",
"user": "username",
"points_count": 320,
"distance_km": 12.4,
"url": "https://www.openstreetmap.org/user/.../traces/12345",
"gpx_url": "https://api.openstreetmap.org/api/0.6/gpx/12345/data"
}
]
}
```
- Поле `distance_km` — посчитано на сервере (Haversine).
- Ошибки → JSON `{error: "..."}`:
- `400` — невалидный bbox / слишком большая область.
- `502` — OSM API недоступен.
- `504` — таймаут.
### REQ-F-06: UI списка найденных треков
В подсекции `#gpx-nearby-results` под кнопкой «Найти треки»:
- Заголовок: «Найдено N треков в этой области».
- Список карточек, каждая:
- Иконка-индикатор источника (OSM-логотип маленький).
- Имя трека (или «Без названия»).
- Метаданные: длина (км, через `units.js`), автор (если есть).
- Кнопка «Показать» — импортирует трек на карту.
- Кнопка «↗» — открывает страницу трека на osm.org в новой вкладке.
- Если `has_more` — кнопка «Показать ещё» внизу списка (увеличивает page).
- Если треков нет — текст «В этой области нет публичных GPS-треков».
### REQ-F-07: Импорт выбранного OSM-трека
При клике на «Показать»:
1. Запрос `GET /api/gpx/fetch?url=<gpx_url>` — тот же эндпоинт, что для
произвольного URL (переиспользование кэша и валидации).
2. После загрузки — те же шаги, что REQ-F-02 (парсинг → модель → рендеринг).
3. Поле `source` = `{kind: 'osm', osm_id: <id>, url: <osm_page_url>}`.
4. Карточка в списке найденных треков получает индикатор «✓ Загружен».
5. Повторный клик «Показать» — no-op (toast «Уже загружен»).
### REQ-F-08: Отображение источника в карточке трека
В существующей карточке трека в списке `#gpx-list` (ET-006):
- Под именем файла мелким шрифтом добавить строку «источник»:
- Локальный файл: «📁 локальный файл» (без изменения для ET-006).
- URL: «🔗 <hostname>» (например, «🔗 github.com»).
- OSM: «🌍 OSM #<id>» — кликабельная ссылка на страницу osm.org.
### REQ-F-09: Расширение модели `window.gpxTracks`
Каждый элемент `window.gpxTracks` дополнительно содержит:
```javascript
{
// ... существующие поля ET-006 (id, filename, color, tracks, waypoints, ...)
source: {
kind: 'file' | 'url' | 'osm',
url: string | null, // для kind='url' и 'osm'
osm_id: number | null, // для kind='osm'
}
}
```
Для треков ET-006 (загруженных из файла) `source.kind = 'file'`
(обратная совместимость через миграцию на лету: если `source` отсутствует,
читать как `{kind: 'file'}`).
### REQ-F-10: Обработка ошибок и toast-уведомления
| Ситуация | Toast |
|----------|-------|
| Невалидный URL | «Невалидная ссылка» |
| URL → приватный IP | «Эта ссылка недоступна» |
| Внешний 404 | «Файл не найден по этой ссылке» |
| Внешний таймаут / 502 | «Сервер не отвечает, попробуйте позже» |
| Файл > 50 МБ | «Файл слишком большой (макс. 50 МБ)» |
| Не GPX (DOMParser fail) | «По этой ссылке не GPX-файл» |
| OSM: bbox > 0.25 deg² | «Слишком большая область, увеличьте zoom» |
| OSM: 0 треков | «В этой области нет публичных GPS-треков» (не toast, а inline-сообщение) |
| OSM: rate limit (429) | «Слишком много запросов к OSM, попробуйте через минуту» |
### REQ-F-11: Сохранение при смене стиля карты
Импортированные треки переживают `map.setStyle()` через тот же механизм
`rebuildGpxOverlays()`, что и локальные ET-006. Никаких изменений в
этой функции не требуется — модель данных совместима.
## 2. Нефункциональные требования
### REQ-NF-01: Безопасность
- Прокси `/api/gpx/fetch` защищён от SSRF (REQ-F-03):
- Whitelist схем.
- Резолв и проверка хоста на публичность.
- Ручная обработка редиректов с повторной валидацией.
- Лимит размера ответа стримом.
- Парсинг XML на бэкенде (если потребуется — для OSM-ответа) через
`defusedxml.ElementTree` — защита от XXE / billion laughs.
- Парсинг GPX на клиенте — нативный `DOMParser`, XXE отключён по умолчанию.
- CORS на новых эндпоинтах — наследуется от существующей конфигурации
(`allow_origins=["*"]`), отдельных правил не требуется.
### REQ-NF-02: Производительность
- Запрос OSM с кэш-хитом: ≤ 50 мс.
- Запрос OSM без кэша: ≤ 3 сек (зависит от OSM API).
- URL-импорт GPX 1 МБ: ≤ 2 сек.
- URL-импорт GPX 50 МБ: ≤ 10 сек (с учётом сети).
- Bbox-валидация и серилизация на бэкенде: ≤ 5 мс.
### REQ-NF-03: Кэширование
- LRU-кэш `/api/gpx/fetch`: 64 записи × до 50 МБ = до 3.2 ГБ памяти —
**слишком много**. Решение: хранить только treki ≤ 5 МБ, остальные не
кэшировать. Корректировка: кэш до 64 записей размером ≤ 5 МБ каждая.
- LRU-кэш `/api/gpx/osm/traces`: 256 записей × ≤ 200 КБ JSON ≈ 50 МБ.
- Оба кэша — in-memory, не персистентные, теряются при рестарте контейнера.
- TTL: 24 часа.
- Метрики кэша (`/api/health`): `gpx_fetch_cache_size`, `gpx_osm_cache_size`.
### REQ-NF-04: Совместимость
- Браузеры: те же, что ET-006 (Chrome 90+, Firefox 90+, Safari 15+).
- Мобильные: input type=url с режимом клавиатуры url.
- Backend: Python 3.12, FastAPI, httpx (уже есть), `defusedxml` (новая).
### REQ-NF-05: UX
- Во время сетевого запроса показывать индикатор (повторно используем
`#gpx-loading` из ET-006).
- Кнопка «Найти треки» дизейблится во время запроса.
- Все toast-уведомления — через существующий механизм `showToast()` из `gpx.js`.
## 3. UI-спецификация
### 3.1 Расширение `#sheet-gpx` — секция «Источники»
```
┌─────────────────────────────────────┐
│ ═══ (handle) │
│ 📄 GPX-треки [свернуть]│
├─────────────────────────────────────┤
│ ИСТОЧНИКИ │
│ [📁 Из файла] [🔗 По ссылке] [🌍 Найти рядом] │
│ │
│ ─ если активна «По ссылке»: ─ │
│ ┌──────────────────────────┐ ┌────┐ │
│ │https://example.com/...gpx│ │Загр│ │
│ └──────────────────────────┘ └────┘ │
│ │
│ ─ если активна «Найти рядом»: ─ │
│ [ Найти треки в этой области карты ]│
│ Найдено 5 треков: │
│ ┌─────────────────────────────────┐ │
│ │🌍 Trail in the woods [Показ.] │ │
│ │ 12.4 км · автор: user42 [↗] │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │🌍 Без названия [✓ Загр.]│ │
│ │ 3.1 км · аноним [↗]│ │
│ └─────────────────────────────────┘ │
│ [ Показать ещё ] │
├─────────────────────────────────────┤
│ ЗАГРУЖЕННЫЕ ТРЕКИ (как в ET-006) │
│ 🔴 morning.gpx [✕] │
│ 📁 локальный файл │
│ 🔵 trail_woods [✕] │
│ 🌍 OSM #12345 │
│ 🟢 strava-export [✕] │
│ 🔗 github.com │
└─────────────────────────────────────┘
```
### 3.2 Segmented control «Источники»
- Контейнер: `<div class="seg-control source-seg" id="source-seg">`.
- Кнопки: `<button class="seg-btn" id="source-btn-file|url|nearby">`.
- Стилизация — переиспользовать существующий `.seg-control` (как в
выборе единиц `unit-seg`).
- Поведение: одна активна, остальные неактивны; контент под секцией
переключается по data-атрибуту.
### 3.3 Карточка найденного OSM-трека
- Контейнер: `<div class="gpx-nearby-card" data-osm-id="...">`.
- Структура:
```html
<div class="gpx-nearby-card">
<div class="gnc-icon">🌍</div>
<div class="gnc-body">
<div class="gnc-title">Trail in the woods</div>
<div class="gnc-meta">12.4 км · автор: user42</div>
</div>
<button class="gnc-import">Показать</button>
<a class="gnc-external" href="..." target="_blank" rel="noopener">↗</a>
</div>
```
### 3.4 Расширение карточки трека в `#gpx-list`
Добавить под именем файла строку:
```html
<div class="gpx-source-row">
<!-- для kind='file' -->
<span>📁 локальный файл</span>
<!-- для kind='url' -->
<span>🔗 <span class="gpx-host">github.com</span></span>
<!-- для kind='osm' -->
<a href="https://www.openstreetmap.org/.../traces/12345"
target="_blank" rel="noopener">🌍 OSM #12345</a>
</div>
```
## 4. Данные
### 4.1 Формат OSM Public GPS Traces API
OSM возвращает GPX 1.0:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
<trk>
<name>Anonymous tracks</name>
<trkseg>
<trkpt lat="55.7558" lon="37.6173">
<time>2024-05-01T08:00:00Z</time>
</trkpt>
...
</trkseg>
</trk>
</gpx>
```
`gpx_id` атрибут точек официально устарел; вместо группировки треков по
gpx_id отдаём весь bbox-ответ как «Публичные треки этой области (N точек)»
— **единая карточка**, импорт всей выборки как одного трека.
Метаданные индивидуальных треков (user, name) недоступны через
`trackpoints` endpoint без дополнительного запроса.
**Уточнение требования REQ-F-05/F-06** (исходя из реального API):
- Список найденных «треков» — это страницы trackpoints (page 0, 1, 2…).
- Карточка отображает: page N, количество точек, длину, bbox-центр.
- Импорт = загрузить эту страницу как один GPX-трек.
- Кнопка «Показать ещё» → следующая страница.
Это упрощает реализацию и соответствует ограничениям OSM API.
### 4.2 Внутренняя модель — расширение
```javascript
window.gpxTracks = [
{
// существующие поля ET-006
id: 'gpx-1716336000000',
filename: 'trail_woods',
color: '#3cb44b',
tracks: [...],
waypoints: [...],
sourceId: 'gpx-source-...',
layerId: 'gpx-layer-...',
waypointLayerId: 'gpx-wpt-...',
// новое поле ET-008
source: {
kind: 'file' | 'url' | 'osm',
url: 'https://...', // null для kind='file'
osm_page: 0, // только для kind='osm'
osm_bbox: [w, s, e, n] // только для kind='osm'
}
}
];
```
## 5. Файловая структура изменений
```
src/api/
├── main.py # + 2 эндпоинта, импорт нового модуля
├── gpx_proxy.py # НОВЫЙ: SSRF-валидация, fetch, кэш
├── osm_traces.py # НОВЫЙ: OSM trackpoints клиент, парсинг
├── requirements.txt # + defusedxml
src/web/
├── index.html # + секция «Источники» в #sheet-gpx
├── gpx.js # + URL-импорт, OSM-поиск, расширение модели
├── app.css # + стили .source-seg, .gpx-nearby-card, .gpx-source-row
tests/
├── api/test_gpx_proxy.py # НОВЫЙ
├── api/test_osm_traces.py # НОВЫЙ
├── web/gpx.test.js # + тесты на URL/OSM источники
docs/work-items/ET-008/
├── 06-adr/
│ ├── ADR-001-ssrf-protection.md
│ └── ADR-002-osm-trackpoints-aggregation.md
```
## 6. Алгоритмы
### 6.1 SSRF-защита `/api/gpx/fetch`
```python
def is_safe_url(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return False
try:
infos = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror:
return False
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if not ip.is_global or ip.is_loopback or ip.is_private:
return False
return True
```
При следовании редиректам — повторная валидация хоста на каждом шаге.
### 6.2 Bbox area check
```python
def bbox_area_deg2(w, s, e, n):
return abs(e - w) * abs(n - s)
if bbox_area_deg2(*bbox) > 0.25:
raise HTTPException(400, "bbox too large")
```
### 6.3 Кэш-ключ для bbox
Округление до 4 знаков (≈ 11 метров на экваторе):
```python
key = (round(w, 4), round(s, 4), round(e, 4), round(n, 4), page)
```
Это обеспечивает попадание в кэш при незначительном движении карты.
## 7. Взаимодействие с существующими модулями
- **ET-006 `gpx.js`** — расширяем, не переписываем. Существующие функции
(`parseGpx`, `addGpxTrack`, `rebuildGpxOverlays`) остаются. Добавляются:
`importGpxFromUrl(url)`, `findOsmTracesInView()`,
`importOsmTrace(osm_url)`.
- **`units.js`** — используется для форматирования длины треков в списке.
- **`#sheet-gpx`** — единственный sheet для всех источников. Никаких
новых sheet не создаётся.
- **`#toolbar`** — кнопка `#tb-gpx` уже открывает `#sheet-gpx`. Не меняется.
- **`/api/health`** — расширить выдачей размеров кэшей (REQ-NF-03).

View File

@@ -1,275 +0,0 @@
---
type: acceptance-criteria
work_item_id: ET-008
title: "AC: GPS-треки с публичных платформ на карте"
version: 1
status: draft
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
- "agent:analyst"
---
# Acceptance Criteria — ET-008: GPS-треки с публичных платформ на карте
## AC-01: Секция «Источники» в `#sheet-gpx`
```gherkin
Feature: Переключатель источников треков
Scenario: Открытие GPX-панели
Given пользователь нажимает кнопку GPX в нижнем тулбаре
Then открывается панель #sheet-gpx
And в верхней части видна секция «Источники» с тремя кнопками: «Из файла», «По ссылке», «Найти рядом»
And по умолчанию активна кнопка «Из файла»
Scenario: Переключение на «По ссылке»
Given панель #sheet-gpx открыта
When пользователь нажимает кнопку «По ссылке»
Then кнопка «По ссылке» становится активной
And отображается поле ввода URL и кнопка «Загрузить»
And контент других вкладок скрыт
Scenario: Переключение на «Найти рядом»
Given панель #sheet-gpx открыта
When пользователь нажимает кнопку «Найти рядом»
Then отображается кнопка «Найти треки в этой области карты»
```
## AC-02: Импорт по URL — успешный сценарий
```gherkin
Feature: Загрузка GPX по прямой ссылке
Scenario: Валидная публичная ссылка
Given активна вкладка «По ссылке»
When пользователь вставляет https://example.com/test-track.gpx (валидный, 1 МБ)
And нажимает «Загрузить»
Then показывается индикатор загрузки
And через 5 сек трек появляется на карте
And карта выполняет fit bounds
And трек добавляется в список #gpx-list
And в карточке трека отображается «🔗 example.com»
Scenario: Загрузка по Enter
Given активна вкладка «По ссылке»
When пользователь вставляет URL и нажимает Enter
Then загрузка начинается без клика по кнопке
```
## AC-03: Импорт по URL — ошибки
```gherkin
Feature: Обработка ошибок URL-импорта
Scenario: Невалидный URL (схема)
Given активна вкладка «По ссылке»
When пользователь вставляет ftp://example.com/file.gpx
Then показывается toast «Невалидная ссылка»
And запрос на бэкенд не отправляется
Scenario: Приватный IP
Given пользователь вставляет http://192.168.1.1/file.gpx
Then бэкенд возвращает 400
And показывается toast «Эта ссылка недоступна»
Scenario: Несуществующий файл
Given URL ведёт на 404
Then показывается toast «Файл не найден по этой ссылке»
Scenario: Файл больше 50 МБ
Given URL ведёт на GPX > 50 МБ
Then показывается toast «Файл слишком большой (макс. 50 МБ)»
Scenario: Не GPX (HTML по ссылке)
Given URL отдаёт HTML-страницу
Then показывается toast «По этой ссылке не GPX-файл»
Scenario: Внешний сервер не отвечает
Given внешний сервер таймаутит
Then показывается toast «Сервер не отвечает, попробуйте позже»
```
## AC-04: Поиск OSM-треков
```gherkin
Feature: Поиск публичных треков OSM в видимой области
Scenario: Успешный поиск с результатами
Given активна вкладка «Найти рядом»
And карта показывает область с публичными треками
When пользователь нажимает «Найти треки в этой области карты»
Then показывается индикатор загрузки
And через 3 сек появляется список найденных треков
And каждая карточка содержит: иконку OSM, описание (page N), длину в км, кнопку «Показать», ссылку «»
Scenario: Пустая область
Given карта показывает область без публичных треков
When пользователь нажимает «Найти треки»
Then отображается inline-сообщение «В этой области нет публичных GPS-треков»
Scenario: Слишком большая область
Given карта показывает область с bbox > 0.25 deg²
When пользователь нажимает «Найти треки»
Then показывается toast «Слишком большая область, увеличьте zoom»
And запрос на бэкенд не отправляется (или возвращается 400)
Scenario: Пагинация
Given поиск вернул has_more=true
Then в конце списка отображается кнопка «Показать ещё»
When пользователь нажимает «Показать ещё»
Then дозагружаются результаты следующей страницы
And они добавляются в конец списка
```
## AC-05: Импорт OSM-трека
```gherkin
Feature: Импорт выбранного OSM-трека на карту
Scenario: Импорт по кнопке «Показать»
Given найдено 3 OSM-трека в списке
When пользователь нажимает «Показать» у первого трека
Then показывается индикатор загрузки
And через 5 сек трек появляется на карте
And карта выполняет fit bounds
And трек добавляется в #gpx-list
And в карточке трека отображается «🌍 OSM #...» (кликабельная ссылка)
And карточка в #gpx-nearby-results получает индикатор «✓ Загружен»
Scenario: Повторный импорт того же трека
Given OSM-трек уже импортирован
When пользователь нажимает «Показать» у этой же карточки в найденных
Then показывается toast «Уже загружен»
And новый трек НЕ добавляется
Scenario: Внешняя ссылка на osm.org
Given в карточке найденного трека есть кнопка «»
When пользователь нажимает «»
Then открывается новая вкладка с страницей трека на openstreetmap.org
```
## AC-06: Отображение источника в карточке трека
```gherkin
Feature: Источник трека виден пользователю
Scenario: Локальный файл (ET-006 совместимость)
Given загружен GPX из локального файла
Then в карточке трека под именем файла отображается «📁 локальный файл»
Scenario: Загружен по URL
Given загружен GPX по ссылке https://github.com/user/repo/track.gpx
Then в карточке трека отображается «🔗 github.com»
Scenario: Загружен из OSM
Given загружен OSM-трек page 0
Then в карточке трека отображается ссылка «🌍 OSM #..» которая ведёт на osm.org
```
## AC-07: Кэширование на бэкенде
```gherkin
Feature: Серверный кэш ответов внешних API
Scenario: Повторный запрос URL из кэша
Given URL запрашивался менее 24 часов назад
When клиент делает повторный GET /api/gpx/fetch?url=...
Then ответ возвращается с заголовком X-Cache: HIT
And время ответа 50 мс
And внешний запрос НЕ выполняется
Scenario: Cache miss
Given URL запрашивается впервые
Then выполняется внешний запрос
And ответ возвращается с X-Cache: MISS
And следующий запрос того же URL HIT
Scenario: Повторный bbox-поиск из кэша
Given bbox запрашивался менее 24 часов назад
When клиент делает повторный GET /api/gpx/osm/traces?bbox=...
Then ответ из кэша
And внешний запрос к OSM API НЕ выполняется
```
## AC-08: Безопасность
```gherkin
Feature: SSRF protection
Scenario: Прямой запрос к loopback
When клиент шлёт GET /api/gpx/fetch?url=http://127.0.0.1/data
Then бэкенд возвращает 400
And никакого запроса к 127.0.0.1 не делается
Scenario: Запрос к приватной подсети
When клиент шлёт URL ведущий на 10.0.0.1, 192.168.x.x, 172.16.x.x
Then бэкенд возвращает 400
Scenario: Редирект на приватный IP
Given внешний URL отдаёт 302 redirect на http://127.0.0.1/...
When клиент шлёт GET /api/gpx/fetch?url=<external>
Then редирект проверяется повторно и блокируется
And бэкенд возвращает 400
Scenario: Запрещённая схема
When клиент шлёт URL с file:// или gopher://
Then бэкенд возвращает 400
Scenario: Размер ответа превышает лимит
Given внешний сервер начинает стримить файл > 50 МБ
Then бэкенд прерывает соединение
And возвращает 413
```
## AC-09: Совместимость с ET-006
```gherkin
Feature: Локальные и удалённые треки в одной модели
Scenario: Смешанный список
Given загружен 1 локальный файл, 1 по URL, 1 из OSM
Then в #gpx-list отображаются 3 карточки
And каждая имеет уникальный цвет из палитры
And каждая имеет свой индикатор источника
And любую можно активировать, удалить, увидеть профиль высот
Scenario: Сохранение при смене темы
Given на карте 3 трека разных источников
When пользователь переключает тёмную тему
Then все 3 трека остаются на карте
And источники в карточках сохраняются
And статистика и профиль активного трека сохраняются
Scenario: Сохранение при переключении слоёв рельефа
Given на карте 3 трека разных источников
When пользователь включает hillshade
Then все 3 трека видны поверх hillshade
```
## AC-10: Метрики кэша в `/api/health`
```gherkin
Feature: Наблюдаемость кэшей
Scenario: Размер кэшей в health-эндпоинте
When клиент шлёт GET /api/health
Then ответ содержит поля gpx_fetch_cache_size и gpx_osm_cache_size
And значения целые числа 0
```
## AC-11: Производительность
```gherkin
Feature: Лимиты времени отклика
Scenario: OSM bbox запрос с кэш-хитом
Given bbox в кэше
Then GET /api/gpx/osm/traces возвращается за 50 мс (p95)
Scenario: URL-импорт малого файла (1 МБ)
Then GET /api/gpx/fetch для 1 МБ файла завершается за 2 сек
Scenario: OSM bbox запрос без кэша
Then GET /api/gpx/osm/traces без кэша возвращается за 3 сек (p95)
```

View File

@@ -1,424 +0,0 @@
---
type: test-plan
work_item_id: ET-008
title: "Test Plan: GPS-треки с публичных платформ на карте"
version: 1
status: draft
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
- "agent:analyst"
test_suites:
- name: unit-gpx-proxy-validation
type: unit
description: "SSRF-валидация URL в gpx_proxy.is_safe_url()"
cases:
- id: U-01
name: "Принимает валидный публичный HTTPS URL"
input: "https://example.com/track.gpx (резолвится в публичный IP)"
expected: "is_safe_url() возвращает True"
- id: U-02
name: "Отклоняет схему ftp://"
input: "ftp://example.com/track.gpx"
expected: "is_safe_url() возвращает False"
- id: U-03
name: "Отклоняет схему file://"
input: "file:///etc/passwd"
expected: "is_safe_url() возвращает False"
- id: U-04
name: "Отклоняет loopback IP"
input: "http://127.0.0.1/x.gpx"
expected: "is_safe_url() возвращает False"
- id: U-05
name: "Отклоняет приватный IP (10.0.0.0/8)"
input: "http://10.1.2.3/x.gpx"
expected: "is_safe_url() возвращает False"
- id: U-06
name: "Отклоняет приватный IP (192.168.0.0/16)"
input: "http://192.168.1.1/x.gpx"
expected: "is_safe_url() возвращает False"
- id: U-07
name: "Отклоняет приватный IP (172.16.0.0/12)"
input: "http://172.16.0.1/x.gpx"
expected: "is_safe_url() возвращает False"
- id: U-08
name: "Отклоняет link-local IP (169.254.x.x)"
input: "http://169.254.169.254/metadata"
expected: "is_safe_url() возвращает False"
- id: U-09
name: "Отклоняет невалидный URL"
input: "not a url"
expected: "is_safe_url() возвращает False (без exception)"
- id: U-10
name: "Отклоняет хост, который не резолвится"
input: "http://nonexistent-host-xyz-12345.invalid/x.gpx"
expected: "is_safe_url() возвращает False"
- name: unit-bbox-validation
type: unit
description: "Валидация bbox в osm_traces"
cases:
- id: U-20
name: "Принимает малый bbox"
input: "bbox=[37.6, 55.7, 37.7, 55.8] (0.01 deg²)"
expected: "validate_bbox() возвращает True"
- id: U-21
name: "Отклоняет bbox > 0.25 deg²"
input: "bbox=[37.0, 55.0, 38.0, 56.0] (1.0 deg²)"
expected: "validate_bbox() возвращает False"
- id: U-22
name: "Отклоняет невалидные координаты"
input: "bbox=[200, 100, 250, 150]"
expected: "validate_bbox() возвращает False"
- id: U-23
name: "Отклоняет перевёрнутый bbox (west > east)"
input: "bbox=[38.0, 55.0, 37.0, 56.0]"
expected: "validate_bbox() возвращает False"
- name: unit-cache
type: unit
description: "LRU кэш с TTL"
cases:
- id: U-30
name: "TTL истёк → cache miss"
input: "Положить запись с TTL 1 сек, ждать 2 сек, запросить"
expected: "Возвращает None (или вызывает loader)"
- id: U-31
name: "LRU вытеснение при переполнении"
input: "Заполнить кэш max=4 записями, добавить 5-ю"
expected: "Первая (LRU) запись вытеснена"
- id: U-32
name: "Округление bbox-ключа до 4 знаков"
input: "bbox=[37.6172999, 55.7558001, ...] и bbox=[37.6173, 55.7558, ...]"
expected: "Один и тот же кэш-ключ → cache hit"
- id: U-33
name: "URL > 5 МБ не кэшируется"
input: "Положить запись размером 6 МБ"
expected: "Запись не попадает в кэш (cache.get → None)"
- name: unit-osm-parser
type: unit
description: "Парсинг OSM trackpoints GPX → JSON"
cases:
- id: U-40
name: "Извлечение точек из GPX 1.0"
input: "GPX с 1 <trk>, 1 <trkseg>, 50 <trkpt>"
expected: "JSON: {tracks: [{points_count: 50, distance_km: ~X}]}"
- id: U-41
name: "Расчёт длины через Haversine"
input: "GPX с 3 точками: [37.6,55.7], [37.7,55.8], [37.8,55.9]"
expected: "distance_km ≈ 28.3 (±0.5)"
- id: U-42
name: "Пустой GPX (нет trkpt)"
input: "GPX без точек"
expected: "JSON: {tracks: [], total_points: 0}"
- id: U-43
name: "Защита от XXE (defusedxml)"
input: "GPX с DOCTYPE и внешней entity"
expected: "Парсер не выполняет загрузку внешней entity (или бросает ошибку)"
- name: unit-web-gpx-source
type: unit
description: "Расширение модели window.gpxTracks полем source"
cases:
- id: U-50
name: "Импорт по URL: source.kind='url'"
input: "importGpxFromUrl('https://github.com/x/y.gpx', mockedFetch)"
expected: "Трек добавлен с source={kind:'url', url:'https://github.com/x/y.gpx'}"
- id: U-51
name: "Импорт OSM: source.kind='osm'"
input: "importOsmTrace({osm_page:0, osm_bbox:[...], gpx_url:'...'}, mockedFetch)"
expected: "Трек добавлен с source={kind:'osm', osm_page:0, osm_bbox:[...], url:'...'}"
- id: U-52
name: "Обратная совместимость: трек без source читается как 'file'"
input: "window.gpxTracks[0] без поля source"
expected: "renderSourceRow() возвращает '📁 локальный файл'"
- id: U-53
name: "Hostname extraction для URL-источника"
input: "source.url='https://raw.githubusercontent.com/user/repo/main/track.gpx'"
expected: "renderSourceRow() возвращает '🔗 raw.githubusercontent.com'"
- name: integration-gpx-fetch
type: integration
description: "GET /api/gpx/fetch — прокси с реальным HTTP"
cases:
- id: I-01
name: "Успешная загрузка GPX по URL (mock-сервер)"
input: "GET /api/gpx/fetch?url=http://test-server/track.gpx"
expected: "200, Content-Type: application/gpx+xml, тело = GPX, X-Cache: MISS"
- id: I-02
name: "Повторный запрос — cache hit"
input: "GET тот же URL"
expected: "200, X-Cache: HIT, время ≤ 50 мс"
- id: I-03
name: "Отклонение приватного IP"
input: "GET /api/gpx/fetch?url=http://127.0.0.1/x.gpx"
expected: "400, JSON {error: ...}"
- id: I-04
name: "Отклонение редиректа на приватный IP"
input: "Внешний URL → 302 на http://127.0.0.1/x.gpx"
expected: "400, JSON {error: ...}"
- id: I-05
name: "Внешний 404"
input: "URL ведёт на несуществующий путь"
expected: "404, JSON {error: ...}"
- id: I-06
name: "Лимит размера 50 МБ"
input: "Mock-сервер стримит 60 МБ"
expected: "413, соединение прервано до конца"
- id: I-07
name: "Таймаут"
input: "Mock-сервер ничего не отвечает"
expected: "504 после 15 сек"
- id: I-08
name: "URL > 5 МБ не попадает в кэш"
input: "Запросить URL с ответом 6 МБ дважды"
expected: "Второй запрос: X-Cache: MISS, внешний запрос повторно выполнен"
- name: integration-osm-traces
type: integration
description: "GET /api/gpx/osm/traces — OSM API клиент"
cases:
- id: I-20
name: "Bbox-запрос с результатами"
input: "GET /api/gpx/osm/traces?bbox=37.6,55.7,37.65,55.75 (mock OSM API)"
expected: "200, JSON с tracks[], каждый имеет points_count, distance_km, gpx_url"
- id: I-21
name: "Bbox > 0.25 deg² → 400"
input: "bbox=37,55,38,56"
expected: "400, error 'bbox too large'"
- id: I-22
name: "OSM API недоступен → 502"
input: "OSM mock возвращает 500"
expected: "502, JSON error"
- id: I-23
name: "Cache hit на повторный bbox"
input: "Тот же bbox дважды"
expected: "Второй запрос: внешний запрос НЕ выполнен, ответ из кэша"
- id: I-24
name: "Пустой bbox → пустой список"
input: "bbox в океане"
expected: "200, tracks=[], has_more=false"
- id: I-25
name: "Пагинация"
input: "page=0 возвращает has_more=true, page=1 возвращает следующие"
expected: "Корректное смещение, оба запроса валидны"
- name: integration-health-metrics
type: integration
description: "Метрики кэшей в /api/health"
cases:
- id: I-30
name: "Health возвращает размеры кэшей"
input: "GET /api/health"
expected: "JSON содержит gpx_fetch_cache_size, gpx_osm_cache_size (числа ≥ 0)"
- id: I-31
name: "Счётчики растут после запросов"
input: "После N успешных fetch и M osm_traces запросов"
expected: "Размеры кэшей отражают добавленные записи"
- name: e2e-url-import
type: e2e
description: "Импорт GPX по ссылке — полный сценарий"
cases:
- id: E-01
name: "URL-импорт валидного трека"
steps:
- "Открыть приложение"
- "Нажать кнопку GPX в нижнем тулбаре"
- "Переключиться на вкладку «По ссылке»"
- "Вставить URL валидного GPX (тестовый mock)"
- "Нажать «Загрузить»"
- "Убедиться: индикатор показан, через ≤ 5 сек трек на карте"
- "Убедиться: в #gpx-list появилась карточка с источником «🔗 host»"
- "Кликнуть на трек → отображается статистика и профиль высот"
- id: E-02
name: "URL-импорт по Enter"
steps:
- "Активировать «По ссылке»"
- "Вставить URL, нажать Enter"
- "Убедиться: трек загружен (как при клике)"
- id: E-03
name: "Невалидный URL → toast"
steps:
- "Вставить ftp://x.com/y"
- "Нажать «Загрузить»"
- "Убедиться: toast «Невалидная ссылка»"
- "Убедиться: на карте ничего нового"
- id: E-04
name: "Приватный IP блокируется"
steps:
- "Вставить http://192.168.1.1/x.gpx"
- "Нажать «Загрузить»"
- "Убедиться: toast «Эта ссылка недоступна»"
- id: E-05
name: "Не GPX по ссылке"
steps:
- "Вставить URL HTML-страницы"
- "Нажать «Загрузить»"
- "Убедиться: toast «По этой ссылке не GPX-файл»"
- name: e2e-osm-search
type: e2e
description: "Поиск и импорт OSM треков"
cases:
- id: E-10
name: "Поиск треков в области и импорт"
steps:
- "Открыть приложение, отзумиться к области Москвы (zoom 12)"
- "Открыть #sheet-gpx, активировать «Найти рядом»"
- "Нажать «Найти треки в этой области карты»"
- "Убедиться: индикатор, потом список карточек"
- "Нажать «Показать» у первой карточки"
- "Убедиться: трек появился на карте, fit bounds"
- "Убедиться: карточка в найденных получила «✓ Загружен»"
- "Убедиться: в #gpx-list появилась карточка с «🌍 OSM #...»"
- id: E-11
name: "Слишком большая область"
steps:
- "Отзумиться на всю Россию"
- "Активировать «Найти рядом»"
- "Нажать «Найти»"
- "Убедиться: toast «Слишком большая область, увеличьте zoom»"
- id: E-12
name: "Пустая область"
steps:
- "Перейти к области без треков (океан)"
- "Активировать «Найти рядом»"
- "Нажать «Найти»"
- "Убедиться: сообщение «В этой области нет публичных GPS-треков»"
- id: E-13
name: "Пагинация"
steps:
- "Найти треки в области с большим количеством"
- "Убедиться: кнопка «Показать ещё» внизу"
- "Нажать «Показать ещё»"
- "Убедиться: список расширился"
- id: E-14
name: "Повторный импорт → toast"
steps:
- "Импортировать трек по «Показать»"
- "Нажать «Показать» у той же карточки ещё раз"
- "Убедиться: toast «Уже загружен»"
- id: E-15
name: "Внешняя ссылка на osm.org"
steps:
- "Найти треки, нажать «↗» у карточки"
- "Убедиться: новая вкладка открыта на openstreetmap.org"
- name: e2e-mixed-sources
type: e2e
description: "Совместимость трёх источников в одной сессии"
cases:
- id: E-20
name: "3 трека разных источников"
steps:
- "Загрузить 1 локальный файл"
- "Загрузить 1 по URL"
- "Загрузить 1 из OSM"
- "Убедиться: 3 карточки в #gpx-list, разные цвета, разные источники"
- "Удалить URL-трек"
- "Убедиться: 2 трека на карте, корректные источники"
- id: E-21
name: "Сохранение при смене темы"
steps:
- "Загрузить 3 трека разных источников"
- "Переключить тёмную тему"
- "Убедиться: все 3 трека на карте"
- "Убедиться: источники в карточках сохранены"
- id: E-22
name: "Сохранение при включении hillshade"
steps:
- "Загрузить 3 трека"
- "Включить hillshade"
- "Убедиться: все 3 трека видны поверх hillshade"
- name: e2e-cache
type: e2e
description: "Поведение кэша через API"
cases:
- id: E-30
name: "Кэш URL-fetch снижает время"
steps:
- "GET /api/gpx/fetch?url=<test-url> — измерить t1"
- "GET /api/gpx/fetch?url=<тот же url> — измерить t2"
- "Убедиться: t2 < 100 мс, заголовок X-Cache: HIT"
- id: E-31
name: "Размеры кэша в health"
steps:
- "Сделать N запросов /api/gpx/fetch"
- "GET /api/health"
- "Убедиться: gpx_fetch_cache_size == N (или min(N, лимит))"
test_data:
- name: "test-track-public.gpx"
description: "Валидный GPX 1.1, 1 МБ, для URL-импорта (mock-сервер)"
- name: "test-track-large.gpx"
description: "GPX 60 МБ — для проверки лимита размера"
- name: "test-osm-trackpoints.gpx"
description: "Реальный ответ OSM trackpoints API (зафиксирован для mock)"
- name: "test-html-page.html"
description: "HTML вместо GPX — для проверки валидации формата"
- name: "test-xxe-payload.gpx"
description: "GPX с DOCTYPE и внешней entity — для проверки defusedxml"
- name: "bbox-moscow-small"
description: "[37.6, 55.7, 37.65, 55.75] — реальная область с публичными треками OSM"
- name: "bbox-too-large"
description: "[37.0, 55.0, 38.0, 56.0] — > 0.25 deg² для проверки 400"
test_environment:
mock_servers:
- "Mock HTTP-сервер для /api/gpx/fetch тестов (отдаёт фиксированные ответы)"
- "Mock OSM API для /api/gpx/osm/traces тестов"
fixtures_dir: "tests/fixtures/gpx-public/"
notes:
- "OSM API в e2e тестах должен мокироваться, чтобы не зависеть от внешней доступности"
- "Для нагрузочных тестов кэша использовать pytest-benchmark"

View File

@@ -1,395 +0,0 @@
---
type: ui-test-cases
work_item_id: ET-008
title: "UI Test Cases: GPS-треки с публичных платформ"
version: 1
status: draft
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
- "agent:analyst"
---
# UI Test Cases — ET-008: GPS-треки с публичных платформ
Базовый URL: `https://openclaw.mva154.duckdns.org/enduro/`
Все тесты проверяют появление и поведение секции «Источники» в
`#sheet-gpx`, импорта по URL и поиска OSM-треков. Внешние сетевые
запросы в test-окружении мокаются (см. test-plan).
Селекторы (новые, добавляются ET-008):
- `#source-seg` — segmented control «Источники»
- `#source-btn-file`, `#source-btn-url`, `#source-btn-nearby` — кнопки вкладок
- `#gpx-source-pane-file`, `#gpx-source-pane-url`, `#gpx-source-pane-nearby` — контент-блоки
- `#gpx-url-input` — поле ввода URL
- `#btn-gpx-fetch-url` — кнопка «Загрузить» URL
- `#btn-gpx-find-nearby` — кнопка «Найти треки в этой области»
- `#gpx-nearby-results` — контейнер списка найденных
- `.gpx-nearby-card` — карточка найденного OSM-трека
- `.gnc-import` — кнопка «Показать»
- `.gnc-external` — ссылка «↗»
- `.gpx-source-row` — индикатор источника в карточке трека
Существующие селекторы (ET-006): `#tb-gpx`, `#sheet-gpx`, `#gpx-list`.
---
### TC-UI-01 — Секция «Источники» видна в #sheet-gpx
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. screenshot: "01-sheet-gpx-sources-section"
6. check-visual: "В верхней части #sheet-gpx (под заголовком, над списком треков) видна секция «ИСТОЧНИКИ» с тремя кнопками segmented control: «Из файла», «По ссылке», «Найти рядом». По умолчанию активна (подсвечена оранжевым) кнопка «Из файла»."
---
### TC-UI-02 — Переключение на вкладку «По ссылке»
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. screenshot: "02-source-url-pane"
8. check-visual: "Кнопка «По ссылке» подсвечена оранжевым, «Из файла» и «Найти рядом» — нет. Под кнопками видно поле ввода с placeholder «https://example.com/track.gpx» и кнопка «Загрузить» справа."
---
### TC-UI-03 — Переключение на вкладку «Найти рядом»
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. screenshot: "03-source-nearby-pane"
8. check-visual: "Кнопка «Найти рядом» подсвечена оранжевым. Под кнопками видна крупная кнопка «Найти треки в этой области карты». Список найденных треков пуст или отсутствует."
---
### TC-UI-04 — Поле URL принимает ввод
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. click: "#gpx-url-input"
8. wait: 200
9. screenshot: "04-url-input-focused"
10. check-visual: "Поле #gpx-url-input получило фокус (видна рамка/каретка), placeholder виден если поле пустое. Кнопка «Загрузить» рядом, активна (не дизейблена)."
---
### TC-UI-05 — Невалидный URL: toast об ошибке
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. click: "#gpx-url-input"
8. wait: 200
9. type: "not-a-url"
10. click: "#btn-gpx-fetch-url"
11. wait: 1000
12. screenshot: "05-invalid-url-toast"
13. check-visual: "Сверху по центру экрана отображается toast-уведомление с текстом «Невалидная ссылка» (или похожим). Никаких изменений на карте."
---
### TC-UI-06 — Кнопка «Найти треки» дизейблится во время запроса
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 300
9. screenshot: "06-finding-tracks-loading"
10. check-visual: "Кнопка «Найти треки в этой области карты» визуально дизейблена (серая / opacity снижен). Виден индикатор загрузки (spinner или moto-wheel)."
---
### TC-UI-07 — Список найденных треков
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. screenshot: "07-nearby-tracks-list"
10. check-visual: "Под кнопкой поиска появился список карточек .gpx-nearby-card. Каждая карточка содержит: иконку 🌍 (или OSM-логотип) слева, имя/описание трека и метаданные (км, аноним/автор), кнопку «Показать» справа, маленькую ссылку «↗». Карточки разделены тонкими линиями."
---
### TC-UI-08 — Импорт OSM-трека: трек на карте, индикатор в карточке
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. click: ".gnc-import"
10. wait: 4000
11. screenshot: "08-osm-track-imported"
12. check-visual: "На карте видна цветная линия импортированного трека. В списке найденных карточка первого трека показывает индикатор «✓ Загружен» вместо кнопки «Показать». В нижней части #sheet-gpx (в #gpx-list) появилась новая карточка трека с источником «🌍 OSM #...»."
---
### TC-UI-09 — Источник «OSM» — кликабельная ссылка в #gpx-list
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. click: ".gnc-import"
10. wait: 4000
11. screenshot: "09-gpx-list-source-osm"
12. check-visual: "В нижнем списке #gpx-list карточка импортированного трека под именем содержит строку «.gpx-source-row» с текстом «🌍 OSM #<число>». Текст оформлен как ссылка (подчёркнут или другой цвет)."
---
### TC-UI-10 — Смешанные источники в #gpx-list
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. click: "#gpx-url-input"
8. wait: 200
9. type: "https://example.test/mock-track.gpx"
10. click: "#btn-gpx-fetch-url"
11. wait: 4000
12. click: "#source-btn-nearby"
13. wait: 500
14. click: "#btn-gpx-find-nearby"
15. wait: 4000
16. click: ".gnc-import"
17. wait: 4000
18. screenshot: "10-mixed-sources-list"
19. check-visual: "В #gpx-list 2 карточки: одна с источником «🔗 example.test», вторая с «🌍 OSM #...». Карточки имеют разные цветовые индикаторы слева. Обе видны на карте как линии разных цветов."
---
### TC-UI-11 — Импорт по URL: трек появляется на карте
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. click: "#gpx-url-input"
8. wait: 200
9. type: "https://example.test/mock-track.gpx"
10. click: "#btn-gpx-fetch-url"
11. wait: 5000
12. screenshot: "11-url-track-loaded"
13. check-visual: "На карте видна цветная линия загруженного трека. В #gpx-list появилась карточка с именем «mock-track» и источником «🔗 example.test». Карта выполнила fit bounds — трек по центру экрана."
---
### TC-UI-12 — Секция «Источники» на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. screenshot: "12-sources-mobile-default"
6. check-visual: "На мобильном viewport секция «Источники» помещается по ширине экрана. Три кнопки segmented control видны и нажимаемы, не выходят за экран. Активна «Из файла»."
---
### TC-UI-13 — Поле URL на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-url"
6. wait: 500
7. screenshot: "13-url-pane-mobile"
8. check-visual: "На мобильном поле #gpx-url-input занимает большую часть ширины, кнопка «Загрузить» справа. Оба элемента не перекрываются, нажимаемы, помещаются в экран."
---
### TC-UI-14 — Список найденных OSM треков на мобильном
- тип: ui
- viewport: mobile
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. screenshot: "14-nearby-list-mobile"
10. check-visual: "На мобильном карточки .gpx-nearby-card отображаются вертикально, занимают всю ширину. Кнопка «Показать» и ссылка «↗» в каждой карточке нажимаемы, не перекрываются. Список скроллится."
---
### TC-UI-15 — Совместимость со спутниковой подложкой
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#terrain-toggle"
4. wait: 500
5. click: "#base-btn-satellite"
6. wait: 5000
7. click: "#tb-gpx"
8. wait: 1000
9. click: "#source-btn-nearby"
10. wait: 500
11. click: "#btn-gpx-find-nearby"
12. wait: 4000
13. click: ".gnc-import"
14. wait: 4000
15. screenshot: "15-osm-track-on-satellite"
16. check-visual: "На спутниковой подложке видна цветная линия импортированного OSM-трека. Линия имеет hover-видимость (контрастная для спутника). Панель #sheet-gpx не конфликтует со спутником визуально."
---
### TC-UI-16 — Сохранение треков при переключении тёмной темы
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. click: ".gnc-import"
10. wait: 4000
11. click: "#btn-theme"
12. wait: 3000
13. screenshot: "16-osm-track-after-theme-switch"
14. check-visual: "После переключения тёмной темы цветная линия импортированного OSM-трека остаётся на карте. В #gpx-list карточка трека с источником «🌍 OSM #...» сохранилась."
---
### TC-UI-17 — Сохранение треков при переключении источника «Из файла»
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. click: ".gnc-import"
10. wait: 4000
11. click: "#source-btn-file"
12. wait: 500
13. screenshot: "17-back-to-file-tab"
14. check-visual: "Активна вкладка «Из файла», секция «Найти рядом» свернута. Импортированный OSM-трек остаётся в нижнем списке #gpx-list (карточка с «🌍 OSM #...»). Сам трек по-прежнему видим на карте."
---
### TC-UI-18 — Внешняя ссылка ↗ на osm.org
- тип: ui
- viewport: desktop
шаги:
1. navigate: https://openclaw.mva154.duckdns.org/enduro/
2. wait: 5000
3. click: "#tb-gpx"
4. wait: 1000
5. click: "#source-btn-nearby"
6. wait: 500
7. click: "#btn-gpx-find-nearby"
8. wait: 4000
9. screenshot: "18-external-link-button"
10. check-visual: "В каждой карточке .gpx-nearby-card в правом углу видна кнопка «↗» (.gnc-external). Кнопка имеет hover-состояние (cursor:pointer), визуально отличима от основной кнопки «Показать»."

View File

@@ -0,0 +1,7 @@
# Business Request: Final verification test
Work Item ID: ET-009
## Description
TBD

View File

@@ -866,36 +866,6 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
cursor: not-allowed;
}
/* ── ET-007: переключатель подложки (Схема/Спутник) в попапе рельефа ── */
.terrain-base-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0 2px;
}
.terrain-base-label {
font-size: 12px;
color: var(--text2);
flex-shrink: 0;
}
.terrain-base-row .seg-control {
flex: 1;
margin-bottom: 0;
}
.base-seg .seg-btn {
font-size: 12px;
height: 34px;
}
/* ET-007 P1-5 / ADR-004 §8: пока активен «Спутник», скрыть UI-кнопку
«Базовая карта» (#btn-basemap) — гибридный режим (схема поверх
спутника) out of scope BRD §3. JS добавляет/снимает класс
.satellite-active на <body> в applyBaseLayer(). На «Схеме» — кнопка
снова видна (если она присутствует в текущей вёрстке). */
body.satellite-active #btn-basemap {
display: none !important;
}
/* ── ET-005: переключатель единиц измерения (км/мили) в попапе рельефа ── */
.terrain-unit-row {
padding: 8px 4px 2px;

View File

@@ -125,11 +125,6 @@ function onMapStyleLoad() {
}
function rebuildMapOverlays() {
// ET-007: восстановить выбранную подложку первой — чтобы terrain/trails/POI
// оказались поверх неё (см. ADR-004, TRZ §5.5).
if (typeof restoreBaseLayerState === 'function') {
restoreBaseLayerState();
}
// Re-apply terrain and trails after style change
restoreTerrainState();
restoreTrailsState();
@@ -2783,14 +2778,14 @@ function onTerrainCheckbox() {
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');
@@ -2798,31 +2793,22 @@ function onTrailsCheckbox() {
if (map.getLayer('trails-path-bridleway')) {
map.setLayoutProperty('trails-path-bridleway', 'visibility', pathChecked ? 'visible' : 'none');
}
// ET-007 P1-6: синхронизируем halo-underlay-слои с состоянием
// чекбоксов, чтобы на спутнике не оставалось «фантома» halo при
// выключенной грунтовке/тропе. Безопасно к ранней инициализации:
// _applyTrailHaloVisibility определена ниже в том же файле (ET-007
// base layer block). См. ADR-004 §9, TRZ §5.7.
if (typeof _applyTrailHaloVisibility === 'function' &&
typeof getStoredBaseLayer === 'function') {
_applyTrailHaloVisibility(map, getStoredBaseLayer());
}
}
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')) {
@@ -2831,11 +2817,6 @@ function restoreTrailsState() {
if (map.getLayer('trails-path-bridleway')) {
map.setLayoutProperty('trails-path-bridleway', 'visibility', pathOn ? 'visible' : 'none');
}
// ET-007 P1-6: тот же контракт, что в onTrailsCheckbox (см. выше).
if (typeof _applyTrailHaloVisibility === 'function' &&
typeof getStoredBaseLayer === 'function') {
_applyTrailHaloVisibility(map, getStoredBaseLayer());
}
}
}
@@ -2895,339 +2876,6 @@ function restorePoiState() {
}
// <<< ET-002 POI visibility block <<<
// >>> ET-007 base layer toggle block (do not remove markers — used by unit tests) >>>
// Переключатель базовой подложки карты «Схема» / «Спутник» в попапе слоёв.
// Реализация: ленивое создание спутникового raster-source/layer при первом
// включении «Спутника»; восстановление выбора из localStorage и
// rebuildMapOverlays() после смены темы. POI / trails halo переключаются
// через visibility у декларативных underlay-слоёв (`*-halo-satellite`) и
// setPaintProperty у POI labels/circles. См.
// docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md.
/**
* Параметры спутникового источника и слоя (ADR-004 §4.1, TRZ §4.1).
* URL без API-ключа, HTTPS обязателен, атрибуция Esri.
*/
const SATELLITE_SOURCE_ID = 'satellite-raster';
const SATELLITE_LAYER_ID = 'satellite-base';
const SATELLITE_TILE_URL =
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
const SATELLITE_ATTRIBUTION =
'Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community';
/**
* Halo-underlay-слои, видимые только в режиме «Спутник» (ADR-004 §5,
* вариант H-B). Объявлены в style.json / style-dark.json с
* visibility: none; здесь только переключаем видимость.
*/
const SATELLITE_HALO_LAYER_IDS = [
'trails-track-halo-satellite',
'trails-path-bridleway-halo-satellite',
];
/**
* Пары (base-layer, halo-underlay) для синхронизации halo с
* пользовательскими чекбоксами «Грунтовки» / «Тропы»
* (ADR-004 §9, TRZ §5.7). Источник истины: halo видим ⇔
* (текущая база === 'satellite') AND (соответствующий чекбокс ON).
*/
const TRAIL_HALO_PAIRS = [
{ base: 'trails-track', halo: 'trails-track-halo-satellite' },
{ base: 'trails-path-bridleway', halo: 'trails-path-bridleway-halo-satellite' },
];
/**
* Сохранённое значение `layerState.basemap` на время активного
* режима «Спутник» (ADR-004 §8, TRZ §5.6). `null` означает «сейчас
* на схеме, восстанавливать нечего». При входе в «Спутник» сохраняем
* сюда `layerState.basemap`, при выходе — восстанавливаем и
* обнуляем. Это сохраняет выбор пользователя по «Базовая карта»
* через ход «Схема → Спутник → Схема» без рассинхрона с
* `layerState.basemap`.
*/
let _savedBasemapState = null;
/**
* Возвращает выбранную пользователем подложку из localStorage.
*
* Любое значение, кроме известных (`'schematic'` / `'satellite'`),
* трактуется как дефолт `'schematic'` (TRZ §4.3, U-04). Безопасно к
* приватному режиму браузера: при ошибке доступа к localStorage
* возвращает дефолт.
* @returns {('schematic'|'satellite')}
*/
function getStoredBaseLayer() {
try {
const v = window.localStorage.getItem('map-base-layer');
return v === 'satellite' ? 'satellite' : 'schematic';
} catch (_) {
return 'schematic';
}
}
/**
* Обработчик сегментированного переключателя «Подложка» (атрибут
* onclick кнопок «Схема» / «Спутник»).
*
* Идемпотентен: повторный вызов с уже активным значением — no-op
* (U-05): не пишет в localStorage и не трогает стиль карты.
* @param {('schematic'|'satellite')} base - выбранная подложка.
*/
function onBaseLayerToggle(base) {
if (base !== 'schematic' && base !== 'satellite') return;
const current = getStoredBaseLayer();
if (current === base) return;
try {
window.localStorage.setItem('map-base-layer', base);
} catch (_) { /* private mode — фича остаётся per-session */ }
applyBaseLayer(base);
syncBaseLayerUI(base);
}
/**
* Применяет выбранную подложку к карте (TRZ §5.2, ADR-004 §3, §5).
*
* Для `'satellite'`: лениво создаёт source/layer (если их ещё нет),
* вставляет слой ниже первого terrain/trails/POI-слоя, скрывает
* `osm-base`, включает halo-underlay-слои у trails, выставляет
* тёмный halo у POI и тёмный background, чтобы белый фон не
* «бликовал» под медленно подгружающимися плитками.
*
* Для `'schematic'`: возвращает все динамически изменённые свойства
* к значениям, объявленным в текущем `style.json` / `style-dark.json`.
* @param {('schematic'|'satellite')} base
*/
function applyBaseLayer(base) {
const map = window._map;
if (!map) return;
if (base === 'satellite') {
if (!map.getSource(SATELLITE_SOURCE_ID)) {
map.addSource(SATELLITE_SOURCE_ID, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
tileSize: 256,
minzoom: 0,
maxzoom: 19,
attribution: SATELLITE_ATTRIBUTION,
});
}
if (!map.getLayer(SATELLITE_LAYER_ID)) {
const before = _firstOverlayLayerId(map);
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
paint: { 'raster-opacity': 1.0, 'raster-resampling': 'linear' },
layout: { visibility: 'none' },
}, before);
}
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'visible');
}
// ET-007 P1-5 / ADR-004 §8: запоминаем layerState.basemap и
// принудительно скрываем osm-base. layerState.basemap не меняем —
// это пользовательский выбор «Базовая карта», его восстановим при
// возврате на «Схему».
if (_savedBasemapState === null && typeof layerState !== 'undefined') {
_savedBasemapState = layerState.basemap;
}
if (map.getLayer('osm-base')) {
map.setLayoutProperty('osm-base', 'visibility', 'none');
}
// CSS-hook: скрыть кнопку #btn-basemap пока активен спутник
// (гибридный режим out of scope — BRD §3). Defensive: mock-DOM в
// unit-тестах может не иметь classList.add/remove.
_setBodyClass('satellite-active', true);
// ET-007 P1-6: halo синхронизирован с состоянием чекбоксов
// «Грунтовки» / «Тропы», а не безусловно включён.
_applyTrailHaloVisibility(map, 'satellite');
_applyPoiSatellitePaint(map, true);
_applyBackgroundForSatellite(map, true);
} else {
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'none');
}
// ET-007 P1-5: восстановить выбор пользователя по «Базовой карте»
// (если он раньше выключал osm-base — оставить выключенным).
if (map.getLayer('osm-base')) {
const wantOsm = _savedBasemapState !== false; // default visible
map.setLayoutProperty('osm-base', 'visibility', wantOsm ? 'visible' : 'none');
}
_savedBasemapState = null;
_setBodyClass('satellite-active', false);
// На «Схеме» halo всегда скрыт независимо от чекбоксов.
_applyTrailHaloVisibility(map, 'schematic');
_applyPoiSatellitePaint(map, false);
_applyBackgroundForSatellite(map, false);
}
}
/**
* Восстанавливает выбор подложки из localStorage и применяет его к
* карте (TRZ §5.3).
*
* Вызывается:
* - в `rebuildMapOverlays()` (первым — TRZ §5.5) после смены темы;
* - в IIFE-инициализаторе ниже на старте приложения.
*
* Идемпотентна: дублирующий вызов с тем же сохранённым значением — no-op.
*/
function restoreBaseLayerState() {
const base = getStoredBaseLayer();
syncBaseLayerUI(base);
applyBaseLayer(base);
}
/**
* Синхронизирует визуальное состояние кнопок переключателя подложки
* с переданным значением (TRZ §5.4).
* @param {('schematic'|'satellite')} base
*/
function syncBaseLayerUI(base) {
const schBtn = document.getElementById('base-btn-schematic');
const satBtn = document.getElementById('base-btn-satellite');
if (schBtn) schBtn.classList.toggle('active', base === 'schematic');
if (satBtn) satBtn.classList.toggle('active', base === 'satellite');
}
// ── Приватные хелперы (ADR-004 §5) ─────────────────────────────────
/**
* Defensive переключатель класса на document.body. Реальный браузерный
* `classList` имеет `add`/`remove`/`toggle`, но в unit-тестах
* (tests/unit/base_layer.test.js) мок-DOM собран минимально и содержит
* только `contains`. Использует `toggle(name, on)` если доступен,
* иначе деградирует в no-op (тестовая среда — побочные эффекты на body
* не важны).
*/
function _setBodyClass(name, on) {
if (typeof document === 'undefined' || !document.body) return;
const cl = document.body.classList;
if (!cl) return;
if (typeof cl.toggle === 'function') { cl.toggle(name, !!on); return; }
if (on && typeof cl.add === 'function') { cl.add(name); return; }
if (!on && typeof cl.remove === 'function') { cl.remove(name); return; }
}
/**
* Возвращает id первого «верхнего» слоя (terrain/trails/POI),
* чтобы спутник был добавлен ПОД ним и terrain/trails/POI/маршрут
* остались видны поверх спутника без вычисления beforeId для каждого
* слоя в отдельности (ADR-004 §O-A).
*/
function _firstOverlayLayerId(map) {
const style = map.getStyle && map.getStyle();
if (!style || !style.layers) return undefined;
const first = style.layers.find((l) =>
l.id.startsWith('terrain-') ||
l.id.startsWith('trails-') ||
l.id.startsWith('poi-')
);
return first ? first.id : undefined;
}
/**
* Применяет видимость halo-underlay-слоёв у trails по правилу
* «halo видим ⇔ (base === 'satellite') AND (соответствующий чекбокс ON)»
* (TRZ §5.7, ADR-004 §9, 12-review.md P1-6).
*
* Состояние чекбоксов читается из DOM (`#trails-track-cb`,
* `#trails-path-cb`). Если узлов нет (тесты под jsdom без HTML или
* ранний вызов до отрисовки попапа) — пары считаются ON (`true`),
* это совпадает с дефолтом `restoreTrailsState()`.
*
* @param {object} map - инстанс MapLibre.
* @param {('schematic'|'satellite')} base - текущая база.
*/
function _applyTrailHaloVisibility(map, base) {
const trackCb = (typeof document !== 'undefined') &&
document.getElementById && document.getElementById('trails-track-cb');
const pathCb = (typeof document !== 'undefined') &&
document.getElementById && document.getElementById('trails-path-cb');
const trackOn = trackCb ? !!trackCb.checked : true;
const pathOn = pathCb ? !!pathCb.checked : true;
const onByBase = base === 'satellite';
const pairs = [
{ halo: 'trails-track-halo-satellite', checked: trackOn },
{ halo: 'trails-path-bridleway-halo-satellite', checked: pathOn },
];
pairs.forEach((p) => {
if (!map.getLayer(p.halo)) return;
const visibility = (onByBase && p.checked) ? 'visible' : 'none';
map.setLayoutProperty(p.halo, 'visibility', visibility);
});
}
// Обратная совместимость для существующих unit-тестов, которые могли
// ссылаться на _toggleSatelliteHalo до P1-6 рефакторинга. Делегирует
// на новую функцию с правильным base. См. tests/unit/base_layer.test.js.
function _toggleSatelliteHalo(map, enabled) {
_applyTrailHaloVisibility(map, enabled ? 'satellite' : 'schematic');
}
/**
* Применяет правки paint к POI labels/circles в зависимости от
* активной подложки (ADR-004 §5, Data §5.15.2).
*
* На «Спутнике» — белый текст с чёрным halo у подписей и белая
* обводка у кружков, чтобы POI оставались читаемыми поверх тёмных
* снимков. На «Схеме» — возврат к baseline-значениям текущей темы
* из `style.json` / `style-dark.json` (см. Data §5.1).
*
* Менять обе пары (`text-color` + `text-halo-*`) обязательно: иначе
* baseline-текст светлой темы `#333333` поверх чёрного halo не
* читается (см. 12-review.md P1-2).
*/
function _applyPoiSatellitePaint(map, satellite) {
const dark = (typeof document !== 'undefined') &&
document.body && document.body.classList &&
document.body.classList.contains('theme-dark');
if (map.getLayer('poi-labels')) {
if (satellite) {
// Satellite — единые значения для обеих тем (Data §5.2).
map.setPaintProperty('poi-labels', 'text-color', '#ffffff');
map.setPaintProperty('poi-labels', 'text-halo-color', '#000000');
map.setPaintProperty('poi-labels', 'text-halo-width', 2);
} else {
// Schematic — baseline текущей темы (Data §5.1).
map.setPaintProperty('poi-labels', 'text-color', dark ? '#e0e0e0' : '#333333');
map.setPaintProperty('poi-labels', 'text-halo-color', dark ? '#1a1a2e' : '#ffffff');
map.setPaintProperty('poi-labels', 'text-halo-width', dark ? 2 : 1.5);
}
}
if (map.getLayer('poi-circles')) {
if (satellite) {
map.setPaintProperty('poi-circles', 'circle-stroke-color', '#ffffff');
map.setPaintProperty('poi-circles', 'circle-stroke-width', 2);
} else {
map.setPaintProperty('poi-circles', 'circle-stroke-color', dark ? '#333333' : '#ffffff');
map.setPaintProperty('poi-circles', 'circle-stroke-width', 1.5);
}
}
}
/**
* Меняет цвет background-слоя под спутником на единый тёмно-серый
* `#2a2a2a` (обе темы) — TRZ §1 REQ-F-03, ADR-004 §6. На «Схеме» —
* возврат к baseline текущей темы из Data §5 (`#f0ede6` light /
* `#1a1a2e` dark; именно `#1a1a2e`, как в `style-dark.json:28`, а
* не `#1a1a1a` из более раннего черновика — см. 12-review.md P1-4).
*/
function _applyBackgroundForSatellite(map, satellite) {
if (!map.getLayer('background')) return;
if (satellite) {
// Единая константа для обеих тем (ADR-004 §6).
map.setPaintProperty('background', 'background-color', '#2a2a2a');
} else {
const dark = (typeof document !== 'undefined') &&
document.body && document.body.classList &&
document.body.classList.contains('theme-dark');
map.setPaintProperty('background', 'background-color', dark ? '#1a1a2e' : '#f0ede6');
}
}
// <<< ET-007 base layer toggle block <<<
// >>> ET-005 unit toggle block >>>
// Переключатель единиц измерения расстояний (км/мили) в попапе рельефа.
// Выбор единицы, его персистентность и форматирование вынесены в
@@ -3393,7 +3041,6 @@ function restoreTerrainState() {
setTimeout(restoreTerrainState, 100);
});
// Initial state
restoreBaseLayerState();
restoreTerrainState();
restoreTrailsState();
restorePoiState();
@@ -3407,7 +3054,6 @@ function restoreTerrainState() {
setTimeout(restoreTerrainState, 100);
});
updateHillshadeAvailability();
restoreBaseLayerState();
restoreTerrainState();
restoreTrailsState();
restorePoiState();

View File

@@ -41,17 +41,6 @@
<!-- ── Terrain popup ────────────────────── -->
<div id="terrain-popup" class="terrain-popup" style="display:none">
<!-- ET-007: переключатель подложки (Схема / Спутник) -->
<div class="terrain-base-row">
<span class="terrain-base-label">Подложка</span>
<div class="seg-control base-seg" id="base-seg">
<button type="button" class="seg-btn active" id="base-btn-schematic"
data-base="schematic" onclick="onBaseLayerToggle('schematic')">Схема</button>
<button type="button" class="seg-btn" id="base-btn-satellite"
data-base="satellite" onclick="onBaseLayerToggle('satellite')">Спутник</button>
</div>
</div>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<div class="terrain-popup-title">Эндуро</div>
<label class="terrain-checkbox">
<input type="checkbox" id="terrain-hillshade-cb" onchange="onTerrainCheckbox()">

View File

@@ -53,21 +53,6 @@
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-track-halo-satellite",
"type": "line",
"source": "trails-tiles",
"source-layer": "trails",
"minzoom": 6,
"filter": ["==", "highway", "track"],
"paint": {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 6, 1.5, 8, 2.6, 10, 4, 12, 6.5, 16, 10],
"line-opacity": 0.55,
"line-blur": 0.5
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-track",
"type": "line",
@@ -90,21 +75,6 @@
},
"layout": { "line-cap": "round", "line-join": "round" }
},
{
"id": "trails-path-bridleway-halo-satellite",
"type": "line",
"source": "trails-tiles",
"source-layer": "trails",
"minzoom": 8,
"filter": ["in", "highway", "path", "bridleway", "footway"],
"paint": {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 7, 1.6, 10, 3.2, 12, 4.2, 16, 5.5],
"line-opacity": 0.5,
"line-blur": 0.5
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-path-bridleway",
"type": "line",

View File

@@ -53,21 +53,6 @@
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-track-halo-satellite",
"type": "line",
"source": "trails-tiles",
"source-layer": "trails",
"minzoom": 6,
"filter": ["==", "highway", "track"],
"paint": {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 6, 1.5, 8, 2.6, 10, 4, 12, 6.5, 16, 10],
"line-opacity": 0.55,
"line-blur": 0.5
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-track",
"type": "line",
@@ -90,21 +75,6 @@
},
"layout": { "line-cap": "round", "line-join": "round" }
},
{
"id": "trails-path-bridleway-halo-satellite",
"type": "line",
"source": "trails-tiles",
"source-layer": "trails",
"minzoom": 8,
"filter": ["in", "highway", "path", "bridleway", "footway"],
"paint": {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 7, 1.6, 10, 3.2, 12, 4.2, 16, 5.5],
"line-opacity": 0.5,
"line-blur": 0.5
},
"layout": { "line-cap": "round", "line-join": "round", "visibility": "none" }
},
{
"id": "trails-path-bridleway",
"type": "line",

View File

@@ -1,468 +0,0 @@
'use strict';
/**
* ET-007 — поведенческие unit-тесты переключателя подложки «Схема / Спутник».
*
* Покрывают U-01..U-05 и U-10..U-11 из docs/work-items/ET-007/04-test-plan.yaml,
* а также часть интеграционных кейсов (I-01..I-04, I-06, I-07, I-24, I-25),
* проверяемых на мок-карте.
*
* Тесты исполняют РЕАЛЬНЫЙ код из блока ET-007 в src/web/app.js: блок
* извлекается по маркерам `>>> ET-007 base layer toggle block` и
* оборачивается в фабрику через `new Function`, которой передаются
* мок-зависимости (window, document, localStorage). Так монолитный
* browser-скрипт проверяется без полной загрузки в Node.
*
* Запуск: `node --test tests/unit/base_layer.test.js`
* (в CI оборачивается pytest-тестом tests/unit/test_base_layer.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');
/**
* Извлекает блок ET-007 из app.js и собирает из него модуль,
* подставляя переданные зависимости.
*/
function loadBaseLayerModule(deps) {
const src = fs.readFileSync(APP_JS, 'utf8');
const m = src.match(
/\/\/ >>> ET-007 base layer toggle block[^\n]*\n([\s\S]*?)\/\/ <<< ET-007 base layer toggle block/
);
assert.ok(m, 'ET-007-блок не найден в app.js (маркеры отсутствуют)');
const factory = new Function(
'window', 'document',
m[1] + '\nreturn { getStoredBaseLayer, onBaseLayerToggle, applyBaseLayer, restoreBaseLayerState, syncBaseLayerUI, _firstOverlayLayerId, SATELLITE_SOURCE_ID, SATELLITE_LAYER_ID, SATELLITE_TILE_URL, SATELLITE_ATTRIBUTION, SATELLITE_HALO_LAYER_IDS };'
);
return factory(deps.window, deps.document);
}
/**
* Готовит изолированное мок-окружение для одного теста.
*
* Создаёт мок-карту с журналом вызовов (addSource/addLayer/
* setLayoutProperty/setPaintProperty), мок-DOM с кнопками
* #base-btn-schematic и #base-btn-satellite, мок-localStorage и
* мок-document.body.classList для определения активной темы.
*/
function makeEnv({
stored,
noStorage = false,
layers = ['background', 'osm-base', 'trails-track-halo-satellite', 'trails-track', 'trails-path-bridleway-halo-satellite', 'trails-path-bridleway', 'poi-circles', 'poi-labels'],
themeDark = false,
noMap = false,
} = {}) {
const calls = {
addSource: [],
addLayer: [],
setLayoutProperty: [],
setPaintProperty: [],
setItem: [],
};
const store = {};
if (stored !== undefined) store['map-base-layer'] = 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 sourceSet = new Set();
const layerSet = new Set(layers);
const layoutByLayer = {};
const map = {
getSource: (id) => (sourceSet.has(id) ? { id } : undefined),
addSource: (id, spec) => { sourceSet.add(id); calls.addSource.push([id, spec]); },
getLayer: (id) => (layerSet.has(id) ? { id } : undefined),
addLayer: (spec, before) => {
layerSet.add(spec.id);
if (spec.layout && spec.layout.visibility) {
layoutByLayer[spec.id] = layoutByLayer[spec.id] || {};
layoutByLayer[spec.id].visibility = spec.layout.visibility;
}
calls.addLayer.push([spec, before]);
},
setLayoutProperty: (id, prop, val) => {
layoutByLayer[id] = layoutByLayer[id] || {};
layoutByLayer[id][prop] = val;
calls.setLayoutProperty.push([id, prop, val]);
},
setPaintProperty: (id, prop, val) => {
calls.setPaintProperty.push([id, prop, val]);
},
getLayoutProperty: (id, prop) => (layoutByLayer[id] || {})[prop],
getStyle: () => ({ layers: layers.map((id) => ({ id })) }),
};
const schBtn = { classList: { _classes: new Set(['seg-btn', 'active']),
toggle(name, on) { if (on) this._classes.add(name); else this._classes.delete(name); },
contains(n) { return this._classes.has(n); } } };
const satBtn = { classList: { _classes: new Set(['seg-btn']),
toggle(name, on) { if (on) this._classes.add(name); else this._classes.delete(name); },
contains(n) { return this._classes.has(n); } } };
const bodyClasses = new Set(themeDark ? ['theme-dark'] : ['theme-light']);
const document = {
getElementById: (id) => {
if (id === 'base-btn-schematic') return schBtn;
if (id === 'base-btn-satellite') return satBtn;
return null;
},
body: {
classList: {
contains: (c) => bodyClasses.has(c),
},
},
};
const win = noMap
? { localStorage }
: (noStorage
? { _map: map, get localStorage() { throw new Error('localStorage disabled'); } }
: { _map: map, localStorage });
const mod = loadBaseLayerModule({ window: win, document });
return { mod, calls, store, schBtn, satBtn, map, sourceSet, layerSet, layoutByLayer };
}
// ── U-01: Default — Схема, если localStorage пуст ─────────────────────
test('U-01: без сохранённого значения getStoredBaseLayer() возвращает "schematic"', () => {
const env = makeEnv();
assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});
test('U-01: restoreBaseLayerState() при пустом localStorage активирует «Схему»', () => {
const env = makeEnv();
env.mod.restoreBaseLayerState();
assert.ok(env.schBtn.classList.contains('active'));
assert.ok(!env.satBtn.classList.contains('active'));
// На схеме спутниковый источник НЕ создаётся.
assert.deepEqual(env.calls.addSource, []);
assert.deepEqual(env.calls.addLayer, []);
});
// ── U-02: Чтение значения 'satellite' из localStorage ─────────────────
test('U-02: restoreBaseLayerState() при stored=satellite активирует «Спутник»', () => {
const env = makeEnv({ stored: 'satellite' });
env.mod.restoreBaseLayerState();
assert.ok(env.satBtn.classList.contains('active'));
assert.ok(!env.schBtn.classList.contains('active'));
// Создан спутниковый source/layer.
assert.equal(env.calls.addSource.length, 1);
assert.equal(env.calls.addSource[0][0], 'satellite-raster');
assert.equal(env.calls.addLayer.length, 1);
assert.equal(env.calls.addLayer[0][0].id, 'satellite-base');
});
// ── U-03: Запись значения при переключении ────────────────────────────
test('U-03: onBaseLayerToggle("satellite") пишет в localStorage', () => {
const env = makeEnv();
env.mod.onBaseLayerToggle('satellite');
assert.deepEqual(env.calls.setItem, [['map-base-layer', 'satellite']]);
assert.equal(env.store['map-base-layer'], 'satellite');
});
// ── U-04: Игнор некорректного значения в localStorage ─────────────────
test('U-04: некорректное stored значение даёт дефолт "schematic"', () => {
const env = makeEnv({ stored: 'unknown' });
assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});
test('U-04: пустая строка трактуется как дефолт', () => {
const env = makeEnv({ stored: '' });
assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});
// ── U-05: Toggle на уже активный режим — no-op ────────────────────────
test('U-05: повторный onBaseLayerToggle("schematic") при активной схеме — no-op', () => {
const env = makeEnv();
env.mod.onBaseLayerToggle('schematic');
assert.deepEqual(env.calls.setItem, []);
assert.deepEqual(env.calls.addSource, []);
assert.deepEqual(env.calls.addLayer, []);
assert.deepEqual(env.calls.setLayoutProperty, []);
});
test('U-05: повторный onBaseLayerToggle("satellite") при активном спутнике — no-op', () => {
const env = makeEnv({ stored: 'satellite' });
// первый вызов вернёт source/layer
env.mod.restoreBaseLayerState();
const setLayoutBefore = env.calls.setLayoutProperty.length;
const setItemBefore = env.calls.setItem.length;
env.mod.onBaseLayerToggle('satellite');
// никаких новых обращений
assert.equal(env.calls.setLayoutProperty.length, setLayoutBefore);
assert.equal(env.calls.setItem.length, setItemBefore);
});
// ── U-10..U-11: syncBaseLayerUI() ─────────────────────────────────────
test('U-10: syncBaseLayerUI("satellite") переносит .active на «Спутник»', () => {
const env = makeEnv();
env.mod.syncBaseLayerUI('satellite');
assert.ok(env.satBtn.classList.contains('active'));
assert.ok(!env.schBtn.classList.contains('active'));
});
test('U-11: syncBaseLayerUI("schematic") переносит .active на «Схему»', () => {
const env = makeEnv();
// сначала сделаем спутник активным
env.mod.syncBaseLayerUI('satellite');
env.mod.syncBaseLayerUI('schematic');
assert.ok(env.schBtn.classList.contains('active'));
assert.ok(!env.satBtn.classList.contains('active'));
});
// ── I-01..I-02: спутниковый source/layer создаются при первом включении ─
test('I-01: applyBaseLayer("satellite") добавляет source satellite-raster (Esri URL)', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
assert.equal(env.calls.addSource.length, 1);
const [id, spec] = env.calls.addSource[0];
assert.equal(id, 'satellite-raster');
assert.equal(spec.type, 'raster');
assert.equal(spec.tileSize, 256);
assert.ok(spec.tiles[0].includes('arcgisonline.com'));
});
test('I-02: applyBaseLayer("satellite") добавляет layer satellite-base типа raster', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const [spec, before] = env.calls.addLayer[0];
assert.equal(spec.id, 'satellite-base');
assert.equal(spec.type, 'raster');
assert.equal(spec.source, 'satellite-raster');
// beforeId — первый terrain/trails/poi слой; в наборе по умолчанию это
// trails-track-halo-satellite (первый с префиксом trails-/poi-/terrain-).
assert.equal(before, 'trails-track-halo-satellite');
});
// ── I-03: visibility osm-base скрыт после переключения на спутник ──────
test('I-03: osm-base скрывается при включении спутника', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const osmCalls = env.calls.setLayoutProperty.filter((c) => c[0] === 'osm-base');
assert.ok(osmCalls.some((c) => c[1] === 'visibility' && c[2] === 'none'));
});
// ── I-04: возврат на схему — osm-base видим, satellite-base скрыт ──────
test('I-04: возврат на «Схему» возвращает osm-base в visible и скрывает satellite-base', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
env.calls.setLayoutProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const osmVisible = env.calls.setLayoutProperty.find(
(c) => c[0] === 'osm-base' && c[1] === 'visibility' && c[2] === 'visible'
);
const satHidden = env.calls.setLayoutProperty.find(
(c) => c[0] === 'satellite-base' && c[1] === 'visibility' && c[2] === 'none'
);
assert.ok(osmVisible, 'osm-base не возвращён в visible');
assert.ok(satHidden, 'satellite-base не скрыт при возврате на схему');
// source НЕ удаляется (TRZ §1 REQ-F-03).
assert.ok(env.sourceSet.has('satellite-raster'));
});
// ── I-07: атрибуция Esri зарегистрирована ──────────────────────────────
test('I-07: source содержит атрибуцию Esri', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const [, spec] = env.calls.addSource[0];
assert.ok(/Esri/.test(spec.attribution),
'attribution source не упоминает Esri');
});
// ── I-23/halo: halo-underlay-слои включаются на спутнике ──────────────
test('halo-underlay-слои включаются при «Спутнике» и скрываются при «Схеме»', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const trackHaloOn = env.calls.setLayoutProperty.find(
(c) => c[0] === 'trails-track-halo-satellite' && c[2] === 'visible'
);
const pathHaloOn = env.calls.setLayoutProperty.find(
(c) => c[0] === 'trails-path-bridleway-halo-satellite' && c[2] === 'visible'
);
assert.ok(trackHaloOn, 'halo для trails-track не включён');
assert.ok(pathHaloOn, 'halo для path/bridleway не включён');
env.calls.setLayoutProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const trackHaloOff = env.calls.setLayoutProperty.find(
(c) => c[0] === 'trails-track-halo-satellite' && c[2] === 'none'
);
const pathHaloOff = env.calls.setLayoutProperty.find(
(c) => c[0] === 'trails-path-bridleway-halo-satellite' && c[2] === 'none'
);
assert.ok(trackHaloOff && pathHaloOff, 'halo не скрыт при возврате на схему');
});
// ── I-24: POI text-halo на спутнике становится чёрным ─────────────────
test('I-24: POI labels на спутнике получают чёрный halo и width=2', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const haloColor = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-halo-color'
);
const haloWidth = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-halo-width'
);
assert.deepEqual(haloColor, ['poi-labels', 'text-halo-color', '#000000']);
assert.deepEqual(haloWidth, ['poi-labels', 'text-halo-width', 2]);
});
// ── I-25: POI text-halo на схеме возвращается к значениям из style.json ─
test('I-25: возврат на «Схему» восстанавливает POI halo из светлого style.json', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
env.calls.setPaintProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const haloColor = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-halo-color'
);
assert.deepEqual(haloColor, ['poi-labels', 'text-halo-color', '#ffffff']);
});
test('I-25/dark: возврат на «Схему» в тёмной теме даёт halo из style-dark.json', () => {
const env = makeEnv({ themeDark: true });
env.mod.applyBaseLayer('satellite');
env.calls.setPaintProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const haloColor = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-halo-color'
);
assert.deepEqual(haloColor, ['poi-labels', 'text-halo-color', '#1a1a2e']);
});
// ── background — единая satellite-константа #2a2a2a для обеих тем ─────
// (P1-4: ранее в спецификации был расходящийся набор констант, в т.ч.
// ошибочный #1a1a1a для светлой темы тёмнее, чем #2a2a2a для тёмной.
// ADR-004 §6 — одна константа #2a2a2a на обе темы.)
test('фон под спутником в светлой теме — единая константа #2a2a2a (P1-4)', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const bg = env.calls.setPaintProperty.find(
(c) => c[0] === 'background' && c[1] === 'background-color'
);
assert.deepEqual(bg, ['background', 'background-color', '#2a2a2a']);
});
test('фон под спутником в тёмной теме — та же константа #2a2a2a (P1-4)', () => {
const env = makeEnv({ themeDark: true });
env.mod.applyBaseLayer('satellite');
const bg = env.calls.setPaintProperty.find(
(c) => c[0] === 'background' && c[1] === 'background-color'
);
assert.deepEqual(bg, ['background', 'background-color', '#2a2a2a']);
});
test('фон при возврате на «Схему» (light) — baseline #f0ede6 (Data §5)', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
env.calls.setPaintProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const bg = env.calls.setPaintProperty.find(
(c) => c[0] === 'background' && c[1] === 'background-color'
);
assert.deepEqual(bg, ['background', 'background-color', '#f0ede6']);
});
test('фон при возврате на «Схему» (dark) — baseline #1a1a2e, не #1a1a1a (P1-4 / P2-3)', () => {
const env = makeEnv({ themeDark: true });
env.mod.applyBaseLayer('satellite');
env.calls.setPaintProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const bg = env.calls.setPaintProperty.find(
(c) => c[0] === 'background' && c[1] === 'background-color'
);
assert.deepEqual(bg, ['background', 'background-color', '#1a1a2e']);
});
// ── P1-2: POI text-color синхронно с halo ─────────────────────────────
test('P1-2: на спутнике poi-labels text-color === #ffffff (читаемо поверх чёрного halo)', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
const textColor = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-color'
);
assert.deepEqual(textColor, ['poi-labels', 'text-color', '#ffffff']);
});
test('P1-2: возврат на «Схему» (light) восстанавливает poi-labels text-color === #333333', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
env.calls.setPaintProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const textColor = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-color'
);
assert.deepEqual(textColor, ['poi-labels', 'text-color', '#333333']);
});
test('P1-2: возврат на «Схему» (dark) восстанавливает poi-labels text-color === #e0e0e0', () => {
const env = makeEnv({ themeDark: true });
env.mod.applyBaseLayer('satellite');
env.calls.setPaintProperty.length = 0;
env.mod.applyBaseLayer('schematic');
const textColor = env.calls.setPaintProperty.find(
(c) => c[0] === 'poi-labels' && c[1] === 'text-color'
);
assert.deepEqual(textColor, ['poi-labels', 'text-color', '#e0e0e0']);
});
// ── валидация входа onBaseLayerToggle() ───────────────────────────────
test('onBaseLayerToggle() игнорирует некорректное значение', () => {
const env = makeEnv();
env.mod.onBaseLayerToggle('hybrid');
assert.deepEqual(env.calls.setItem, []);
assert.deepEqual(env.calls.addSource, []);
});
// ── устойчивость: отсутствует window._map ─────────────────────────────
test('applyBaseLayer() без window._map не падает', () => {
const env = makeEnv({ noMap: true });
assert.doesNotThrow(() => env.mod.applyBaseLayer('satellite'));
});
// ── устойчивость: недоступный localStorage (private mode) ─────────────
test('getStoredBaseLayer() при недоступном localStorage возвращает "schematic"', () => {
const env = makeEnv({ noStorage: true });
assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});
test('onBaseLayerToggle() не падает при недоступном localStorage', () => {
const env = makeEnv({ noStorage: true });
assert.doesNotThrow(() => env.mod.onBaseLayerToggle('satellite'));
});
// ── z-order: спутник вставляется ПОД terrain/trails/POI ───────────────
test('Z-order: satellite-base вставляется beforeId=первый terrain/trails/poi слой', () => {
const env = makeEnv({
layers: ['background', 'osm-base', 'terrain-hillshade', 'trails-track', 'poi-circles'],
});
env.mod.applyBaseLayer('satellite');
const [, before] = env.calls.addLayer[0];
assert.equal(before, 'terrain-hillshade');
});
test('Z-order: если overlay-слоёв ещё нет — addLayer вызывается без beforeId', () => {
const env = makeEnv({ layers: ['background', 'osm-base'] });
env.mod.applyBaseLayer('satellite');
const [, before] = env.calls.addLayer[0];
assert.equal(before, undefined);
});
// ── повторный applyBaseLayer('satellite') не пересоздаёт source/layer ─
test('повторный applyBaseLayer("satellite") не дублирует addSource/addLayer', () => {
const env = makeEnv();
env.mod.applyBaseLayer('satellite');
env.mod.applyBaseLayer('schematic');
env.mod.applyBaseLayer('satellite');
assert.equal(env.calls.addSource.length, 1);
assert.equal(env.calls.addLayer.length, 1);
});

View File

@@ -1,301 +0,0 @@
"""ET-007 — тесты переключателя базовой подложки (Схема / Спутник).
Изменение ET-007 — исключительно фронтендовое: правки `src/web/index.html`,
`src/web/app.js`, `src/web/app.css`, `src/web/style.json`,
`src/web/style-dark.json` (см. ADR-004). В CI исполняется только
`pytest tests/`, поэтому файл покрывает фичу двумя способами:
1. Статические проверки структуры файлов — выполняются всегда, без
внешних зависимостей.
2. Поведенческие JS unit-тесты (U-01..U-05, U-10..U-11, часть I-*) —
запускаются через встроенный тест-раннер Node (`node --test`). Если
`node` в системе отсутствует — эта часть помечается `skip`.
Браузерные e2e-сценарии (E-01..E-10, TC-UI-01..14) требуют Playwright-
инфраструктуры, которой в репозитории нет (см. ET-002 ADR-0001,
07-infra-requirements.md). Их поведенческая суть покрыта JS unit-тестами
и статическими проверками ниже.
"""
from __future__ import annotations
import json
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"
APP_CSS = REPO_ROOT / "src" / "web" / "app.css"
STYLE_LIGHT = REPO_ROOT / "src" / "web" / "style.json"
STYLE_DARK = REPO_ROOT / "src" / "web" / "style-dark.json"
JS_TEST = REPO_ROOT / "tests" / "unit" / "base_layer.test.js"
def _read(path: Path) -> str:
assert path.is_file(), f"не найден {path}"
return path.read_text(encoding="utf-8")
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки index.html (TRZ §3, AC-01)
# ──────────────────────────────────────────────────────────────────────────────
def test_base_toggle_present_in_html():
"""AC-01: сегментированный переключатель «Подложка» в попапе слоёв."""
html = _read(INDEX_HTML)
assert 'id="base-seg"' in html, "нет переключателя base-seg"
assert 'id="base-btn-schematic"' in html, "нет кнопки «Схема»"
assert 'id="base-btn-satellite"' in html, "нет кнопки «Спутник»"
assert "onclick=\"onBaseLayerToggle('schematic')\"" in html
assert "onclick=\"onBaseLayerToggle('satellite')\"" in html
def test_base_toggle_default_active_schematic():
"""AC-01/Default: кнопка «Схема» отрисована с классом active."""
html = _read(INDEX_HTML)
start = html.index('id="base-btn-schematic"')
# Открывающий тег button начинается до id="..."
tag_start = html.rfind("<button", 0, start)
tag_end = html.index(">", start)
assert "active" in html[tag_start:tag_end], (
"у кнопки «Схема» нет начального класса active (Default — Схема)"
)
def test_base_toggle_reuses_seg_control_component():
"""ADR-004 §M-A: переключатель использует общий .seg-control."""
html = _read(INDEX_HTML)
start = html.index('id="base-seg"')
container_start = html.rfind("<div", 0, start)
container_open_end = html.index(">", container_start)
assert "seg-control" in html[container_start:container_open_end], (
"переключатель подложки должен использовать класс seg-control"
)
def test_base_toggle_placed_at_top_of_terrain_popup():
"""TRZ §3.1/3.2: блок «Подложка» — первая секция попапа слоёв."""
html = _read(INDEX_HTML)
popup_pos = html.index('id="terrain-popup"')
base_pos = html.index('id="base-seg"')
title_pos = html.index('class="terrain-popup-title"')
assert base_pos > popup_pos, "блок «Подложка» вне попапа слоёв"
assert base_pos < title_pos, (
"блок «Подложка» должен идти ВЫШЕ заголовка «Эндуро» (TRZ §3.1)"
)
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки app.css (TRZ §3.3)
# ──────────────────────────────────────────────────────────────────────────────
def test_base_toggle_styles_defined():
"""TRZ §3.3: стили .terrain-base-row, .terrain-base-label, .base-seg."""
css = _read(APP_CSS)
assert ".terrain-base-row" in css, "нет стилей строки переключателя подложки"
assert ".terrain-base-label" in css, "нет стилей метки «Подложка»"
assert ".base-seg" in css, "нет селектора .base-seg"
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки app.js (TRZ §5, ADR-004 §2-4)
# ──────────────────────────────────────────────────────────────────────────────
def test_app_js_base_layer_functions_defined():
"""TRZ §5: определены публичные функции фичи."""
js = _read(APP_JS)
for fn in (
"onBaseLayerToggle",
"applyBaseLayer",
"restoreBaseLayerState",
"syncBaseLayerUI",
"getStoredBaseLayer",
):
assert f"function {fn}(" in js, f"не определена функция {fn}()"
def test_app_js_has_et007_block_markers():
"""Блок ET-007 обрамлён маркерами (как POI-блок ET-002, единичный блок)."""
js = _read(APP_JS)
assert "// >>> ET-007 base layer toggle block" in js, (
"нет открывающего маркера блока ET-007"
)
assert "// <<< ET-007 base layer toggle block <<<" in js, (
"нет закрывающего маркера блока ET-007"
)
def test_app_js_uses_localstorage_key():
"""TRZ §4.3: персистентность через localStorage ключ map-base-layer."""
js = _read(APP_JS)
assert "'map-base-layer'" in js, (
"состояние подложки не использует ключ map-base-layer"
)
def test_app_js_uses_esri_world_imagery():
"""ADR-004 §P: провайдер — Esri World Imagery без API-ключа."""
js = _read(APP_JS)
assert "server.arcgisonline.com" in js, (
"URL спутниковых тайлов не Esri World Imagery"
)
assert "/World_Imagery/MapServer/" in js, (
"URL не соответствует Esri World Imagery service"
)
assert "Esri" in js, "атрибуция Esri не упоминается в коде"
def test_app_js_satellite_source_and_layer_ids():
"""TRZ §1 REQ-F-02: id источника satellite-raster, id слоя satellite-base."""
js = _read(APP_JS)
assert "'satellite-raster'" in js, "не используется id source 'satellite-raster'"
assert "'satellite-base'" in js, "не используется id layer 'satellite-base'"
def test_app_js_lazy_source_creation():
"""ADR-004 §S-B: source/layer создаются лениво при первом включении."""
js = _read(APP_JS)
assert "map.getSource(SATELLITE_SOURCE_ID)" in js or \
"getSource('satellite-raster')" in js, (
"проверка существования source не выполняется (ADR-004 S-B)"
)
def test_rebuild_overlays_calls_restore_base_layer_first():
"""TRZ §5.5, ADR-004 §O-A: restoreBaseLayerState() — первый вызов."""
js = _read(APP_JS)
assert "restoreBaseLayerState" in js, (
"restoreBaseLayerState() не подключён"
)
# В rebuildMapOverlays() restoreBaseLayerState идёт перед restoreTerrainState.
start = js.index("function rebuildMapOverlays(")
body = js[start:start + 800]
base_pos = body.find("restoreBaseLayerState")
terrain_pos = body.find("restoreTerrainState")
assert 0 <= base_pos < terrain_pos, (
"restoreBaseLayerState() должен вызываться ДО restoreTerrainState() "
"в rebuildMapOverlays() (TRZ §5.5)"
)
def test_restore_base_layer_state_wired_into_init():
"""TRZ §5.5: restoreBaseLayerState() вызывается в инициализации страницы.
Покрывает обе ветки IIFE-инициализатора: когда карта уже готова и
когда мы дожидаемся её через setInterval. Плюс вызов из rebuildMapOverlays().
"""
js = _read(APP_JS)
# Один def + минимум 3 вызова (rebuildMapOverlays + 2 ветки init).
assert js.count("restoreBaseLayerState()") >= 4, (
"restoreBaseLayerState() не подключён ко всем точкам восстановления"
)
def test_app_js_uses_setpaint_for_poi_halo():
"""ADR-004 §H-B: POI text-halo меняется через setPaintProperty."""
js = _read(APP_JS)
block_start = js.index("// >>> ET-007 base layer toggle block")
block_end = js.index("// <<< ET-007 base layer toggle block")
block = js[block_start:block_end]
assert "setPaintProperty" in block, (
"блок ET-007 не использует setPaintProperty для POI halo"
)
assert "'text-halo-color'" in block, (
"POI text-halo не настраивается в режиме «Спутник»"
)
def test_app_js_uses_visibility_for_trails_halo():
"""ADR-004 §H-B: halo trails — через visibility у underlay-слоёв."""
js = _read(APP_JS)
assert "'trails-track-halo-satellite'" in js, (
"halo-слой trails-track не упомянут в коде"
)
assert "'trails-path-bridleway-halo-satellite'" in js, (
"halo-слой path/bridleway не упомянут в коде"
)
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки style.json / style-dark.json (ADR-004 §5/H-B)
# ──────────────────────────────────────────────────────────────────────────────
def _layer_ids(style_path: Path) -> list[str]:
style = json.loads(style_path.read_text(encoding="utf-8"))
return [layer["id"] for layer in style.get("layers", [])]
@pytest.mark.parametrize("style_path", [STYLE_LIGHT, STYLE_DARK])
def test_style_contains_halo_layers(style_path: Path):
"""ADR-004 §H-B: halo-underlay-слои объявлены декларативно."""
layers = _layer_ids(style_path)
assert "trails-track-halo-satellite" in layers, (
f"в {style_path.name} нет слоя trails-track-halo-satellite"
)
assert "trails-path-bridleway-halo-satellite" in layers, (
f"в {style_path.name} нет слоя trails-path-bridleway-halo-satellite"
)
@pytest.mark.parametrize("style_path", [STYLE_LIGHT, STYLE_DARK])
def test_halo_layers_hidden_by_default(style_path: Path):
"""ADR-004 §H-B: halo-слои по умолчанию скрыты (visibility: none)."""
style = json.loads(style_path.read_text(encoding="utf-8"))
halos = {
l["id"]: l for l in style["layers"]
if l["id"].endswith("-halo-satellite")
}
assert len(halos) == 2, f"в {style_path.name} должны быть 2 halo-слоя"
for layer_id, layer in halos.items():
layout = layer.get("layout", {})
assert layout.get("visibility") == "none", (
f"{layer_id} в {style_path.name} не скрыт по умолчанию"
)
@pytest.mark.parametrize("style_path", [STYLE_LIGHT, STYLE_DARK])
def test_halo_layers_below_real_trails(style_path: Path):
"""ADR-004 §H-B: halo должен идти ПЕРЕД соответствующим trails-слоем
(рисуется снизу — обводка под линией)."""
layers = _layer_ids(style_path)
track_halo = layers.index("trails-track-halo-satellite")
track = layers.index("trails-track")
path_halo = layers.index("trails-path-bridleway-halo-satellite")
path = layers.index("trails-path-bridleway")
assert track_halo < track, (
f"halo для trails-track в {style_path.name} должен идти ПЕРЕД trails-track"
)
assert path_halo < path, (
f"halo для path/bridleway в {style_path.name} должен идти ПЕРЕД trails-path-bridleway"
)
# ──────────────────────────────────────────────────────────────────────────────
# Поведенческие JS unit-тесты через Node (U-01..U-05, U-10..U-11, I-*)
# ──────────────────────────────────────────────────────────────────────────────
node_required = pytest.mark.skipif(
which("node") is None,
reason="node не установлен — поведенческие JS unit-тесты пропущены",
)
@node_required
def test_js_unit_tests_pass():
"""U-01..U-05, U-10..U-11, I-*: behavioral JS-тесты через `node --test`."""
assert JS_TEST.is_file(), f"не найден JS-тест {JS_TEST}"
node = which("node")
result = subprocess.run(
[node, "--test", str(JS_TEST)],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
assert result.returncode == 0, (
f"JS unit-тесты подложки упали (код {result.returncode}):\n"
f"{result.stdout}\n{result.stderr}"
)