"""Unit tests для utility_infrastructure_loader — OSM инженерные сети (#1746). Mock-based / pure — без реального Overpass и БД. Покрывает: - _build_overpass_query: node / way / nwr варианты + bbox ЕКБ - _passes_tag_filters: substance-фильтр (man_made=pipeline) + tower:type (man_made=tower) - _upsert_elements: парсинг way→LineString / node→Point, классификация kind, per-element SAVEPOINT (битый элемент не валит батч), per-kind counts """ from __future__ import annotations import json from contextlib import contextmanager from typing import Any from app.services.site_finder import utility_infrastructure_loader as ul # ── _build_overpass_query ───────────────────────────────────────────────────── def test_build_query_node() -> None: q = ul._build_overpass_query("power", "tower", "node") assert q.startswith("[out:json][timeout:30];") assert 'node["power"="tower"]' in q assert "out body;" in q def test_build_query_way_uses_out_geom() -> None: """way → out geom (нужны координаты узлов для LineString ЛЭП/трубы).""" q = ul._build_overpass_query("power", "line", "way") assert 'way["power"="line"]' in q assert "out geom;" in q def test_build_query_nwr_for_points() -> None: """nwr ловит и node, и way (точка/площадка подстанции), с out geom.""" q = ul._build_overpass_query("power", "substation", "nwr") assert 'nwr["power"="substation"]' in q assert "out geom;" in q def test_build_query_bbox_present() -> None: """Bbox ЕКБ присутствует во всех вариантах запроса.""" q = ul._build_overpass_query("pipeline", "gas", "way") assert "(56.7,60.5,56.95,60.75)" in q # ── _UTILITY_QUERIES coverage ───────────────────────────────────────────────── def test_all_required_kinds_present() -> None: """Все 6 видов сети покрыты запросами.""" kinds = {kind for _k, _v, _t, kind in ul._UTILITY_QUERIES} for expected in ("power", "water", "gas", "heat", "communication", "sewage"): assert expected in kinds, f"вид {expected!r} должен быть в _UTILITY_QUERIES" def test_power_line_is_way_and_tower_is_node() -> None: """ЛЭП = way (линия), опора = node (точка).""" by_kv = {(k, v): t for k, v, t, _ in ul._UTILITY_QUERIES} assert by_kv[("power", "line")] == "way" assert by_kv[("power", "tower")] == "node" assert by_kv[("power", "substation")] == "nwr" # ── _passes_tag_filters ─────────────────────────────────────────────────────── def test_pipeline_substance_gas_passes() -> None: assert ul._passes_tag_filters("man_made", "pipeline", {"substance": "gas"}) def test_pipeline_substance_non_gas_filtered() -> None: """man_made=pipeline с substance=oil НЕ проходит под gas.""" assert not ul._passes_tag_filters("man_made", "pipeline", {"substance": "oil"}) def test_pipeline_legacy_type_tag_fallback() -> None: """legacy `type=gas` (без substance) тоже распознаётся.""" assert ul._passes_tag_filters("man_made", "pipeline", {"type": "gas"}) def test_tower_communication_passes() -> None: assert ul._passes_tag_filters("man_made", "tower", {"tower:type": "communication"}) def test_tower_non_communication_filtered() -> None: """Водонапорная башня (tower:type=water) НЕ проходит под communication.""" assert not ul._passes_tag_filters("man_made", "tower", {"tower:type": "water"}) def test_unfiltered_key_always_passes() -> None: """Ключи без substance/tower фильтра проходят как есть.""" assert ul._passes_tag_filters("power", "line", {}) # ── _upsert_elements (fake DB) ──────────────────────────────────────────────── class _Scalar: def __init__(self, value: Any) -> None: self._value = value def scalar(self) -> Any: return self._value class _FakeDB: """Мини-фейк SQLAlchemy session: пишет executed, отдаёт is_insert через scalar().""" def __init__(self, *, fail_ids: set[int] | None = None) -> None: self.executed: list[tuple[Any, dict[str, Any] | None]] = [] self.committed = False self.closed = False self.rolled_back = False self._fail_ids = fail_ids or set() @contextmanager def begin_nested(self) -> Any: yield def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any: self.executed.append((sql, params)) if params is not None and params.get("osm_id") in self._fail_ids: raise RuntimeError("bad geometry") return _Scalar(True) # is_insert=True def commit(self) -> None: self.committed = True def rollback(self) -> None: self.rolled_back = True def close(self) -> None: self.closed = True def _way(osm_id: int, kind: str, key: str, value: str, nodes: list[dict]) -> dict: return { "id": osm_id, "type": "way", "geometry": nodes, "tags": {"name": f"way-{osm_id}"}, "_infrastructure_kind": kind, "_source_tag": f"{key}={value}", "_query_key": key, "_query_value": value, } def _node(osm_id: int, kind: str, key: str, value: str, tags: dict | None = None) -> dict: return { "id": osm_id, "type": "node", "lat": 56.83, "lon": 60.6, "tags": tags or {"name": f"node-{osm_id}"}, "_infrastructure_kind": kind, "_source_tag": f"{key}={value}", "_query_key": key, "_query_value": value, } _TWO_NODES = [{"lat": 56.8, "lon": 60.6}, {"lat": 56.81, "lon": 60.61}] def test_upsert_mixed_power_water_gas(monkeypatch: Any) -> None: """Power line (way), substation (node), water_works (node), gas pipeline (way).""" fake_db = _FakeDB() monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) elements = [ _way(1, "power", "power", "line", _TWO_NODES), _node(2, "power", "power", "substation"), _node(3, "water", "man_made", "water_works"), _way(4, "gas", "pipeline", "gas", _TWO_NODES), ] result = ul._upsert_elements(elements) assert result["fetched"] == 4 assert result["inserted"] == 4 assert result["skipped"] == 0 assert result["kind_power"] == 2 assert result["kind_water"] == 1 assert result["kind_gas"] == 1 assert fake_db.committed assert fake_db.closed assert len(fake_db.executed) == 4 def test_upsert_way_geometry_is_linestring(monkeypatch: Any) -> None: """way → WKT LINESTRING передаётся в params (geom-build из noise_loader).""" fake_db = _FakeDB() monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) ul._upsert_elements([_way(1, "power", "power", "line", _TWO_NODES)]) _, params = fake_db.executed[0] assert params is not None assert params["wkt"] == "LINESTRING(60.6 56.8, 60.61 56.81)" assert params["infrastructure_kind"] == "power" assert params["source_tag"] == "power=line" def test_upsert_node_geometry_is_point(monkeypatch: Any) -> None: """node → lat/lon передаются для ST_MakePoint.""" fake_db = _FakeDB() monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) ul._upsert_elements([_node(2, "power", "power", "substation")]) _, params = fake_db.executed[0] assert params is not None assert params["lat"] == 56.83 assert params["lon"] == 60.6 assert "wkt" not in params def test_upsert_skips_way_with_too_few_nodes(monkeypatch: Any) -> None: """way с <2 узлами (битый bbox-обрезок) пропускается, не валит батч.""" fake_db = _FakeDB() monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) elements = [ _way(1, "power", "power", "line", [{"lat": 56.8, "lon": 60.6}]), # 1 узел _node(2, "power", "power", "substation"), ] result = ul._upsert_elements(elements) assert result["skipped"] == 1 assert result["inserted"] == 1 assert len(fake_db.executed) == 1 # только node дошёл до UPSERT def test_upsert_substance_filter_skips_non_gas_pipeline(monkeypatch: Any) -> None: """man_made=pipeline substance=oil пропускается (не газ).""" fake_db = _FakeDB() monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) oil = _way(1, "gas", "man_made", "pipeline", _TWO_NODES) oil["tags"] = {"substance": "oil"} gas = _way(2, "gas", "man_made", "pipeline", _TWO_NODES) gas["tags"] = {"substance": "gas"} result = ul._upsert_elements([oil, gas]) assert result["skipped"] == 1 assert result["inserted"] == 1 assert result["kind_gas"] == 1 def test_upsert_savepoint_isolates_bad_element(monkeypatch: Any) -> None: """Битый элемент (execute бросает) → SAVEPOINT откатывает только его, батч жив.""" fake_db = _FakeDB(fail_ids={2}) monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) elements = [ _node(1, "power", "power", "substation"), _node(2, "power", "power", "substation"), # упадёт _node(3, "water", "man_made", "water_works"), ] result = ul._upsert_elements(elements) assert result["inserted"] == 2 assert result["skipped"] == 1 assert fake_db.committed # outer tx НЕ откатан assert not fake_db.rolled_back def test_upsert_tags_serialized_json(monkeypatch: Any) -> None: """tags сериализуются в JSON-строку (для CAST(:tags AS jsonb)).""" fake_db = _FakeDB() monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) ul._upsert_elements([_node(2, "power", "power", "substation", {"name": "ТП-5"})]) _, params = fake_db.executed[0] assert params is not None assert json.loads(params["tags"]) == {"name": "ТП-5"} assert params["name"] == "ТП-5" def test_upsert_empty_returns_zero(monkeypatch: Any) -> None: fake_db = _FakeDB() monkeypatch.setattr(ul, "SessionLocal", lambda: fake_db) result = ul._upsert_elements([]) assert result["fetched"] == 0 assert result["inserted"] == 0 assert len(fake_db.executed) == 0