feat(web): переключатель единиц измерения расстояний (км/мили)
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

Добавляет сегментированный 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:
2026-05-21 19:36:13 +00:00
parent bafbea2dab
commit 2fe5cfe453
7 changed files with 774 additions and 24 deletions

219
tests/unit/units.test.js Normal file
View 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');
});