diff --git a/CHANGELOG.md b/CHANGELOG.md index 7825a8e..85381e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 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 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 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 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 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'); +});