Files
enduro-trails/tests/unit/test_unit_toggle.py
claude-bot 2fe5cfe453
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 5s
CI / build (push) Successful in 2s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 4s
CI / build (pull_request) Successful in 1s
feat(web): переключатель единиц измерения расстояний (км/мили)
Добавляет сегментированный toggle км/мили в попап рельефа. Новый модуль
src/web/units.js — единственный источник истины по выбору единицы, её
персистентности (localStorage: distance_unit, дефолт km) и форматированию
отображаемых расстояний (Units.formatDistance).

Все места форматирования в app.js переведены на централизованный
форматтер; пересчёт всех видимых расстояний выполняет единый оркестратор
onUnitChange по событию unitchange (карточки маршрутов, лист точек,
линейка, масштабная линейка, связка, «красивый» маршрут).

Экспорт GPX и параметры построения маршрута остаются метрическими
(риск R6). units.js подключается строго перед app.js (риск R7).

Refs: ET-005
2026-05-21 19:36:13 +00:00

212 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ET-005 — тесты переключения единиц измерения расстояний (км/мили).
Изменение ET-005 — исключительно фронтендовое: новый модуль
`src/web/units.js` плюс правки `src/web/index.html`, `src/web/app.js`,
`src/web/app.css` (см. `06-adr/adr-0001-unit-toggle-client-side.md`).
В CI исполняется только `pytest tests/`, поэтому файл покрывает фичу
двумя способами:
1. Статические проверки структуры `index.html`, `app.js`, `units.js` —
выполняются всегда, без внешних зависимостей.
2. Поведенческие JS unit-тесты (TP-01..TP-04 из `04-test-plan.yaml`) —
запускаются через встроенный тест-раннер Node (`node --test`). Если
`node` в системе отсутствует — эта часть помечается `skip` (по аналогии
с `tests/unit/test_poi_toggle.py`).
Браузерный e2e-сценарий TP-05 (mobile responsive) требует Playwright-
инфраструктуры, которой в репозитории нет; его поведенческая суть в
доступном объёме покрыта статическими проверками UI ниже.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from shutil import which
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
INDEX_HTML = REPO_ROOT / "src" / "web" / "index.html"
APP_JS = REPO_ROOT / "src" / "web" / "app.js"
UNITS_JS = REPO_ROOT / "src" / "web" / "units.js"
APP_CSS = REPO_ROOT / "src" / "web" / "app.css"
JS_TEST = REPO_ROOT / "tests" / "unit" / "units.test.js"
def _read(path: Path) -> str:
assert path.is_file(), f"не найден {path}"
return path.read_text(encoding="utf-8")
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки units.js (ADR-0001, 08-data-requirements.md)
# ──────────────────────────────────────────────────────────────────────────────
def test_units_module_exists():
"""ADR-0001 п.2: модуль размещён в src/web/units.js (не static/js/)."""
assert UNITS_JS.is_file(), "не найден src/web/units.js"
def test_units_module_public_api():
"""ADR-0001 п.3: модуль определяет публичный контракт Units."""
js = _read(UNITS_JS)
for fn in ("getUnit", "setUnit", "toggleUnit", "formatDistance"):
assert f"function {fn}(" in js, f"в units.js не определена функция {fn}()"
def test_units_module_constants():
"""AC-2 / 08-data-requirements.md §5: коэффициент и ключ хранилища."""
js = _read(UNITS_JS)
assert "0.621371" in js, "в units.js нет коэффициента перевода 1 км = 0.621371 ми"
assert "'distance_unit'" in js, "ключ localStorage distance_unit не объявлен"
def test_units_module_exports_for_browser_and_node():
"""ADR-0001 п.3: window.Units для браузера и module.exports для тестов."""
js = _read(UNITS_JS)
assert "global.Units" in js, "units.js не публикует глобальный неймспейс Units"
assert "module.exports" in js, "units.js не экспортируется для Node unit-тестов"
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки index.html (AC-1, риск R7)
# ──────────────────────────────────────────────────────────────────────────────
def test_unit_toggle_present_in_html():
"""AC-1: сегментированный переключатель км/мили присутствует в попапе."""
html = _read(INDEX_HTML)
assert 'id="unit-seg"' in html, "нет переключателя единиц unit-seg"
assert 'id="unit-btn-km"' in html, "нет кнопки «км»"
assert 'id="unit-btn-mi"' in html, "нет кнопки «мили»"
assert "onclick=\"onUnitToggle('km')\"" in html, "кнопка «км» не привязана к onUnitToggle"
assert "onclick=\"onUnitToggle('mi')\"" in html, "кнопка «мили» не привязана к onUnitToggle"
def test_unit_toggle_reuses_seg_control_component():
"""ADR-0001 п.8: переиспользуется готовый компонент .seg-control."""
html = _read(INDEX_HTML)
start = html.index('id="unit-seg"')
container_start = html.rfind("<div", 0, start)
container_open_end = html.index(">", container_start)
assert "seg-control" in html[container_start:container_open_end], (
"переключатель единиц должен использовать класс seg-control"
)
def test_units_js_loaded_before_app_js():
"""Риск R7: units.js подключается строго перед app.js."""
html = _read(INDEX_HTML)
units_pos = html.find('src="units.js"')
app_pos = html.find('src="app.js"')
assert units_pos != -1, "units.js не подключён в index.html"
assert app_pos != -1, "app.js не подключён в index.html"
assert units_pos < app_pos, "units.js должен подключаться ДО app.js (риск R7)"
def test_unit_toggle_has_styles():
"""AC-4: для строки переключателя единиц заданы стили в app.css."""
css = _read(APP_CSS)
assert ".terrain-unit-row" in css, "нет стилей строки переключателя единиц"
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки app.js (ADR-0001 п.6-7, риски R1, R3, R6)
# ──────────────────────────────────────────────────────────────────────────────
def test_app_js_unit_functions_defined():
"""ADR-0001 п.6: определены UI-обработчик и оркестратор пересчёта."""
js = _read(APP_JS)
for fn in ("onUnitToggle", "syncUnitToggleUI", "onUnitChange"):
assert f"function {fn}(" in js, f"не определена функция {fn}()"
def test_app_js_has_et005_block_markers():
"""Блок ET-005 обрамлён маркерами (как POI-блок ET-002)."""
js = _read(APP_JS)
assert "// >>> ET-005 unit toggle block >>>" in js, "нет открывающего маркера блока ET-005"
assert "// <<< ET-005 unit toggle block <<<" in js, "нет закрывающего маркера блока ET-005"
def test_app_js_single_unitchange_subscription():
"""ADR-0001 п.6: ровно одна подписка-оркестратор на событие unitchange."""
js = _read(APP_JS)
assert js.count("addEventListener('unitchange'") == 1, (
"подписка на unitchange должна быть единственной (оркестратор)"
)
assert "addEventListener('unitchange', onUnitChange)" in js, (
"событие unitchange не привязано к оркестратору onUnitChange"
)
def test_app_js_uses_centralized_formatter():
"""Риск R1: места форматирования расстояний переведены на units.js."""
js = _read(APP_JS)
# 13 call-sites из 10-tech-risks.md R1 минус GPX (R6) → ≥ 11 вызовов.
assert js.count("Units.formatDistance(") >= 11, (
"не все места форматирования расстояний переведены на Units.formatDistance()"
)
def test_app_js_distance_helpers_delegate_to_units():
"""Риск R1/R4: единые форматтеры делегируют расчёт модулю units.js."""
js = _read(APP_JS)
for fn in ("formatDist", "formatSegmentDist"):
idx = js.index(f"function {fn}(")
body = js[idx:idx + 220]
assert "Units.formatDistance(" in body, (
f"{fn}() должен делегировать форматирование в units.js"
)
def test_app_js_scale_bar_is_unit_aware():
"""Риск R3: масштабная линейка учитывает выбранную единицу."""
js = _read(APP_JS)
assert "Units.getUnit() === 'mi'" in js, "scale-bar не реагирует на режим миль (R3)"
assert "window._updateScaleZoom" in js, (
"updateScaleZoom не доступен оркестратору onUnitChange()"
)
def test_app_js_gpx_export_stays_metric():
"""Риск R6: экспорт GPX не конвертируется в мили (остаётся метрическим)."""
js = _read(APP_JS)
gpx_start = js.index("function generateGPX(")
gpx_body = js[gpx_start:gpx_start + 1400]
assert "Units.formatDistance(" not in gpx_body, (
"generateGPX() не должен конвертировать расстояния (риск R6)"
)
def test_app_js_restores_unit_choice_on_load():
"""AC-3: выбор единицы восстанавливается при инициализации страницы."""
js = _read(APP_JS)
assert "syncUnitToggleUI()" in js, "состояние переключателя не восстанавливается при загрузке"
# ──────────────────────────────────────────────────────────────────────────────
# Поведенческие JS unit-тесты через Node (TP-01..TP-04)
# ──────────────────────────────────────────────────────────────────────────────
node_required = pytest.mark.skipif(
which("node") is None,
reason="node не установлен — поведенческие JS unit-тесты пропущены",
)
@node_required
def test_js_unit_tests_pass():
"""TP-01..TP-04: запускает behavioral JS-тесты units.js через `node --test`."""
assert JS_TEST.is_file(), f"не найден JS-тест {JS_TEST}"
node = which("node")
result = subprocess.run(
[node, "--test", str(JS_TEST)],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
assert result.returncode == 0, (
f"JS unit-тесты единиц измерения упали (код {result.returncode}):\n"
f"{result.stdout}\n{result.stderr}"
)