"""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, _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]