All checks were successful
Понижаем 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>
301 lines
15 KiB
Python
301 lines
15 KiB
Python
"""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}"
|