gendesign/backend/tests/test_nspd_client.py
Light1YT e9585bcd3b
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 1m8s
Deploy / build-backend (push) Successful in 36s
Deploy / deploy (push) Successful in 1m13s
test(suite): re-align drifted tests with current code (CI-rehab 2/3)
Suite was uncollectable for a while → code evolved, mocks didn't (42 failed /
5 errors → 1687 passed / 0 failed / 0 errors). TEST-ONLY, no app code changed,
no real bugs found (code-reviewed  — no test weakened to fake-green).

- best_layouts: sum_deals→deals_window rename; velocity-scaling direction for
  new SF-01 divisors (last_month=1.0/last_year=12.0)
- quarter_dump_lookup: 7→10-tuple mock rows (risks/opportunity/red_lines cols);
  fix early-exit call-counts for the cad_zouit fallback (#232)
- cadastre_bulk: add xmin/ymin/xmax/ymax to harvest-quarter db mock
- nspd_client: add required QuarterDump.opportunity
- TopLayoutRow: add required is_oversold
- analyze_{market_price,recent_permits,inline_weights}: SQL-signature dispatch
  instead of fragile positional db.execute indices (SF-B5 query reorder; also
  fixes a double-POST geom-starvation false-negative)
- custom_pois: move centroid >15km so center_bonus=0 isolates custom-POI delta
- layout/report PDF: runtime WeasyPrint probe (skip macOS dev, RUN on CI)
- mv_layout SQL: normalize +psycopg DSN + connectivity-probe skipif
- admin token tests: skip (X-Admin-Token gate removed #437/#426; protection is
  Caddy basic_auth + RBAC, covered by test_rbac)

Refs #944.
2026-06-03 19:05:43 +05:00

707 lines
25 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.

"""Unit tests для nspd_client (#94). Без сетевых запросов — только parsing.
Network calls (search_by_cad / get_feature_info / list_layers) проверяются
вручную через `python -c "from app.services.scrapers.nspd_client import
NSPDClient; print(NSPDClient().search_by_cad('66:41:0204016:10'))"` —
проверяется в production smoke test после deploy.
"""
from __future__ import annotations
from typing import Any
import pytest
from app.services.scrapers.nspd_client import (
LAYERS,
NSPDClient,
NSPDFeature,
NSPDLayer,
NSPDSearchResult,
QuarterDump,
_geojson_bbox_3857,
_walk_layer_tree,
bbox_around_point_m,
lonlat_to_3857,
)
def test_layers_catalog_has_critical_ids() -> None:
"""Sanity check: TIER 1 critical layers зарегистрированы."""
# Из #94: TIER 1 включает 875838 (территориальные зоны / ПЗЗ G1)
assert LAYERS["territorial_zones"] == 875838
assert LAYERS["parcels"] == 36048
assert LAYERS["buildings"] == 36049
assert LAYERS["quarters"] == 36071
# TIER 2 ЗОУИТ (G3)
assert LAYERS["zouit_okn"] == 37577
assert LAYERS["zouit_engineering"] == 37578
assert LAYERS["zouit_natural"] == 37580
def test_lonlat_to_3857_zero() -> None:
"""0,0 → ~0,0 (with tiny floating error)."""
x, y = lonlat_to_3857(0.0, 0.0)
assert abs(x) < 1.0
assert abs(y) < 1.0
def test_lonlat_to_3857_ekb_center() -> None:
"""ЕКБ центр (60.605, 56.838) — known approximate projection."""
x, y = lonlat_to_3857(60.605, 56.838)
# Sanity: положительный x (восточная долгота), положительный y (северная)
assert x > 6_700_000
assert x < 6_800_000
assert y > 7_700_000
assert y < 7_800_000
def test_bbox_around_point_m_basic() -> None:
"""100m buffer вокруг точки → bbox с side ≈ 200m."""
xmin, ymin, xmax, ymax = bbox_around_point_m(60.605, 56.838, 100)
assert abs((xmax - xmin) - 200) < 1.0
assert abs((ymax - ymin) - 200) < 1.0
def test_nspd_feature_from_raw_minimal() -> None:
"""Парсинг минимального GeoJSON Feature."""
raw = {
"id": "feat-1",
"geometry": {"type": "Point", "coordinates": [1, 2]},
"properties": {"cad_num": "66:41:0204016:10"},
}
feat = NSPDFeature.from_raw(raw)
assert feat.feature_id == "feat-1"
assert feat.geometry is not None
assert feat.geometry["type"] == "Point"
assert feat.properties["cad_num"] == "66:41:0204016:10"
assert feat.crs == "EPSG:3857"
def test_nspd_feature_from_raw_missing_id() -> None:
"""Feature без id → feature_id is None."""
feat = NSPDFeature.from_raw({"geometry": None, "properties": {}})
assert feat.feature_id is None
assert feat.geometry is None
assert feat.properties == {}
def test_nspd_search_result_empty() -> None:
"""Empty result → first is None, is_empty True."""
result = NSPDSearchResult(cad_num="x", features=[], raw={})
assert result.first is None
assert result.is_empty is True
def test_nspd_search_result_nonempty() -> None:
"""First feature accessible через .first."""
feat = NSPDFeature.from_raw({"id": "x", "geometry": None, "properties": {}})
result = NSPDSearchResult(cad_num="x", features=[feat], raw={})
assert result.first is feat
assert result.is_empty is False
def test_walk_layer_tree_flat() -> None:
"""Flat list of layers — все попадают в output."""
tree: list[dict[str, Any]] = [
{"id": 1, "title": "A", "children": []},
{"id": 2, "title": "B", "children": []},
]
out = _walk_layer_tree(tree)
ids = sorted(x["id"] for x in out)
assert ids == [1, 2]
def test_walk_layer_tree_nested() -> None:
"""Nested tree — все leaves вытаскиваются."""
tree = {
"id": 1,
"title": "root",
"children": [
{"id": 2, "title": "child", "children": []},
{
"id": 3,
"title": "branch",
"children": [
{"id": 4, "title": "grandchild", "children": []},
],
},
],
}
out = _walk_layer_tree(tree)
ids = sorted(x["id"] for x in out)
# Все уровни включены — root, child, branch, grandchild
assert ids == [1, 2, 3, 4]
def test_walk_layer_tree_empty() -> None:
"""Empty input → empty output, no crash."""
assert _walk_layer_tree({}) == []
assert _walk_layer_tree([]) == []
assert _walk_layer_tree(None) == [] # type: ignore[arg-type]
def test_nspd_client_search_by_cad_parses_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""search_by_cad оборачивает fetch_geoportal в NSPDSearchResult."""
fake_raw = {
"data": {
"type": "FeatureCollection",
"features": [
{
"id": "f1",
"geometry": {"type": "Polygon", "coordinates": []},
"properties": {
"cad_num": "66:41:0204016:10",
"land_record_area": 1500,
"permitted_use_established_by_document": "Многоэтажная жилая",
},
}
],
}
}
def fake_fetch(query: str, **kwargs: Any) -> dict[str, Any]:
assert query == "66:41:0204016:10"
return fake_raw
# Patch the imported reference in nspd_client (it was imported by name)
monkeypatch.setattr(
"app.services.scrapers.nspd_client.fetch_geoportal",
fake_fetch,
)
client = NSPDClient()
result = client.search_by_cad("66:41:0204016:10")
assert result.cad_num == "66:41:0204016:10"
assert len(result.features) == 1
assert result.first is not None
assert result.first.properties["permitted_use_established_by_document"] == "Многоэтажная жилая"
assert result.raw is fake_raw # preserved для debugging
def test_nspd_client_search_by_cad_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""Empty response → is_empty True, no crash."""
monkeypatch.setattr(
"app.services.scrapers.nspd_client.fetch_geoportal",
lambda *a, **kw: {"data": {"type": "FeatureCollection", "features": []}},
)
result = NSPDClient().search_by_cad("99:99:9999999")
assert result.is_empty is True
assert result.first is None
def test_nspd_layer_dataclass() -> None:
"""NSPDLayer immutable, hashable (frozen + slots)."""
layer = NSPDLayer(layer_id=875838, title="Терр.зоны", layer_type="wms", metadata={})
assert layer.layer_id == 875838
# Frozen
with pytest.raises(AttributeError):
layer.title = "x" # type: ignore[misc]
# ── Mock tests for WMS/layers methods (no network) ──────────────────────────
def test_get_feature_info_parses_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_feature_info вызывает _http_get_json с правильным URL + парсит features."""
captured_urls: list[str] = []
def fake_http(url: str, **kwargs: Any) -> dict[str, Any]:
captured_urls.append(url)
return {
"type": "FeatureCollection",
"features": [
{
"id": "tz-1",
"geometry": {"type": "Polygon", "coordinates": []},
"properties": {"zone_code": "Ж-1"},
}
],
}
monkeypatch.setattr("app.services.scrapers.nspd_client._http_get_json", fake_http)
client = NSPDClient()
feats = client.get_feature_info(875838, lon=60.605, lat=56.838, buffer_m=100)
assert len(feats) == 1
assert feats[0].properties["zone_code"] == "Ж-1"
# Verify URL structure
assert len(captured_urls) == 1
url = captured_urls[0]
assert "/api/aeggis/v4/875838/wms?" in url
assert "REQUEST=GetFeatureInfo" in url
assert "INFO_FORMAT=application%2Fjson" in url
assert "STYLES=" in url # required per NSPD WAF
assert "CRS=EPSG%3A3857" in url
# bbox should reflect 100m buffer (200m side)
assert "BBOX=" in url
def test_get_features_in_bbox_url_builds_correctly(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_features_in_bbox использует переданный bbox без преобразования."""
captured_urls: list[str] = []
def fake_http(url: str, **kwargs: Any) -> dict[str, Any]:
captured_urls.append(url)
return {"features": []}
monkeypatch.setattr("app.services.scrapers.nspd_client._http_get_json", fake_http)
client = NSPDClient()
bbox = (6700000.0, 7700000.0, 6800000.0, 7800000.0) # ЕКБ region in 3857
feats = client.get_features_in_bbox(875838, bbox)
assert feats == []
assert len(captured_urls) == 1
url = captured_urls[0]
# bbox passed directly into BBOX param (URL-encoded comma = %2C)
assert "BBOX=6700000.0%2C7700000.0%2C6800000.0%2C7800000.0" in url
# Large width/height для bulk fetch
assert "WIDTH=4096" in url
assert "HEIGHT=4096" in url
# I/J pointer в центр виртуального тайла
assert "I=2048" in url
assert "J=2048" in url
def test_list_layers_walks_tree_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""list_layers вызывает /layers-theme-tree и распаковывает в NSPDLayer."""
fake_tree = [
{
"id": 1,
"title": "PKK тема",
"children": [
{
"id": 875838,
"title": "Территориальные зоны",
"layerType": "wms",
"children": [],
},
{
"id": 37577,
"title": "ЗОУИТ ОКН",
"layerType": "wms",
"children": [],
},
],
}
]
monkeypatch.setattr(
"app.services.scrapers.nspd_client._http_get_json",
lambda url, **kw: fake_tree,
)
client = NSPDClient()
layers = client.list_layers(theme_id=1)
layer_ids = [layer.layer_id for layer in layers]
# Root + 2 children = 3 leaves
assert 875838 in layer_ids
assert 37577 in layer_ids
# Sample layer metadata preserved
tz_layer = next(layer for layer in layers if layer.layer_id == 875838)
assert tz_layer.title == "Территориальные зоны"
assert tz_layer.layer_type == "wms"
def test_list_layers_handles_data_wrapper(monkeypatch: pytest.MonkeyPatch) -> None:
"""Shape defense — NSPD иногда оборачивает в {"data": [...]}."""
fake_wrapped = {
"data": [{"id": 36048, "title": "ЗУ", "children": []}],
"meta": {"count": 1},
}
monkeypatch.setattr(
"app.services.scrapers.nspd_client._http_get_json",
lambda url, **kw: fake_wrapped,
)
layers = NSPDClient().list_layers(theme_id=1)
assert len(layers) >= 1
assert any(layer.layer_id == 36048 for layer in layers)
def test_list_layers_handles_garbage_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""Не dict/list response → empty list + warning log (no crash)."""
monkeypatch.setattr(
"app.services.scrapers.nspd_client._http_get_json",
lambda url, **kw: "garbage string",
)
assert NSPDClient().list_layers(theme_id=1) == []
# ── _geojson_bbox_3857 tests ──────────────────────────────────────────────────
def test_geojson_bbox_3857_polygon() -> None:
"""Простой Polygon → bbox охватывает все vertices."""
geom = {
"type": "Polygon",
"coordinates": [[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]],
}
result = _geojson_bbox_3857(geom)
assert result == (0.0, 0.0, 100.0, 100.0)
def test_geojson_bbox_3857_multipolygon() -> None:
"""MultiPolygon с двумя кусками → bbox охватывает оба."""
geom = {
"type": "MultiPolygon",
"coordinates": [
[[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
[[[20, 30], [50, 30], [50, 60], [20, 60], [20, 30]]],
],
}
result = _geojson_bbox_3857(geom)
assert result is not None
xmin, ymin, xmax, ymax = result
assert xmin == 0.0
assert ymin == 0.0
assert xmax == 50.0
assert ymax == 60.0
def test_geojson_bbox_3857_linestring() -> None:
"""LineString из 2 точек → bbox прямоугольник вокруг них."""
geom = {
"type": "LineString",
"coordinates": [[10, 20], [30, 40]],
}
result = _geojson_bbox_3857(geom)
assert result == (10.0, 20.0, 30.0, 40.0)
def test_geojson_bbox_3857_point() -> None:
"""Point → bbox с шириной и высотой 0 (один пиксель)."""
geom = {
"type": "Point",
"coordinates": [60.0, 56.0],
}
result = _geojson_bbox_3857(geom)
assert result == (60.0, 56.0, 60.0, 56.0)
def test_geojson_bbox_3857_empty() -> None:
"""Polygon с пустым coordinates → None."""
geom = {"type": "Polygon", "coordinates": []}
assert _geojson_bbox_3857(geom) is None
def test_geojson_bbox_3857_no_type() -> None:
"""Нет поля type → None (не можем определить геометрию)."""
geom: dict[str, Any] = {"coordinates": [[0, 0]]}
assert _geojson_bbox_3857(geom) is None
# ── QuarterDump tests ─────────────────────────────────────────────────────────
def _make_feat(fid: str = "x") -> NSPDFeature:
"""Вспомогательная фабрика NSPDFeature для тестов."""
return NSPDFeature.from_raw({"id": fid, "geometry": None, "properties": {}})
def test_quarter_dump_total_features() -> None:
"""total_features корректно суммирует features по всем layers."""
dump = QuarterDump(
quarter_cad="66:41:0204016",
quarter=_make_feat("q"),
parcels=[_make_feat("p1"), _make_feat("p2"), _make_feat("p3")], # 3
buildings=[_make_feat("b1")], # 1
territorial_zones=[_make_feat("tz1"), _make_feat("tz2")], # 2
red_lines=[], # 0
engineering_structures=[_make_feat("e1")], # 1
zouit={
"okn": [_make_feat("ok1"), _make_feat("ok2")], # 2
"natural": [_make_feat("nat1")], # 1
},
risks={
"flooding": [_make_feat("fl1"), _make_feat("fl2"), _make_feat("fl3")], # 3
"landslide": [_make_feat("ls1")], # 1
},
opportunity={}, # 0
layers_fetched=("search", "parcels", "buildings"),
bbox_3857=(6700000.0, 7700000.0, 6800000.0, 7800000.0),
fetched_at_utc="2026-05-12T00:00:00+00:00",
)
# 3+1+2+0+1 (core) + 2+1 (zouit) + 3+1 (risks) = 14
assert dump.total_features == 14
def test_quarter_dump_frozen() -> None:
"""QuarterDump frozen=True: попытка assign → AttributeError."""
dump = QuarterDump(
quarter_cad="66:41:0204016",
quarter=None,
parcels=[],
buildings=[],
territorial_zones=[],
red_lines=[],
engineering_structures=[],
zouit={},
risks={},
opportunity={},
layers_fetched=("search",),
bbox_3857=None,
fetched_at_utc="2026-05-12T00:00:00+00:00",
)
with pytest.raises(AttributeError):
dump.parcels = [] # type: ignore[misc]
# ── search_by_quarter mock tests ──────────────────────────────────────────────
# Квартал в районе ЕКБ (EPSG:3857 метры)
_QUARTER_COORDS = [
[6700000.0, 7700000.0],
[6800000.0, 7700000.0],
[6800000.0, 7800000.0],
[6700000.0, 7800000.0],
[6700000.0, 7700000.0],
]
_QUARTER_BBOX = (6700000.0, 7700000.0, 6800000.0, 7800000.0)
_FAKE_QUARTER_SEARCH = {
"data": {
"type": "FeatureCollection",
"features": [
{
"id": "q-1",
"geometry": {"type": "Polygon", "coordinates": [_QUARTER_COORDS]},
"properties": {"cad_num": "66:41:0204016"},
}
],
}
}
_LAYER_FEATURE_COUNTS: dict[str, int] = {
"parcels": 4,
"buildings": 2,
"territorial_zones": 1,
"red_lines": 0,
"engineering_structures": 3,
}
def _make_fake_http(
layer_feature_counts: dict[str, int] | None = None,
) -> Any:
"""Возвращает fake _http_get_json который генерирует N features по layer ID.
layer_feature_counts: {layer_name: count} — если None, возвращает 1 feature.
"""
counts = layer_feature_counts or {}
# Строим reverse-map layer_id → count для подстановки в URL
from app.services.scrapers.nspd_client import LAYERS as _LAYERS
id_to_name: dict[int, str] = {v: k for k, v in _LAYERS.items()}
def fake_http(url: str, **kwargs: Any) -> dict[str, Any]:
# Извлекаем layer_id из URL вида /api/aeggis/v4/{id}/wms?...
parts = url.split("/")
layer_id_str = parts[6] if len(parts) > 6 else "0"
try:
layer_id = int(layer_id_str.split("?")[0])
except ValueError:
layer_id = 0
layer_name = id_to_name.get(layer_id, "unknown")
n = counts.get(layer_name, 1)
return {
"type": "FeatureCollection",
"features": [
{"id": f"{layer_name}-{i}", "geometry": None, "properties": {}} for i in range(n)
],
}
return fake_http
def _make_fake_grid_walk(
layer_feature_counts: dict[str, int] | None = None,
) -> Any:
"""Возвращает fake `NSPDClient.get_features_in_bbox_grid`.
После Sub-PR B (#260) area/linear layers (territorial_zones, red_lines,
engineering_structures, zouit_*, risk_*) идут через grid-walk вместо
legacy `_http_get_json` → тесты обязаны мокать оба пути, иначе грид-walk
бьёт по живому NSPD API.
"""
counts = layer_feature_counts or {}
from app.services.scrapers.nspd_client import LAYERS as _LAYERS
from app.services.scrapers.nspd_client import NSPDFeature
id_to_name: dict[int, str] = {v: k for k, v in _LAYERS.items()}
def fake_grid(
self: Any,
layer_id: int,
bbox: tuple[float, float, float, float],
*,
grid_n: int = 7,
step_m: float = 50.0,
) -> list[NSPDFeature]:
layer_name = id_to_name.get(layer_id, "unknown")
n = counts.get(layer_name, 1)
return [
NSPDFeature.from_raw({"id": f"{layer_name}-{i}", "geometry": None, "properties": {}})
for i in range(n)
]
return fake_grid
def test_search_by_quarter_core_only(monkeypatch: pytest.MonkeyPatch) -> None:
"""core_only (include_zouit=False, include_risks=False): 1 search + 5 bulk."""
search_calls: list[str] = []
def fake_fetch(query: str, **kwargs: Any) -> dict[str, Any]:
search_calls.append(query)
return _FAKE_QUARTER_SEARCH
monkeypatch.setattr("app.services.scrapers.nspd_client.fetch_geoportal", fake_fetch)
monkeypatch.setattr(
"app.services.scrapers.nspd_client._http_get_json",
_make_fake_http(_LAYER_FEATURE_COUNTS),
)
# Sub-PR B (#260): area layers идут через grid-walk — мокаем оба пути.
monkeypatch.setattr(
"app.services.scrapers.nspd_client.NSPDClient.get_features_in_bbox_grid",
_make_fake_grid_walk(_LAYER_FEATURE_COUNTS),
)
result = NSPDClient().search_by_quarter(
"66:41:0204016", include_zouit=False, include_risks=False
)
# Quarter populated
assert result.quarter is not None
assert result.quarter.feature_id == "q-1"
# Core layer lengths match mock counts
assert len(result.parcels) == 4
assert len(result.buildings) == 2
assert len(result.territorial_zones) == 1
assert len(result.red_lines) == 0
assert len(result.engineering_structures) == 3
# ЗОУИТ / risks пусты
assert result.zouit == {}
assert result.risks == {}
# layers_fetched: search + 5 core (red_lines тоже: _fetch_layer вызывается,
# но возвращает пустой список — имя всё равно добавляется). tuple, not list
# — frozen-dataclass immutability invariant.
assert result.layers_fetched == (
"search",
"parcels",
"buildings",
"territorial_zones",
"red_lines",
"engineering_structures",
)
# bbox вычислен из четырёхугольного polygon
assert result.bbox_3857 == _QUARTER_BBOX
def test_search_by_quarter_with_zouit(monkeypatch: pytest.MonkeyPatch) -> None:
"""include_zouit=True → zouit dict содержит 5 ключей."""
monkeypatch.setattr(
"app.services.scrapers.nspd_client.fetch_geoportal",
lambda *a, **kw: _FAKE_QUARTER_SEARCH,
)
monkeypatch.setattr(
"app.services.scrapers.nspd_client._http_get_json",
_make_fake_http(),
)
# Sub-PR B (#260): zouit_* и area layers идут через grid-walk.
monkeypatch.setattr(
"app.services.scrapers.nspd_client.NSPDClient.get_features_in_bbox_grid",
_make_fake_grid_walk(),
)
result = NSPDClient().search_by_quarter(
"66:41:0204016", include_zouit=True, include_risks=False
)
assert set(result.zouit.keys()) == {"okn", "engineering", "natural", "protected", "other"}
assert result.risks == {}
# layers_fetched содержит search + 5 core + 5 zouit = 11 записей
assert len(result.layers_fetched) == 11
def test_search_by_quarter_empty_quarter(monkeypatch: pytest.MonkeyPatch) -> None:
"""Пустой FeatureCollection → quarter=None, все layer feature lists пустые.
При bbox=None `_fetch_layer` возвращает [] без HTTP вызовов. ЗОУИТ-словарь
всё равно создаётся (с пустыми списками) — ключи присутствуют, features нет.
"""
http_calls: list[str] = []
def fake_http(url: str, **kwargs: Any) -> dict[str, Any]:
http_calls.append(url)
return {"type": "FeatureCollection", "features": []}
monkeypatch.setattr(
"app.services.scrapers.nspd_client.fetch_geoportal",
lambda *a, **kw: {"data": {"type": "FeatureCollection", "features": []}},
)
monkeypatch.setattr("app.services.scrapers.nspd_client._http_get_json", fake_http)
result = NSPDClient().search_by_quarter("66:41:9999999", include_zouit=True)
assert result.quarter is None
assert result.parcels == []
assert result.buildings == []
assert result.territorial_zones == []
assert result.red_lines == []
assert result.engineering_structures == []
# include_zouit=True: ключи созданы, но все списки пустые (bbox=None → _fetch_layer → [])
assert set(result.zouit.keys()) == {"okn", "engineering", "natural", "protected", "other"}
assert all(v == [] for v in result.zouit.values())
assert result.risks == {}
assert result.bbox_3857 is None
# Никаких bulk HTTP запросов — bbox=None ⇒ ранний выход из _fetch_layer
assert http_calls == []
# layers_fetched содержит только "search" (tuple, no bulk fetches happened)
assert result.layers_fetched == ("search",)
def test_search_by_quarter_layers_fetched_with_risks(monkeypatch: pytest.MonkeyPatch) -> None:
"""include_risks=True (без zouit) → layers_fetched включает 11 risk layer имён."""
monkeypatch.setattr(
"app.services.scrapers.nspd_client.fetch_geoportal",
lambda *a, **kw: _FAKE_QUARTER_SEARCH,
)
monkeypatch.setattr(
"app.services.scrapers.nspd_client._http_get_json",
_make_fake_http(),
)
# Sub-PR B (#260): risk_* и area layers идут через grid-walk.
monkeypatch.setattr(
"app.services.scrapers.nspd_client.NSPDClient.get_features_in_bbox_grid",
_make_fake_grid_walk(),
)
result = NSPDClient().search_by_quarter(
"66:41:0204016", include_zouit=False, include_risks=True
)
assert result.zouit == {}
assert set(result.risks.keys()) == {
"flooding_underground",
"flooding",
"swampification",
"landslide",
"abrasion",
"erosion_water",
"erosion_linear",
"erosion_wind",
"desertification",
"clutter",
"burns",
}
# layers_fetched: search + 5 core + 11 risks = 17
assert len(result.layers_fetched) == 17
# Убедимся что risk имена действительно присутствуют в layers_fetched
assert "risk_flooding" in result.layers_fetched
assert "risk_landslide" in result.layers_fetched
assert "risk_burns" in result.layers_fetched