'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'); });