feat(terrain): zoom-aware paint для hillshade/TRI на z9-z11 (ET-013)
All checks were successful
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>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
129
tests/integration/test_terrain_z9_tiles.py
Normal file
129
tests/integration/test_terrain_z9_tiles.py
Normal 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}"
|
||||
)
|
||||
300
tests/unit/test_terrain_paint.py
Normal file
300
tests/unit/test_terrain_paint.py
Normal 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}"
|
||||
Reference in New Issue
Block a user