Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
241 lines
10 KiB
Python
241 lines
10 KiB
Python
"""Тесты build_ird_analyze_block (#1067 D9b analyze-wiring, #1102 concurrent geoportal).
|
||
|
||
DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation (module-level)
|
||
и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, graceful-деградацию
|
||
каждого источника, skip geoportal при отсутствии centroid, и корректность конкурентного исполнения.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
from typing import Any
|
||
|
||
from app.services.site_finder import ird_analyze
|
||
|
||
|
||
class _Feat:
|
||
def __init__(self, properties: dict[str, Any]) -> None:
|
||
self.properties = properties
|
||
|
||
|
||
class _FakeClient:
|
||
def __init__(self, *, fz: list[_Feat], krt: list[_Feat], boom: bool = False) -> None:
|
||
self._fz = fz
|
||
self._krt = krt
|
||
self._boom = boom
|
||
self.calls: list[tuple[str, float, float]] = []
|
||
|
||
def features_at_point(self, layer: str, lon: float, lat: float) -> list[_Feat]:
|
||
self.calls.append((layer, lon, lat))
|
||
if self._boom:
|
||
raise RuntimeError("geoportal down")
|
||
return self._fz if layer == "functional_zone" else self._krt
|
||
|
||
|
||
_OVERLAY = {
|
||
"ird_overlaps": [
|
||
{"layer_kind": "zouit_okn", "reg_numb_border": "66:41-6.1"},
|
||
{"layer_kind": "opportunity_auction", "reg_numb_border": "66:41:1:10"},
|
||
{"layer_kind": "opportunity_krt", "reg_numb_border": None},
|
||
],
|
||
"ird_by_kind": {"zouit_okn": 1, "opportunity_auction": 1, "opportunity_krt": 1},
|
||
}
|
||
|
||
|
||
_PLANNING = [{"project_type": "ppt", "doc_status_name": "действующий", "full_name": "ПАГЕ №1"}]
|
||
|
||
|
||
def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None:
|
||
monkeypatch.setattr(ird_analyze, "parcel_ird_overlaps", lambda db, wkt: overlay)
|
||
monkeypatch.setattr(ird_analyze, "parcel_planning_overlaps", lambda db, wkt: list(_PLANNING))
|
||
monkeypatch.setattr(ird_analyze, "parcel_reservations", lambda db, cad_num: [])
|
||
|
||
|
||
def test_full_block_assembly(monkeypatch: Any) -> None:
|
||
"""Все источники → overlaps + split opportunity + КРТ + регламент.
|
||
functional_zone всегда None: слой пуст в геопортале ЕКБ (0 фич, 2026-06) — WFS не дёргается.
|
||
"""
|
||
_patch_overlay(monkeypatch, _OVERLAY)
|
||
monkeypatch.setattr(
|
||
ird_analyze,
|
||
"get_or_fetch_zone_regulation",
|
||
lambda db, lon, lat, client=None: {"zone_index": "Ц-1"},
|
||
)
|
||
client = _FakeClient(fz=[_Feat({"name": "Ж-зона"})], krt=[_Feat({"num_oks_krt": "КРТ-7"})])
|
||
|
||
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
|
||
|
||
assert len(block["ird_overlaps"]) == 3
|
||
assert block["ird_by_kind"]["opportunity_auction"] == 1
|
||
assert [o["layer_kind"] for o in block["opportunity_overlaps"]] == [
|
||
"opportunity_auction",
|
||
"opportunity_krt",
|
||
]
|
||
# functional_zone WFS выключен (слой пуст в геопортале ЕКБ, #1058) — всегда None
|
||
assert block["functional_zone"] is None
|
||
assert "functional_zone" not in [layer for layer, _, _ in client.calls]
|
||
assert block["krt"] == [{"num_oks_krt": "КРТ-7"}]
|
||
assert block["zone_regulation"] == {"zone_index": "Ц-1"}
|
||
assert block["planning_projects"] == _PLANNING
|
||
|
||
|
||
def test_geoportal_failure_is_graceful(monkeypatch: Any) -> None:
|
||
"""Сбой геопортала → функц.зона None, КРТ [], регламент None; overlaps уцелели."""
|
||
_patch_overlay(monkeypatch, _OVERLAY)
|
||
|
||
def _boom(*a: Any, **kw: Any) -> Any:
|
||
raise RuntimeError("zone reg down")
|
||
|
||
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _boom)
|
||
client = _FakeClient(fz=[], krt=[], boom=True)
|
||
|
||
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
|
||
|
||
assert len(block["ird_overlaps"]) == 3 # DB-overlay не задет
|
||
assert block["functional_zone"] is None
|
||
assert block["krt"] == []
|
||
assert block["zone_regulation"] is None
|
||
|
||
|
||
def test_no_centroid_skips_geoportal(monkeypatch: Any) -> None:
|
||
"""lon/lat None → только DB-overlay, geoportal не зовётся (client.calls пуст)."""
|
||
_patch_overlay(monkeypatch, _OVERLAY)
|
||
client = _FakeClient(fz=[_Feat({"x": 1})], krt=[])
|
||
|
||
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client)
|
||
|
||
assert client.calls == []
|
||
assert block["functional_zone"] is None
|
||
assert block["krt"] == []
|
||
assert block["zone_regulation"] is None
|
||
assert len(block["ird_overlaps"]) == 3
|
||
|
||
|
||
def test_empty_overlay(monkeypatch: Any) -> None:
|
||
"""Пустой overlay → пустые списки, без KeyError."""
|
||
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
|
||
client = _FakeClient(fz=[], krt=[])
|
||
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
|
||
|
||
block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client)
|
||
|
||
assert block["ird_overlaps"] == []
|
||
assert block["opportunity_overlaps"] == []
|
||
assert block["ird_by_kind"] == {}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Concurrency tests (#1102)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_concurrent_both_calls_succeed(monkeypatch: Any) -> None:
|
||
"""krt и zone_regulation оба вызываются и попадают в block (конкурентный путь)."""
|
||
_patch_overlay(monkeypatch, _OVERLAY)
|
||
zone_reg_calls: list[int] = []
|
||
|
||
def _fake_zone_reg(db: Any, lon: float, lat: float, client: Any = None) -> dict[str, Any]:
|
||
zone_reg_calls.append(1)
|
||
return {"zone_index": "Ж-3"}
|
||
|
||
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _fake_zone_reg)
|
||
client = _FakeClient(fz=[], krt=[_Feat({"num_oks_krt": "КРТ-99"})])
|
||
|
||
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
|
||
|
||
assert block["krt"] == [{"num_oks_krt": "КРТ-99"}]
|
||
assert block["zone_regulation"] == {"zone_index": "Ж-3"}
|
||
assert len(zone_reg_calls) == 1
|
||
# krt layer was requested
|
||
krt_calls = [layer for layer, _, _ in client.calls if layer == "krt"]
|
||
assert len(krt_calls) == 1
|
||
|
||
|
||
def test_concurrent_krt_exception_graceful(monkeypatch: Any) -> None:
|
||
"""Исключение в krt (через ThreadPoolExecutor) → block['krt']==[] и zone_regulation заполнен."""
|
||
_patch_overlay(monkeypatch, _OVERLAY)
|
||
monkeypatch.setattr(
|
||
ird_analyze,
|
||
"get_or_fetch_zone_regulation",
|
||
lambda db, lon, lat, client=None: {"zone_index": "Б-1"},
|
||
)
|
||
# boom=True → features_at_point бросает RuntimeError → _krt_at ловит и возвращает []
|
||
client = _FakeClient(fz=[], krt=[], boom=True)
|
||
|
||
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
|
||
|
||
assert block["krt"] == []
|
||
assert block["zone_regulation"] == {"zone_index": "Б-1"}
|
||
assert len(block["ird_overlaps"]) == 3
|
||
|
||
|
||
def test_session_not_shared_between_threads(monkeypatch: Any) -> None:
|
||
"""Session не шарится между потоками: zone_regulation вызывается в основном потоке.
|
||
|
||
Проверяем через threading.current_thread(): zone_reg видит тот же поток, что и вызывающий
|
||
(основной), а не поток из ThreadPoolExecutor (который обрабатывает krt_future).
|
||
"""
|
||
_patch_overlay(monkeypatch, _OVERLAY)
|
||
main_thread = threading.current_thread()
|
||
zone_reg_thread: list[threading.Thread] = []
|
||
|
||
def _fake_zone_reg(db: Any, lon: float, lat: float, client: Any = None) -> dict[str, Any]:
|
||
zone_reg_thread.append(threading.current_thread())
|
||
return {"zone_index": "Ц-2"}
|
||
|
||
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _fake_zone_reg)
|
||
client = _FakeClient(fz=[], krt=[_Feat({"a": 1})])
|
||
|
||
ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
|
||
|
||
assert len(zone_reg_thread) == 1
|
||
assert (
|
||
zone_reg_thread[0] is main_thread
|
||
), "zone_regulation должен исполняться в основном потоке, а не в фоновом (Session safety)"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Reservation tests (#1091, #1062)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
_RESERVATION_ROW = {
|
||
"reservation_kind": "изъятие",
|
||
"basis_act": "Постановление Правительства",
|
||
"act_number": "12-ПП",
|
||
"act_date": "2024-03-01",
|
||
"purpose": "дорожное строительство",
|
||
"doc_url": "https://example.com/doc.pdf",
|
||
}
|
||
|
||
|
||
def test_reservations_wired_to_block(monkeypatch: Any) -> None:
|
||
"""cad_num передан → parcel_reservations возвращает запись → ключ reservations в block."""
|
||
_patch_overlay(monkeypatch, _OVERLAY)
|
||
monkeypatch.setattr(
|
||
ird_analyze,
|
||
"parcel_reservations",
|
||
lambda db, cad_num: [_RESERVATION_ROW] if cad_num == "66:41:0101001:123" else [],
|
||
)
|
||
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
|
||
client = _FakeClient(fz=[], krt=[])
|
||
|
||
block = ird_analyze.build_ird_analyze_block(
|
||
object(), "POINT(60 56)", None, None, "66:41:0101001:123", client=client
|
||
)
|
||
|
||
assert "reservations" in block
|
||
assert len(block["reservations"]) == 1
|
||
assert block["reservations"][0]["reservation_kind"] == "изъятие"
|
||
|
||
|
||
def test_reservations_none_cad_num_returns_empty(monkeypatch: Any) -> None:
|
||
"""cad_num=None → reservations == [] (parcel_reservations graceful, ключ присутствует)."""
|
||
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
|
||
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
|
||
client = _FakeClient(fz=[], krt=[])
|
||
|
||
block = ird_analyze.build_ird_analyze_block(object(), None, None, None, None, client=client)
|
||
|
||
assert "reservations" in block
|
||
assert block["reservations"] == []
|