fix(ui): terrain-popup закрывается при открытии bottom-sheet (ET-014)
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 10s
CI / build (push) Successful in 3s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 9s
CI / build (pull_request) Successful in 2s

При открытии любого .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) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:20:49 +00:00
parent bc63122221
commit 39348f6781
3 changed files with 471 additions and 0 deletions

View File

@@ -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);

View File

@@ -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'));
});

View File

@@ -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}"
)