diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a1c8a5..7825a8e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,3 +11,5 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- Agent system prompts (architect, developer, reviewer, tester, deployer)
- CI pipeline (Gitea Actions)
- Docker configuration
+- ET-002: чекбокс «POI» в попапе рельефа — показ/скрытие маркеров POI
+ с сохранением состояния в localStorage (ключ `poi-visible`)
diff --git a/src/web/app.js b/src/web/app.js
index cde51d7..4dfdee3 100644
--- a/src/web/app.js
+++ b/src/web/app.js
@@ -128,7 +128,8 @@ function rebuildMapOverlays() {
// Re-apply terrain and trails after style change
restoreTerrainState();
restoreTrailsState();
-
+ restorePoiState();
+
// Re-apply recon circle if active
if (reconMode && reconCenter) {
doRecon(reconCenter[0], reconCenter[1]);
@@ -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) {
const map = window._map;
if (!map) return;
@@ -2890,6 +2947,7 @@ function restoreTerrainState() {
// Initial state
restoreTerrainState();
restoreTrailsState();
+ restorePoiState();
} else {
// Map not ready yet, wait
const interval = setInterval(() => {
@@ -2902,6 +2960,7 @@ function restoreTerrainState() {
updateHillshadeAvailability();
restoreTerrainState();
restoreTrailsState();
+ restorePoiState();
}
}, 500);
}
diff --git a/src/web/index.html b/src/web/index.html
index 90c91ac..2a89e35 100644
--- a/src/web/index.html
+++ b/src/web/index.html
@@ -53,6 +53,11 @@
Тропы
+
+
diff --git a/tests/unit/poi_toggle.test.js b/tests/unit/poi_toggle.test.js
new file mode 100644
index 0000000..a7fa448
--- /dev/null
+++ b/tests/unit/poi_toggle.test.js
@@ -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, []);
+});
diff --git a/tests/unit/test_poi_toggle.py b/tests/unit/test_poi_toggle.py
new file mode 100644
index 0000000..b82585b
--- /dev/null
+++ b/tests/unit/test_poi_toggle.py
@@ -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 "POI" 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 стоит после «Тропы» и отделён
."""
+ 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 "
"
+
+
+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("