feat(web): add POI visibility checkbox to terrain popup
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 5s
CI / build (push) Successful in 15s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 4s
CI / build (pull_request) Successful in 3s

Adds a «POI» checkbox to the terrain popup that toggles the
poi-circles and poi-labels layers via map.setLayoutProperty. The
choice is persisted in localStorage (key `poi-visible`) and restored
on page load and after style changes, kept consistent with the
runtime layerState.poi per ADR-0001.

Tests: behavioral JS unit tests (TP-01..TP-04) run via `node --test`,
wrapped by tests/unit/test_poi_toggle.py with static structure checks
so they execute under the existing `pytest tests/` CI step.

Refs: ET-002

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 15:50:54 +00:00
parent af579f7f2a
commit 8c17a4f508
5 changed files with 396 additions and 1 deletions

View File

@@ -11,3 +11,5 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- Agent system prompts (architect, developer, reviewer, tester, deployer) - Agent system prompts (architect, developer, reviewer, tester, deployer)
- CI pipeline (Gitea Actions) - CI pipeline (Gitea Actions)
- Docker configuration - Docker configuration
- ET-002: чекбокс «POI» в попапе рельефа — показ/скрытие маркеров POI
с сохранением состояния в localStorage (ключ `poi-visible`)

View File

@@ -128,6 +128,7 @@ function rebuildMapOverlays() {
// Re-apply terrain and trails after style change // Re-apply terrain and trails after style change
restoreTerrainState(); restoreTerrainState();
restoreTrailsState(); restoreTrailsState();
restorePoiState();
// Re-apply recon circle if active // Re-apply recon circle if active
if (reconMode && reconCenter) { if (reconMode && reconCenter) {
@@ -2796,6 +2797,62 @@ function restoreTrailsState() {
} }
} }
// >>> ET-002 POI visibility block (do not remove markers — used by unit tests) >>>
// Видимость POI (слои poi-circles, poi-labels) управляется чекбоксом
// «POI» в попапе рельефа. Состояние хранится в localStorage под ключом
// 'poi-visible' ('1'/'0'). Источник истины в рантайме — layerState.poi.
// См. docs/work-items/ET-002/06-adr/adr-0001-poi-visibility-client-side.md
/**
* Применяет видимость группы слоёв POI и синхронизирует layerState.poi.
*
* Единый приватный хелпер: переиспользуется чекбоксом попапа
* (onPoiCheckbox) и восстановлением состояния при загрузке/смене стиля
* (restorePoiState). Не пишет в localStorage — персистентность остаётся
* ответственностью обработчика чекбокса.
*
* @param {boolean} visible - true — показать POI, false — скрыть.
*/
function applyPoiVisibility(visible) {
layerState.poi = visible;
const map = window._map;
if (!map) return;
const visibility = visible ? 'visible' : 'none';
layerGroups.poi.forEach(id => {
if (map.getLayer(id)) {
map.setLayoutProperty(id, 'visibility', visibility);
}
});
}
/**
* Обработчик чекбокса «POI» в попапе рельефа (атрибут onchange).
*
* Сохраняет выбор в localStorage ('poi-visible': '1' видимы | '0' скрыты)
* и применяет видимость слоёв POI через applyPoiVisibility().
*/
function onPoiCheckbox() {
const checked = document.getElementById('poi-visible-cb').checked;
localStorage.setItem('poi-visible', checked ? '1' : '0');
applyPoiVisibility(checked);
}
/**
* Восстанавливает видимость POI при загрузке страницы и после смены
* стиля карты (переключение темы).
*
* По умолчанию (ключ 'poi-visible' отсутствует или равен '1') POI
* видимы; '0' — скрыты. Синхронизирует чекбокс, layerState.poi и
* фактическую видимость слоёв.
*/
function restorePoiState() {
const stored = localStorage.getItem('poi-visible');
const poiOn = stored === null || stored === '1';
const cb = document.getElementById('poi-visible-cb');
if (cb) cb.checked = poiOn;
applyPoiVisibility(poiOn);
}
// <<< ET-002 POI visibility 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;
@@ -2890,6 +2947,7 @@ function restoreTerrainState() {
// Initial state // Initial state
restoreTerrainState(); restoreTerrainState();
restoreTrailsState(); restoreTrailsState();
restorePoiState();
} else { } else {
// Map not ready yet, wait // Map not ready yet, wait
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -2902,6 +2960,7 @@ function restoreTerrainState() {
updateHillshadeAvailability(); updateHillshadeAvailability();
restoreTerrainState(); restoreTerrainState();
restoreTrailsState(); restoreTrailsState();
restorePoiState();
} }
}, 500); }, 500);
} }

View File

@@ -53,6 +53,11 @@
<input type="checkbox" id="trails-path-cb" onchange="onTrailsCheckbox()" checked> <input type="checkbox" id="trails-path-cb" onchange="onTrailsCheckbox()" checked>
<span>Тропы</span> <span>Тропы</span>
</label> </label>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<label class="terrain-checkbox">
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
<span>POI</span>
</label>
</div> </div>
<!-- ── Map Buttons (right) ───────────────── --> <!-- ── Map Buttons (right) ───────────────── -->

View File

@@ -0,0 +1,167 @@
'use strict';
/**
* ET-002 — поведенческие unit-тесты чекбокса видимости POI.
*
* Покрывают TP-01..TP-04 из docs/work-items/ET-002/04-test-plan.yaml.
*
* Тесты исполняют РЕАЛЬНЫЙ код из src/web/app.js: блок POI извлекается
* по маркерам `>>> ET-002 POI visibility block` и оборачивается в
* фабрику через `new Function`, которой передаются мок-зависимости
* (window, document, localStorage, layerState, layerGroups). Так
* монолитный browser-скрипт проверяется без полной загрузки в Node.
*
* Запуск: `node --test tests/unit/poi_toggle.test.js`
* (в CI оборачивается pytest-тестом tests/unit/test_poi_toggle.py).
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const APP_JS = path.join(__dirname, '..', '..', 'src', 'web', 'app.js');
/**
* Извлекает блок POI-логики из app.js и собирает из него модуль,
* подставляя переданные зависимости.
*/
function loadPoiModule(deps) {
const src = fs.readFileSync(APP_JS, 'utf8');
const m = src.match(
/\/\/ >>> ET-002 POI visibility block[^\n]*\n([\s\S]*?)\/\/ <<< ET-002 POI visibility block/
);
assert.ok(m, 'POI-блок не найден в app.js (маркеры ET-002 отсутствуют)');
const factory = new Function(
'layerState', 'layerGroups', 'window', 'document', 'localStorage',
m[1] + '\nreturn { applyPoiVisibility, onPoiCheckbox, restorePoiState };'
);
return factory(
deps.layerState, deps.layerGroups, deps.window, deps.document, deps.localStorage
);
}
/**
* Готовит изолированное мок-окружение для одного теста.
* @param {object} [opts]
* @param {string} [opts.stored] - значение ключа poi-visible в localStorage
* (если не указан — ключ отсутствует).
* @param {boolean} [opts.layerExists] - что возвращает map.getLayer().
*/
function makeEnv({ stored, layerExists = true } = {}) {
const calls = { setLayoutProperty: [], setItem: [] };
const store = {};
if (stored !== undefined) store['poi-visible'] = stored;
const localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); calls.setItem.push([k, String(v)]); },
};
const map = {
getLayer: () => layerExists,
setLayoutProperty: (id, prop, val) => calls.setLayoutProperty.push([id, prop, val]),
};
const checkbox = { checked: true };
const document = {
getElementById: (id) => (id === 'poi-visible-cb' ? checkbox : null),
};
const layerState = { tracks: true, paths: true, poi: true, basemap: true };
const layerGroups = { poi: ['poi-circles', 'poi-labels'] };
const win = { _map: map };
const mod = loadPoiModule({
layerState, layerGroups, window: win, document, localStorage,
});
return { mod, calls, checkbox, layerState, store };
}
// ── TP-01: onPoiCheckbox() скрывает слои при checked=false ───────────────
test('TP-01: снятый чекбокс скрывает слои POI и сохраняет 0', () => {
const env = makeEnv();
env.checkbox.checked = false;
env.mod.onPoiCheckbox();
assert.deepEqual(env.calls.setLayoutProperty, [
['poi-circles', 'visibility', 'none'],
['poi-labels', 'visibility', 'none'],
]);
assert.deepEqual(env.calls.setItem, [['poi-visible', '0']]);
assert.equal(env.layerState.poi, false);
});
// ── TP-02: onPoiCheckbox() показывает слои при checked=true ──────────────
test('TP-02: установленный чекбокс показывает слои POI и сохраняет 1', () => {
const env = makeEnv();
env.checkbox.checked = true;
env.mod.onPoiCheckbox();
assert.deepEqual(env.calls.setLayoutProperty, [
['poi-circles', 'visibility', 'visible'],
['poi-labels', 'visibility', 'visible'],
]);
assert.deepEqual(env.calls.setItem, [['poi-visible', '1']]);
assert.equal(env.layerState.poi, true);
});
// ── TP-03: восстановление состояния — POI скрыты ─────────────────────────
test('TP-03: restorePoiState() при poi-visible=0 скрывает POI', () => {
const env = makeEnv({ stored: '0' });
env.mod.restorePoiState();
assert.equal(env.checkbox.checked, false);
assert.equal(env.layerState.poi, false);
assert.deepEqual(env.calls.setLayoutProperty, [
['poi-circles', 'visibility', 'none'],
['poi-labels', 'visibility', 'none'],
]);
// restore не должен переписывать localStorage
assert.deepEqual(env.calls.setItem, []);
});
// ── TP-04: восстановление состояния — POI видны (default) ────────────────
test('TP-04: restorePoiState() без ключа включает POI по умолчанию', () => {
const env = makeEnv();
env.mod.restorePoiState();
assert.equal(env.checkbox.checked, true);
assert.equal(env.layerState.poi, true);
assert.deepEqual(env.calls.setLayoutProperty, [
['poi-circles', 'visibility', 'visible'],
['poi-labels', 'visibility', 'visible'],
]);
});
// ── Доп.: значение '1' восстанавливает видимость явно ────────────────────
test('restorePoiState() при poi-visible=1 показывает POI', () => {
const env = makeEnv({ stored: '1' });
env.mod.restorePoiState();
assert.equal(env.checkbox.checked, true);
assert.equal(env.layerState.poi, true);
});
// ── Доп.: POI-логика не трогает чужие слои (дух TP-08) ───────────────────
test('onPoiCheckbox() меняет только слои poi-circles и poi-labels', () => {
const env = makeEnv();
env.checkbox.checked = false;
env.mod.onPoiCheckbox();
const touched = env.calls.setLayoutProperty.map((c) => c[0]);
assert.deepEqual([...touched].sort(), ['poi-circles', 'poi-labels']);
});
// ── Доп.: layerState синхронизируется даже без слоёв на карте ────────────
test('applyPoiVisibility() обновляет layerState даже если слой ещё не добавлен', () => {
const env = makeEnv({ layerExists: false });
env.mod.applyPoiVisibility(false);
assert.equal(env.layerState.poi, false);
assert.deepEqual(env.calls.setLayoutProperty, []);
});

View File

@@ -0,0 +1,162 @@
"""ET-002 — тесты чекбокса показа/скрытия POI в попапе рельефа.
Изменение ET-002 — исключительно фронтендовое (`src/web/index.html`,
`src/web/app.js`). В CI исполняется только `pytest tests/`, поэтому файл
покрывает фичу двумя способами:
1. Статические проверки структуры `index.html` и `app.js` — выполняются
всегда, без внешних зависимостей.
2. Поведенческие JS unit-тесты (TP-01..TP-04 из `04-test-plan.yaml`) —
запускаются через встроенный тест-раннер Node (`node --test`). Если
`node` в системе отсутствует — эта часть помечается `skip` (по аналогии
с `tests/integration/test_routing_barriers.py::test_lua_syntax` и
`luac`).
Браузерные e2e-сценарии (TP-05..TP-09) требуют Playwright-инфраструктуры,
которой в репозитории нет; добавление новых npm-пакетов запрещено
`07-infra-requirements.md`. Их поведенческая суть покрыта JS unit-тестами
и статическими проверками ниже.
"""
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"
JS_TEST = REPO_ROOT / "tests" / "unit" / "poi_toggle.test.js"
def _index_html() -> str:
assert INDEX_HTML.is_file(), f"не найден {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8")
def _app_js() -> str:
assert APP_JS.is_file(), f"не найден {APP_JS}"
return APP_JS.read_text(encoding="utf-8")
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки index.html (REQ-F-01, UI-спецификация)
# ──────────────────────────────────────────────────────────────────────────────
def test_poi_checkbox_present_in_html():
"""REQ-F-01: чекбокс POI присутствует в попапе с корректными атрибутами."""
html = _index_html()
assert 'id="poi-visible-cb"' in html, "нет чекбокса poi-visible-cb"
assert 'onchange="onPoiCheckbox()"' in html, "чекбокс не привязан к onPoiCheckbox()"
assert "<span>POI</span>" in html, "нет подписи «POI»"
def test_poi_checkbox_checked_by_default():
"""REQ-F-02: чекбокс POI отрисован как checked (POI видны по умолчанию)."""
html = _index_html()
# Атрибут checked должен стоять именно на инпуте poi-visible-cb.
start = html.index('id="poi-visible-cb"')
tag_end = html.index(">", start)
assert "checked" in html[start:tag_end], "у чекбокса POI нет атрибута checked"
def test_poi_checkbox_placed_after_trails_separated_by_hr():
"""REQ-F-01: чекбокс POI стоит после «Тропы» и отделён <hr>."""
html = _index_html()
trails_pos = html.index('id="trails-path-cb"')
poi_pos = html.index('id="poi-visible-cb"')
assert poi_pos > trails_pos, "POI должен идти после чекбокса «Тропы»"
between = html[trails_pos:poi_pos]
assert "<hr" in between, "POI не отделён горизонтальной линией <hr>"
def test_poi_checkbox_uses_shared_style_class():
"""UI-спецификация: чекбокс использует общий класс terrain-checkbox."""
html = _index_html()
start = html.index('id="poi-visible-cb"')
label_start = html.rfind("<label", 0, start)
label_open_end = html.index(">", label_start)
assert 'class="terrain-checkbox"' in html[label_start:label_open_end], (
"чекбокс POI должен быть в label с классом terrain-checkbox"
)
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки app.js (ADR-0001)
# ──────────────────────────────────────────────────────────────────────────────
def test_poi_functions_defined():
"""ADR-0001: определены хелпер и обработчики POI."""
js = _app_js()
for fn in ("applyPoiVisibility", "onPoiCheckbox", "restorePoiState"):
assert f"function {fn}(" in js, f"не определена функция {fn}()"
def test_poi_logic_uses_localstorage_key():
"""REQ-F-05: персистентность через localStorage ключ poi-visible."""
js = _app_js()
assert "localStorage.setItem('poi-visible'" in js, "состояние POI не сохраняется"
assert "localStorage.getItem('poi-visible')" in js, "состояние POI не читается"
def test_poi_logic_reuses_layer_state_and_groups():
"""ADR-0001 п.3-4: источник истины — layerState.poi, группа слоёв не дублируется."""
js = _app_js()
assert "layerState.poi" in js, "POI-логика не синхронизирует layerState.poi"
assert "layerGroups.poi" in js, "POI-логика должна переиспользовать layerGroups.poi"
# poi-группа объявлена ровно один раз — в общей карте layerGroups.
assert js.count("poi: ['poi-circles', 'poi-labels']") == 1, (
"карта групп слоёв POI задублирована"
)
def test_restore_poi_state_wired_into_init():
"""REQ-F-06: restorePoiState() вызывается при инициализации/смене стиля."""
js = _app_js()
# Один def + минимум 3 вызова (rebuildMapOverlays + 2 ветки initTerrain).
assert js.count("restorePoiState()") >= 4, (
"restorePoiState() не подключён ко всем точкам восстановления"
)
def test_poi_visibility_toggled_via_set_layout_property():
"""ADR-0001 п.1: видимость переключается через setLayoutProperty."""
js = _app_js()
marker = "// >>> ET-002 POI visibility block"
end = "// <<< ET-002 POI visibility block"
assert marker in js and end in js, "маркеры POI-блока отсутствуют"
block = js[js.index(marker):js.index(end)]
assert "setLayoutProperty" in block, "POI-блок не использует setLayoutProperty"
assert "removeLayer" not in block, (
"POI-блок не должен удалять слои (ADR-0001 отвергает вариант C)"
)
# ──────────────────────────────────────────────────────────────────────────────
# Поведенческие 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-тесты POI через `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-тесты POI упали (код {result.returncode}):\n"
f"{result.stdout}\n{result.stderr}"
)