From d16a4747a007be917c650032ae2547d9da7f7120 Mon Sep 17 00:00:00 2001 From: Slava Date: Thu, 21 May 2026 22:02:32 +0300 Subject: [PATCH 1/9] docs: init ET-005 business request --- docs/work-items/ET-005/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ET-005/00-business-request.md diff --git a/docs/work-items/ET-005/00-business-request.md b/docs/work-items/ET-005/00-business-request.md new file mode 100644 index 0000000..ed5cded --- /dev/null +++ b/docs/work-items/ET-005/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Добавить кнопку переключения единиц измерения (км/мили) + +Work Item ID: ET-005 + +## Description + +TBD From 420df39347ab8e212b003b274f4e01c8c3dde9ed Mon Sep 17 00:00:00 2001 From: Slava Date: Thu, 21 May 2026 22:05:07 +0300 Subject: [PATCH 2/9] docs(ET-005): BRD --- docs/work-items/ET-005/01-brd.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/work-items/ET-005/01-brd.md diff --git a/docs/work-items/ET-005/01-brd.md b/docs/work-items/ET-005/01-brd.md new file mode 100644 index 0000000..9a4b612 --- /dev/null +++ b/docs/work-items/ET-005/01-brd.md @@ -0,0 +1,18 @@ +# BRD: Переключение единиц измерения (км/мили) + +## Проблема +Пользователи из разных стран используют разные единицы измерения. UI показывает только километры. + +## Решение +Кнопка переключения км/мили в панели настроек карты. + +## Метрики успеха +- Переключение за 1 клик +- Сохранение в localStorage +- Мгновенный пересчёт расстояний + +## Scope +- Кнопка в панели настроек +- Пересчёт расстояний +- localStorage persistence +- Default: км \ No newline at end of file From 2f2beb04478cc558660afd23264511ec7fe3e871 Mon Sep 17 00:00:00 2001 From: Slava Date: Thu, 21 May 2026 22:05:21 +0300 Subject: [PATCH 3/9] docs(ET-005): TRZ --- docs/work-items/ET-005/02-trz.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/work-items/ET-005/02-trz.md diff --git a/docs/work-items/ET-005/02-trz.md b/docs/work-items/ET-005/02-trz.md new file mode 100644 index 0000000..298d875 --- /dev/null +++ b/docs/work-items/ET-005/02-trz.md @@ -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" \ No newline at end of file From 2d29106c45c42aa7f0cc8b4f70eb55fa1fdc293d Mon Sep 17 00:00:00 2001 From: Slava Date: Thu, 21 May 2026 22:05:33 +0300 Subject: [PATCH 4/9] docs(ET-005): Acceptance Criteria --- .../ET-005/03-acceptance-criteria.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/work-items/ET-005/03-acceptance-criteria.md diff --git a/docs/work-items/ET-005/03-acceptance-criteria.md b/docs/work-items/ET-005/03-acceptance-criteria.md new file mode 100644 index 0000000..516a852 --- /dev/null +++ b/docs/work-items/ET-005/03-acceptance-criteria.md @@ -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 (без видимой задержки) \ No newline at end of file From 2458a3efe0e3f540530ad3ee4718084ccadcd701 Mon Sep 17 00:00:00 2001 From: Slava Date: Thu, 21 May 2026 22:07:40 +0300 Subject: [PATCH 5/9] docs(ET-005): Test Plan --- docs/work-items/ET-005/04-test-plan.yaml | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/work-items/ET-005/04-test-plan.yaml diff --git a/docs/work-items/ET-005/04-test-plan.yaml b/docs/work-items/ET-005/04-test-plan.yaml new file mode 100644 index 0000000..1c85195 --- /dev/null +++ b/docs/work-items/ET-005/04-test-plan.yaml @@ -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 \ No newline at end of file From bafbea2dab4f71ca95579b29f3566e9879dc6115 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Thu, 21 May 2026 22:18:08 +0300 Subject: [PATCH 6/9] architect(ET-005): ADR, infra-requirements, data-requirements, tech-risks --- .../adr-0001-unit-toggle-client-side.md | 205 ++++++++++++++++++ .../ET-005/07-infra-requirements.md | 107 +++++++++ .../work-items/ET-005/08-data-requirements.md | 76 +++++++ docs/work-items/ET-005/10-tech-risks.md | 128 +++++++++++ 4 files changed, 516 insertions(+) create mode 100644 docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md create mode 100644 docs/work-items/ET-005/07-infra-requirements.md create mode 100644 docs/work-items/ET-005/08-data-requirements.md create mode 100644 docs/work-items/ET-005/10-tech-risks.md diff --git a/docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md b/docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md new file mode 100644 index 0000000..84a4dc3 --- /dev/null +++ b/docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md @@ -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 строк) подключается как **классический скрипт** + (``, `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`**: + `` затем ` + + diff --git a/src/web/units.js b/src/web/units.js new file mode 100644 index 0000000..43e1d5f --- /dev/null +++ b/src/web/units.js @@ -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)); diff --git a/tests/unit/test_unit_toggle.py b/tests/unit/test_unit_toggle.py new file mode 100644 index 0000000..a4bb04d --- /dev/null +++ b/tests/unit/test_unit_toggle.py @@ -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("", 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}" + ) diff --git a/tests/unit/units.test.js b/tests/unit/units.test.js new file mode 100644 index 0000000..4b9ed5f --- /dev/null +++ b/tests/unit/units.test.js @@ -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'); +}); From d32ad8f0180ccad44df1e7f63c5da91eb3ef756e Mon Sep 17 00:00:00 2001 From: claude-bot Date: Thu, 21 May 2026 19:59:23 +0000 Subject: [PATCH 8/9] reviewer(ET): auto-commit from reviewer run_id=11 --- docs/work-items/ET-005/12-review.md | 224 ++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/work-items/ET-005/12-review.md diff --git a/docs/work-items/ET-005/12-review.md b/docs/work-items/ET-005/12-review.md new file mode 100644 index 0000000..5356906 --- /dev/null +++ b/docs/work-items/ET-005/12-review.md @@ -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, отделён `
` | +| ФТ-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` с разделителем `
`; `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 не блокирует. From 3f6e7ae284903db2d3ddce94192b9a46554b9f9b Mon Sep 17 00:00:00 2001 From: claude-bot Date: Thu, 21 May 2026 21:29:11 +0000 Subject: [PATCH 9/9] tester(ET): auto-commit from tester run_id=12 --- docs/work-items/ET-005/13-test-report.md | 247 +++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/work-items/ET-005/13-test-report.md diff --git a/docs/work-items/ET-005/13-test-report.md b/docs/work-items/ET-005/13-test-report.md new file mode 100644 index 0000000..aaba56a --- /dev/null +++ b/docs/work-items/ET-005/13-test-report.md @@ -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, отделён `
`; `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`.