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