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