From 39348f6781790a558416305ed2b7a370fbedc671 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Thu, 4 Jun 2026 11:20:49 +0000 Subject: [PATCH] =?UTF-8?q?fix(ui):=20terrain-popup=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B8=20bot?= =?UTF-8?q?tom-sheet=20(ET-014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При открытии любого .bottom-sheet через openSheet() теперь принудительно скрывается #terrain-popup и снимается .active с #terrain-toggle. Это устраняет z-index конфликт (popup z=500 над sheet z=400) и убирает anti-pattern «два меню одновременно» на desktop без правки CSS-стека (marker-dialog z=500, search-panel, ruler-info — без регрессий). Реализация — Вариант A из ADR-019: helper closeTerrainPopup() + один вызов первой строкой в openSheet() после null-check. Для других sheets (sheet-route, sheet-recon, sheet-scenic, sheet-link, sheet-gpx) вызов безопасный no-op, REQ-F-06 выполняется автоматически. Тесты: - tests/unit/sheet_popup.test.js — 8 behavioral JS unit-тестов (TC-U-02, REQ-F-04, REQ-F-06 + ребра closeTerrainPopup). - tests/unit/test_sheet_popup.py — pytest-обёртка: статические проверки app.js (порядок вызовов в openSheet, маркеры блока), охранные тесты что z-stack не тронут и что gps_tracks.js/index.html не правились. Refs: ET-014 ADR: docs/work-items/ET-014/06-adr/ADR-019-terrain-popup-yields-to-sheet.md Co-Authored-By: Claude Opus 4.7 (1M context) --- src/web/app.js | 17 +++ tests/unit/sheet_popup.test.js | 259 +++++++++++++++++++++++++++++++++ tests/unit/test_sheet_popup.py | 195 +++++++++++++++++++++++++ 3 files changed, 471 insertions(+) create mode 100644 tests/unit/sheet_popup.test.js create mode 100644 tests/unit/test_sheet_popup.py diff --git a/src/web/app.js b/src/web/app.js index cedc0ff..541a10e 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -203,9 +203,25 @@ function formatDist(m) { // ─── Sheet Management ────────────────────────────────────────────── +// >>> ET-014 sheet-popup yield block (см. ADR-019) +// При открытии любого bottom-sheet'а принудительно закрываем +// #terrain-popup. Это устраняет z-index конфликт (popup z=500 над +// sheet z=400) и убирает anti-pattern «два меню открыты одновременно» +// на desktop. См. docs/work-items/ET-014/06-adr/ADR-019-*. +function closeTerrainPopup() { + const popup = document.getElementById('terrain-popup'); + const btn = document.getElementById('terrain-toggle'); + if (!popup || popup.style.display === 'none') return; + popup.style.display = 'none'; + if (btn) btn.classList.remove('active'); + document.removeEventListener('click', closeTerrainOnOutside); +} + function openSheet(id) { const sheet = document.getElementById(id); if (!sheet) return; + // ET-014: terrain-popup yields to any opening sheet (ADR-019). + closeTerrainPopup(); // Close all other sheets first document.querySelectorAll('.bottom-sheet.open').forEach(s => { if (s.id !== id) closeSheet(s.id); @@ -214,6 +230,7 @@ function openSheet(id) { const backdrop = document.getElementById('sheet-backdrop'); backdrop.classList.add('visible'); } +// <<< ET-014 sheet-popup yield block <<< function closeSheet(id) { const sheet = document.getElementById(id); diff --git a/tests/unit/sheet_popup.test.js b/tests/unit/sheet_popup.test.js new file mode 100644 index 0000000..48a7f90 --- /dev/null +++ b/tests/unit/sheet_popup.test.js @@ -0,0 +1,259 @@ +'use strict'; + +/** + * ET-014 — поведенческие unit-тесты для closeTerrainPopup() и openSheet(). + * + * Покрывают TC-U-01..TC-U-02 (часть) из docs/work-items/ET-014/04-test-plan.yaml, + * а также проверяют логику ADR-019: при открытии любого bottom-sheet + * `#terrain-popup` принудительно закрывается, а `#terrain-toggle` теряет + * класс `.active`. Поведение базируется на JS-функциях из блока ET-014 в + * src/web/app.js (между маркерами `// >>> ET-014 sheet-popup yield block` + * и `// <<< ET-014 sheet-popup yield block <<<`). + * + * Запуск: `node --test tests/unit/sheet_popup.test.js` + * (в CI оборачивается pytest-тестом tests/unit/test_sheet_popup.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'); + +/** + * Извлекает ET-014-блок из app.js и собирает из него модуль, подставляя + * переданные зависимости (window, document, closeTerrainOnOutside, + * closeSheet). Стиль повторяет загрузчик ET-007 (base_layer.test.js). + */ +function loadEt014Module(deps) { + const src = fs.readFileSync(APP_JS, 'utf8'); + const m = src.match( + /\/\/ >>> ET-014 sheet-popup yield block[^\n]*\n([\s\S]*?)\/\/ <<< ET-014 sheet-popup yield block/ + ); + assert.ok(m, 'ET-014-блок не найден в app.js (маркеры отсутствуют)'); + const factory = new Function( + 'window', 'document', 'closeTerrainOnOutside', 'closeSheet', + m[1] + '\nreturn { closeTerrainPopup, openSheet };' + ); + return factory( + deps.window, + deps.document, + deps.closeTerrainOnOutside || (() => {}), + deps.closeSheet || (() => {}), + ); +} + +/** + * Готовит мок-DOM: #terrain-popup, #terrain-toggle, #sheet-backdrop, + * а также произвольный набор bottom-sheets. Каждый bottom-sheet имеет + * classList с методами add/remove/contains и querySelectorAll-совместимый + * матчинг по селектору '.bottom-sheet.open' (через document.querySelectorAll). + */ +function makeEnv({ + popupVisible = false, + toggleActive = false, + sheets = [], // [{ id, open }] + backdropVisible = false, +} = {}) { + const popup = { + style: { display: popupVisible ? 'block' : 'none' }, + }; + const _toggleClasses = new Set(['map-btn']); + if (toggleActive) _toggleClasses.add('active'); + const toggle = { + classList: { + _classes: _toggleClasses, + add(c) { this._classes.add(c); }, + remove(c) { this._classes.delete(c); }, + contains(c) { return this._classes.has(c); }, + }, + }; + + const _backdropClasses = new Set(); + if (backdropVisible) _backdropClasses.add('visible'); + const backdrop = { + classList: { + _classes: _backdropClasses, + add(c) { this._classes.add(c); }, + remove(c) { this._classes.delete(c); }, + contains(c) { return this._classes.has(c); }, + }, + }; + + // Bottom-sheets с classList API. + const sheetEls = sheets.map(({ id, open }) => { + const _classes = new Set(['bottom-sheet']); + if (open) _classes.add('open'); + return { + id, + classList: { + _classes, + add(c) { this._classes.add(c); }, + remove(c) { this._classes.delete(c); }, + contains(c) { return this._classes.has(c); }, + }, + }; + }); + + const docCalls = { + removeEventListener: [], + }; + + const document = { + getElementById(id) { + if (id === 'terrain-popup') return popup; + if (id === 'terrain-toggle') return toggle; + if (id === 'sheet-backdrop') return backdrop; + const s = sheetEls.find((e) => e.id === id); + return s || null; + }, + querySelectorAll(selector) { + if (selector === '.bottom-sheet.open') { + return sheetEls.filter((s) => s.classList.contains('open')); + } + return []; + }, + removeEventListener(type, fn) { + docCalls.removeEventListener.push([type, fn]); + }, + addEventListener() { /* not used by closeTerrainPopup */ }, + }; + + return { document, popup, toggle, backdrop, sheetEls, docCalls }; +} + +// ─── TC-U-02 (часть А): popup закрывается при открытии sheet ──────────── +test('TC-U-02: openSheet() закрывает открытый terrain-popup и снимает .active', () => { + const env = makeEnv({ + popupVisible: true, + toggleActive: true, + sheets: [{ id: 'sheet-gps-filters', open: false }], + }); + const mod = loadEt014Module({ document: env.document }); + + mod.openSheet('sheet-gps-filters'); + + assert.equal(env.popup.style.display, 'none', 'popup должен быть скрыт'); + assert.ok(!env.toggle.classList.contains('active'), + 'кнопка #terrain-toggle должна потерять класс active'); +}); + +// ─── REQ-F-04 / AC-06: повторное открытие стабильно ───────────────────── +test('REQ-F-04: повторный openSheet() — sheet остаётся open, без артефактов', () => { + const env = makeEnv({ + popupVisible: false, + sheets: [{ id: 'sheet-gps-filters', open: false }], + }); + const mod = loadEt014Module({ document: env.document }); + + mod.openSheet('sheet-gps-filters'); + const sheet = env.sheetEls.find((s) => s.id === 'sheet-gps-filters'); + assert.ok(sheet.classList.contains('open'), 'sheet должен иметь класс open'); + assert.ok(env.backdrop.classList.contains('visible'), + 'backdrop должен быть видим'); + + // Повторный вызов — sheet остаётся открытым, никаких регрессий. + mod.openSheet('sheet-gps-filters'); + assert.ok(sheet.classList.contains('open'), 'sheet всё ещё open'); + assert.ok(env.backdrop.classList.contains('visible'), + 'backdrop всё ещё visible'); +}); + +// ─── REQ-F-06: другие sheets — popup-helper тоже срабатывает (но no-op) ─ +test('REQ-F-06: openSheet() для других sheets тоже зовёт closeTerrainPopup', () => { + // Popup закрыт изначально — closeTerrainPopup должна быть no-op. + const env = makeEnv({ + popupVisible: false, + sheets: [ + { id: 'sheet-route', open: false }, + { id: 'sheet-recon', open: false }, + ], + }); + const mod = loadEt014Module({ document: env.document }); + + mod.openSheet('sheet-route'); + const sheet = env.sheetEls.find((s) => s.id === 'sheet-route'); + assert.ok(sheet.classList.contains('open')); + assert.equal(env.popup.style.display, 'none', 'popup остаётся скрытым'); + assert.ok(!env.toggle.classList.contains('active'), + 'active не появляется (popup и не был открыт)'); +}); + +// ─── closeTerrainPopup — no-op если popup уже скрыт ───────────────────── +test('closeTerrainPopup: no-op если popup уже скрыт', () => { + const env = makeEnv({ popupVisible: false }); + const mod = loadEt014Module({ document: env.document }); + + mod.closeTerrainPopup(); + + assert.equal(env.popup.style.display, 'none'); + // removeEventListener не должен вызываться (нечего отписывать). + assert.equal(env.docCalls.removeEventListener.length, 0, + 'removeEventListener не должен вызываться при закрытом popup'); +}); + +// ─── closeTerrainPopup: отписывает closeTerrainOnOutside ──────────────── +test('closeTerrainPopup: при открытом popup отписывает click-listener', () => { + const env = makeEnv({ popupVisible: true, toggleActive: true }); + const dummyHandler = function closeTerrainOnOutside() {}; + const mod = loadEt014Module({ + document: env.document, + closeTerrainOnOutside: dummyHandler, + }); + + mod.closeTerrainPopup(); + + assert.equal(env.popup.style.display, 'none'); + assert.ok(!env.toggle.classList.contains('active')); + assert.equal(env.docCalls.removeEventListener.length, 1, + 'removeEventListener должен быть вызван 1 раз'); + assert.equal(env.docCalls.removeEventListener[0][0], 'click'); + assert.equal(env.docCalls.removeEventListener[0][1], dummyHandler); +}); + +// ─── closeTerrainPopup: безопасен при отсутствии #terrain-popup ───────── +test('closeTerrainPopup: безопасен если #terrain-popup отсутствует', () => { + const env = makeEnv({ popupVisible: false }); + // Перекроем getElementById чтобы вернуть null для terrain-popup. + const origGet = env.document.getElementById.bind(env.document); + env.document.getElementById = (id) => (id === 'terrain-popup' ? null : origGet(id)); + const mod = loadEt014Module({ document: env.document }); + + assert.doesNotThrow(() => mod.closeTerrainPopup()); +}); + +// ─── openSheet: ранний выход если sheet не найден (без побочных эффектов) ─ +test('openSheet: ранний выход если sheet не найден (popup не трогается)', () => { + const env = makeEnv({ popupVisible: true, toggleActive: true }); + const mod = loadEt014Module({ document: env.document }); + + mod.openSheet('does-not-exist'); + + // popup остаётся открытым: helper вызывается ПОСЛЕ null-check на sheet. + assert.equal(env.popup.style.display, 'block', + 'popup должен остаться открытым, если sheet не найден'); + assert.ok(env.toggle.classList.contains('active')); +}); + +// ─── REQ-F-01: закрытие конкурирующих sheets продолжает работать ──────── +test('openSheet: закрывает другие открытые sheets (через closeSheet)', () => { + const env = makeEnv({ + sheets: [ + { id: 'sheet-route', open: true }, + { id: 'sheet-gps-filters', open: false }, + ], + }); + const closeSheetCalls = []; + const mod = loadEt014Module({ + document: env.document, + closeSheet: (id) => closeSheetCalls.push(id), + }); + + mod.openSheet('sheet-gps-filters'); + + assert.deepEqual(closeSheetCalls, ['sheet-route'], + 'closeSheet должен быть вызван для sheet-route'); + const target = env.sheetEls.find((s) => s.id === 'sheet-gps-filters'); + assert.ok(target.classList.contains('open')); +}); diff --git a/tests/unit/test_sheet_popup.py b/tests/unit/test_sheet_popup.py new file mode 100644 index 0000000..83a9a33 --- /dev/null +++ b/tests/unit/test_sheet_popup.py @@ -0,0 +1,195 @@ +"""ET-014 — тесты sheet ⇄ terrain-popup взаимодействия (ADR-019). + +ET-014 — исключительно фронтендовое изменение (см. ADR-019): правки +`src/web/app.js`. Никаких изменений в CSS, HTML, backend, миграциях. +В CI исполняется только `pytest tests/`, поэтому файл покрывает фичу +двумя способами: + +1. Статические проверки структуры `src/web/app.js` — выполняются всегда. +2. Поведенческие JS unit-тесты (TC-U-02, REQ-F-04, REQ-F-06) — + запускаются через встроенный тест-раннер Node (`node --test`). Если + `node` в системе отсутствует — эта часть помечается `skip`. + +Браузерные e2e-сценарии (TC-E-01..TC-E-06, TC-UI-01..TC-UI-08) требуют +Playwright-инфраструктуры, которой в репозитории нет. Их поведенческая +суть покрыта JS unit-тестами и статическими проверками ниже. + +См.: +- ADR-019: docs/work-items/ET-014/06-adr/ADR-019-terrain-popup-yields-to-sheet.md +- TRZ: docs/work-items/ET-014/02-trz.md +- AC: docs/work-items/ET-014/03-acceptance-criteria.md +- Test plan: docs/work-items/ET-014/04-test-plan.yaml +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from shutil import which + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +APP_JS = REPO_ROOT / "src" / "web" / "app.js" +APP_CSS = REPO_ROOT / "src" / "web" / "app.css" +INDEX_HTML = REPO_ROOT / "src" / "web" / "index.html" +JS_TEST = REPO_ROOT / "tests" / "unit" / "sheet_popup.test.js" + + +def _read(path: Path) -> str: + assert path.is_file(), f"не найден {path}" + return path.read_text(encoding="utf-8") + + +# ────────────────────────────────────────────────────────────────────────────── +# Статические проверки app.js (ADR-019) +# ────────────────────────────────────────────────────────────────────────────── + +def test_app_js_has_et014_block_markers(): + """Блок ET-014 обрамлён маркерами для извлечения JS unit-тестами.""" + js = _read(APP_JS) + assert "// >>> ET-014 sheet-popup yield block" in js, ( + "нет открывающего маркера блока ET-014" + ) + assert "// <<< ET-014 sheet-popup yield block <<<" in js, ( + "нет закрывающего маркера блока ET-014" + ) + + +def test_close_terrain_popup_function_defined(): + """ADR-019 §Решение/1: функция closeTerrainPopup() определена.""" + js = _read(APP_JS) + assert "function closeTerrainPopup(" in js, ( + "не определена функция closeTerrainPopup()" + ) + + +def test_close_terrain_popup_inside_block(): + """closeTerrainPopup() расположена внутри ET-014-блока (для unit-тестов).""" + js = _read(APP_JS) + block_start = js.index("// >>> ET-014 sheet-popup yield block") + block_end = js.index("// <<< ET-014 sheet-popup yield block <<<") + block = js[block_start:block_end] + assert "function closeTerrainPopup(" in block, ( + "closeTerrainPopup() должна быть внутри ET-014-блока" + ) + + +def test_open_sheet_calls_close_terrain_popup_first(): + """ADR-019 §Решение/2: closeTerrainPopup() — первый вызов в openSheet() + после null-check на sheet.""" + js = _read(APP_JS) + # Берём тело openSheet до первой закрывающей фигурной скобки на новой строке. + m = re.search( + r"function openSheet\(id\)\s*\{([\s\S]*?)\n\}", + js, + ) + assert m, "функция openSheet(id) не найдена" + body = m.group(1) + # Проверим порядок: null-check, потом closeTerrainPopup, потом всё остальное. + nullcheck_pos = body.find("if (!sheet) return;") + close_popup_pos = body.find("closeTerrainPopup()") + close_sheet_pos = body.find("closeSheet(") + add_open_pos = body.find("classList.add('open')") + + assert nullcheck_pos >= 0, "null-check на sheet в openSheet() отсутствует" + assert close_popup_pos > nullcheck_pos, ( + "closeTerrainPopup() должна вызываться ПОСЛЕ null-check" + ) + assert close_sheet_pos > close_popup_pos, ( + "closeTerrainPopup() должна вызываться ДО закрытия других sheets" + ) + assert add_open_pos > close_popup_pos, ( + "closeTerrainPopup() должна вызываться ДО classList.add('open')" + ) + + +def test_open_sheet_calls_close_terrain_popup_exactly_once(): + """REQ-NF-02: никакого дублирования вызовов (не должно быть лишних + обработчиков). closeTerrainPopup() вызывается ровно один раз в openSheet.""" + js = _read(APP_JS) + m = re.search( + r"function openSheet\(id\)\s*\{([\s\S]*?)\n\}", + js, + ) + assert m, "функция openSheet(id) не найдена" + body = m.group(1) + calls = body.count("closeTerrainPopup()") + assert calls == 1, ( + f"closeTerrainPopup() должна вызываться ровно один раз в openSheet(), " + f"найдено {calls}" + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Статические проверки: что НЕ меняется (ADR-019 §Что НЕ меняется) +# ────────────────────────────────────────────────────────────────────────────── + +def test_z_index_stack_unchanged_for_affected_widgets(): + """ADR-019 §Что НЕ меняется: z-index ключевых виджетов из конфликта + (.bottom-sheet, #sheet-backdrop, .terrain-popup, #marker-dialog) + остаётся неизменным. Эти значения — фундамент аргументации ADR-019 + (Вариант A не правит CSS), любая их правка ломает обоснование. + + REQ-NF-03: marker-dialog (z=500) сохраняется на верху относительно sheet'ов. + """ + css = _read(APP_CSS) + expected = [ + (".bottom-sheet", "z-index: 400"), + ("#sheet-backdrop", "z-index: 390"), + ("#marker-dialog", "z-index: 500"), + (".terrain-popup", "z-index: 500"), + ] + for selector, z in expected: + sel_pos = css.find(selector) + assert sel_pos >= 0, f"селектор {selector} не найден в app.css" + # Смотрим в окне 600 символов после селектора (CSS-блок укладывается). + window = css[sel_pos:sel_pos + 600] + assert z in window, ( + f"в блоке {selector} отсутствует {z}; ADR-019 запрещает менять z-stack" + ) + + +def test_gps_tracks_js_not_touched_by_et014(): + """ADR-019 §Что НЕ меняется: src/web/gps_tracks.js не правится ET-014.""" + gps = _read(REPO_ROOT / "src" / "web" / "gps_tracks.js") + # Маркеров ET-014 в gps_tracks.js не должно быть — логика живёт в openSheet. + assert "ET-014" not in gps, ( + "ET-014 не должен изменять src/web/gps_tracks.js (см. ADR-019)" + ) + + +def test_index_html_not_touched_by_et014(): + """ADR-019 §Что НЕ меняется: src/web/index.html без изменений.""" + html = _read(INDEX_HTML) + assert "ET-014" not in html, ( + "ET-014 не должен изменять src/web/index.html (см. ADR-019)" + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Поведенческие JS unit-тесты через Node (TC-U-02, REQ-F-04, REQ-F-06) +# ────────────────────────────────────────────────────────────────────────────── + +node_required = pytest.mark.skipif( + which("node") is None, + reason="node не установлен — поведенческие JS unit-тесты пропущены", +) + + +@node_required +def test_js_unit_tests_pass(): + """TC-U-02 / REQ-F-04 / REQ-F-06: behavioral 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-тесты ET-014 упали (код {result.returncode}):\n" + f"{result.stdout}\n{result.stderr}" + )