From 8c17a4f508b8bf34fb5b2c00389b8ab7005a547a Mon Sep 17 00:00:00 2001 From: claude-bot Date: Thu, 21 May 2026 15:50:54 +0000 Subject: [PATCH] feat(web): add POI visibility checkbox to terrain popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 2 + src/web/app.js | 61 ++++++++++++- src/web/index.html | 5 + tests/unit/poi_toggle.test.js | 167 ++++++++++++++++++++++++++++++++++ tests/unit/test_poi_toggle.py | 162 +++++++++++++++++++++++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 tests/unit/poi_toggle.test.js create mode 100644 tests/unit/test_poi_toggle.py 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("", 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}" + )