"""ET-013 — unit-тесты zoom-aware paint для hillshade и TRI. ET-013 — фронтенд-калибровка растровых terrain-слоёв (см. ADR-017). В CI исполняется только ``pytest tests/``, JS-тест-раннера в проекте нет, поэтому проверки выполнены как статический парсинг ``src/web/app.js`` и ``src/web/index.html`` (см. TRZ REQ-F-13 Вариант B). Покрытие тест-плана (`04-test-plan.yaml`): - UT-PAINT-HS-OPACITY, UT-PAINT-HS-CONTRAST, UT-PAINT-HS-RESAMPLING - UT-PAINT-TRI-OPACITY-Z8, UT-PAINT-TRI-OPACITY-PEAK, UT-PAINT-TRI-RESAMPLING - UT-PAINT-COMPAT-01, UT-PAINT-COMPAT-02 - UT-REG-MINZOOM-9, UT-REG-HINT-TEXT, UT-REG-CALLERS """ from __future__ import annotations import re from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[2] APP_JS = REPO_ROOT / "src" / "web" / "app.js" INDEX_HTML = REPO_ROOT / "src" / "web" / "index.html" def _app_js() -> str: assert APP_JS.is_file(), f"не найден {APP_JS}" return APP_JS.read_text(encoding="utf-8") def _index_html() -> str: assert INDEX_HTML.is_file(), f"не найден {INDEX_HTML}" return INDEX_HTML.read_text(encoding="utf-8") def _extract_block(name: str, src: str) -> str: """Достать тело объявления `const NAME = { ... };` (один уровень фигурных скобок).""" start_match = re.search(rf"const\s+{re.escape(name)}\s*=\s*\{{", src) assert start_match, f"не найдено объявление {name}" i = start_match.end() - 1 # позиция открывающей `{` depth = 0 end = -1 while i < len(src): ch = src[i] if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: end = i break i += 1 assert end > 0, f"не найден конец объявления {name}" return src[start_match.end() - 1:end + 1] def _parse_zoom_stops(interpolate_src: str) -> dict[int, float]: """Достать пары (zoom, value) из 'interpolate' блока. Толерантно к пробелам/переносам.""" # ищем секцию ['zoom'] и далее парами «целое, число» zoom_pos = interpolate_src.find("['zoom']") assert zoom_pos > 0, "ожидался ['zoom'] в interpolate-выражении" tail = interpolate_src[zoom_pos + len("['zoom']"):] # тело продолжается до закрывающей ']' уровня нашего массива; ищем все числа # сначала отрезаем хвост по конечной `]` bracket_close = tail.rfind("]") assert bracket_close > 0, "не найден конец interpolate-массива" body = tail[:bracket_close] nums = re.findall(r"-?\d+(?:\.\d+)?", body) assert len(nums) % 2 == 0 and nums, ( f"ожидаются чётные пары (zoom, value), получено {nums}" ) stops: dict[int, float] = {} for i in range(0, len(nums), 2): z = int(float(nums[i])) v = float(nums[i + 1]) stops[z] = v return stops # ────────────────────────────────────────────────────────────────────────────── # HILLSHADE_PAINT (REQ-F-05, F-06, F-07; AC-04) # ────────────────────────────────────────────────────────────────────────────── def test_hillshade_paint_defined(): """REQ-F-05: HILLSHADE_PAINT объявлен в app.js.""" js = _app_js() assert "const HILLSHADE_PAINT" in js, "HILLSHADE_PAINT не объявлен" def test_hillshade_opacity_is_interpolate_by_zoom(): """UT-PAINT-HS-OPACITY: raster-opacity — interpolate linear по zoom.""" block = _extract_block("HILLSHADE_PAINT", _app_js()) # достаём массив 'raster-opacity' m = re.search(r"'raster-opacity'\s*:\s*\[(.*?)\]\s*,\s*'raster-contrast'", block, re.DOTALL) assert m, "не найдена секция 'raster-opacity' в HILLSHADE_PAINT" op_src = "[" + m.group(1) + "]" assert "'interpolate'" in op_src, "raster-opacity должен быть 'interpolate'" assert "'linear'" in op_src, "ожидается linear-interpolate" assert "'zoom'" in op_src, "ожидается интерполяция по zoom" def test_hillshade_opacity_stops(): """UT-PAINT-HS-OPACITY: stops по zoom монотонно убывают, ключевые значения совпадают.""" block = _extract_block("HILLSHADE_PAINT", _app_js()) m = re.search(r"'raster-opacity'\s*:\s*(\[.*?\])\s*,\s*'raster-contrast'", block, re.DOTALL) assert m stops = _parse_zoom_stops(m.group(1)) # требования ADR-017 / TRZ §3 REQ-F-05 assert 9 in stops and stops[9] == pytest.approx(0.65, abs=0.001) assert 11 in stops and stops[11] == pytest.approx(0.55, abs=0.001) assert 14 in stops and stops[14] == pytest.approx(0.40, abs=0.001) # монотонность 9 → 14 zooms = sorted(stops.keys()) values = [stops[z] for z in zooms] assert values == sorted(values, reverse=True), ( f"raster-opacity hillshade не монотонно убывает: {stops}" ) def test_hillshade_contrast_peak_z9(): """UT-PAINT-HS-CONTRAST: contrast на z9 ≥ 0.30, на z14 ≤ 0.10, монотонно убывает.""" block = _extract_block("HILLSHADE_PAINT", _app_js()) m = re.search( r"'raster-contrast'\s*:\s*(\[.*?\])\s*,\s*'raster-resampling'", block, re.DOTALL, ) assert m, "не найдена секция 'raster-contrast' в HILLSHADE_PAINT" contrast_src = m.group(1) assert "'interpolate'" in contrast_src stops = _parse_zoom_stops(contrast_src) assert stops[9] >= 0.30, f"z=9 contrast должен быть ≥0.30, факт {stops[9]}" assert stops[14] <= 0.10, f"z=14 contrast должен быть ≤0.10, факт {stops[14]}" zooms = sorted(stops.keys()) values = [stops[z] for z in zooms] assert values == sorted(values, reverse=True), ( f"raster-contrast hillshade не монотонно убывает: {stops}" ) def test_hillshade_resampling_nearest(): """UT-PAINT-HS-RESAMPLING: raster-resampling = 'nearest'.""" block = _extract_block("HILLSHADE_PAINT", _app_js()) assert "'raster-resampling': 'nearest'" in block, ( "HILLSHADE_PAINT должен использовать nearest-resampling" ) # ────────────────────────────────────────────────────────────────────────────── # TRI_PAINT (REQ-F-08, F-09; AC-05, AC-06) # ────────────────────────────────────────────────────────────────────────────── def test_tri_paint_defined(): """REQ-F-08: TRI_PAINT объявлен в app.js.""" js = _app_js() assert "const TRI_PAINT" in js, "TRI_PAINT не объявлен" def test_tri_opacity_z8_regression(): """UT-PAINT-TRI-OPACITY-Z8 (AC-06): на z=8 opacity = 0.70 ровно (регрессия).""" block = _extract_block("TRI_PAINT", _app_js()) m = re.search(r"'raster-opacity'\s*:\s*(\[.*?\])\s*,\s*'raster-resampling'", block, re.DOTALL) assert m stops = _parse_zoom_stops(m.group(1)) assert 8 in stops and stops[8] == pytest.approx(0.70, abs=0.001), ( f"регрессия z8: TRI opacity должен быть 0.70, факт {stops.get(8)}" ) def test_tri_opacity_peak_z9_z11(): """UT-PAINT-TRI-OPACITY-PEAK: на z9-z11 opacity ≥ 0.80.""" block = _extract_block("TRI_PAINT", _app_js()) m = re.search(r"'raster-opacity'\s*:\s*(\[.*?\])\s*,\s*'raster-resampling'", block, re.DOTALL) assert m stops = _parse_zoom_stops(m.group(1)) assert stops[10] >= 0.80, f"z=10 TRI opacity должен быть ≥0.80, факт {stops[10]}" assert stops[11] >= 0.80, f"z=11 TRI opacity должен быть ≥0.80, факт {stops[11]}" def test_tri_resampling_nearest(): """UT-PAINT-TRI-RESAMPLING: raster-resampling = 'nearest'.""" block = _extract_block("TRI_PAINT", _app_js()) assert "'raster-resampling': 'nearest'" in block, ( "TRI_PAINT должен использовать nearest-resampling" ) # ────────────────────────────────────────────────────────────────────────────── # applyTerrainLayer: обратная совместимость (REQ-F-04; AC-22) # ────────────────────────────────────────────────────────────────────────────── def test_apply_terrain_layer_signature_uses_opacity_or_paint(): """UT-PAINT-COMPAT-01: сигнатура использует opacityOrPaint.""" js = _app_js() assert ( "function applyTerrainLayer(id, tileUrl, enabled, opacityOrPaint, minzoom, maxzoom)" in js ), "сигнатура applyTerrainLayer должна принимать opacityOrPaint" def test_apply_terrain_layer_normalizes_number_to_legacy_paint(): """UT-PAINT-COMPAT-01: ветвление по typeof opacityOrPaint === 'number'.""" js = _app_js() assert "typeof opacityOrPaint === 'number'" in js, ( "applyTerrainLayer должен ветвиться по типу (number → legacy paint)" ) # «старый» путь должен собирать legacy-paint с linear-resampling assert "'raster-opacity': opacityOrPaint" in js, ( "при числовом opacityOrPaint paint должен содержать 'raster-opacity': opacityOrPaint" ) assert "'raster-resampling': 'linear'" in js, ( "legacy-ветка должна использовать linear-resampling" ) def test_apply_terrain_layer_uses_paint_variable(): """UT-PAINT-COMPAT-02: объект paint пробрасывается в map.addLayer как есть.""" js = _app_js() # после нормализации код должен передавать `paint: paint` в addLayer assert re.search(r"paint:\s*paint\s*,", js), ( "applyTerrainLayer должен использовать переменную `paint` в map.addLayer" ) # ────────────────────────────────────────────────────────────────────────────── # Регрессии: пороги, hint, callers (REQ-F-01, F-10, F-14) # ────────────────────────────────────────────────────────────────────────────── def test_minzoom_threshold_lowered_to_9(): """UT-REG-MINZOOM-9 (AC-01): updateHillshadeAvailability использует порог 9.""" js = _app_js() # внутри updateHillshadeAvailability должно быть `zoom < 9` m = re.search( r"function updateHillshadeAvailability\(\)\s*\{(.*?)^\}", js, re.DOTALL | re.MULTILINE, ) assert m, "не найдена функция updateHillshadeAvailability" body = m.group(1) assert "zoom < 9" in body, "порог должен быть `zoom < 9`" assert "zoom < 10" not in body, "старый порог `zoom < 10` должен быть удалён" def test_hint_text_updated_to_z9(): """UT-REG-HINT-TEXT (AC-01): hint содержит «Зум 9+».""" html = _index_html() # ищем содержимое #terrain-hillshade-hint m = re.search( r'id="terrain-hillshade-hint"[^>]*>\s*([^<]+)\s*', html, ) assert m, "не найден #terrain-hillshade-hint в index.html" text = m.group(1).strip() assert text == "Зум 9+", f"hint должен быть «Зум 9+», факт «{text}»" def test_apply_terrain_layer_caller_count(): """UT-REG-CALLERS: applyTerrainLayer вызывается минимум 2 раза в onTerrainCheckbox.""" js = _app_js() # ищем вызовы (исключая саму декларацию функции) pattern = re.compile(r"applyTerrainLayer\s*\(") matches = pattern.findall(js) # одно совпадение — объявление функции, остальные — вызовы assert len(matches) >= 3, ( f"ожидается ≥3 вхождений applyTerrainLayer (1 декл. + ≥2 вызова), факт {len(matches)}" ) def test_hillshade_call_uses_paint_constant_and_minzoom_9(): """REQ-F-02 + REQ-F-05: вызов hillshade использует HILLSHADE_PAINT и minzoom=9.""" js = _app_js() # ищем строку вызова, привязанную к hillshade m = re.search( r"applyTerrainLayer\(\s*'terrain-hillshade'\s*,\s*[^,]+,\s*hillshadeChecked\s*,\s*([A-Za-z_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", js, ) assert m, "вызов applyTerrainLayer для terrain-hillshade не найден" paint_arg, minz, maxz = m.group(1), int(m.group(2)), int(m.group(3)) assert paint_arg == "HILLSHADE_PAINT", ( f"hillshade должен использовать HILLSHADE_PAINT, факт {paint_arg}" ) assert minz == 9, f"hillshade minzoom должен быть 9, факт {minz}" assert maxz == 15, f"hillshade maxzoom должен быть 15, факт {maxz}" def test_tri_call_uses_paint_constant_and_minzoom_5(): """REQ-F-03 + REQ-F-08: вызов TRI использует TRI_PAINT и minzoom=5 (без изменений).""" js = _app_js() m = re.search( r"applyTerrainLayer\(\s*'terrain-tri'\s*,\s*[^,]+,\s*triChecked\s*,\s*([A-Za-z_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", js, ) assert m, "вызов applyTerrainLayer для terrain-tri не найден" paint_arg, minz, maxz = m.group(1), int(m.group(2)), int(m.group(3)) assert paint_arg == "TRI_PAINT", ( f"tri должен использовать TRI_PAINT, факт {paint_arg}" ) assert minz == 5, f"tri minzoom должен быть 5, факт {minz}" assert maxz == 15, f"tri maxzoom должен быть 15, факт {maxz}"