Files
enduro-trails/tests/unit/test_terrain_paint.py
claude-bot 5be81f97a5
All checks were successful
CI / lint (push) Successful in 5s
CI / test (push) Successful in 9s
CI / build (push) Successful in 2s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 9s
CI / build (pull_request) Successful in 2s
feat(terrain): zoom-aware paint для hillshade/TRI на z9-z11 (ET-013)
Понижаем UI-минзум hillshade с 10 до 9 и переводим raster-paint
обоих terrain-слоёв в zoom-aware форму через MapLibre interpolate.
На z9-z11 — пик opacity/contrast, чтобы рельеф читался как на z8;
на z12-z14 — возврат к исходным значениям (регрессия по AC-10).
TRI на z8 остаётся 0.70 (регрессия по AC-06), пик 0.80-0.85 на z9-z11.

Изменения:
- src/web/app.js: добавлены HILLSHADE_PAINT и TRI_PAINT; applyTerrainLayer
  расширена для поддержки object-paint (обратно-совместимо); порог
  updateHillshadeAvailability понижен до 9; вызовы для hillshade переведены
  на minzoom=9.
- src/web/index.html: hint обновлён с «Зум 10+» на «Зум 9+».
- tests/unit/test_terrain_paint.py: 17 тестов покрытия zoom-stops, контракта
  applyTerrainLayer и регрессий (UT-PAINT-*, UT-REG-*).
- tests/integration/test_terrain_z9_tiles.py: smoke /terrain endpoint на
  z9-z11 + кэш-заголовки (IT-TILE-*).

Backend, тайлы на диске, конфиги, стили — без изменений.

Refs: ET-013
ADR: ADR-017

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 09:45:43 +00:00

301 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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*</span>',
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}"