Merge pull request 'ET-005: переключатель единиц измерения расстояний (км/мили)' (#6) from feature/ET-005- into main
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled

This commit was merged in pull request #6.
This commit is contained in:
2026-05-22 01:50:13 +03:00
18 changed files with 1859 additions and 24 deletions

View File

@@ -13,3 +13,8 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- Docker configuration
- ET-002: чекбокс «POI» в попапе рельефа — показ/скрытие маркеров POI
с сохранением состояния в localStorage (ключ `poi-visible`)
- ET-005: переключатель единиц измерения расстояний (км/мили) в попапе
рельефа — новый модуль `src/web/units.js` с централизованным
форматтером `Units.formatDistance()`; выбор сохраняется в localStorage
(ключ `distance_unit`), пересчёт всех видимых расстояний выполняется
единым оркестратором по событию `unitchange`

View File

@@ -0,0 +1,7 @@
# Business Request: Добавить кнопку переключения единиц измерения (км/мили)
Work Item ID: ET-005
## Description
TBD

View File

@@ -0,0 +1,18 @@
# BRD: Переключение единиц измерения (км/мили)
## Проблема
Пользователи из разных стран используют разные единицы измерения. UI показывает только километры.
## Решение
Кнопка переключения км/мили в панели настроек карты.
## Метрики успеха
- Переключение за 1 клик
- Сохранение в localStorage
- Мгновенный пересчёт расстояний
## Scope
- Кнопка в панели настроек
- Пересчёт расстояний
- localStorage persistence
- Default: км

View File

@@ -0,0 +1,19 @@
# ТЗ: Переключение единиц измерения
## Функциональные требования
1. Кнопка-toggle в панели настроек карты (рядом с другими controls)
2. Два состояния: km (default) / mi
3. При переключении — пересчёт всех видимых расстояний
4. Сохранение выбора в localStorage (ключ: distance_unit)
5. При загрузке страницы — читать из localStorage
## Нефункциональные требования
- Пересчёт < 100ms
- Кнопка доступна на всех размерах экрана
- Не блокирует другие UI элементы
## Технический дизайн
- Новый модуль: src/web/static/js/units.js
- Коэффициент: 1 km = 0.621371 mi
- Event: custom event "unitchange" на document
- Все компоненты с расстояниями слушают "unitchange"

View File

@@ -0,0 +1,21 @@
# Acceptance Criteria: ET-005
## AC-1: Кнопка переключения
- [ ] В панели настроек карты есть кнопка km/mi
- [ ] Кнопка визуально показывает текущий выбор
- [ ] Клик переключает между km и mi
## AC-2: Пересчёт расстояний
- [ ] Все расстояния на карте пересчитываются при переключении
- [ ] Коэффициент: 1 km = 0.621371 mi
- [ ] Округление до 1 знака после запятой
## AC-3: Persistence
- [ ] Выбор сохраняется в localStorage
- [ ] При перезагрузке страницы — восстанавливается
- [ ] По умолчанию (без сохранённого значения) — km
## AC-4: UI/UX
- [ ] Кнопка не перекрывает другие элементы
- [ ] Работает на мобильных устройствах
- [ ] Переключение < 100ms (без видимой задержки)

View File

@@ -0,0 +1,33 @@
name: ET-005 Unit Toggle Test Plan
tests:
- id: TP-01
title: Default unit is km
steps:
- Clear localStorage
- Load page
expected: All distances shown in km
- id: TP-02
title: Toggle to miles
steps:
- Click km/mi button
expected: All distances recalculated to miles
- id: TP-03
title: Persistence
steps:
- Switch to miles
- Reload page
expected: Miles still selected after reload
- id: TP-04
title: Toggle back to km
steps:
- While in miles mode, click button
expected: Distances back in km
- id: TP-05
title: Mobile responsive
steps:
- Open on 375px viewport
expected: Button visible and clickable

View File

@@ -0,0 +1,205 @@
---
type: adr
work_item_id: ET-005
adr_id: adr-0001
title: "ADR-0001: Переключение единиц измерения (км/мили) — клиентское решение с централизованным форматтером"
status: accepted
created_at: 2026-05-21
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels: []
---
# ADR-0001 — Переключение единиц измерения (км/мили): клиентское решение с централизованным форматтером
## Статус
Accepted
## Контекст
ET-005 добавляет переключатель единиц измерения расстояний (км/мили) в
панель настроек карты. Выбор сохраняется в `localStorage`, по умолчанию —
километры, при переключении все видимые расстояния пересчитываются за
< 100 мс (см. `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`).
Существующее состояние кодовой базы (`src/web/`):
- Фронтенд **плоский, без сборщика и без модульной системы**:
`index.html`, `app.js`, `app.css`, `style.json`, `style-dark.json`.
`app.js` (≈ 3036 строк) подключается как **классический скрипт**
(`<script src="app.js"></script>`, `index.html:405`), а не ES-модуль.
Каталога `src/web/static/js/` в репозитории **нет**.
- Форматирование расстояний **не централизовано** — захардкоженный
паттерн `(m / 1000).toFixed(N) + ' км'` встречается минимум в **13
местах** `app.js`: `formatDist()` (стр. 187189), карточки сегментов
(638), карточки маршрута (1157, 1191, 2202, 2212, 2357, 2370, 2605),
scale-bar (14161440), всплывающие подсказки (1478), линейка/ruler
(1837, 1875, 1885, 1931). Единого форматтера нет.
- Внутренняя каноническая единица расстояния — **метры**
(`route.distance_m`), для линейки — километры (`rulerTotal`).
- Сложился устойчивый паттерн персистентности UI-настроек в
`localStorage`: ключи `enduro-theme-mode`, `terrain-hillshade`,
`terrain-tri`, `trails-track`, `trails-path`, `poi-visible` (ET-002),
`MARKERS_KEY`.
- «Панель настроек карты» из BRD/ТЗ де-факто реализована как попап
`#terrain-popup` (заголовок «Эндуро»), открываемый кнопкой «Рельеф».
В ET-002 в этот же попап добавлен чекбокс POI. В `app.css` уже есть
готовый компонент сегментированного переключателя `.seg-control` /
`.seg-btn` (стр. 360363).
Backend (FastAPI), БД (SQLite/Spatialite), тайл-сервер и OSRM к выбору
единиц измерения отношения **не имеют**: расстояния приходят с backend
в метрах, перевод — исключительно вопрос представления на клиенте.
## Рассматриваемые варианты
### Вариант A — Централизованный модуль-форматтер + единый оркестратор (выбран)
Новый модуль `src/web/units.js`, подключаемый как **классический скрипт
до `app.js`**, экспортирует глобальный неймспейс `window.Units` с
функциями состояния (`getUnit()` / `setUnit()`) и форматирования
(`formatDistance(meters, {precision})`). Все 13 мест форматирования в
`app.js` переводятся на вызов `Units.formatDistance(...)`. При смене
единицы `setUnit()` пишет в `localStorage` и диспатчит `unitchange` на
`document`; в `app.js` регистрируется **один** обработчик-оркестратор
`onUnitChange()`, который пере-вызывает существующие функции отрисовки
видимых поверхностей с расстояниями.
- **Плюсы:** единственный источник истины по форматированию убирает
риск рассинхрона; нулевые изменения backend/БД/инфраструктуры;
пересчёт мгновенный (нет ни сети, ни перезагрузки тайлов) — НФТ
«< 100 мс» выполняется тривиально; согласуется с принципом «минимум
зависимостей»; модуль покрывается unit-тестами изолированно.
- **Минусы:** требует рефакторинга 13 мест в `app.js` (риск пропустить
вызов — вынесен в `10-tech-risks.md`, R1); глобальный неймспейс вместо
ES-import — но это сознательное соответствие текущему стилю `app.js`.
### Вариант B — Рассыпанные подписчики `unitchange` по компонентам (буквальный тех-дизайн ТЗ)
Каждый компонент с расстоянием самостоятельно подписывается на
`unitchange` и сам себя перерисовывает.
- **Плюсы:** формально совпадает с разделом «Технический дизайн» ТЗ.
- **Минусы:** N подписок вместо одной — высокая связность, легко
забыть подписку у нового компонента; дублирование логики перерисовки;
не решает корневую проблему — захардкоженное `+ ' км'` остаётся
размазанным. Отклонён: оркестрация в одном месте надёжнее.
### Вариант C — Вынести `units.js` в ES-модуль `src/web/static/js/units.js`
Буквально следовать пути из ТЗ: создать `src/web/static/js/units.js` как
ES-модуль.
- **Плюсы:** «правильная» модульность.
- **Минусы:** в проекте нет каталога `static/js/`, нет сборщика, а
`app.js` подключён как классический скрипт. Полноценный `import`
потребовал бы перевода `app.js` в `type="module"` и реструктуризации
≈ 3000 строк — несопоставимо со scope ET-005 и противоречит принципу
«минимум зависимостей». Отклонён.
## Решение
Принимается **Вариант A**.
1. **Только клиент.** Backend, БД, API, тайл-сервер, OSRM, инфраструктура
— без изменений. Расстояния продолжают приходить с backend в метрах.
2. **Новый модуль `src/web/units.js`** (НЕ `src/web/static/js/units.js`).
Раздел «Технический дизайн» ТЗ носит рекомендательный характер;
финальное размещение файлов — мандат архитектуры. Путь скорректирован
под фактическую плоскую структуру `src/web/`. Модуль подключается в
`index.html` как классический скрипт **строго перед `app.js`**:
`<script src="units.js"></script>` затем `<script src="app.js">`.
3. **Глобальный API модуля** — неймспейс `window.Units` (соответствует
стилю `app.js`, где функции глобальны):
- `Units.getUnit()``'km' | 'mi'`;
- `Units.setUnit(unit)` — валидирует, пишет `localStorage`, диспатчит
`unitchange`;
- `Units.formatDistance(meters, { precision })` — возвращает строку с
корректной единицей в текущем режиме;
- `Units.KM_TO_MI = 0.621371` — единственная константа коэффициента.
4. **Каноническая единица — метры.** Конвертация выполняется **только в
момент форматирования**. Запрещено хранить или пересчитывать значения
расстояний в милях во внутреннем состоянии (`route.distance_m`,
`rulerTotal` и т.п. остаются метрическими) — это исключает накопление
ошибок округления при многократном переключении (ФТ-3, AC-2/TP-04).
5. **Персистентность**`localStorage`, ключ `distance_unit` (как в ТЗ),
значения `'km'` / `'mi'`. При отсутствии или некорректном значении —
дефолт `'km'` (AC-3, TP-01). Чтение — при инициализации модуля.
6. **Пересчёт — единый оркестратор.** `setUnit()` диспатчит `unitchange`
на `document`. В `app.js` регистрируется ровно **один** обработчик
`onUnitChange()`, пере-вызывающий существующие функции отрисовки
видимых поверхностей с расстояниями (лист маршрута, список точек,
карточки маршрутов, линейка, scale-bar). Рассыпать подписки по
компонентам запрещено.
7. **Все 13 мест форматирования** `(m/1000).toFixed(N)+' км'` в `app.js`
переводятся на `Units.formatDistance()`. Список call-sites и риск
пропуска зафиксированы в `10-tech-risks.md` (R1). Форматтер принимает
`precision`, чтобы сохранить текущую точность карточек (`toFixed(0)`)
и при этом по умолчанию давать 1 знак (AC-2, R5).
8. **UI-элемент** — сегментированный переключатель `km | mi` на готовом
компоненте `.seg-control` / `.seg-btn`, размещаемый в попапе
`#terrain-popup` (та же «панель настроек», что и чекбоксы слоёв и
POI-чекбокс из ET-002). Новый CSS-компонент не вводится.
9. **C4 без изменений.** Состав компонентов системы не меняется,
обновление `docs/architecture/c4-*.mmd` не требуется (диаграммы C4 в
репозитории отсутствуют — только `docs/architecture/README.md`).
## Последствия
### Положительные
- Изменение полностью клиентское: деплой без миграций и без изменения
состава контейнеров (штатный релиз фронтенда).
- Пересчёт < 100 мс (НФТ ТЗ) — нет ни сетевых вызовов, ни перезагрузки
тайлов.
- Появляется единственная точка форматирования расстояний — снижается
будущий риск рассинхрона и упрощается поддержка (положительный
побочный эффект рефакторинга 13 call-sites).
- Модуль `units.js` изолирован и тестируется юнит-тестами отдельно от
`app.js`.
### Отрицательные / ограничения
- Настройка локальна для браузера: при очистке `localStorage` или смене
устройства сбрасывается на дефолт `km`. Принято осознанно — вне scope
BRD (синхронизация между устройствами не требуется).
- Требуется рефакторинг 13 мест в `app.js` — операционный риск пропуска
call-site (митигация — `10-tech-risks.md`, R1; grep-аудит + e2e).
- Имперский эквивалент суб-километровых значений («м») и единиц
scale-bar требует политики — см. `10-tech-risks.md` R2, R3.
### Технический долг
- `units.js` подключается как глобальный классический скрипт. Если в
будущих фазах появится сборщик/модульность фронтенда, модуль легко
переводится в ES-модуль без изменения публичного контракта. На ET-005
перевод `app.js` в модули — вне scope.
## Классификация изменения
**Minor change.** Новые сервисы, БД, API-эндпоинты и контейнеры не
вводятся; состав компонентов C4 не меняется; backend и инфраструктура не
затрагиваются. Лейбл `arch:major-change` **не требуется**. Обязательного
архитектурного approve не требуется.
## Связанные документы
- `docs/work-items/ET-005/01-brd.md`
- `docs/work-items/ET-005/02-trz.md`
- `docs/work-items/ET-005/03-acceptance-criteria.md`
- `docs/work-items/ET-005/04-test-plan.yaml`
- `docs/work-items/ET-005/07-infra-requirements.md`
- `docs/work-items/ET-005/08-data-requirements.md`
- `docs/work-items/ET-005/10-tech-risks.md`
- `docs/architecture/README.md`

View File

@@ -0,0 +1,107 @@
---
type: infra-requirements
work_item_id: ET-005
title: "Инфраструктурные требования — ET-005: Переключение единиц измерения (км/мили)"
version: 1
status: approved
created_at: 2026-05-21
authors:
- "agent:architect"
---
# Инфраструктурные требования — ET-005
## 1. Резюме
ET-005 — изменение **исключительно фронтенда** (`src/web/index.html`,
`src/web/app.js`, `src/web/app.css` и новый файл `src/web/units.js`).
Новой инфраструктуры не требуется. Документ зафиксирован для полноты
work-item и явно подтверждает отсутствие инфра-воздействия (см.
`06-adr/adr-0001-unit-toggle-client-side.md`).
## 2. Контейнеры и сервисы
| Аспект | Требование |
|--------|------------|
| Новые контейнеры | Нет |
| Изменения существующих сервисов (api, osrm, nginx) | Нет |
| Изменения `docker-compose.yml` | Нет |
| Изменения `Dockerfile` | Нет — `units.js` это статика, попадает в образ тем же путём, что и `app.js` |
## 3. Сеть
| Аспект | Требование |
|--------|------------|
| Новые порты | Нет |
| Изменения reverse proxy (nginx, `/enduro/`) | Нет |
| Новые внешние домены / DNS | Нет |
| Исходящие сетевые вызовы из фронтенда | Нет (конвертация и хранение — локальные) |
## 4. Хранилища данных
| Аспект | Требование |
|--------|------------|
| Изменения схемы SQLite/Spatialite | Нет |
| Миграции БД (`migrations/`) | Нет |
| Серверное хранилище состояния | Нет |
| Клиентское хранилище | `localStorage`, ключ `distance_unit` (`'km'`/`'mi'`), ≈ 2 байта полезной нагрузки на браузер |
Подробности по данным — `08-data-requirements.md`.
## 5. Конфигурация и секреты
| Аспект | Требование |
|--------|------------|
| Новые переменные окружения | Нет |
| Новые секреты | Нет |
| Изменения конфигурации FastAPI / uvicorn | Нет |
## 6. Зависимости
| Аспект | Требование |
|--------|------------|
| Новые npm/Python пакеты | Нет |
| Новые внешние сервисы | Нет |
| Новые CDN-скрипты | Нет — `units.js` это локальный файл проекта, не внешний CDN |
| Версия MapLibre GL JS | Без изменений |
## 7. Сборка и деплой
- **Pipeline:** существующий Gitea Actions без изменений (`lint`, `test`,
`build`).
- **Артефакт:** статические ассеты фронтенда (`src/web/`), включая новый
`units.js`. Деплой — штатная пересборка/перевыкладка и
`docker compose up -d` на mva154.
- **Порядок подключения скриптов:** `units.js` должен подключаться в
`index.html` **строго перед** `app.js` (см. ADR-0001, риск R7 в
`10-tech-risks.md`). Контрольная точка для review.
- **Простой (downtime):** нет — изменение только в статике фронтенда.
- **План отката:** обратный коммит (revert) и повторный деплой;
миграций/серверного состояния, требующих отдельного отката, нет.
`localStorage`-ключ `distance_unit` у пользователей при откате
безвреден (игнорируется старым кодом).
## 8. Ресурсы (CPU / RAM / диск)
Воздействие отсутствует. Конвертация расстояний — несколько
арифметических операций в браузере клиента; тайлы и маршруты не
перезапрашиваются. Размер `units.js` пренебрежимо мал (единицы КБ).
## 9. Наблюдаемость
Новые метрики, логи и алерты не требуются. Поведение проверяется
unit-тестами модуля `units.js` и e2e-тестами фронтенда согласно
`04-test-plan.yaml` (TP-01…TP-05).
## 10. Влияние на C4
Состав компонентов системы **не меняется** — добавляется лишь файл
внутри уже существующего компонента «Frontend». Обновление
`docs/architecture/c4-*.mmd` не требуется (диаграммы C4 в репозитории
на данный момент отсутствуют — только `docs/architecture/README.md`).
## 11. Вывод
Инфраструктурных, сетевых, конфигурационных изменений и изменений БД
**нет**. ET-005 безопасен для деплоя в рамках обычного релизного цикла
фронтенда. Эскалация `arch:major-change` не требуется.

View File

@@ -0,0 +1,76 @@
---
type: data-requirements
work_item_id: ET-005
title: "Требования к данным — ET-005: Переключение единиц измерения (км/мили)"
version: 1
status: approved
created_at: 2026-05-21
authors:
- "agent:architect"
---
# Требования к данным — ET-005
## 1. Резюме
ET-005 не вводит и не изменяет ни одной серверной структуры данных.
Единственное «данное» фичи — пользовательская UI-настройка выбранной
единицы измерения, хранимая на клиенте.
## 2. Серверные данные
| Аспект | Требование |
|--------|------------|
| Изменения схемы SQLite/Spatialite | Нет |
| Новые таблицы / колонки / индексы | Нет |
| Миграции (`migrations/`) | Нет |
| Изменения контракта API | Нет — расстояния и далее отдаются в **метрах** |
## 3. Каноническая единица расстояния
- Внутренняя каноническая единица системы — **метр**. Backend отдаёт
`route.distance_m` (метры); линейка во фронтенде оперирует километрами
(`rulerTotal`).
- ET-005 **не меняет** ни хранимые, ни передаваемые значения. Перевод в
мили выполняется **только в момент форматирования строки для UI**
(`Units.formatDistance()`).
- Запрещено хранить в рантайм-состоянии значения, конвертированные в
мили — это исключает накопление ошибок округления при многократном
переключении (ФТ-3 ТЗ, AC-2, тест TP-04).
## 4. Клиентское хранилище
| Параметр | Значение |
|----------|----------|
| Механизм | `localStorage` |
| Ключ | `distance_unit` |
| Допустимые значения | `'km'`, `'mi'` |
| Значение по умолчанию | `'km'` (при отсутствии ключа или некорректном значении) |
| Объём | ≈ 2 байта полезной нагрузки на браузер |
| Запись | при переключении (`Units.setUnit()`) |
| Чтение | при инициализации модуля `units.js` |
Ключ согласуется с уже сложившейся в проекте конвенцией UI-настроек в
`localStorage` (`enduro-theme-mode`, `terrain-*`, `trails-*`,
`poi-visible`).
## 5. Константы и точность
| Параметр | Значение |
|----------|----------|
| Коэффициент перевода | `1 km = 0.621371 mi` (`Units.KM_TO_MI`), единственное место объявления |
| Точность по умолчанию | 1 знак после запятой (AC-2) |
| Параметризация точности | `formatDistance()` принимает `precision`, чтобы сохранить существующую точность карточек маршрутов (`toFixed(0)`) — см. `10-tech-risks.md`, R5 |
| Разделитель дробной части | Привести к единому виду; сейчас в `app.js` есть расхождение (`.` и `,`) — см. `10-tech-risks.md`, R4 |
## 6. Персональные данные
Персональные данные (PII) отсутствуют. `distance_unit` — обезличенная
UI-настройка, не покидает браузер пользователя, не логируется на
backend.
## 7. Вывод
Серверных данных, схем и миграций ET-005 **не затрагивает**. Единственная
единица данных — клиентский флаг `localStorage:distance_unit`. Требований
к резервному копированию, ретенции и приватности не возникает.

View File

@@ -0,0 +1,128 @@
---
type: tech-risks
work_item_id: ET-005
title: "Технические риски — ET-005: Переключение единиц измерения (км/мили)"
version: 1
status: approved
created_at: 2026-05-21
authors:
- "agent:architect"
---
# Технические риски — ET-005
Шкала: вероятность (Н/С/В) × влияние (Н/С/В).
## R1 — Пропуск одного из мест форматирования расстояний
- **Описание:** форматирование `(m/1000).toFixed(N)+' км'` захардкожено
минимум в **13 местах** `src/web/app.js`. Если хотя бы одно не
переведено на `Units.formatDistance()`, после переключения на мили это
расстояние останется в км — нарушение AC-2 («все расстояния»).
- **Известные call-sites** (ориентир для разработки и review):
`formatDist()` ≈ стр. 187189; карточка сегмента ≈ 638; карточки
маршрута ≈ 1157, 1191, 2202/2212, 2357/2370, 2605; scale-bar
≈ 14161440; всплывающая подсказка ≈ 1478; линейка/ruler ≈ 1837,
1875, 1885, 1931. Список ориентировочный — нумерация строк может
сдвинуться; обязателен повторный grep-аудит.
- **Вероятность / Влияние:** С / В.
- **Митигация:** централизация в `units.js` (ADR-0001); grep-аудит
по `' км'` / `/ 1000` / `toFixed` на этапе разработки; e2e TP-02
должен проверять **каждую** видимую поверхность с расстоянием, а не
одну.
## R2 — Отсутствие политики для суб-километровых расстояний
- **Описание:** часть мест отображает расстояния < 1 км в метрах
(«м» — напр. линейка, стр. 1837/1885). Имперский эквивалент в AC не
определён (футы? мили с долями?). Без явной политики возможен
показ «м» в режиме «mi».
- **Вероятность / Влияние:** В / С.
- **Митигация:** зафиксировать политику в реализации `units.js`.
Рекомендация архитектуры: в режиме `mi` суб-километровые значения
показывать в милях с увеличенной точностью (напр. 2 знака), **футы
не вводить** — это упрощает форматтер и достаточно для эндуро-сценария.
Финальное решение — за разработкой/ревью, но политика должна быть
единой и явной.
## R3 — Scale-bar имеет собственную логику единиц
- **Описание:** масштабная линейка карты (`szb`, стр. 14161440) уже
содержит независимую логику `km`/`m` с «красивым» округлением. Это
отдельный код-путь, который легко не заметить при рефакторинге.
- **Вероятность / Влияние:** С / С.
- **Митигация:** явно включить scale-bar в список перевода на `Units`
и в оркестратор `onUnitChange()`; добавить scale-bar в проверки e2e
TP-02.
## R4 — Несогласованный разделитель дробной части
- **Описание:** в `app.js` есть расхождение — `formatDist()` (стр. 189)
использует точку (`12.3 км`), карточка сегмента (стр. 638) —
`.replace('.', ',')` (`12,3 км`). При централизации форматтер должен
выбрать один вариант, иначе UI станет визуально неоднородным.
- **Вероятность / Влияние:** В / Н.
- **Митигация:** `units.js` задаёт единый разделитель для всех
поверхностей. Так как у задачи нет требования на локаль, рекомендуется
оставить запятую (текущий пользовательский язык интерфейса — русский),
но решение должно быть единым и явным.
## R5 — Конфликт точности: AC-2 vs существующие карточки
- **Описание:** AC-2 требует «округление до 1 знака», но карточки
маршрутов сейчас используют `toFixed(0)` (целые км, стр. 2202, 2357).
Слепое применение «1 знака» изменит вид карточек; слепое сохранение
`toFixed(0)` нарушит букву AC-2.
- **Вероятность / Влияние:** С / Н.
- **Митигация:** `formatDistance(meters, {precision})` параметризует
точность; по умолчанию — 1 знак (AC-2), карточки явно запрашивают
свою точность. Трактовка: AC-2 задаёт точность пересчёта/дефолта, а
не запрещает целочисленный показ в карточках.
## R6 — Экспорт GPX и параметрические значения не должны конвертироваться
- **Описание:** есть риск «заодно» сконвертировать величины, которые
обязаны остаться метрическими: экспорт GPX (стандарт GPX — метры) и
кнопки-параметры построения маршрута (`data-km` радиусы разведки/
scenic: «20 км», «50», «100», `index.html:130180`). Это **входные
параметры**, а не отображаемые расстояния.
- **Вероятность / Влияние:** Н / С.
- **Митигация:** scope ET-005 — **только отображаемые** расстояния.
GPX-данные и `data-km`-параметры из перевода единиц **исключены**;
зафиксировать в review-чеклисте.
## R7 — Порядок подключения скриптов
- **Описание:** `units.js` подключается как классический скрипт; `app.js`
обращается к `window.Units` на старте. Если `units.js` подключён после
`app.js`, инициализация упадёт с `Units is not defined`.
- **Вероятность / Влияние:** Н / В.
- **Митигация:** `<script src="units.js">` строго перед
`<script src="app.js">` в `index.html`; контрольная точка review;
smoke-проверка загрузки страницы.
## R8 — Мобильное размещение переключателя
- **Описание:** НФТ ТЗ и AC-4 требуют доступности кнопки на всех
размерах экрана и неперекрытия других элементов. Попап `#terrain-popup`
пополняется ещё одним рядом — на узких экранах (375px, TP-05) возможен
выход за пределы/перекрытие.
- **Вероятность / Влияние:** Н / С.
- **Митигация:** переиспользовать готовый `.seg-control` (адаптивен по
ширине); проверить попап на 375px; e2e TP-05.
## Сводка
| ID | Риск | Вер. | Влияние | Класс |
|----|------|------|---------|-------|
| R1 | Пропуск call-site форматирования | С | В | Высокий |
| R2 | Нет политики для суб-км значений | В | С | Высокий |
| R3 | Scale-bar — отдельная логика единиц | С | С | Средний |
| R4 | Несогласованный десятичный разделитель | В | Н | Средний |
| R5 | Конфликт точности AC-2 vs карточки | С | Н | Низкий |
| R6 | GPX / параметры не конвертировать | Н | С | Низкий |
| R7 | Порядок подключения скриптов | Н | В | Средний |
| R8 | Мобильное размещение переключателя | Н | С | Низкий |
Блокирующих рисков нет. R1 и R2 требуют явного внимания на разработке и
ревью. Эскалация `arch:major-change` или возврат в Анализ не требуются.

View File

@@ -0,0 +1,224 @@
---
type: review
work_item_id: ET-005
title: "Code Review: Переключение единиц измерения расстояний (км/мили)"
version: 1
status: approved
verdict: APPROVED
created_at: 2026-05-21
authors:
- "agent:reviewer"
---
# Code Review — ET-005
## Вердикт
**APPROVED** (с комментариями).
P0/P1-findings нет. Зафиксировано 1×P2 и 2×P3 — все некритичные, не
блокируют merge. P2 — побочный эффект оркестратора (сброс выбора варианта
связки при переключении единиц), не нарушает требований ТЗ.
## Объект ревью
- Ветка: `feature/ET-005-`
- Код-коммит: `2fe5cfe` `feat(web): переключатель единиц измерения расстояний (км/мили)`
- Изменённые файлы кода:
- `src/web/units.js` (новый, 190 строк)
- `src/web/index.html` (+11 строк)
- `src/web/app.js` (+118 / 24 строк)
- `src/web/app.css` (+20 строк)
- `tests/unit/units.test.js` (новый, 219 строк)
- `tests/unit/test_unit_toggle.py` (новый, 211 строк)
- `CHANGELOG.md` (+5 строк)
- Прочитано: `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`,
`06-adr/adr-0001-unit-toggle-client-side.md`, `10-tech-risks.md`,
`CLAUDE.md`.
## 1. Соответствие ТЗ
### Функциональные требования
| Требование | Статус | Комментарий |
|------------|--------|-------------|
| ФТ-1 Кнопка-toggle в панели настроек карты | ✅ | `index.html:62-69` — сегментированный `.seg-control` в попапе `#terrain-popup`, после чекбокса POI, отделён `<hr>` |
| ФТ-2 Два состояния: km (default) / mi | ✅ | `units.js` `DEFAULT_UNIT = 'km'`, `VALID_UNITS = ['km','mi']` |
| ФТ-3 Пересчёт всех видимых расстояний | ✅ | Единый оркестратор `onUnitChange()` — карточки маршрутов, мини-карточка, лист точек, линейка, scale-bar, связка, «красивый» маршрут. См. R-01 (P2) — побочный сброс выбора варианта связки |
| ФТ-4 Сохранение выбора в `localStorage` (`distance_unit`) | ✅ | `units.js` `setUnit()``writeStored()`, ключ `distance_unit` |
| ФТ-5 При загрузке — чтение из `localStorage` | ✅ | `getUnit()` лениво читает ключ; `syncUnitToggleUI()` на `DOMContentLoaded` восстанавливает вид кнопок |
### Нефункциональные требования
| Требование | Статус | Комментарий |
|------------|--------|-------------|
| Пересчёт < 100 мс | ✅ | Чисто клиентская конвертация, нет ни сети, ни перезапроса тайлов |
| Кнопка доступна на всех размерах экрана | ✅ | Переиспользован адаптивный `.seg-control`; статически подтверждено `test_unit_toggle_reuses_seg_control_component`. Браузерный e2e на 375px (TP-05) — на этап тестирования |
| Не блокирует другие UI элементы | ✅ | Аддитивный ряд `.terrain-unit-row` с разделителем `<hr>`; `margin-bottom:0` для последнего элемента попапа |
### Технический дизайн ТЗ
Два пункта раздела «Технический дизайн» ТЗ скорректированы в ADR-0001 —
оба отклонения **обоснованы и легитимны**, дефектом не являются:
- путь `src/web/static/js/units.js``src/web/units.js` (ADR-0001 п.2:
каталога `static/js/` нет, сборщика нет, `app.js` — классический
скрипт; Вариант C отклонён);
- «все компоненты слушают `unitchange`» → единый оркестратор
`onUnitChange()` (ADR-0001 п.6; Вариант B отклонён ради низкой
связности).
Согласно CLAUDE.md ADR обладает архитектурным мандатом; раздел
«Технический дизайн» ТЗ носит рекомендательный характер. Реализация
следует ADR — это корректно.
Все функциональные и нефункциональные требования ТЗ выполнены.
## 2. Соответствие ADR-0001
| Пункт решения ADR | Статус | Комментарий |
|-------------------|--------|-------------|
| п.1 Только клиент; backend/БД/инфра без изменений | ✅ | Затронут только `src/web/` + тесты |
| п.2 Модуль `src/web/units.js`, подключён перед `app.js` | ✅ | `index.html:415` `units.js` строго до `app.js`; статически подтверждено `test_units_js_loaded_before_app_js` (R7) |
| п.3 Неймспейс `window.Units` (`getUnit`/`setUnit`/`formatDistance`/`KM_TO_MI`) | ✅ | Публичный контракт собран; дополнительно `module.exports` для Node-тестов |
| п.4 Каноническая единица — метры; конвертация только при форматировании | ✅ | `formatDistance(meters,...)` принимает метры; внутреннее состояние (`route.distance_m`, `rulerTotal`) не трогается |
| п.5 `localStorage` ключ `distance_unit`, дефолт `km` при отсутствии/мусоре | ✅ | `getUnit()` через `isValidUnit()` деградирует к `DEFAULT_UNIT` |
| п.6 Единый оркестратор — ровно одна подписка на `unitchange` | ✅ | `document.addEventListener('unitchange', onUnitChange)` единственная; подтверждено `test_app_js_single_unitchange_subscription` |
| п.7 Все 13 мест форматирования → `Units.formatDistance()`; GPX остаётся метрическим | ✅ | grep-аудит: вне `generateGPX()` захардкоженного `(m/1000).toFixed()+' км'` не осталось; GPX-экспорт намеренно метрический (R6), подтверждено `test_app_js_gpx_export_stays_metric` |
| п.8 UI на готовом `.seg-control` в `#terrain-popup` | ✅ | Новый CSS-компонент не вводится; добавлены только обёрточные стили `.terrain-unit-row` |
| п.9 C4 без изменений | ✅ | Состав компонентов не меняется |
Реализация полностью соответствует принятому ADR-0001. Риски R1R8 из
`10-tech-risks.md` отработаны: R1 (call-sites) — централизация + аудит;
R2 (суб-км значения) — явная политика в `formatDistance()`; R3
(scale-bar) — отдельная unit-aware ветка + включение в оркестратор; R4
(разделитель) — единая запятая в `units.js` (по scale-bar см. R-02); R5
(точность) — параметр `precision`; R6 — GPX исключён; R7 — порядок
скриптов; R8 — переиспользование `.seg-control`.
## 3. Acceptance Criteria
| AC | Покрытие |
|----|----------|
| AC-1 Кнопка km/mi в панели, показывает выбор, клик переключает | ✅ `test_unit_toggle_present_in_html`; `syncUnitToggleUI()` переключает класс `.active` |
| AC-2 Пересчёт всех расстояний, коэф. 0.621371, округление до 1 знака | ✅ JS `TP-02`, `AC-2: KM_TO_MI`, `точность по умолчанию — 1 знак`. Карточки маршрутов — `precision:0` (R5, осознанная трактовка), суб-км в милях — 2 знака (R2) |
| AC-3 Сохранение и восстановление из `localStorage`, дефолт km | ✅ JS `TP-01`, `TP-03`, `недоступный localStorage` |
| AC-4 Кнопка не перекрывает элементы, mobile, переключение < 100мс | ✅ (структурно) `test_unit_toggle_has_styles`; e2e mobile (TP-05) — этап тестирования |
Поведенческое ядро (модуль `units.js`) покрыто полностью. Замечание по
покрытию слоя `app.js` — см. R-03 (P3).
## 4. Качество кода
Сильные стороны:
- **Единый источник истины.** `KM_TO_MI` объявлен ровно один раз;
форматирование расстояний централизовано в `units.js`; 13 call-sites
в `app.js` сведены к `Units.formatDistance()`. Дублирования нет.
- **Защитное программирование.** `readStored()`/`writeStored()` обёрнуты
в `try/catch` (private mode); `dispatchEvent` защищён от headless-среды;
`formatDistance()` корректно отдаёт `'-'` для `null/undefined/NaN`;
`setUnit()` валидирует вход и не шлёт событие при неизменной единице.
- **Каноническое состояние в метрах.** Конвертация — только в момент
форматирования; тест `TP-04: многократное переключение` подтверждает
отсутствие дрейфа округления.
- **Консистентность с кодовой базой.** Глобальный неймспейс и
классический скрипт соответствуют стилю `app.js`; паттерн
`window._updateScaleZoom` зеркалит существующий `window._map`;
блок-маркеры `>>> ET-005 ... >>>` повторяют приём ET-002.
- **Документированность.** JSDoc на всех публичных и приватных функциях
со ссылками на ADR/риски; запись в `CHANGELOG.md`; commit —
Conventional Commits (`feat(web):`).
- **Оркестратор аккуратен.** `renderLinkCards()`/`drawScenicRoutes()`
идемпотентны (remove-before-add), повторный вызов из `onUnitChange()`
не вызывает «layer already exists»; перерисовка связки/«красивого»
маршрута огорожена флагами `linkMode`/`scenicMode`.
Замечания — см. findings R-01 (P2), R-02/R-03 (P3).
## 5. Качество тестов
- **Unit (`units.test.js`)** — высокое качество: тесты исполняют
**реальный** `src/web/units.js` (сброс `require.cache` + инъекция
моков `window`/`document`/`localStorage` перед каждым тестом).
Покрыты TP-01..TP-04, AC-2, AC-3, граница 1000 м, недоступный
`localStorage`, валидация `setUnit()`, публикация неймспейса.
- **Python (`test_unit_toggle.py`)** — статические проверки структуры
`units.js`/`index.html`/`app.js` (ADR-0001, риски R1/R3/R6/R7) +
запуск JS-раннера через `node --test` со `skip` при отсутствии `node`
(по аналогии с `test_poi_toggle.py`).
- e2e TP-05 (mobile responsive) не реализован — Playwright-инфраструктуры
в репозитории нет; отклонение задокументировано в шапке
`test_unit_toggle.py`. На merge не влияет, относится к этапу
тестирования.
## Findings
### R-01 — Переключение единиц сбрасывает выбор варианта связки (P2)
`onUnitChange()` (`app.js:2937-2938`) при активном режиме связки
вызывает `renderLinkCards(linkRoutes)`. В отличие от
`drawScenicRoutes(routes, activeIdx)`, функция `renderLinkCards()` **не
принимает индекс активного варианта** и всегда отрисовывает «Вариант 1»
как активный: карточке `i===0` присваивается класс `active`, линии
`link-line-0``line-width:5`. В коде нет переменной `activeLinkIdx`
выбор существует только как класс в DOM и ширина линии на карте.
Следствие: пользователь, выбравший вариант 2/3 связки через
`selectLinkRoute()`, при каждом переключении км/мили теряет выбор —
подсветка карточки и акцент линии откатываются на вариант 1. Требования
ТЗ (ФТ-3 «пересчёт расстояний») формально выполнены — расстояния
пересчитываются корректно; это побочный дефект UX, ограниченный режимом
связки, поэтому P2, а не P1.
**Рекомендация:** ввести `activeLinkIdx` (по аналогии с
`activeScenicIdx`), обновлять его в `selectLinkRoute()` и после
повторного `renderLinkCards()` в `onUnitChange()` вызывать
`selectLinkRoute(activeLinkIdx)` для восстановления подсветки. Можно
поправить в этом же PR либо вынести в техдолг.
### R-02 — Scale-bar в режиме «mi» использует точку и латиницу (P3)
Масштабная линейка (`updateScaleZoom`, `app.js:1455`) формирует подпись
как `distance + ' ' + unit`, где `unit` — латинские `'mi'`/`'km'`/`'m'`,
а суб-единичные значения выводятся стандартным `Number.toString()` с
точкой (`'0.5 mi'`). Это расходится с `units.js`, который по R4 задаёт
запятую и русские подписи (`'0,5 ми'`). Митигация R4 в `10-tech-risks.md`
предписывает «единый разделитель для всех поверхностей».
Замечание ограничено P3: латиница и точка в scale-bar — **поведение,
существовавшее до ET-005** (в режиме «km» линейка и раньше показывала
`'30 km'`/`'0.5 km'`); ET-005 лишь расширил тот же стиль на мили и
консистентен внутри компонента. Полная унификация требует отдельного
форматирования подписи scale-bar и выходит за scope ET-005.
**Рекомендация:** при желании привести подпись scale-bar к запятой и
русским единицам — отдельной косметической правкой/техдолгом.
### R-03 — Слой `app.js` (оркестратор, scale-bar) покрыт только статически (P3)
Поведенческими тестами покрыт изолированный модуль `units.js`. Логика
`app.js``onUnitChange()` (перерисовка поверхностей), unit-aware ветка
scale-bar — проверяется лишь строковыми ассертами наличия в
`test_unit_toggle.py` (`test_app_js_*`). Поведение «после переключения
каждая видимая поверхность пересчиталась» (TP-02) не верифицируется
автоматически.
Это приемлемо: в репозитории нет DOM/MapLibre-харнесса для `app.js`
(монолитный non-module скрипт), и подход повторяет принятый в ET-002.
Сквозную проверку TP-02 закрывает этап тестирования. Фиксируется как
технический долг — учесть при будущей модуляризации фронтенда.
## Заключение
Реализация ET-005 корректна и полна: все функциональные и
нефункциональные требования ТЗ выполнены, ADR-0001 (пп.19) соблюдён
полностью, риски R1R8 отработаны. Архитектурные отклонения от раздела
«Технический дизайн» ТЗ легитимны и зафиксированы в ADR. Дублирования
нет, форматирование централизовано, защитное программирование на месте,
модуль `units.js` тщательно покрыт unit-тестами на реальном коде.
Блокирующих замечаний нет.
**Вердикт: APPROVED.** R-01 (P2) рекомендуется поправить в этом же PR
либо осознанно вынести в техдолг; R-02/R-03 (P3) — на усмотрение
разработчика. Ни одно замечание merge не блокирует.

View File

@@ -0,0 +1,247 @@
---
type: test-report
work_item_id: ET-005
version: 1
status: pass
tester: "agent:tester"
date: 2026-05-21
commit_tested: 2fe5cfe
verdict: PASS
---
# Test Report — ET-005
## Verdict: **PASS** → `stage:ready-to-deploy`
Полный регресс зелёный: **pytest 31 passed, 4 skipped, 0 failed**;
JS-юнит-тесты `units.js` **20/20 pass**; **e2e Playwright TP-01…TP-05
6/6 pass** (0 JS-ошибок на странице); lint чистый; тест-окружение
отвечает 200. Блокирующих багов (P0/P1) не найдено.
Весь тест-план `04-test-plan.yaml` (TP-01…TP-05) исполнен **в реальном
браузере** — в отличие от ET-002, e2e не блокирован. Все 4 acceptance-
критерия покрыты и не нарушены.
## Окружение
- **Дата прогона:** 2026-05-21
- **Ветка:** `feature/ET-005-`
- **Код-коммит:** `2fe5cfe` (`feat(web): переключатель единиц измерения
расстояний (км/мили)`; источник — `12-review.md`)
- **HEAD:** `d32ad8f` (`reviewer(ET): auto-commit ...`) — поверх кода
только артефакт ревью, изменений кода нет; тестировалось рабочее дерево
- **Python:** 3.12.13
- **pytest:** 8.3.3 (plugins: asyncio-1.3.0, anyio-4.13.0)
- **Node:** v22.22.2 (`node --test`)
- **Playwright:** 1.60.0, Chromium headless (chromium-headless-shell v1223)
- **ruff:** установлен по `pyproject.toml [dev]`
- **test-env:** https://openclaw.mva154.duckdns.org/enduro/ → HTTP 200
## Healthcheck
| Среда | URL | Код |
|---|---|---|
| local dev | http://localhost:5556/health | connection refused (dev не поднят — ОК, прогон оффлайн) |
| test | https://openclaw.mva154.duckdns.org/enduro/ | 200 |
ET-005 — фронтенд-изменение. В test задеплоен предыдущий код, поэтому
healthcheck подтверждает только живость окружения; фича попадёт в test
штатной перевыкладкой `src/web/` после merge (см.
`07-infra-requirements.md §7`). e2e-прогон выполнен против **кода ветки**,
поднятого локально статическим сервером (`python -m http.server` →
`src/web/`), не против test-окружения. На prom ничего не запускалось.
## Команды запуска
```bash
# Unit + integration (эквивалент make test)
python -m pytest tests/ -v
# JS behavioral unit-тесты units.js (TP-01..TP-04, AC-2, AC-3)
node --test tests/unit/units.test.js
# e2e (TP-01..TP-05): локальная раздача src/web + Playwright/Chromium
python -m http.server 8777 --directory src/web &
python /tmp/et005_e2e.py
# Lint
ruff check src/ tests/
```
## Результаты pytest
`python -m pytest tests/ -v` → **31 passed, 4 skipped, 1 warning in 0.62s**
| Файл | Тестов | PASS | SKIP |
|---|---|---|---|
| `integration/test_routing_barriers.py` | 7 | 3 | 4 |
| `unit/test_health.py` | 1 | 1 | 0 |
| `unit/test_poi_toggle.py` (ET-002, регресс) | 10 | 10 | 0 |
| `unit/test_unit_toggle.py` (**ET-005**) | 17 | 17 | 0 |
| **Итого** | **35** | **31** | **4** |
**ET-005 — `test_unit_toggle.py` (17/17 PASS):**
| Тест | Покрывает | Результат |
|---|---|---|
| `test_units_module_exists` | наличие `src/web/units.js` (ADR-0001 п.2) | **PASS** |
| `test_units_module_public_api` | контракт `window.Units` (ADR-0001 п.3) | **PASS** |
| `test_units_module_constants` | `KM_TO_MI=0.621371`, `distance_unit`, дефолт `km` | **PASS** |
| `test_units_module_exports_for_browser_and_node` | `window.Units` + `module.exports` | **PASS** |
| `test_unit_toggle_present_in_html` | кнопка km/mi в попапе (ФТ-1, AC-1) | **PASS** |
| `test_unit_toggle_reuses_seg_control_component` | переиспользование `.seg-control` (R8) | **PASS** |
| `test_units_js_loaded_before_app_js` | порядок скриптов (R7, ADR-0001 п.2) | **PASS** |
| `test_unit_toggle_has_styles` | стили `.terrain-unit-row` (AC-4) | **PASS** |
| `test_app_js_unit_functions_defined` | `onUnitToggle`/`syncUnitToggleUI`/`onUnitChange` | **PASS** |
| `test_app_js_has_et005_block_markers` | блок-маркеры `>>> ET-005 ... >>>` | **PASS** |
| `test_app_js_single_unitchange_subscription` | ровно одна подписка `unitchange` (ADR п.6) | **PASS** |
| `test_app_js_uses_centralized_formatter` | форматирование через `Units.formatDistance` | **PASS** |
| `test_app_js_distance_helpers_delegate_to_units` | хелперы делегируют в `units.js` (R1) | **PASS** |
| `test_app_js_scale_bar_is_unit_aware` | scale-bar учитывает единицу (R3) | **PASS** |
| `test_app_js_gpx_export_stays_metric` | GPX-экспорт остаётся метрическим (R6) | **PASS** |
| `test_app_js_restores_unit_choice_on_load` | восстановление выбора при загрузке (AC-3) | **PASS** |
| `test_js_unit_tests_pass` | запуск `units.test.js` через Node-раннер | **PASS** |
**4 SKIP** — интеграционные тесты роутинга ET-001
(`test_routing_barriers.py::test_route_*`); требуют поднятого OSRM,
недоступного в окружении тестера (штатный `skip`, чтобы CI без
инфраструктуры не падал). ET-005 — фронтенд-изменение, на роутинг не
влияет; к регрессу не относится.
Предупреждение `PytestDeprecationWarning` (`asyncio_default_fixture_loop_scope`)
— внешняя зависимость `pytest-asyncio`, к ET-005 отношения не имеет, не
блокирует.
## Результаты JS unit-тестов `units.js`
`node --test tests/unit/units.test.js` → **# tests 20, # pass 20, # fail 0**
Тесты исполняют **реальный** `src/web/units.js` (сброс `require.cache` +
инъекция моков `window`/`document`/`localStorage` перед каждым тестом).
Покрыты TP-01…TP-04, AC-2, AC-3, граница 1000 м, недоступный
`localStorage`, валидация `setUnit()`, публикация неймспейса, единый
разделитель «запятая» (R4), точность по умолчанию.
## Результаты e2e (Playwright / Chromium) — TP-01…TP-05
Прогон в headless-Chromium против кода ветки, поднятого локально
(`src/web/` через `http.server`). Взаимодействие — через **реальные
DOM-клики** по кнопкам попапа (`onUnitToggle` срабатывает по inline
`onclick`). Пересчёт видимых расстояний верифицирован на живой
масштабной линейке карты (`#scale-zoom-bar`), которую перерисовывает
оркестратор `onUnitChange()`.
| TC | Сценарий | Факт | Результат |
|---|---|---|---|
| **TP-01** | дефолт после очистки `localStorage` | `getUnit()='km'`, кнопка «км» `.active`, «мили» нет, `localStorage`=пусто, `formatDistance(12345)='12,3 км'` | **PASS** |
| **TP-02** | переключение в мили | `getUnit()='mi'`, «мили» `.active`, `localStorage='mi'`, `formatDistance(12345)='7,7 ми'`, scale-bar `'55 km'→'35 mi'` | **PASS** |
| **TP-03** | persistence после reload | после перезагрузки `getUnit()='mi'`, «мили» `.active` | **PASS** |
| **TP-04** | возврат в км | `getUnit()='km'`, «км» `.active`, `localStorage='km'`, scale-bar снова `'55 km'` | **PASS** |
| **TP-05** | mobile responsive 375px | обе кнопки видимы и в пределах вьюпорта (km `x=166 w=57`, mi `x=226 w=57`), клик переключает | **PASS** |
| NFR-perf | переключение < 100 мс | клик + пересчёт всех поверхностей = **0,5 мс** | **PASS** |
**Итог e2e: 6/6 PASS.** На странице **не зафиксировано ни одной
JS-ошибки** (`pageerror` за весь прогон — none).
## Покрытие тест-плана (04-test-plan.yaml)
| TC | Тип | Исполнение | Статус |
|---|---|---|---|
| **TP-01** | e2e | Playwright + JS-тест `units.test.js` | **PASS** |
| **TP-02** | e2e | Playwright (scale-bar `km→mi`) + JS-тест | **PASS** |
| **TP-03** | e2e | Playwright (reload) + JS-тест | **PASS** |
| **TP-04** | e2e | Playwright (scale-bar `mi→km`) + JS-тест | **PASS** |
| **TP-05** | e2e | Playwright, viewport 375×667 | **PASS** |
**Исполнено и пройдено: 5/5 тест-кейсов.**
## Соответствие Acceptance Criteria
| AC | Описание | Источник проверки | Статус |
|---|---|---|---|
| **AC-1** | Кнопка km/mi в панели, показывает выбор, клик переключает | e2e TP-01 (км active по умолчанию), TP-02/TP-04 (клик переключает класс `.active`), `test_unit_toggle_present_in_html` | **PASS** |
| **AC-2** | Пересчёт всех расстояний, коэф. 0.621371, округление до 1 знака | e2e TP-02 (scale-bar `55 km→35 mi`, `formatDistance(12345)=12,3 км→7,7 ми`), `units.test.js` (`KM_TO_MI`, точность 1 знак), `test_units_module_constants` | **PASS** |
| **AC-3** | Сохранение/восстановление из `localStorage`, дефолт km | e2e TP-01 (дефолт km, `localStorage` пуст), TP-02 (`localStorage='mi'`), TP-03 (выживает reload) | **PASS** |
| **AC-4** | Кнопка не перекрывает элементы, mobile, переключение < 100мс | e2e TP-05 (375px, кнопки в пределах вьюпорта, кликабельны), NFR-perf (0,5 мс ≪ 100 мс), `test_unit_toggle_has_styles` | **PASS** |
Все 4 критерия имеют поведенческое покрытие в реальном браузере; ни один
не нарушен. Коэффициент `0.621371` и округление до 1 знака подтверждены
и unit-тестами на реальном `units.js`, и e2e-конвертацией.
## Найденные баги
### P0 (блокирующие)
Нет.
### P1 (критические)
Нет.
### P2 (важные)
**T-01 (= R-01 из `12-review.md`) — переключение единиц сбрасывает выбор
варианта связки.** В режиме связки `onUnitChange()` вызывает
`renderLinkCards(linkRoutes)`, которая всегда подсвечивает «Вариант 1»;
выбранный пользователем вариант 2/3 теряется при каждом переключении
км/мили. Дефект **унаследован из ревью** (зафиксирован reviewer'ом как
R-01/P2). **Тестером в этом прогоне не воспроизводился инструментально** —
режим связки требует построенного маршрута и поднятого OSRM, недоступного
в окружении (см. 4 SKIP). Расстояния при этом пересчитываются корректно,
ФТ-3 ТЗ формально выполнено; дефект ограничен UX режима связки.
**P2 — merge/деплой не блокирует.** Действие: dev — поправить в этом же
PR (ввести `activeLinkIdx`) либо осознанно вынести в техдолг.
### P3 (косметика / наблюдения)
1. **(= R-02 из `12-review.md`)** Масштабная линейка в режиме «mi»
использует латиницу и точку (`'0.5 mi'`) вместо запятой и русских
подписей `units.js` (`'0,5 ми'`). Это **пред-существующее** поведение
scale-bar (в режиме «km» и раньше было `'30 km'`), ET-005 лишь
расширил тот же стиль на мили — регрессии нет. e2e подтвердил: scale-bar
корректно меняет суффикс `km↔mi`. Косметика, не блокирует.
2. **(= R-03 из `12-review.md`)** Слой `app.js` (оркестратор,
unit-aware ветка scale-bar) в репозитории покрыт только статикой.
В этом прогоне пробел закрыт **e2e**: оркестратор проверен на живой
масштабной линейке (TP-02/TP-04). Перерисовка карточек маршрута/связки
через `onUnitChange()` инструментально не покрыта (нет OSRM); поведение
подтверждено `units.test.js` на реальном коде + статикой `test_app_js_*`.
Техдолг на DOM/MapLibre-харнесс для `app.js` остаётся.
3. **Окружение тестера.** Пакеты `shapely`, `mapbox-vector-tile`
(`requirements.txt`) и `pytest-asyncio`, `ruff` (`pyproject.toml [dev]`)
не были предустановлены в песочнице — без `shapely` падал сбор
`test_health.py` (импорт `src.api.main`). Тестер доустановил их по
манифестам проекта. Это дефект провижининга окружения, **не дефект
ET-005**. CI обязан выполнять `pip install -r requirements.txt` и
`.[dev]` перед `make test`.
## Замечания тестера
- **e2e-инструментарий.** Playwright + Chromium установлены **только в
песочницу тестера** для исполнения e2e. В артефакты проекта
(`requirements.txt`, `pyproject.toml`, `package.json`) ничего **не
добавлено** — ограничение `07-infra-requirements.md §6` («новые
npm/Python пакеты — Нет», касается production/build-зависимостей) **не
нарушено**. e2e-тесты `04-test-plan.yaml` (TP-01…TP-05) явно ожидаются
`07-infra-requirements.md §9`; здесь они исполнены без изменения кода и
тестов проекта. Скрипт прогона — временный, в репозиторий не коммитится.
- Ручная сверка реализации: `index.html:62-69` — сегментированный
переключатель `#unit-seg` (кнопки `unit-btn-km`/`unit-btn-mi`) в попапе
`#terrain-popup` после чекбокса POI, отделён `<hr>`; `index.html:415` —
`units.js` подключён строго перед `app.js`; `app.js:2877-2948` — блок
ET-005 (`onUnitToggle`, `syncUnitToggleUI`, оркестратор `onUnitChange`);
`app.js:2437-2438` — восстановление выбора и единственная подписка
`unitchange` в `DOMContentLoaded`. Соответствует ТЗ, ADR-0001 и выводам
`12-review.md`.
- Тесты не подгонялись под код; продакшн-код не правился; на prom ничего
не запускалось.
## Итог
**Verdict: PASS** → `stage:ready-to-deploy`.
Весь тест-план (TP-01…TP-05) исполнен в реальном браузере и пройден;
все 4 acceptance-критерия зелёные; pytest-регресс (31 passed) и lint
чистые; JS-ошибок на странице нет; нефункциональное требование
«пересчёт < 100 мс» выполнено с запасом (0,5 мс). Блокирующих (P0/P1)
багов нет. Унаследованный из ревью T-01/P2 (сброс варианта связки)
деплой не блокирует — решение по нему за dev. Готово к штатной
перевыкладке фронтенда согласно `07-infra-requirements.md §7`.

View File

@@ -866,6 +866,26 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
cursor: not-allowed;
}
/* ── ET-005: переключатель единиц измерения (км/мили) в попапе рельефа ── */
.terrain-unit-row {
padding: 8px 4px 2px;
}
.terrain-unit-label {
display: block;
font-size: 15px;
line-height: 1.3;
color: var(--text, #fff);
margin-bottom: 8px;
}
.theme-light .terrain-unit-label {
color: var(--text, #111);
}
/* Сегментированный переключатель внутри попапа — без нижнего отступа,
он последний элемент (см. .seg-control в блоке Segment Control). */
.terrain-unit-row .seg-control {
margin-bottom: 0;
}
/* ── Scale + Zoom bar (one line, top-right) ───────── */
#scale-zoom-bar {
position: absolute;

View File

@@ -184,10 +184,10 @@ function formatDuration(seconds) {
return `${hours} ч ${mins} мин`;
}
// ET-005: форматирование расстояний централизовано в units.js (ADR-0001).
function formatDist(m) {
if (!m) return '-';
if (m >= 1000) return (m / 1000).toFixed(1) + ' км';
return Math.round(m) + ' м';
return Units.formatDistance(m);
}
// ─── Sheet Management ──────────────────────────────────────────────
@@ -633,9 +633,9 @@ function haversineM(a, b) {
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s));
}
// ET-005: единица и разделитель определяются units.js (ADR-0001, риск R4).
function formatSegmentDist(m) {
if (m < 1000) return Math.round(m) + ' м';
return (m / 1000).toFixed(1).replace('.', ',') + ' км';
return Units.formatDistance(m);
}
// Returns array of route-distance segments (meters) for each waypoint.
@@ -1154,7 +1154,6 @@ function renderRouteCards(routes) {
const container = document.getElementById('route-cards');
container.innerHTML = routes.map((route, i) => {
const color = ROUTE_COLORS[i] || '#888888';
const distKm = (route.distance_m / 1000).toFixed(1);
const timeStr = formatDuration(route.duration_s);
const isActive = i === activeRouteIdx;
const s = route.stats || {};
@@ -1165,7 +1164,7 @@ function renderRouteCards(routes) {
<div class="rc-header">
<span class="rc-dot" style="background:${color}"></span>
<span class="rc-title">Вариант ${i + 1}</span>
<span class="rc-meta">${distKm} км · ${timeStr}</span>
<span class="rc-meta">${Units.formatDistance(route.distance_m)} · ${timeStr}</span>
</div>
<div class="rc-bar-wrap">
<div class="rc-bar">
@@ -1188,6 +1187,8 @@ function generateGPX() {
if (!route) return '';
const now = new Date();
const dateStr = now.toISOString().slice(0, 10);
// ET-005: экспорт GPX остаётся метрическим — стандарт GPX и риск R6
// исключают конвертацию выгружаемых данных в мили.
const distKm = (route.distance_m / 1000).toFixed(1);
const dirtPct = route.stats ? route.stats.dirt_total_pct : '?';
@@ -1412,9 +1413,22 @@ async function initMap() {
const targetPx = 80;
const rawMeters = metersPerPixel * targetPx;
// ET-005 (риск R3): масштабная линейка учитывает выбранную единицу.
// niceMeters всегда остаётся в метрах — по нему считается ширина в px.
let distance, unit, niceMeters;
if (rawMeters >= 1000) {
if (window.Units && Units.getUnit() === 'mi') {
const rawMiles = (rawMeters / 1000) * Units.KM_TO_MI;
if (rawMiles >= 1) {
distance = rawMiles >= 100 ? Math.round(rawMiles / 50) * 50 :
rawMiles >= 10 ? Math.round(rawMiles / 5) * 5 :
Math.round(rawMiles);
} else {
distance = Math.max(0.1, Math.round(rawMiles * 10) / 10);
}
unit = 'mi';
niceMeters = (distance / Units.KM_TO_MI) * 1000;
} else if (rawMeters >= 1000) {
const km = rawMeters / 1000;
distance = km >= 100 ? Math.round(km / 50) * 50 :
km >= 10 ? Math.round(km / 5) * 5 :
@@ -1441,6 +1455,8 @@ async function initMap() {
zoomEl.textContent = 'z' + zoom;
}
// ET-005: оркестратор onUnitChange() обновляет линейку при смене единицы.
window._updateScaleZoom = updateScaleZoom;
updateScaleZoom();
map.on('zoom', updateScaleZoom);
map.on('move', updateScaleZoom);
@@ -1473,10 +1489,10 @@ async function initMap() {
maxWidth: '300px',
});
// ET-005: расстояние во всплывающих подсказках — через units.js (ADR-0001).
function formatLength(m) {
if (!m) return '-';
if (m >= 1000) return (m / 1000).toFixed(1) + ' км';
return Math.round(m) + ' м';
return Units.formatDistance(m);
}
function poiTypeLabel(t) {
@@ -1834,7 +1850,8 @@ function updateRulerLine() {
});
}
// Update ruler info display
const dist = rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м';
// ET-005: rulerTotal хранится в км — переводим в метры для units.js.
const dist = Units.formatDistance(rulerTotal * 1000);
document.getElementById('ruler-dist').textContent = dist;
}
@@ -1871,9 +1888,8 @@ function updateRulerLabels() {
const segDist = haversineKm(rulerPoints[i - 1], rulerPoints[i]);
rulerTotal += segDist;
if (labelText) {
labelText.textContent = segDist >= 1
? segDist.toFixed(1) + ' км'
: Math.round(segDist * 1000) + ' м';
// ET-005: segDist в км — переводим в метры для units.js.
labelText.textContent = Units.formatDistance(segDist * 1000);
}
}
// Update remove button index
@@ -1882,7 +1898,8 @@ function updateRulerLabels() {
}
}
// Update total display
const dist = rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м';
// ET-005: rulerTotal хранится в км — переводим в метры для units.js.
const dist = Units.formatDistance(rulerTotal * 1000);
document.getElementById('ruler-dist').textContent = dist;
}
@@ -1927,9 +1944,8 @@ function addRulerPoint(lngLat) {
if (idx === 0) {
labelText.textContent = 'Старт';
} else {
labelText.textContent = segDist >= 1
? segDist.toFixed(1) + ' км'
: Math.round(segDist * 1000) + ' м';
// ET-005: segDist в км — переводим в метры для units.js.
labelText.textContent = Units.formatDistance(segDist * 1000);
}
// Bug 5: use button element for better tap target and semantics
@@ -2105,6 +2121,9 @@ function clearRecon() {
let linkMode = false;
let linkPoints = [];
let linkMarkers = [];
// ET-005: последние построенные связки кэшируются, чтобы оркестратор
// onUnitChange() мог перерисовать карточки без повторного запроса к API.
let linkRoutes = [];
function toggleLinkMode() {
const btn = document.getElementById('tb-link');
@@ -2178,6 +2197,7 @@ async function buildLinkRoute() {
function renderLinkCards(routes) {
const map = window._map;
linkRoutes = routes; // ET-005: кэш для перерисовки при смене единицы
const colors = ['#0066ff', '#00aa44', '#9933cc'];
const cardsEl = document.getElementById('link-cards');
cardsEl.innerHTML = '';
@@ -2199,7 +2219,6 @@ function renderLinkCards(routes) {
layout: { 'line-cap': 'round', 'line-join': 'round' }
});
const km = (r.distance_m / 1000).toFixed(0);
const time = formatDuration(r.duration_s);
const dirt = r.stats?.dirt_total_pct || '?';
const col = colors[i % colors.length];
@@ -2209,7 +2228,7 @@ function renderLinkCards(routes) {
<div class="rc-header">
<span class="rc-dot" style="background:${col}"></span>
<span class="rc-title">Вариант ${i+1}</span>
<span class="rc-km">${km} км</span>
<span class="rc-km">${Units.formatDistance(r.distance_m, { precision: 0 })}</span>
<span class="rc-time">${time}</span>
</div>
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт</div>
@@ -2354,7 +2373,6 @@ function drawScenicRoutes(routes, activeIdx) {
if (cardsEl) {
cardsEl.innerHTML = routes.map((r, i) => {
const col = colors[i % colors.length];
const km = (r.distance_m / 1000).toFixed(0);
const time = formatDuration(r.duration_s);
const dirt = r.stats?.dirt_total_pct || '?';
const pois = (r.scenic_pois || []).map(p => {
@@ -2367,7 +2385,7 @@ function drawScenicRoutes(routes, activeIdx) {
<div class="rc-header">
<span class="rc-dot" style="background:${col}"></span>
<span class="rc-title">${r.name || 'Вариант '+(i+1)}</span>
<span class="rc-km">${km} км</span>
<span class="rc-km">${Units.formatDistance(r.distance_m, { precision: 0 })}</span>
<span class="rc-time">${time}</span>
</div>
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт · score=${r.scenic_score||0}</div>
@@ -2414,6 +2432,10 @@ document.addEventListener('DOMContentLoaded', () => {
initSheetSwipe();
// Apply saved theme immediately (before map loads)
applyTheme();
// ET-005: восстановить выбор единиц измерения (AC-3) и подключить
// единый оркестратор пересчёта расстояний (ADR-0001 п.6).
syncUnitToggleUI();
document.addEventListener('unitchange', onUnitChange);
});
// ─── Mini Route Bar ──────────────────────────────────────────────────
@@ -2602,11 +2624,10 @@ function hideMiniRouteSheet() {
function updateMiniRouteCard() {
const r = routeResults[activeRouteIdx];
if (!r) return;
const km = (r.distance_m / 1000).toFixed(1);
const dirt = r.stats?.dirt_total_pct ?? '-';
document.getElementById('mini-dot').style.background = ROUTE_COLORS[activeRouteIdx % ROUTE_COLORS.length];
document.getElementById('mini-label').textContent = `Вариант ${activeRouteIdx + 1} из ${routeResults.length}`;
document.getElementById('mini-stats').textContent = `${km} км · ${dirt}% грунт`;
document.getElementById('mini-stats').textContent = `${Units.formatDistance(r.distance_m)} · ${dirt}% грунт`;
document.getElementById('mini-prev').style.opacity = activeRouteIdx > 0 ? '1' : '0.3';
document.getElementById('mini-next').style.opacity = activeRouteIdx < routeResults.length - 1 ? '1' : '0.3';
}
@@ -2853,6 +2874,79 @@ function restorePoiState() {
}
// <<< ET-002 POI visibility block <<<
// >>> ET-005 unit toggle block >>>
// Переключатель единиц измерения расстояний (км/мили) в попапе рельефа.
// Выбор единицы, его персистентность и форматирование вынесены в
// src/web/units.js (ADR-0001). Здесь — UI-обработчик попапа и единый
// оркестратор пересчёта видимых расстояний.
// См. docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md
/**
* Обработчик сегментированного переключателя единиц в попапе рельефа
* (атрибут onclick кнопок «км» / «мили»).
*
* Делегирует смену единицы модулю Units. Пересчёт всех видимых
* расстояний выполняет оркестратор onUnitChange() по событию
* 'unitchange'; здесь дополнительно синхронизируется вид кнопок.
* @param {('km'|'mi')} unit - выбранная единица измерения.
*/
function onUnitToggle(unit) {
Units.setUnit(unit);
syncUnitToggleUI();
}
/**
* Синхронизирует визуальное состояние кнопок «км» / «мили» с текущей
* выбранной единицей измерения.
*
* Вызывается при инициализации страницы (восстановление выбора из
* localStorage — AC-3) и после каждого переключения.
*/
function syncUnitToggleUI() {
const unit = Units.getUnit();
const kmBtn = document.getElementById('unit-btn-km');
const miBtn = document.getElementById('unit-btn-mi');
if (kmBtn) kmBtn.classList.toggle('active', unit === 'km');
if (miBtn) miBtn.classList.toggle('active', unit === 'mi');
}
/**
* Единый оркестратор пересчёта расстояний при смене единицы измерения
* (ADR-0001 п.6). Подписан на событие 'unitchange' ровно один раз —
* вместо россыпи подписок по компонентам пере-вызывает функции
* отрисовки всех видимых поверхностей с расстояниями.
*
* Внутреннее состояние остаётся метрическим: конвертация выполняется
* исключительно в Units.formatDistance().
*/
function onUnitChange() {
// Карточки основного маршрута, лист точек и мини-карточка.
if (routeResults.length > 0) {
renderRouteCards(routeResults);
updateMiniRouteCard();
}
if (routeWaypoints.length > 0) {
renderWaypointsList();
}
// Линейка: updateRulerLabels() обновляет и подписи отрезков, и итог.
if (rulerMarkers.length > 0) {
updateRulerLabels();
}
// Карточки связки и «красивого» маршрута — только при активном режиме,
// чтобы перерисовка не возвращала на карту скрытые слои.
if (linkMode && linkRoutes.length > 0) {
renderLinkCards(linkRoutes);
}
if (scenicMode && scenicRoutes.length > 0) {
drawScenicRoutes(scenicRoutes, activeScenicIdx);
}
// Масштабная линейка карты (риск R3).
if (typeof window._updateScaleZoom === 'function') {
window._updateScaleZoom();
}
}
// <<< ET-005 unit toggle block <<<
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
const map = window._map;
if (!map) return;

View File

@@ -58,6 +58,15 @@
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
<span>POI</span>
</label>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<!-- ET-005: переключатель единиц измерения расстояний (км/мили) -->
<div class="terrain-unit-row">
<span class="terrain-unit-label">Единицы</span>
<div class="seg-control unit-seg" id="unit-seg">
<button type="button" class="seg-btn" id="unit-btn-km" data-unit="km" onclick="onUnitToggle('km')">км</button>
<button type="button" class="seg-btn" id="unit-btn-mi" data-unit="mi" onclick="onUnitToggle('mi')">мили</button>
</div>
</div>
</div>
<!-- ── Map Buttons (right) ───────────────── -->
@@ -402,6 +411,8 @@
<!-- Scripts -->
<script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script>
<!-- ET-005: units.js ДОЛЖЕН подключаться строго перед app.js (ADR-0001 п.2, риск R7) -->
<script src="units.js"></script>
<script src="app.js"></script>
</body>
</html>

190
src/web/units.js Normal file
View File

@@ -0,0 +1,190 @@
'use strict';
/**
* units.js — ET-005: централизованный модуль единиц измерения расстояний.
*
* Единственный источник истины по выбору единицы (км/мили), её
* персистентности и форматированию ОТОБРАЖАЕМЫХ расстояний.
*
* Подключается в index.html как классический скрипт СТРОГО перед app.js
* (ADR-0001 п.2, риск R7 в 10-tech-risks.md) и публикует глобальный
* неймспейс `window.Units`. Для unit-тестов модуль дополнительно
* экспортируется через `module.exports` (среда Node).
*
* Каноническая единица — МЕТРЫ: `formatDistance()` всегда принимает
* расстояние в метрах, конвертация в мили выполняется только в момент
* форматирования строки (ADR-0001 п.4, 08-data-requirements.md §3).
* Хранить или пересчитывать значения в милях во внутреннем состоянии
* запрещено — это исключает накопление ошибок округления при
* многократном переключении (ФТ-3 ТЗ, AC-2, тест TP-04).
*
* См. docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md
*/
(function (global) {
'use strict';
/** Ключ localStorage для сохранённой единицы (02-trz.md, 08-data-requirements.md §4). */
var STORAGE_KEY = 'distance_unit';
/** Коэффициент перевода: 1 км = 0.621371 мили (AC-2). Единственное место объявления. */
var KM_TO_MI = 0.621371;
/** Единица по умолчанию при отсутствии/некорректности сохранённого значения (AC-3). */
var DEFAULT_UNIT = 'km';
/** Допустимые значения единицы измерения. */
var VALID_UNITS = ['km', 'mi'];
/** Разделитель дробной части — запятая (R4: единый разделитель, ru-локаль интерфейса). */
var DECIMAL_SEP = ',';
/** Подписи единиц в UI. */
var UNIT_LABEL = { km: 'км', mi: 'ми' };
/** Подпись суб-километровых значений в режиме «км». */
var SUBKM_LABEL = 'м';
/** Рантайм-кэш текущей единицы; null — ещё не прочитана из localStorage. */
var current = null;
/**
* Безопасное чтение localStorage: возвращает null, если хранилище
* недоступно (private mode, отключённые cookies и т.п.).
* @returns {?string}
*/
function readStored() {
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch (e) {
return null;
}
}
/**
* Безопасная запись в localStorage. Недоступность хранилища не
* считается ошибкой — выбор просто не персистится между сессиями.
* @param {string} value
*/
function writeStored(value) {
try {
window.localStorage.setItem(STORAGE_KEY, value);
} catch (e) {
/* localStorage недоступен — намеренно проглатываем */
}
}
/**
* Проверяет, что переданное значение — допустимая единица измерения.
* @param {*} unit
* @returns {boolean}
*/
function isValidUnit(unit) {
return VALID_UNITS.indexOf(unit) !== -1;
}
/**
* Возвращает текущую выбранную единицу измерения.
*
* При первом обращении значение читается из localStorage; отсутствующее
* или некорректное значение даёт дефолт `'km'` (AC-3, TP-01).
* @returns {('km'|'mi')}
*/
function getUnit() {
if (current === null) {
var stored = readStored();
current = isValidUnit(stored) ? stored : DEFAULT_UNIT;
}
return current;
}
/**
* Устанавливает единицу измерения.
*
* Валидирует значение, сохраняет его в localStorage и диспатчит
* событие `unitchange` на `document` (ADR-0001 п.3, п.6). Некорректное
* значение игнорируется. Если единица фактически не изменилась — ни
* записи, ни события не происходит.
* @param {('km'|'mi')} unit
* @returns {('km'|'mi')} актуальная единица после вызова
*/
function setUnit(unit) {
if (!isValidUnit(unit)) return getUnit();
if (getUnit() === unit) return current;
current = unit;
writeStored(unit);
try {
document.dispatchEvent(new CustomEvent('unitchange', { detail: { unit: unit } }));
} catch (e) {
/* document/CustomEvent недоступны (headless-окружение) */
}
return current;
}
/**
* Переключает единицу измерения km ⇄ mi.
* @returns {('km'|'mi')} единица после переключения
*/
function toggleUnit() {
return setUnit(getUnit() === 'km' ? 'mi' : 'km');
}
/**
* Форматирует число с фиксированной точностью и единым разделителем
* дробной части (R4).
* @param {number} value
* @param {number} precision - знаков после запятой
* @returns {string}
*/
function formatNumber(value, precision) {
var s = value.toFixed(precision);
return DECIMAL_SEP === '.' ? s : s.replace('.', DECIMAL_SEP);
}
/**
* Форматирует расстояние, заданное в МЕТРАХ, строкой в текущей единице.
*
* Политика суб-километровых расстояний (R2 в 10-tech-risks.md):
* - режим «km»: значения < 1000 м показываются в метрах ('850 м');
* - режим «mi»: всё показывается в милях; для значений < 1000 м
* точность повышается минимум до 2 знаков, чтобы короткие отрезки
* не схлопывались в «0 ми». Футы намеренно не вводятся.
*
* @param {number} meters - расстояние в метрах (каноническая единица).
* @param {object} [opts]
* @param {number} [opts.precision=1] - знаков после запятой для значения
* в км/милях. По умолчанию 1 (AC-2). Карточки маршрутов
* запрашивают `precision: 0` ради целочисленного вида (R5).
* @returns {string} например '12,3 км', '7,6 ми', '850 м', '0,53 ми'.
*/
function formatDistance(meters, opts) {
if (meters === null || meters === undefined || isNaN(meters)) return '-';
var precision = (opts && typeof opts.precision === 'number') ? opts.precision : 1;
if (getUnit() === 'mi') {
var miles = (meters / 1000) * KM_TO_MI;
// R2: суб-километровые расстояния в милях — с повышенной точностью.
var miPrecision = (meters < 1000) ? Math.max(precision, 2) : precision;
return formatNumber(miles, miPrecision) + ' ' + UNIT_LABEL.mi;
}
// Режим «км».
if (meters < 1000) {
return Math.round(meters) + ' ' + SUBKM_LABEL;
}
return formatNumber(meters / 1000, precision) + ' ' + UNIT_LABEL.km;
}
/** Публичный контракт модуля (ADR-0001 п.3). */
var Units = {
STORAGE_KEY: STORAGE_KEY,
KM_TO_MI: KM_TO_MI,
getUnit: getUnit,
setUnit: setUnit,
toggleUnit: toggleUnit,
formatDistance: formatDistance,
};
// Браузер — глобальный неймспейс window.Units (соответствует стилю app.js).
if (global) {
global.Units = Units;
}
// Node — экспорт для изолированных unit-тестов.
if (typeof module === 'object' && module.exports) {
module.exports = Units;
}
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null));

View File

@@ -0,0 +1,211 @@
"""ET-005 — тесты переключения единиц измерения расстояний (км/мили).
Изменение ET-005 — исключительно фронтендовое: новый модуль
`src/web/units.js` плюс правки `src/web/index.html`, `src/web/app.js`,
`src/web/app.css` (см. `06-adr/adr-0001-unit-toggle-client-side.md`).
В CI исполняется только `pytest tests/`, поэтому файл покрывает фичу
двумя способами:
1. Статические проверки структуры `index.html`, `app.js`, `units.js` —
выполняются всегда, без внешних зависимостей.
2. Поведенческие JS unit-тесты (TP-01..TP-04 из `04-test-plan.yaml`) —
запускаются через встроенный тест-раннер Node (`node --test`). Если
`node` в системе отсутствует — эта часть помечается `skip` (по аналогии
с `tests/unit/test_poi_toggle.py`).
Браузерный e2e-сценарий TP-05 (mobile responsive) требует Playwright-
инфраструктуры, которой в репозитории нет; его поведенческая суть в
доступном объёме покрыта статическими проверками UI ниже.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from shutil import which
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
INDEX_HTML = REPO_ROOT / "src" / "web" / "index.html"
APP_JS = REPO_ROOT / "src" / "web" / "app.js"
UNITS_JS = REPO_ROOT / "src" / "web" / "units.js"
APP_CSS = REPO_ROOT / "src" / "web" / "app.css"
JS_TEST = REPO_ROOT / "tests" / "unit" / "units.test.js"
def _read(path: Path) -> str:
assert path.is_file(), f"не найден {path}"
return path.read_text(encoding="utf-8")
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки units.js (ADR-0001, 08-data-requirements.md)
# ──────────────────────────────────────────────────────────────────────────────
def test_units_module_exists():
"""ADR-0001 п.2: модуль размещён в src/web/units.js (не static/js/)."""
assert UNITS_JS.is_file(), "не найден src/web/units.js"
def test_units_module_public_api():
"""ADR-0001 п.3: модуль определяет публичный контракт Units."""
js = _read(UNITS_JS)
for fn in ("getUnit", "setUnit", "toggleUnit", "formatDistance"):
assert f"function {fn}(" in js, f"в units.js не определена функция {fn}()"
def test_units_module_constants():
"""AC-2 / 08-data-requirements.md §5: коэффициент и ключ хранилища."""
js = _read(UNITS_JS)
assert "0.621371" in js, "в units.js нет коэффициента перевода 1 км = 0.621371 ми"
assert "'distance_unit'" in js, "ключ localStorage distance_unit не объявлен"
def test_units_module_exports_for_browser_and_node():
"""ADR-0001 п.3: window.Units для браузера и module.exports для тестов."""
js = _read(UNITS_JS)
assert "global.Units" in js, "units.js не публикует глобальный неймспейс Units"
assert "module.exports" in js, "units.js не экспортируется для Node unit-тестов"
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки index.html (AC-1, риск R7)
# ──────────────────────────────────────────────────────────────────────────────
def test_unit_toggle_present_in_html():
"""AC-1: сегментированный переключатель км/мили присутствует в попапе."""
html = _read(INDEX_HTML)
assert 'id="unit-seg"' in html, "нет переключателя единиц unit-seg"
assert 'id="unit-btn-km"' in html, "нет кнопки «км»"
assert 'id="unit-btn-mi"' in html, "нет кнопки «мили»"
assert "onclick=\"onUnitToggle('km')\"" in html, "кнопка «км» не привязана к onUnitToggle"
assert "onclick=\"onUnitToggle('mi')\"" in html, "кнопка «мили» не привязана к onUnitToggle"
def test_unit_toggle_reuses_seg_control_component():
"""ADR-0001 п.8: переиспользуется готовый компонент .seg-control."""
html = _read(INDEX_HTML)
start = html.index('id="unit-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_units_js_loaded_before_app_js():
"""Риск R7: units.js подключается строго перед app.js."""
html = _read(INDEX_HTML)
units_pos = html.find('src="units.js"')
app_pos = html.find('src="app.js"')
assert units_pos != -1, "units.js не подключён в index.html"
assert app_pos != -1, "app.js не подключён в index.html"
assert units_pos < app_pos, "units.js должен подключаться ДО app.js (риск R7)"
def test_unit_toggle_has_styles():
"""AC-4: для строки переключателя единиц заданы стили в app.css."""
css = _read(APP_CSS)
assert ".terrain-unit-row" in css, "нет стилей строки переключателя единиц"
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки app.js (ADR-0001 п.6-7, риски R1, R3, R6)
# ──────────────────────────────────────────────────────────────────────────────
def test_app_js_unit_functions_defined():
"""ADR-0001 п.6: определены UI-обработчик и оркестратор пересчёта."""
js = _read(APP_JS)
for fn in ("onUnitToggle", "syncUnitToggleUI", "onUnitChange"):
assert f"function {fn}(" in js, f"не определена функция {fn}()"
def test_app_js_has_et005_block_markers():
"""Блок ET-005 обрамлён маркерами (как POI-блок ET-002)."""
js = _read(APP_JS)
assert "// >>> ET-005 unit toggle block >>>" in js, "нет открывающего маркера блока ET-005"
assert "// <<< ET-005 unit toggle block <<<" in js, "нет закрывающего маркера блока ET-005"
def test_app_js_single_unitchange_subscription():
"""ADR-0001 п.6: ровно одна подписка-оркестратор на событие unitchange."""
js = _read(APP_JS)
assert js.count("addEventListener('unitchange'") == 1, (
"подписка на unitchange должна быть единственной (оркестратор)"
)
assert "addEventListener('unitchange', onUnitChange)" in js, (
"событие unitchange не привязано к оркестратору onUnitChange"
)
def test_app_js_uses_centralized_formatter():
"""Риск R1: места форматирования расстояний переведены на units.js."""
js = _read(APP_JS)
# 13 call-sites из 10-tech-risks.md R1 минус GPX (R6) → ≥ 11 вызовов.
assert js.count("Units.formatDistance(") >= 11, (
"не все места форматирования расстояний переведены на Units.formatDistance()"
)
def test_app_js_distance_helpers_delegate_to_units():
"""Риск R1/R4: единые форматтеры делегируют расчёт модулю units.js."""
js = _read(APP_JS)
for fn in ("formatDist", "formatSegmentDist"):
idx = js.index(f"function {fn}(")
body = js[idx:idx + 220]
assert "Units.formatDistance(" in body, (
f"{fn}() должен делегировать форматирование в units.js"
)
def test_app_js_scale_bar_is_unit_aware():
"""Риск R3: масштабная линейка учитывает выбранную единицу."""
js = _read(APP_JS)
assert "Units.getUnit() === 'mi'" in js, "scale-bar не реагирует на режим миль (R3)"
assert "window._updateScaleZoom" in js, (
"updateScaleZoom не доступен оркестратору onUnitChange()"
)
def test_app_js_gpx_export_stays_metric():
"""Риск R6: экспорт GPX не конвертируется в мили (остаётся метрическим)."""
js = _read(APP_JS)
gpx_start = js.index("function generateGPX(")
gpx_body = js[gpx_start:gpx_start + 1400]
assert "Units.formatDistance(" not in gpx_body, (
"generateGPX() не должен конвертировать расстояния (риск R6)"
)
def test_app_js_restores_unit_choice_on_load():
"""AC-3: выбор единицы восстанавливается при инициализации страницы."""
js = _read(APP_JS)
assert "syncUnitToggleUI()" in js, "состояние переключателя не восстанавливается при загрузке"
# ──────────────────────────────────────────────────────────────────────────────
# Поведенческие JS unit-тесты через Node (TP-01..TP-04)
# ──────────────────────────────────────────────────────────────────────────────
node_required = pytest.mark.skipif(
which("node") is None,
reason="node не установлен — поведенческие JS unit-тесты пропущены",
)
@node_required
def test_js_unit_tests_pass():
"""TP-01..TP-04: запускает behavioral JS-тесты units.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}"
)

219
tests/unit/units.test.js Normal file
View File

@@ -0,0 +1,219 @@
'use strict';
/**
* ET-005 — поведенческие unit-тесты модуля единиц измерения.
*
* Покрывают TP-01..TP-04 из docs/work-items/ET-005/04-test-plan.yaml и
* критерии AC-2/AC-3 из 03-acceptance-criteria.md.
*
* Тесты исполняют РЕАЛЬНЫЙ модуль src/web/units.js: перед каждым тестом
* подставляются мок-зависимости (window.localStorage, document) и модуль
* загружается заново со сбросом кэша require — так гарантируется чистое
* состояние (рантайм-кэш выбранной единицы внутри модуля).
*
* Запуск: `node --test tests/unit/units.test.js`
* (в CI оборачивается pytest-тестом tests/unit/test_unit_toggle.py).
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const UNITS_PATH = path.join(__dirname, '..', '..', 'src', 'web', 'units.js');
/**
* Готовит изолированное мок-окружение и загружает свежий модуль units.js.
* @param {object} [opts]
* @param {string} [opts.stored] - значение ключа distance_unit в localStorage
* (если не указан — ключ отсутствует).
* @param {boolean} [opts.noStorage] - смоделировать недоступный localStorage.
* @returns {{Units: object, events: object[], calls: object, store: object}}
*/
function loadUnits({ stored, noStorage = false } = {}) {
const store = {};
if (stored !== undefined) store['distance_unit'] = stored;
const events = [];
const calls = { setItem: [] };
const localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); calls.setItem.push([k, String(v)]); },
};
global.window = noStorage
? { get localStorage() { throw new Error('localStorage disabled'); } }
: { localStorage };
global.document = {
dispatchEvent: (ev) => { events.push(ev); return true; },
};
delete require.cache[require.resolve(UNITS_PATH)];
const Units = require(UNITS_PATH);
return { Units, events, calls, store };
}
// ── TP-01: единица по умолчанию — километры ─────────────────────────────
test('TP-01: без сохранённого значения getUnit() возвращает km', () => {
const { Units } = loadUnits();
assert.equal(Units.getUnit(), 'km');
});
test('TP-01: некорректное сохранённое значение даёт дефолт km', () => {
const { Units } = loadUnits({ stored: 'parsec' });
assert.equal(Units.getUnit(), 'km');
});
test('TP-01: в режиме km расстояния форматируются в км/м', () => {
const { Units } = loadUnits();
assert.equal(Units.formatDistance(12340), '12,3 км');
assert.equal(Units.formatDistance(850), '850 м');
});
// ── TP-02: переключение на мили ─────────────────────────────────────────
test('TP-02: setUnit("mi") меняет единицу, пишет localStorage и шлёт событие', () => {
const { Units, events, calls } = loadUnits();
const result = Units.setUnit('mi');
assert.equal(result, 'mi');
assert.equal(Units.getUnit(), 'mi');
assert.deepEqual(calls.setItem, [['distance_unit', 'mi']]);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'unitchange');
assert.equal(events[0].detail.unit, 'mi');
});
test('TP-02: в режиме mi расстояния пересчитываются в мили', () => {
const { Units } = loadUnits();
Units.setUnit('mi');
// 100 км × 0.621371 = 62.1371 мили
assert.equal(Units.formatDistance(100000), '62,1 ми');
// карточки маршрутов запрашивают целочисленную точность (R5)
assert.equal(Units.formatDistance(50000, { precision: 0 }), '31 ми');
});
test('TP-02: суб-километровые значения в режиме mi — с повышенной точностью (R2)', () => {
const { Units } = loadUnits();
Units.setUnit('mi');
// 850 м × 0.621371 / 1000 = 0.528... → 2 знака, футы не вводятся
assert.equal(Units.formatDistance(850), '0,53 ми');
});
// ── TP-03: персистентность выбора между загрузками страницы ─────────────
test('TP-03: сохранённое "mi" восстанавливается при следующей загрузке', () => {
const { Units } = loadUnits({ stored: 'mi' });
assert.equal(Units.getUnit(), 'mi');
});
test('TP-03: setUnit() сохраняет выбор в localStorage для будущих сессий', () => {
const first = loadUnits();
first.Units.setUnit('mi');
assert.equal(first.store['distance_unit'], 'mi');
// Имитация перезагрузки страницы: новый модуль с тем же хранилищем.
const reloaded = loadUnits({ stored: first.store['distance_unit'] });
assert.equal(reloaded.Units.getUnit(), 'mi');
});
// ── TP-04: обратное переключение на километры ───────────────────────────
test('TP-04: setUnit("km") из режима mi возвращает километры', () => {
const { Units } = loadUnits({ stored: 'mi' });
assert.equal(Units.getUnit(), 'mi');
Units.setUnit('km');
assert.equal(Units.getUnit(), 'km');
assert.equal(Units.formatDistance(12340), '12,3 км');
});
test('TP-04: многократное переключение не накапливает ошибку округления', () => {
const { Units } = loadUnits();
const meters = 12340;
const original = Units.formatDistance(meters);
for (let i = 0; i < 20; i++) {
Units.toggleUnit();
}
// 20 переключений = чётное число → снова km; значение не «дрейфует»,
// потому что каноническое состояние всегда в метрах (ADR-0001 п.4).
assert.equal(Units.getUnit(), 'km');
assert.equal(Units.formatDistance(meters), original);
});
// ── toggleUnit() ────────────────────────────────────────────────────────
test('toggleUnit() переключает km ⇄ mi', () => {
const { Units } = loadUnits();
assert.equal(Units.toggleUnit(), 'mi');
assert.equal(Units.toggleUnit(), 'km');
});
// ── Валидация setUnit() ─────────────────────────────────────────────────
test('setUnit() игнорирует некорректное значение без события и записи', () => {
const { Units, events, calls } = loadUnits();
const result = Units.setUnit('nautical-miles');
assert.equal(result, 'km');
assert.equal(Units.getUnit(), 'km');
assert.deepEqual(calls.setItem, []);
assert.equal(events.length, 0);
});
test('setUnit() с уже выбранной единицей не шлёт событие повторно', () => {
const { Units, events, calls } = loadUnits();
Units.setUnit('km'); // единица не изменилась
assert.equal(events.length, 0);
assert.deepEqual(calls.setItem, []);
});
// ── AC-2: коэффициент перевода и разделитель дробной части ──────────────
test('AC-2: KM_TO_MI равен 0.621371', () => {
const { Units } = loadUnits();
assert.equal(Units.KM_TO_MI, 0.621371);
});
test('AC-2/R4: дробная часть отделяется запятой, а не точкой', () => {
const { Units } = loadUnits();
assert.ok(Units.formatDistance(12340).includes(','));
assert.ok(!Units.formatDistance(12340).includes('.'));
});
test('AC-2: точность по умолчанию — 1 знак после запятой', () => {
const { Units } = loadUnits();
assert.equal(Units.formatDistance(1000), '1,0 км');
});
// ── Граничные значения formatDistance() ─────────────────────────────────
test('formatDistance() возвращает "-" для отсутствующего/нечислового значения', () => {
const { Units } = loadUnits();
assert.equal(Units.formatDistance(null), '-');
assert.equal(Units.formatDistance(undefined), '-');
assert.equal(Units.formatDistance(NaN), '-');
});
test('formatDistance() переключает км/м на границе 1000 м', () => {
const { Units } = loadUnits();
assert.equal(Units.formatDistance(999), '999 м');
assert.equal(Units.formatDistance(1000), '1,0 км');
});
// ── Устойчивость к недоступному localStorage (private mode) ─────────────
test('недоступный localStorage не ломает getUnit()/setUnit()', () => {
const { Units } = loadUnits({ noStorage: true });
assert.equal(Units.getUnit(), 'km');
assert.doesNotThrow(() => Units.setUnit('mi'));
assert.equal(Units.getUnit(), 'mi');
});
// ── Публикация глобального неймспейса window.Units (R7) ─────────────────
test('модуль публикует window.Units для классического подключения', () => {
const { Units } = loadUnits();
assert.equal(global.window.Units, Units);
assert.equal(typeof Units.formatDistance, 'function');
assert.equal(typeof Units.getUnit, 'function');
assert.equal(typeof Units.setUnit, 'function');
});