Files
enduro-trails/tests/unit/units.test.js
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

220 lines
9.9 KiB
JavaScript
Raw Permalink 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.
'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');
});