feat(web): переключатель единиц измерения расстояний (км/мили)
All checks were successful
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
This commit is contained in:
@@ -13,3 +13,8 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
- Docker configuration
|
- Docker configuration
|
||||||
- ET-002: чекбокс «POI» в попапе рельефа — показ/скрытие маркеров POI
|
- ET-002: чекбокс «POI» в попапе рельефа — показ/скрытие маркеров POI
|
||||||
с сохранением состояния в localStorage (ключ `poi-visible`)
|
с сохранением состояния в localStorage (ключ `poi-visible`)
|
||||||
|
- ET-005: переключатель единиц измерения расстояний (км/мили) в попапе
|
||||||
|
рельефа — новый модуль `src/web/units.js` с централизованным
|
||||||
|
форматтером `Units.formatDistance()`; выбор сохраняется в localStorage
|
||||||
|
(ключ `distance_unit`), пересчёт всех видимых расстояний выполняется
|
||||||
|
единым оркестратором по событию `unitchange`
|
||||||
|
|||||||
@@ -866,6 +866,26 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
|
|||||||
cursor: not-allowed;
|
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 (one line, top-right) ───────── */
|
||||||
#scale-zoom-bar {
|
#scale-zoom-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
140
src/web/app.js
140
src/web/app.js
@@ -184,10 +184,10 @@ function formatDuration(seconds) {
|
|||||||
return `${hours} ч ${mins} мин`;
|
return `${hours} ч ${mins} мин`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ET-005: форматирование расстояний централизовано в units.js (ADR-0001).
|
||||||
function formatDist(m) {
|
function formatDist(m) {
|
||||||
if (!m) return '-';
|
if (!m) return '-';
|
||||||
if (m >= 1000) return (m / 1000).toFixed(1) + ' км';
|
return Units.formatDistance(m);
|
||||||
return Math.round(m) + ' м';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sheet Management ──────────────────────────────────────────────
|
// ─── Sheet Management ──────────────────────────────────────────────
|
||||||
@@ -633,9 +633,9 @@ function haversineM(a, b) {
|
|||||||
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s));
|
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ET-005: единица и разделитель определяются units.js (ADR-0001, риск R4).
|
||||||
function formatSegmentDist(m) {
|
function formatSegmentDist(m) {
|
||||||
if (m < 1000) return Math.round(m) + ' м';
|
return Units.formatDistance(m);
|
||||||
return (m / 1000).toFixed(1).replace('.', ',') + ' км';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns array of route-distance segments (meters) for each waypoint.
|
// Returns array of route-distance segments (meters) for each waypoint.
|
||||||
@@ -1154,7 +1154,6 @@ function renderRouteCards(routes) {
|
|||||||
const container = document.getElementById('route-cards');
|
const container = document.getElementById('route-cards');
|
||||||
container.innerHTML = routes.map((route, i) => {
|
container.innerHTML = routes.map((route, i) => {
|
||||||
const color = ROUTE_COLORS[i] || '#888888';
|
const color = ROUTE_COLORS[i] || '#888888';
|
||||||
const distKm = (route.distance_m / 1000).toFixed(1);
|
|
||||||
const timeStr = formatDuration(route.duration_s);
|
const timeStr = formatDuration(route.duration_s);
|
||||||
const isActive = i === activeRouteIdx;
|
const isActive = i === activeRouteIdx;
|
||||||
const s = route.stats || {};
|
const s = route.stats || {};
|
||||||
@@ -1165,7 +1164,7 @@ function renderRouteCards(routes) {
|
|||||||
<div class="rc-header">
|
<div class="rc-header">
|
||||||
<span class="rc-dot" style="background:${color}"></span>
|
<span class="rc-dot" style="background:${color}"></span>
|
||||||
<span class="rc-title">Вариант ${i + 1}</span>
|
<span class="rc-title">Вариант ${i + 1}</span>
|
||||||
<span class="rc-meta">${distKm} км · ${timeStr}</span>
|
<span class="rc-meta">${Units.formatDistance(route.distance_m)} · ${timeStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rc-bar-wrap">
|
<div class="rc-bar-wrap">
|
||||||
<div class="rc-bar">
|
<div class="rc-bar">
|
||||||
@@ -1188,6 +1187,8 @@ function generateGPX() {
|
|||||||
if (!route) return '';
|
if (!route) return '';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateStr = now.toISOString().slice(0, 10);
|
const dateStr = now.toISOString().slice(0, 10);
|
||||||
|
// ET-005: экспорт GPX остаётся метрическим — стандарт GPX и риск R6
|
||||||
|
// исключают конвертацию выгружаемых данных в мили.
|
||||||
const distKm = (route.distance_m / 1000).toFixed(1);
|
const distKm = (route.distance_m / 1000).toFixed(1);
|
||||||
const dirtPct = route.stats ? route.stats.dirt_total_pct : '?';
|
const dirtPct = route.stats ? route.stats.dirt_total_pct : '?';
|
||||||
|
|
||||||
@@ -1413,8 +1414,21 @@ async function initMap() {
|
|||||||
const targetPx = 80;
|
const targetPx = 80;
|
||||||
const rawMeters = metersPerPixel * targetPx;
|
const rawMeters = metersPerPixel * targetPx;
|
||||||
|
|
||||||
|
// ET-005 (риск R3): масштабная линейка учитывает выбранную единицу.
|
||||||
|
// niceMeters всегда остаётся в метрах — по нему считается ширина в px.
|
||||||
let distance, unit, niceMeters;
|
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;
|
const km = rawMeters / 1000;
|
||||||
distance = km >= 100 ? Math.round(km / 50) * 50 :
|
distance = km >= 100 ? Math.round(km / 50) * 50 :
|
||||||
km >= 10 ? Math.round(km / 5) * 5 :
|
km >= 10 ? Math.round(km / 5) * 5 :
|
||||||
@@ -1441,6 +1455,8 @@ async function initMap() {
|
|||||||
zoomEl.textContent = 'z' + zoom;
|
zoomEl.textContent = 'z' + zoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ET-005: оркестратор onUnitChange() обновляет линейку при смене единицы.
|
||||||
|
window._updateScaleZoom = updateScaleZoom;
|
||||||
updateScaleZoom();
|
updateScaleZoom();
|
||||||
map.on('zoom', updateScaleZoom);
|
map.on('zoom', updateScaleZoom);
|
||||||
map.on('move', updateScaleZoom);
|
map.on('move', updateScaleZoom);
|
||||||
@@ -1473,10 +1489,10 @@ async function initMap() {
|
|||||||
maxWidth: '300px',
|
maxWidth: '300px',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ET-005: расстояние во всплывающих подсказках — через units.js (ADR-0001).
|
||||||
function formatLength(m) {
|
function formatLength(m) {
|
||||||
if (!m) return '-';
|
if (!m) return '-';
|
||||||
if (m >= 1000) return (m / 1000).toFixed(1) + ' км';
|
return Units.formatDistance(m);
|
||||||
return Math.round(m) + ' м';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function poiTypeLabel(t) {
|
function poiTypeLabel(t) {
|
||||||
@@ -1834,7 +1850,8 @@ function updateRulerLine() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Update ruler info display
|
// 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;
|
document.getElementById('ruler-dist').textContent = dist;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1871,9 +1888,8 @@ function updateRulerLabels() {
|
|||||||
const segDist = haversineKm(rulerPoints[i - 1], rulerPoints[i]);
|
const segDist = haversineKm(rulerPoints[i - 1], rulerPoints[i]);
|
||||||
rulerTotal += segDist;
|
rulerTotal += segDist;
|
||||||
if (labelText) {
|
if (labelText) {
|
||||||
labelText.textContent = segDist >= 1
|
// ET-005: segDist в км — переводим в метры для units.js.
|
||||||
? segDist.toFixed(1) + ' км'
|
labelText.textContent = Units.formatDistance(segDist * 1000);
|
||||||
: Math.round(segDist * 1000) + ' м';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Update remove button index
|
// Update remove button index
|
||||||
@@ -1882,7 +1898,8 @@ function updateRulerLabels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Update total display
|
// 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;
|
document.getElementById('ruler-dist').textContent = dist;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1927,9 +1944,8 @@ function addRulerPoint(lngLat) {
|
|||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
labelText.textContent = 'Старт';
|
labelText.textContent = 'Старт';
|
||||||
} else {
|
} else {
|
||||||
labelText.textContent = segDist >= 1
|
// ET-005: segDist в км — переводим в метры для units.js.
|
||||||
? segDist.toFixed(1) + ' км'
|
labelText.textContent = Units.formatDistance(segDist * 1000);
|
||||||
: Math.round(segDist * 1000) + ' м';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bug 5: use button element for better tap target and semantics
|
// Bug 5: use button element for better tap target and semantics
|
||||||
@@ -2105,6 +2121,9 @@ function clearRecon() {
|
|||||||
let linkMode = false;
|
let linkMode = false;
|
||||||
let linkPoints = [];
|
let linkPoints = [];
|
||||||
let linkMarkers = [];
|
let linkMarkers = [];
|
||||||
|
// ET-005: последние построенные связки кэшируются, чтобы оркестратор
|
||||||
|
// onUnitChange() мог перерисовать карточки без повторного запроса к API.
|
||||||
|
let linkRoutes = [];
|
||||||
|
|
||||||
function toggleLinkMode() {
|
function toggleLinkMode() {
|
||||||
const btn = document.getElementById('tb-link');
|
const btn = document.getElementById('tb-link');
|
||||||
@@ -2178,6 +2197,7 @@ async function buildLinkRoute() {
|
|||||||
|
|
||||||
function renderLinkCards(routes) {
|
function renderLinkCards(routes) {
|
||||||
const map = window._map;
|
const map = window._map;
|
||||||
|
linkRoutes = routes; // ET-005: кэш для перерисовки при смене единицы
|
||||||
const colors = ['#0066ff', '#00aa44', '#9933cc'];
|
const colors = ['#0066ff', '#00aa44', '#9933cc'];
|
||||||
const cardsEl = document.getElementById('link-cards');
|
const cardsEl = document.getElementById('link-cards');
|
||||||
cardsEl.innerHTML = '';
|
cardsEl.innerHTML = '';
|
||||||
@@ -2199,7 +2219,6 @@ function renderLinkCards(routes) {
|
|||||||
layout: { 'line-cap': 'round', 'line-join': 'round' }
|
layout: { 'line-cap': 'round', 'line-join': 'round' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const km = (r.distance_m / 1000).toFixed(0);
|
|
||||||
const time = formatDuration(r.duration_s);
|
const time = formatDuration(r.duration_s);
|
||||||
const dirt = r.stats?.dirt_total_pct || '?';
|
const dirt = r.stats?.dirt_total_pct || '?';
|
||||||
const col = colors[i % colors.length];
|
const col = colors[i % colors.length];
|
||||||
@@ -2209,7 +2228,7 @@ function renderLinkCards(routes) {
|
|||||||
<div class="rc-header">
|
<div class="rc-header">
|
||||||
<span class="rc-dot" style="background:${col}"></span>
|
<span class="rc-dot" style="background:${col}"></span>
|
||||||
<span class="rc-title">Вариант ${i+1}</span>
|
<span class="rc-title">Вариант ${i+1}</span>
|
||||||
<span class="rc-km">${km} км</span>
|
<span class="rc-km">${Units.formatDistance(r.distance_m, { precision: 0 })}</span>
|
||||||
<span class="rc-time">${time}</span>
|
<span class="rc-time">${time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт</div>
|
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт</div>
|
||||||
@@ -2354,7 +2373,6 @@ function drawScenicRoutes(routes, activeIdx) {
|
|||||||
if (cardsEl) {
|
if (cardsEl) {
|
||||||
cardsEl.innerHTML = routes.map((r, i) => {
|
cardsEl.innerHTML = routes.map((r, i) => {
|
||||||
const col = colors[i % colors.length];
|
const col = colors[i % colors.length];
|
||||||
const km = (r.distance_m / 1000).toFixed(0);
|
|
||||||
const time = formatDuration(r.duration_s);
|
const time = formatDuration(r.duration_s);
|
||||||
const dirt = r.stats?.dirt_total_pct || '?';
|
const dirt = r.stats?.dirt_total_pct || '?';
|
||||||
const pois = (r.scenic_pois || []).map(p => {
|
const pois = (r.scenic_pois || []).map(p => {
|
||||||
@@ -2367,7 +2385,7 @@ function drawScenicRoutes(routes, activeIdx) {
|
|||||||
<div class="rc-header">
|
<div class="rc-header">
|
||||||
<span class="rc-dot" style="background:${col}"></span>
|
<span class="rc-dot" style="background:${col}"></span>
|
||||||
<span class="rc-title">${r.name || 'Вариант '+(i+1)}</span>
|
<span class="rc-title">${r.name || 'Вариант '+(i+1)}</span>
|
||||||
<span class="rc-km">${km} км</span>
|
<span class="rc-km">${Units.formatDistance(r.distance_m, { precision: 0 })}</span>
|
||||||
<span class="rc-time">${time}</span>
|
<span class="rc-time">${time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт · score=${r.scenic_score||0}</div>
|
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт · score=${r.scenic_score||0}</div>
|
||||||
@@ -2414,6 +2432,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initSheetSwipe();
|
initSheetSwipe();
|
||||||
// Apply saved theme immediately (before map loads)
|
// Apply saved theme immediately (before map loads)
|
||||||
applyTheme();
|
applyTheme();
|
||||||
|
// ET-005: восстановить выбор единиц измерения (AC-3) и подключить
|
||||||
|
// единый оркестратор пересчёта расстояний (ADR-0001 п.6).
|
||||||
|
syncUnitToggleUI();
|
||||||
|
document.addEventListener('unitchange', onUnitChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Mini Route Bar ──────────────────────────────────────────────────
|
// ─── Mini Route Bar ──────────────────────────────────────────────────
|
||||||
@@ -2602,11 +2624,10 @@ function hideMiniRouteSheet() {
|
|||||||
function updateMiniRouteCard() {
|
function updateMiniRouteCard() {
|
||||||
const r = routeResults[activeRouteIdx];
|
const r = routeResults[activeRouteIdx];
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
const km = (r.distance_m / 1000).toFixed(1);
|
|
||||||
const dirt = r.stats?.dirt_total_pct ?? '-';
|
const dirt = r.stats?.dirt_total_pct ?? '-';
|
||||||
document.getElementById('mini-dot').style.background = ROUTE_COLORS[activeRouteIdx % ROUTE_COLORS.length];
|
document.getElementById('mini-dot').style.background = ROUTE_COLORS[activeRouteIdx % ROUTE_COLORS.length];
|
||||||
document.getElementById('mini-label').textContent = `Вариант ${activeRouteIdx + 1} из ${routeResults.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-prev').style.opacity = activeRouteIdx > 0 ? '1' : '0.3';
|
||||||
document.getElementById('mini-next').style.opacity = activeRouteIdx < routeResults.length - 1 ? '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-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) {
|
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
|
||||||
const map = window._map;
|
const map = window._map;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|||||||
@@ -58,6 +58,15 @@
|
|||||||
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
|
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
|
||||||
<span>POI</span>
|
<span>POI</span>
|
||||||
</label>
|
</label>
|
||||||
|
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
|
||||||
|
<!-- ET-005: переключатель единиц измерения расстояний (км/мили) -->
|
||||||
|
<div class="terrain-unit-row">
|
||||||
|
<span class="terrain-unit-label">Единицы</span>
|
||||||
|
<div class="seg-control unit-seg" id="unit-seg">
|
||||||
|
<button type="button" class="seg-btn" id="unit-btn-km" data-unit="km" onclick="onUnitToggle('km')">км</button>
|
||||||
|
<button type="button" class="seg-btn" id="unit-btn-mi" data-unit="mi" onclick="onUnitToggle('mi')">мили</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Map Buttons (right) ───────────────── -->
|
<!-- ── Map Buttons (right) ───────────────── -->
|
||||||
@@ -402,6 +411,8 @@
|
|||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script>
|
<script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script>
|
||||||
<script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script>
|
<script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script>
|
||||||
|
<!-- ET-005: units.js ДОЛЖЕН подключаться строго перед app.js (ADR-0001 п.2, риск R7) -->
|
||||||
|
<script src="units.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
190
src/web/units.js
Normal file
190
src/web/units.js
Normal file
@@ -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));
|
||||||
211
tests/unit/test_unit_toggle.py
Normal file
211
tests/unit/test_unit_toggle.py
Normal file
@@ -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("<div", 0, start)
|
||||||
|
container_open_end = html.index(">", 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}"
|
||||||
|
)
|
||||||
219
tests/unit/units.test.js
Normal file
219
tests/unit/units.test.js
Normal 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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user