feat(terrain): zoom-aware paint для hillshade/TRI на z9-z11 (ET-013)
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

Понижаем 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>
This commit is contained in:
2026-06-04 09:45:43 +00:00
parent 6b88bcee28
commit 5be81f97a5
4 changed files with 498 additions and 12 deletions

View File

@@ -2725,6 +2725,48 @@ function initMiniRouteInteraction() {
const TERRAIN_BASE_URL = window.location.pathname.replace(/\/[^/]*$/, '') + '/terrain';
// ET-013: zoom-aware paint для слоёв рельефа (ADR-017).
// Цель — компенсировать «потерю выразительности» перепадов на z9-z11.
// Pre-z9 — hillshade не показывается (UI-минзум). На z9-z11 — максимальный
// контраст и opacity, чтобы тени читались как на z8. К z12-z14 — возврат
// к исходным значениям (тогда у пользователя есть другие способы
// читать рельеф: подложка, грунтовки, POI).
const HILLSHADE_PAINT = {
'raster-opacity': [
'interpolate', ['linear'], ['zoom'],
9, 0.65,
10, 0.60,
11, 0.55,
12, 0.50,
14, 0.40
],
'raster-contrast': [
'interpolate', ['linear'], ['zoom'],
9, 0.40,
10, 0.35,
11, 0.30,
12, 0.15,
14, 0.00
],
'raster-resampling': 'nearest'
};
// ET-013: TRI остаётся 0.70 на z8 (регрессия), пик 0.80-0.85 на z9-z11.
const TRI_PAINT = {
'raster-opacity': [
'interpolate', ['linear'], ['zoom'],
5, 0.55,
7, 0.65,
8, 0.70,
9, 0.80,
10, 0.85,
11, 0.85,
12, 0.75,
15, 0.70
],
'raster-resampling': 'nearest'
};
function toggleTerrainPopup() {
const popup = document.getElementById('terrain-popup');
const btn = document.getElementById('terrain-toggle');
@@ -2779,8 +2821,9 @@ function onTerrainCheckbox() {
btn.classList.toggle('active', hillshadeChecked || triChecked);
// Apply layers
applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, 0.40, 10, 15);
applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', triChecked, 0.70, 5, 15);
// ET-013: hillshade теперь доступен с z9; paint zoom-aware (см. HILLSHADE_PAINT / TRI_PAINT).
applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, HILLSHADE_PAINT, 9, 15);
applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', triChecked, TRI_PAINT, 5, 15);
}
@@ -3313,12 +3356,29 @@ function onUnitChange() {
}
// <<< ET-005 unit toggle block <<<
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
/**
* ET-013: обратно-совместимое расширение для поддержки zoom-aware paint.
*
* @param {string} id - id слоя.
* @param {string} tileUrl - URL-шаблон тайлов.
* @param {boolean} enabled - показывать ли слой.
* @param {number|object} opacityOrPaint - либо число (старый контракт,
* станет 'raster-opacity' + linear-resampling), либо объект paint-properties
* целиком (должен содержать как минимум 'raster-opacity').
* @param {number} minzoom
* @param {number} maxzoom
*/
function applyTerrainLayer(id, tileUrl, enabled, opacityOrPaint, minzoom, maxzoom) {
const map = window._map;
if (!map) return;
const sourceId = id + '-source';
// ET-013: нормализация paint — число (старый контракт) или объект.
const paint = (typeof opacityOrPaint === 'number')
? { 'raster-opacity': opacityOrPaint, 'raster-resampling': 'linear' }
: opacityOrPaint;
if (enabled) {
// Add source if not exists
if (!map.getSource(sourceId)) {
@@ -3334,17 +3394,14 @@ function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
// Add layer if not exists
if (!map.getLayer(id)) {
// Insert before first road/trail layer for correct z-order
const firstTrailLayer = map.getStyle().layers.find(l =>
const firstTrailLayer = map.getStyle().layers.find(l =>
l.id.startsWith('trails-') || l.id.startsWith('poi-')
);
map.addLayer({
id: id,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': opacity,
'raster-resampling': 'linear'
},
paint: paint,
minzoom: minzoom,
maxzoom: maxzoom
}, firstTrailLayer ? firstTrailLayer.id : undefined);
@@ -3365,7 +3422,7 @@ function updateHillshadeAvailability() {
const hint = document.getElementById('terrain-hillshade-hint');
const label = cb ? cb.closest('.terrain-checkbox') : null;
if (zoom < 10) {
if (zoom < 9) { // ET-013: на z9 hillshade уже доступен
if (cb) cb.disabled = true;
if (label) label.classList.add('disabled');
if (hint) hint.style.display = 'inline';

View File

@@ -57,7 +57,7 @@
<input type="checkbox" id="terrain-hillshade-cb" onchange="onTerrainCheckbox()">
<span>Тени рельефа</span>
</label>
<span class="terrain-hint" id="terrain-hillshade-hint" style="display:none">Зум 10+</span>
<span class="terrain-hint" id="terrain-hillshade-hint" style="display:none">Зум 9+</span>
<label class="terrain-checkbox">
<input type="checkbox" id="terrain-tri-cb" onchange="onTerrainCheckbox()">
<span>Перепады</span>

View File

@@ -0,0 +1,129 @@
"""ET-013 — integration-тесты endpoint ``/terrain/{layer}/{z}/{x}/{y}.png``
для z9-z11 (REQ-F-15; AC-16).
Тесты используют FastAPI TestClient против ``src.api.main:app``. Реальные
тайлы рельефа в репозиторий не коммитятся (PH-6 data live in ``data/terrain/``
на test-сервере). Поэтому:
* Если директория с тайлами недоступна — тесты ``IT-TILE-*`` помечаются
``skipped`` с пояснением.
* Регрессии «404 на невалидный zoom / неизвестный layer» работают всегда —
они не требуют исходных данных.
Покрытие тест-плана (`04-test-plan.yaml`):
- IT-TILE-Z9-01, IT-TILE-Z10-01, IT-TILE-Z11-01
- IT-TILE-INVALID-LAYER, IT-TILE-MISSING
- IT-TILE-CACHE-HEADER
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from src.api.main import TERRAIN_DIR, app
REPO_ROOT = Path(__file__).resolve().parents[2]
# Опционально каталог тайлов перекрывается через env. По умолчанию — берём
# тот же путь, что использует api (см. src/api/main.py TERRAIN_DIR).
TERRAIN_ROOT = Path(os.environ.get("TERRAIN_DIR", TERRAIN_DIR))
@pytest.fixture(scope="module")
def client() -> TestClient:
return TestClient(app)
def _find_sample_tile(layer: str, z: int) -> tuple[int, int] | None:
"""Найти любую существующую (x, y) пару тайла для layer/z.
Возвращает None, если данных нет — тогда вызывающий тест помечается skipped.
"""
z_dir = TERRAIN_ROOT / layer / str(z)
if not z_dir.is_dir():
return None
for x_dir in sorted(z_dir.iterdir()):
if not x_dir.is_dir():
continue
try:
x = int(x_dir.name)
except ValueError:
continue
for y_file in sorted(x_dir.iterdir()):
if y_file.suffix != ".png":
continue
try:
y = int(y_file.stem)
except ValueError:
continue
return (x, y)
return None
def _maybe_skip(layer: str, z: int) -> tuple[int, int]:
sample = _find_sample_tile(layer, z)
if sample is None:
pytest.skip(
f"PH-6 data not present: {TERRAIN_ROOT}/{layer}/{z}/ — "
"integration smoke skipped (см. TRZ REQ-F-15)."
)
return sample
# ──────────────────────────────────────────────────────────────────────────────
# IT-TILE-Z9 / Z10 / Z11 — hillshade доступен на расширенном диапазоне зумов
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("zoom", [9, 10, 11])
def test_hillshade_tile_available_z9_z10_z11(client: TestClient, zoom: int):
"""IT-TILE-Z9/Z10/Z11-01: hillshade-тайл на z9-z11 отдаётся 200 PNG."""
x, y = _maybe_skip("hillshade", zoom)
resp = client.get(f"/terrain/hillshade/{zoom}/{x}/{y}.png")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert len(resp.content) > 0
# ──────────────────────────────────────────────────────────────────────────────
# Регрессии 404 — работают независимо от наличия данных
# ──────────────────────────────────────────────────────────────────────────────
def test_unknown_terrain_layer_returns_404(client: TestClient):
"""IT-TILE-INVALID-LAYER: неизвестный layer → 404."""
resp = client.get("/terrain/unknown_layer/9/0/0.png")
assert resp.status_code == 404
def test_missing_terrain_tile_returns_404(client: TestClient):
"""IT-TILE-MISSING: hillshade-тайл с нереальными x/y → 404."""
resp = client.get("/terrain/hillshade/9/999999/999999.png")
assert resp.status_code == 404
def test_invalid_zoom_returns_404(client: TestClient):
"""Доп. регрессия: zoom вне нарезанного диапазона → 404 (тайла нет на диске)."""
resp = client.get("/terrain/hillshade/99/0/0.png")
assert resp.status_code == 404
# ──────────────────────────────────────────────────────────────────────────────
# IT-TILE-CACHE-HEADER — Cache-Control: immutable сохраняется (NFR-03, REQ-F-18)
# ──────────────────────────────────────────────────────────────────────────────
def test_terrain_tile_cache_control_immutable(client: TestClient):
"""IT-TILE-CACHE-HEADER: тайл рельефа отдаётся с Cache-Control: immutable."""
x, y = _maybe_skip("hillshade", 9)
resp = client.get(f"/terrain/hillshade/9/{x}/{y}.png")
assert resp.status_code == 200
cache_control = resp.headers.get("cache-control", "")
assert "immutable" in cache_control, f"ожидался immutable в Cache-Control, факт: {cache_control}"
assert "max-age=31536000" in cache_control, (
f"ожидался max-age=31536000 в Cache-Control, факт: {cache_control}"
)

View File

@@ -0,0 +1,300 @@
"""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}"