"""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}" )