feat(web): переключатель единиц измерения расстояний (км/мили)
All checks were successful
All checks were successful
Добавляет сегментированный 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
This commit is contained in:
211
tests/unit/test_unit_toggle.py
Normal file
211
tests/unit/test_unit_toggle.py
Normal file
@@ -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("<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}"
|
||||
)
|
||||
219
tests/unit/units.test.js
Normal file
219
tests/unit/units.test.js
Normal file
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user