---
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`.
