From 2fe5cfe45343a68540c7169a960aecc8010c63c6 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Thu, 21 May 2026 19:36:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=86=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B0=D1=81=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20(=D0=BA=D0=BC/=D0=BC=D0=B8=D0=BB=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавляет сегментированный 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 --- CHANGELOG.md | 5 + src/web/app.css | 20 +++ src/web/app.js | 142 +++++++++++++++++---- src/web/index.html | 11 ++ src/web/units.js | 190 ++++++++++++++++++++++++++++ tests/unit/test_unit_toggle.py | 211 +++++++++++++++++++++++++++++++ tests/unit/units.test.js | 219 +++++++++++++++++++++++++++++++++ 7 files changed, 774 insertions(+), 24 deletions(-) create mode 100644 src/web/units.js create mode 100644 tests/unit/test_unit_toggle.py create mode 100644 tests/unit/units.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7825a8e..85381e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,8 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - Docker configuration - ET-002: чекбокс «POI» в попапе рельефа — показ/скрытие маркеров POI с сохранением состояния в localStorage (ключ `poi-visible`) +- ET-005: переключатель единиц измерения расстояний (км/мили) в попапе + рельефа — новый модуль `src/web/units.js` с централизованным + форматтером `Units.formatDistance()`; выбор сохраняется в localStorage + (ключ `distance_unit`), пересчёт всех видимых расстояний выполняется + единым оркестратором по событию `unitchange` diff --git a/src/web/app.css b/src/web/app.css index d9de6a9..3e63c3b 100644 --- a/src/web/app.css +++ b/src/web/app.css @@ -866,6 +866,26 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; } cursor: not-allowed; } +/* ── ET-005: переключатель единиц измерения (км/мили) в попапе рельефа ── */ +.terrain-unit-row { + padding: 8px 4px 2px; +} +.terrain-unit-label { + display: block; + font-size: 15px; + line-height: 1.3; + color: var(--text, #fff); + margin-bottom: 8px; +} +.theme-light .terrain-unit-label { + color: var(--text, #111); +} +/* Сегментированный переключатель внутри попапа — без нижнего отступа, + он последний элемент (см. .seg-control в блоке Segment Control). */ +.terrain-unit-row .seg-control { + margin-bottom: 0; +} + /* ── Scale + Zoom bar (one line, top-right) ───────── */ #scale-zoom-bar { position: absolute; diff --git a/src/web/app.js b/src/web/app.js index 4dfdee3..a57df52 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -184,10 +184,10 @@ function formatDuration(seconds) { return `${hours} ч ${mins} мин`; } +// ET-005: форматирование расстояний централизовано в units.js (ADR-0001). function formatDist(m) { if (!m) return '-'; - if (m >= 1000) return (m / 1000).toFixed(1) + ' км'; - return Math.round(m) + ' м'; + return Units.formatDistance(m); } // ─── Sheet Management ────────────────────────────────────────────── @@ -633,9 +633,9 @@ function haversineM(a, b) { return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s)); } +// ET-005: единица и разделитель определяются units.js (ADR-0001, риск R4). function formatSegmentDist(m) { - if (m < 1000) return Math.round(m) + ' м'; - return (m / 1000).toFixed(1).replace('.', ',') + ' км'; + return Units.formatDistance(m); } // Returns array of route-distance segments (meters) for each waypoint. @@ -1154,7 +1154,6 @@ function renderRouteCards(routes) { const container = document.getElementById('route-cards'); container.innerHTML = routes.map((route, i) => { const color = ROUTE_COLORS[i] || '#888888'; - const distKm = (route.distance_m / 1000).toFixed(1); const timeStr = formatDuration(route.duration_s); const isActive = i === activeRouteIdx; const s = route.stats || {}; @@ -1165,7 +1164,7 @@ function renderRouteCards(routes) {
Вариант ${i + 1} - ${distKm} км · ${timeStr} + ${Units.formatDistance(route.distance_m)} · ${timeStr}
@@ -1188,6 +1187,8 @@ function generateGPX() { if (!route) return ''; const now = new Date(); const dateStr = now.toISOString().slice(0, 10); + // ET-005: экспорт GPX остаётся метрическим — стандарт GPX и риск R6 + // исключают конвертацию выгружаемых данных в мили. const distKm = (route.distance_m / 1000).toFixed(1); const dirtPct = route.stats ? route.stats.dirt_total_pct : '?'; @@ -1412,9 +1413,22 @@ async function initMap() { const targetPx = 80; const rawMeters = metersPerPixel * targetPx; - + + // ET-005 (риск R3): масштабная линейка учитывает выбранную единицу. + // niceMeters всегда остаётся в метрах — по нему считается ширина в px. let distance, unit, niceMeters; - if (rawMeters >= 1000) { + if (window.Units && Units.getUnit() === 'mi') { + const rawMiles = (rawMeters / 1000) * Units.KM_TO_MI; + if (rawMiles >= 1) { + distance = rawMiles >= 100 ? Math.round(rawMiles / 50) * 50 : + rawMiles >= 10 ? Math.round(rawMiles / 5) * 5 : + Math.round(rawMiles); + } else { + distance = Math.max(0.1, Math.round(rawMiles * 10) / 10); + } + unit = 'mi'; + niceMeters = (distance / Units.KM_TO_MI) * 1000; + } else if (rawMeters >= 1000) { const km = rawMeters / 1000; distance = km >= 100 ? Math.round(km / 50) * 50 : km >= 10 ? Math.round(km / 5) * 5 : @@ -1441,6 +1455,8 @@ async function initMap() { zoomEl.textContent = 'z' + zoom; } + // ET-005: оркестратор onUnitChange() обновляет линейку при смене единицы. + window._updateScaleZoom = updateScaleZoom; updateScaleZoom(); map.on('zoom', updateScaleZoom); map.on('move', updateScaleZoom); @@ -1473,10 +1489,10 @@ async function initMap() { maxWidth: '300px', }); + // ET-005: расстояние во всплывающих подсказках — через units.js (ADR-0001). function formatLength(m) { if (!m) return '-'; - if (m >= 1000) return (m / 1000).toFixed(1) + ' км'; - return Math.round(m) + ' м'; + return Units.formatDistance(m); } function poiTypeLabel(t) { @@ -1834,7 +1850,8 @@ function updateRulerLine() { }); } // Update ruler info display - const dist = rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; + // ET-005: rulerTotal хранится в км — переводим в метры для units.js. + const dist = Units.formatDistance(rulerTotal * 1000); document.getElementById('ruler-dist').textContent = dist; } @@ -1871,9 +1888,8 @@ function updateRulerLabels() { const segDist = haversineKm(rulerPoints[i - 1], rulerPoints[i]); rulerTotal += segDist; if (labelText) { - labelText.textContent = segDist >= 1 - ? segDist.toFixed(1) + ' км' - : Math.round(segDist * 1000) + ' м'; + // ET-005: segDist в км — переводим в метры для units.js. + labelText.textContent = Units.formatDistance(segDist * 1000); } } // Update remove button index @@ -1882,7 +1898,8 @@ function updateRulerLabels() { } } // Update total display - const dist = rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; + // ET-005: rulerTotal хранится в км — переводим в метры для units.js. + const dist = Units.formatDistance(rulerTotal * 1000); document.getElementById('ruler-dist').textContent = dist; } @@ -1927,9 +1944,8 @@ function addRulerPoint(lngLat) { if (idx === 0) { labelText.textContent = 'Старт'; } else { - labelText.textContent = segDist >= 1 - ? segDist.toFixed(1) + ' км' - : Math.round(segDist * 1000) + ' м'; + // ET-005: segDist в км — переводим в метры для units.js. + labelText.textContent = Units.formatDistance(segDist * 1000); } // Bug 5: use button element for better tap target and semantics @@ -2105,6 +2121,9 @@ function clearRecon() { let linkMode = false; let linkPoints = []; let linkMarkers = []; +// ET-005: последние построенные связки кэшируются, чтобы оркестратор +// onUnitChange() мог перерисовать карточки без повторного запроса к API. +let linkRoutes = []; function toggleLinkMode() { const btn = document.getElementById('tb-link'); @@ -2178,6 +2197,7 @@ async function buildLinkRoute() { function renderLinkCards(routes) { const map = window._map; + linkRoutes = routes; // ET-005: кэш для перерисовки при смене единицы const colors = ['#0066ff', '#00aa44', '#9933cc']; const cardsEl = document.getElementById('link-cards'); cardsEl.innerHTML = ''; @@ -2199,7 +2219,6 @@ function renderLinkCards(routes) { layout: { 'line-cap': 'round', 'line-join': 'round' } }); - const km = (r.distance_m / 1000).toFixed(0); const time = formatDuration(r.duration_s); const dirt = r.stats?.dirt_total_pct || '?'; const col = colors[i % colors.length]; @@ -2209,7 +2228,7 @@ function renderLinkCards(routes) {
Вариант ${i+1} - ${km} км + ${Units.formatDistance(r.distance_m, { precision: 0 })} ${time}
${dirt}% грунт
@@ -2354,7 +2373,6 @@ function drawScenicRoutes(routes, activeIdx) { if (cardsEl) { cardsEl.innerHTML = routes.map((r, i) => { const col = colors[i % colors.length]; - const km = (r.distance_m / 1000).toFixed(0); const time = formatDuration(r.duration_s); const dirt = r.stats?.dirt_total_pct || '?'; const pois = (r.scenic_pois || []).map(p => { @@ -2367,7 +2385,7 @@ function drawScenicRoutes(routes, activeIdx) {
${r.name || 'Вариант '+(i+1)} - ${km} км + ${Units.formatDistance(r.distance_m, { precision: 0 })} ${time}
${dirt}% грунт · score=${r.scenic_score||0}
@@ -2414,6 +2432,10 @@ document.addEventListener('DOMContentLoaded', () => { initSheetSwipe(); // Apply saved theme immediately (before map loads) applyTheme(); + // ET-005: восстановить выбор единиц измерения (AC-3) и подключить + // единый оркестратор пересчёта расстояний (ADR-0001 п.6). + syncUnitToggleUI(); + document.addEventListener('unitchange', onUnitChange); }); // ─── Mini Route Bar ────────────────────────────────────────────────── @@ -2602,11 +2624,10 @@ function hideMiniRouteSheet() { function updateMiniRouteCard() { const r = routeResults[activeRouteIdx]; if (!r) return; - const km = (r.distance_m / 1000).toFixed(1); const dirt = r.stats?.dirt_total_pct ?? '-'; document.getElementById('mini-dot').style.background = ROUTE_COLORS[activeRouteIdx % ROUTE_COLORS.length]; document.getElementById('mini-label').textContent = `Вариант ${activeRouteIdx + 1} из ${routeResults.length}`; - document.getElementById('mini-stats').textContent = `${km} км · ${dirt}% грунт`; + document.getElementById('mini-stats').textContent = `${Units.formatDistance(r.distance_m)} · ${dirt}% грунт`; document.getElementById('mini-prev').style.opacity = activeRouteIdx > 0 ? '1' : '0.3'; document.getElementById('mini-next').style.opacity = activeRouteIdx < routeResults.length - 1 ? '1' : '0.3'; } @@ -2853,6 +2874,79 @@ function restorePoiState() { } // <<< ET-002 POI visibility block <<< +// >>> ET-005 unit toggle block >>> +// Переключатель единиц измерения расстояний (км/мили) в попапе рельефа. +// Выбор единицы, его персистентность и форматирование вынесены в +// src/web/units.js (ADR-0001). Здесь — UI-обработчик попапа и единый +// оркестратор пересчёта видимых расстояний. +// См. docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md + +/** + * Обработчик сегментированного переключателя единиц в попапе рельефа + * (атрибут onclick кнопок «км» / «мили»). + * + * Делегирует смену единицы модулю Units. Пересчёт всех видимых + * расстояний выполняет оркестратор onUnitChange() по событию + * 'unitchange'; здесь дополнительно синхронизируется вид кнопок. + * @param {('km'|'mi')} unit - выбранная единица измерения. + */ +function onUnitToggle(unit) { + Units.setUnit(unit); + syncUnitToggleUI(); +} + +/** + * Синхронизирует визуальное состояние кнопок «км» / «мили» с текущей + * выбранной единицей измерения. + * + * Вызывается при инициализации страницы (восстановление выбора из + * localStorage — AC-3) и после каждого переключения. + */ +function syncUnitToggleUI() { + const unit = Units.getUnit(); + const kmBtn = document.getElementById('unit-btn-km'); + const miBtn = document.getElementById('unit-btn-mi'); + if (kmBtn) kmBtn.classList.toggle('active', unit === 'km'); + if (miBtn) miBtn.classList.toggle('active', unit === 'mi'); +} + +/** + * Единый оркестратор пересчёта расстояний при смене единицы измерения + * (ADR-0001 п.6). Подписан на событие 'unitchange' ровно один раз — + * вместо россыпи подписок по компонентам пере-вызывает функции + * отрисовки всех видимых поверхностей с расстояниями. + * + * Внутреннее состояние остаётся метрическим: конвертация выполняется + * исключительно в Units.formatDistance(). + */ +function onUnitChange() { + // Карточки основного маршрута, лист точек и мини-карточка. + if (routeResults.length > 0) { + renderRouteCards(routeResults); + updateMiniRouteCard(); + } + if (routeWaypoints.length > 0) { + renderWaypointsList(); + } + // Линейка: updateRulerLabels() обновляет и подписи отрезков, и итог. + if (rulerMarkers.length > 0) { + updateRulerLabels(); + } + // Карточки связки и «красивого» маршрута — только при активном режиме, + // чтобы перерисовка не возвращала на карту скрытые слои. + if (linkMode && linkRoutes.length > 0) { + renderLinkCards(linkRoutes); + } + if (scenicMode && scenicRoutes.length > 0) { + drawScenicRoutes(scenicRoutes, activeScenicIdx); + } + // Масштабная линейка карты (риск R3). + if (typeof window._updateScaleZoom === 'function') { + window._updateScaleZoom(); + } +} +// <<< ET-005 unit toggle block <<< + function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) { const map = window._map; if (!map) return; diff --git a/src/web/index.html b/src/web/index.html index 2a89e35..b9b8ade 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -58,6 +58,15 @@ POI +
+ +
+ Единицы +
+ + +
+
@@ -402,6 +411,8 @@ + + diff --git a/src/web/units.js b/src/web/units.js new file mode 100644 index 0000000..43e1d5f --- /dev/null +++ b/src/web/units.js @@ -0,0 +1,190 @@ +'use strict'; + +/** + * units.js — ET-005: централизованный модуль единиц измерения расстояний. + * + * Единственный источник истины по выбору единицы (км/мили), её + * персистентности и форматированию ОТОБРАЖАЕМЫХ расстояний. + * + * Подключается в index.html как классический скрипт СТРОГО перед app.js + * (ADR-0001 п.2, риск R7 в 10-tech-risks.md) и публикует глобальный + * неймспейс `window.Units`. Для unit-тестов модуль дополнительно + * экспортируется через `module.exports` (среда Node). + * + * Каноническая единица — МЕТРЫ: `formatDistance()` всегда принимает + * расстояние в метрах, конвертация в мили выполняется только в момент + * форматирования строки (ADR-0001 п.4, 08-data-requirements.md §3). + * Хранить или пересчитывать значения в милях во внутреннем состоянии + * запрещено — это исключает накопление ошибок округления при + * многократном переключении (ФТ-3 ТЗ, AC-2, тест TP-04). + * + * См. docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md + */ +(function (global) { + 'use strict'; + + /** Ключ localStorage для сохранённой единицы (02-trz.md, 08-data-requirements.md §4). */ + var STORAGE_KEY = 'distance_unit'; + /** Коэффициент перевода: 1 км = 0.621371 мили (AC-2). Единственное место объявления. */ + var KM_TO_MI = 0.621371; + /** Единица по умолчанию при отсутствии/некорректности сохранённого значения (AC-3). */ + var DEFAULT_UNIT = 'km'; + /** Допустимые значения единицы измерения. */ + var VALID_UNITS = ['km', 'mi']; + /** Разделитель дробной части — запятая (R4: единый разделитель, ru-локаль интерфейса). */ + var DECIMAL_SEP = ','; + /** Подписи единиц в UI. */ + var UNIT_LABEL = { km: 'км', mi: 'ми' }; + /** Подпись суб-километровых значений в режиме «км». */ + var SUBKM_LABEL = 'м'; + + /** Рантайм-кэш текущей единицы; null — ещё не прочитана из localStorage. */ + var current = null; + + /** + * Безопасное чтение localStorage: возвращает null, если хранилище + * недоступно (private mode, отключённые cookies и т.п.). + * @returns {?string} + */ + function readStored() { + try { + return window.localStorage.getItem(STORAGE_KEY); + } catch (e) { + return null; + } + } + + /** + * Безопасная запись в localStorage. Недоступность хранилища не + * считается ошибкой — выбор просто не персистится между сессиями. + * @param {string} value + */ + function writeStored(value) { + try { + window.localStorage.setItem(STORAGE_KEY, value); + } catch (e) { + /* localStorage недоступен — намеренно проглатываем */ + } + } + + /** + * Проверяет, что переданное значение — допустимая единица измерения. + * @param {*} unit + * @returns {boolean} + */ + function isValidUnit(unit) { + return VALID_UNITS.indexOf(unit) !== -1; + } + + /** + * Возвращает текущую выбранную единицу измерения. + * + * При первом обращении значение читается из localStorage; отсутствующее + * или некорректное значение даёт дефолт `'km'` (AC-3, TP-01). + * @returns {('km'|'mi')} + */ + function getUnit() { + if (current === null) { + var stored = readStored(); + current = isValidUnit(stored) ? stored : DEFAULT_UNIT; + } + return current; + } + + /** + * Устанавливает единицу измерения. + * + * Валидирует значение, сохраняет его в localStorage и диспатчит + * событие `unitchange` на `document` (ADR-0001 п.3, п.6). Некорректное + * значение игнорируется. Если единица фактически не изменилась — ни + * записи, ни события не происходит. + * @param {('km'|'mi')} unit + * @returns {('km'|'mi')} актуальная единица после вызова + */ + function setUnit(unit) { + if (!isValidUnit(unit)) return getUnit(); + if (getUnit() === unit) return current; + current = unit; + writeStored(unit); + try { + document.dispatchEvent(new CustomEvent('unitchange', { detail: { unit: unit } })); + } catch (e) { + /* document/CustomEvent недоступны (headless-окружение) */ + } + return current; + } + + /** + * Переключает единицу измерения km ⇄ mi. + * @returns {('km'|'mi')} единица после переключения + */ + function toggleUnit() { + return setUnit(getUnit() === 'km' ? 'mi' : 'km'); + } + + /** + * Форматирует число с фиксированной точностью и единым разделителем + * дробной части (R4). + * @param {number} value + * @param {number} precision - знаков после запятой + * @returns {string} + */ + function formatNumber(value, precision) { + var s = value.toFixed(precision); + return DECIMAL_SEP === '.' ? s : s.replace('.', DECIMAL_SEP); + } + + /** + * Форматирует расстояние, заданное в МЕТРАХ, строкой в текущей единице. + * + * Политика суб-километровых расстояний (R2 в 10-tech-risks.md): + * - режим «km»: значения < 1000 м показываются в метрах ('850 м'); + * - режим «mi»: всё показывается в милях; для значений < 1000 м + * точность повышается минимум до 2 знаков, чтобы короткие отрезки + * не схлопывались в «0 ми». Футы намеренно не вводятся. + * + * @param {number} meters - расстояние в метрах (каноническая единица). + * @param {object} [opts] + * @param {number} [opts.precision=1] - знаков после запятой для значения + * в км/милях. По умолчанию 1 (AC-2). Карточки маршрутов + * запрашивают `precision: 0` ради целочисленного вида (R5). + * @returns {string} например '12,3 км', '7,6 ми', '850 м', '0,53 ми'. + */ + function formatDistance(meters, opts) { + if (meters === null || meters === undefined || isNaN(meters)) return '-'; + + var precision = (opts && typeof opts.precision === 'number') ? opts.precision : 1; + + if (getUnit() === 'mi') { + var miles = (meters / 1000) * KM_TO_MI; + // R2: суб-километровые расстояния в милях — с повышенной точностью. + var miPrecision = (meters < 1000) ? Math.max(precision, 2) : precision; + return formatNumber(miles, miPrecision) + ' ' + UNIT_LABEL.mi; + } + + // Режим «км». + if (meters < 1000) { + return Math.round(meters) + ' ' + SUBKM_LABEL; + } + return formatNumber(meters / 1000, precision) + ' ' + UNIT_LABEL.km; + } + + /** Публичный контракт модуля (ADR-0001 п.3). */ + var Units = { + STORAGE_KEY: STORAGE_KEY, + KM_TO_MI: KM_TO_MI, + getUnit: getUnit, + setUnit: setUnit, + toggleUnit: toggleUnit, + formatDistance: formatDistance, + }; + + // Браузер — глобальный неймспейс window.Units (соответствует стилю app.js). + if (global) { + global.Units = Units; + } + // Node — экспорт для изолированных unit-тестов. + if (typeof module === 'object' && module.exports) { + module.exports = Units; + } +})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)); diff --git a/tests/unit/test_unit_toggle.py b/tests/unit/test_unit_toggle.py new file mode 100644 index 0000000..a4bb04d --- /dev/null +++ b/tests/unit/test_unit_toggle.py @@ -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("", 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}" + ) diff --git a/tests/unit/units.test.js b/tests/unit/units.test.js new file mode 100644 index 0000000..4b9ed5f --- /dev/null +++ b/tests/unit/units.test.js @@ -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'); +});