18 KiB
type, work_item_id, title, version, status, verdict, created_at, authors
| type | work_item_id | title | version | status | verdict | created_at | authors | |
|---|---|---|---|---|---|---|---|---|
| review | ET-005 | Code Review: Переключение единиц измерения расстояний (км/мили) | 1 | approved | APPROVED | 2026-05-21 |
|
Code Review — ET-005
Вердикт
APPROVED (с комментариями).
P0/P1-findings нет. Зафиксировано 1×P2 и 2×P3 — все некритичные, не блокируют merge. P2 — побочный эффект оркестратора (сброс выбора варианта связки при переключении единиц), не нарушает требований ТЗ.
Объект ревью
- Ветка:
feature/ET-005- - Код-коммит:
2fe5cfefeat(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 не блокирует.