ET-005: переключатель единиц измерения расстояний (км/мили) #6
@@ -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`
|
||||
|
||||
7
docs/work-items/ET-005/00-business-request.md
Normal file
7
docs/work-items/ET-005/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Добавить кнопку переключения единиц измерения (км/мили)
|
||||
|
||||
Work Item ID: ET-005
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
18
docs/work-items/ET-005/01-brd.md
Normal file
18
docs/work-items/ET-005/01-brd.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# BRD: Переключение единиц измерения (км/мили)
|
||||
|
||||
## Проблема
|
||||
Пользователи из разных стран используют разные единицы измерения. UI показывает только километры.
|
||||
|
||||
## Решение
|
||||
Кнопка переключения км/мили в панели настроек карты.
|
||||
|
||||
## Метрики успеха
|
||||
- Переключение за 1 клик
|
||||
- Сохранение в localStorage
|
||||
- Мгновенный пересчёт расстояний
|
||||
|
||||
## Scope
|
||||
- Кнопка в панели настроек
|
||||
- Пересчёт расстояний
|
||||
- localStorage persistence
|
||||
- Default: км
|
||||
19
docs/work-items/ET-005/02-trz.md
Normal file
19
docs/work-items/ET-005/02-trz.md
Normal 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"
|
||||
21
docs/work-items/ET-005/03-acceptance-criteria.md
Normal file
21
docs/work-items/ET-005/03-acceptance-criteria.md
Normal 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 (без видимой задержки)
|
||||
33
docs/work-items/ET-005/04-test-plan.yaml
Normal file
33
docs/work-items/ET-005/04-test-plan.yaml
Normal 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
|
||||
@@ -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()` (стр. 187–189), карточки сегментов
|
||||
(638), карточки маршрута (1157, 1191, 2202, 2212, 2357, 2370, 2605),
|
||||
scale-bar (1416–1440), всплывающие подсказки (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` (стр. 360–363).
|
||||
|
||||
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`
|
||||
107
docs/work-items/ET-005/07-infra-requirements.md
Normal file
107
docs/work-items/ET-005/07-infra-requirements.md
Normal 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` не требуется.
|
||||
76
docs/work-items/ET-005/08-data-requirements.md
Normal file
76
docs/work-items/ET-005/08-data-requirements.md
Normal 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`. Требований
|
||||
к резервному копированию, ретенции и приватности не возникает.
|
||||
128
docs/work-items/ET-005/10-tech-risks.md
Normal file
128
docs/work-items/ET-005/10-tech-risks.md
Normal 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()` ≈ стр. 187–189; карточка сегмента ≈ 638; карточки
|
||||
маршрута ≈ 1157, 1191, 2202/2212, 2357/2370, 2605; scale-bar
|
||||
≈ 1416–1440; всплывающая подсказка ≈ 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`, стр. 1416–1440) уже
|
||||
содержит независимую логику `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:130–180`). Это **входные
|
||||
параметры**, а не отображаемые расстояния.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:** 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` или возврат в Анализ не требуются.
|
||||
224
docs/work-items/ET-005/12-review.md
Normal file
224
docs/work-items/ET-005/12-review.md
Normal 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. Риски R1–R8 из
|
||||
`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 (пп.1–9) соблюдён
|
||||
полностью, риски R1–R8 отработаны. Архитектурные отклонения от раздела
|
||||
«Технический дизайн» ТЗ легитимны и зафиксированы в ADR. Дублирования
|
||||
нет, форматирование централизовано, защитное программирование на месте,
|
||||
модуль `units.js` тщательно покрыт unit-тестами на реальном коде.
|
||||
|
||||
Блокирующих замечаний нет.
|
||||
|
||||
**Вердикт: APPROVED.** R-01 (P2) рекомендуется поправить в этом же PR
|
||||
либо осознанно вынести в техдолг; R-02/R-03 (P3) — на усмотрение
|
||||
разработчика. Ни одно замечание merge не блокирует.
|
||||
247
docs/work-items/ET-005/13-test-report.md
Normal file
247
docs/work-items/ET-005/13-test-report.md
Normal 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`.
|
||||
@@ -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;
|
||||
|
||||
142
src/web/app.js
142
src/web/app.js
@@ -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;
|
||||
|
||||
@@ -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
190
src/web/units.js
Normal 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));
|
||||
211
tests/unit/test_unit_toggle.py
Normal file
211
tests/unit/test_unit_toggle.py
Normal 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
219
tests/unit/units.test.js
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user