gendesign/backend/tests/test_gate_verdict.py
bot-backend 827459e72a
Some checks failed
CI / changes (push) Has been cancelled
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m46s
CI / backend-tests (pull_request) Failing after 8m53s
test(site-finder): cover is_residential_zone raw_props/subcategory branch (#1353 follow-up)
2026-06-17 21:22:04 +03:00

361 lines
17 KiB
Python
Raw 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.

"""Tests for gate_verdict aggregator — pure function, no DB."""
from app.services.site_finder.gate_verdict import (
compute_gate_verdict,
is_residential_zone,
)
# ── is_residential_zone unit tests ────────────────────────────────────────────
def test_residential_zone_code_zh2():
assert is_residential_zone("Ж-2", None) is True
def test_residential_zone_code_zh5():
assert is_residential_zone("Ж5", None) is True
def test_residential_zone_name_fallback():
assert is_residential_zone(None, "Жилая зона смешанной застройки") is True
def test_non_residential_zone_code():
assert is_residential_zone("П-1", "Производственная П-1") is False
def test_non_residential_zone_recreation():
assert is_residential_zone("Р-2", "Рекреационная Р-2") is False
# ── compute_gate_verdict ──────────────────────────────────────────────────────
def test_no_dump_returns_unknown():
"""Если nspd_dump отсутствует или available=False → unknown + no_data."""
verdict = compute_gate_verdict(None, None, None, None)
assert verdict["can_build_mkd"] == "unknown"
assert verdict["source"] == "no_data"
assert verdict["verdict_label"] == "Нужна проверка"
assert any(w["code"] == "NO_NSPD_DUMP" for w in verdict["warnings"])
assert verdict["checks_performed"] == []
def test_no_dump_available_false():
verdict = compute_gate_verdict(None, None, None, {"available": False})
assert verdict["can_build_mkd"] == "unknown"
assert verdict["source"] == "no_data"
def test_residential_zone_no_overlaps_can_build():
"""Жилая зона + нет ЗОУИТ + есть сети → Можно."""
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая зона Ж-2"}
verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "ТП-5"}], {"available": True})
assert verdict["can_build_mkd"] is True
assert verdict["verdict_label"] == "Можно"
assert verdict["blockers"] == []
assert verdict["source"] == "nspd_dump"
def test_production_zone_blocks():
"""Производственная зона → blocker PZZ_NOT_RESIDENTIAL → Нельзя."""
nspd_zoning = {"zone_code": "П-1", "zone_name": "Производственная П-1"}
verdict = compute_gate_verdict(nspd_zoning, [], [], {"available": True})
assert verdict["can_build_mkd"] is False
assert verdict["verdict_label"] == "Нельзя"
assert any(b["code"] == "PZZ_NOT_RESIDENTIAL" for b in verdict["blockers"])
def test_zouit_subcategory_17_blocks():
"""ЗОУИТ subcategory=17 (охранная зона ЛЭП) блокирует даже жилую зону."""
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578"}]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is False
assert any("ZOUIT_OVERLAP_SUB17" == b["code"] for b in verdict["blockers"])
assert "ЛЭП 110кВ" in verdict["blockers"][0]["detail"]
def test_zouit_non_blocker_subcategory_is_warning():
"""ЗОУИТ с subcategory != 17 → warning, не blocker."""
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
overlaps = [{"subcategory": 5, "name": "Придорожная полоса", "layer": "12345"}]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
assert verdict["can_build_mkd"] is True
assert verdict["verdict_label"] == "С ограничениями"
assert any("ZOUIT_SUB5" == w["code"] for w in verdict["warnings"])
assert verdict["blockers"] == []
def test_no_engineering_warning_not_blocker():
"""NO_ENGINEERING_NEARBY — информационное предупреждение, не блокер и не даунгрейдит вердикт.
Чистый жилой участок (Ж-зона, нет ЗОУИТ) без сетей в 200м должен оставаться «Можно»
— отсутствие сетей не является юридическим запретом на МКД (#1617).
"""
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
verdict = compute_gate_verdict(nspd_zoning, [], [], {"available": True})
assert verdict["can_build_mkd"] is True
assert verdict["verdict_label"] == "Можно" # не «С ограничениями» (#1617)
assert any(w["code"] == "NO_ENGINEERING_NEARBY" for w in verdict["warnings"])
assert verdict["blockers"] == []
def test_no_engineering_plus_real_warning_still_downgrades():
"""NO_ENGINEERING_NEARBY совместно с реальным предупреждением (ЗОУИТ) → «С ограничениями».
Реальный ЗОУИТ-warning должен продолжать даунгрейдить вердикт;
NO_ENGINEERING_NEARBY при этом тоже присутствует в warnings[] (#1617).
"""
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
overlaps = [{"subcategory": 5, "name": "Придорожная полоса", "layer": "12345"}]
# нет инженерных сетей рядом — пустой список
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is True
assert verdict["verdict_label"] == "С ограничениями"
assert any(w["code"] == "ZOUIT_SUB5" for w in verdict["warnings"])
assert any(w["code"] == "NO_ENGINEERING_NEARBY" for w in verdict["warnings"])
assert verdict["blockers"] == []
def test_no_engineering_real_blocker_still_rejects():
"""Реальный блокер (охранная зона ЛЭП) при отсутствии сетей → «Нельзя» (#1617)."""
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578"}]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is False
assert verdict["verdict_label"] == "Нельзя"
assert any(b["code"] == "ZOUIT_OVERLAP_SUB17" for b in verdict["blockers"])
def test_stale_dump_marks_source_partial():
"""stale=True → source='nspd_dump_partial'."""
nspd_zoning = {"zone_code": "Ж-1", "zone_name": "Жилая"}
dump = {"available": True, "stale": True}
verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "ТП-3"}], dump)
assert verdict["source"] == "nspd_dump_partial"
def test_checks_performed_list():
"""checks_performed содержит все 3 проверки при наличии данных."""
nspd_zoning = {"zone_code": "Ж-1", "zone_name": "Жилая"}
verdict = compute_gate_verdict(nspd_zoning, [], [], {"available": True})
assert len(verdict["checks_performed"]) == 3
assert "ПЗЗ зональность" in verdict["checks_performed"]
assert "ЗОУИТ пересечения" in verdict["checks_performed"]
def test_pzz_unknown_warning_when_no_zoning():
"""Если nspd_zoning=None при наличии dump — PZZ_UNKNOWN warning."""
verdict = compute_gate_verdict(None, [], [{"name": "ТП-1"}], {"available": True})
assert any(w["code"] == "PZZ_UNKNOWN" for w in verdict["warnings"])
assert verdict["can_build_mkd"] is True
# ── cad_zouit fallback path (#232) ────────────────────────────────────────────
def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
"""cad_zouit 'охранная зона трубопроводов' → сетевое обременение (газ) → Нельзя (#1070).
После #1070 трубопроводы/газ классифицируются как ZOUIT_NETWORK_OBREMENENIE
(более специфичный код, чем прежний ZOUIT_CAD_BLOCKER) — поведение «блокирует МКД»
сохранено.
"""
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)",
"layer": "Охранная зона трубопроводов",
"name": "Газопровод высокого давления",
}
]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is False
assert verdict["verdict_label"] == "Нельзя"
assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"])
def test_cad_zouit_elektr_blocks():
"""cad_zouit электросетевая охранная зона → сетевое обременение (электро) → blocker (#1070)."""
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Охранная зона электросетевого объекта",
"layer": "Охранная зона электросетевого объекта",
"name": "ЛЭП 110 кВ",
}
]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is False
assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"])
def test_cad_zouit_szz_is_warning_not_blocker():
"""cad_zouit overlap с 'СЗЗ' → ZOUIT_CAD_SZZ warning, не blocker."""
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Санитарно-защитная зона предприятия",
"layer": "Санитарно-защитная зона предприятия",
"name": "СЗЗ завода",
}
]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
assert verdict["can_build_mkd"] is True
assert verdict["verdict_label"] == "С ограничениями"
assert any(w["code"] == "ZOUIT_CAD_SZZ" for w in verdict["warnings"])
assert verdict["blockers"] == []
def test_cad_zouit_other_is_warning():
"""cad_zouit overlap без blocker/СЗЗ keyword → ZOUIT_CAD_OTHER warning."""
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
overlaps = [
{
"source": "cad_zouit",
"type_zone": "Зона публичного сервитута",
"layer": "Зона публичного сервитута",
"name": None,
}
]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
assert verdict["can_build_mkd"] is True
assert any(w["code"] == "ZOUIT_CAD_OTHER" for w in verdict["warnings"])
assert verdict["blockers"] == []
def test_nspd_subcategory_path_backward_compat():
"""NSPD dump path (source != 'cad_zouit') продолжает работать через subcategory."""
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
overlaps = [
{
"source": "nspd-quarter-dump",
"subcategory": 17,
"name": "ЛЭП 110кВ",
"layer": "37578",
}
]
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
assert verdict["can_build_mkd"] is False
assert any(b["code"] == "ZOUIT_OVERLAP_SUB17" for b in verdict["blockers"])
# ── is_residential_zone: raw_props / subcategory branch (#1353) ───────────────
def test_rawprops_subcategory_2_is_residential():
"""raw_props subcategory=2 (Жилые зоны МКД) → True (#1353)."""
assert is_residential_zone(None, None, {"subcategory": 2}) is True
def test_rawprops_subcategory_3_is_residential():
"""raw_props subcategory=3 (Смешанное использование, МКД допустим по ПЗЗ ЕКБ) → True (#1353)."""
assert is_residential_zone(None, None, {"subcategory": 3}) is True
def test_rawprops_subcategory_1_not_residential():
"""raw_props subcategory=1 (ИЖС/малоэтажка) → False: МКД не разрешён (#1353)."""
assert is_residential_zone(None, None, {"subcategory": 1}) is False
def test_rawprops_subcategory_4_not_residential():
"""raw_props subcategory=4 (Общественно-деловые) → False."""
assert is_residential_zone(None, None, {"subcategory": 4}) is False
def test_rawprops_subcategory_as_string_int_coercion():
"""raw_props subcategory='2' (строка из JSON) → int() coercion → True (#1353)."""
assert is_residential_zone(None, None, {"subcategory": "2"}) is True
def test_rawprops_subcategory_absent_fallback_to_zone_code():
"""raw_props без subcategory, но zone_code ^Ж → True (tier-1 wins)."""
assert is_residential_zone("Ж-2", None, {"other_field": "value"}) is True
def test_rawprops_absent_zone_code_zh_still_residential():
"""raw_props=None, zone_code ^Ж → tier-1 detects residential (no crash)."""
assert is_residential_zone("Жс", None, None) is True
def test_rawprops_absent_no_zone_code_no_name_not_residential():
"""raw_props=None, zone_code=None, zone_name=None → False, no crash (#1353)."""
assert is_residential_zone(None, None, None) is False
def test_rawprops_empty_dict_no_crash():
"""raw_props={} (пустой) → subcategory=None → переходит на tier-3 fallback, без исключения."""
assert is_residential_zone(None, None, {}) is False
def test_rawprops_subcategory_invalid_value_no_crash():
"""raw_props subcategory='N/A' (нечисловое) → ValueError поглощается → False."""
assert is_residential_zone(None, None, {"subcategory": "N/A"}) is False
# ── compute_gate_verdict: nspd_zoning with raw_props (#1353) ─────────────────
def test_verdict_nspd_zoning_rawprops_sub2_can_build():
"""nspd_zoning с raw_props subcategory=2 (без zone_code Ж) → жилая → Можно."""
nspd_zoning = {
"zone_code": "66:41-7.14", # кадастровый номер NSPD quarter-dump (не ^Ж)
"zone_name": None,
"raw_props": {"subcategory": 2},
}
verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "ТП-5"}], {"available": True})
assert verdict["can_build_mkd"] is True
assert verdict["verdict_label"] == "Можно"
assert verdict["blockers"] == []
def test_verdict_nspd_zoning_rawprops_sub3_can_build():
"""nspd_zoning с raw_props subcategory=3 (смешанное, без zone_code Ж) → Можно."""
nspd_zoning = {
"zone_code": "66:41-7.21",
"zone_name": None,
"raw_props": {"subcategory": 3},
}
verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "ТП-1"}], {"available": True})
assert verdict["can_build_mkd"] is True
assert verdict["verdict_label"] == "Можно"
assert verdict["blockers"] == []
def test_verdict_nspd_zoning_rawprops_sub1_blocks():
"""nspd_zoning с raw_props subcategory=1 (ИЖС) → PZZ_NOT_RESIDENTIAL → Нельзя (#1353)."""
nspd_zoning = {
"zone_code": "66:41-7.05",
"zone_name": "Малоэтажная застройка",
"raw_props": {"subcategory": 1},
}
verdict = compute_gate_verdict(nspd_zoning, [], [], {"available": True})
assert verdict["can_build_mkd"] is False
assert verdict["verdict_label"] == "Нельзя"
assert any(b["code"] == "PZZ_NOT_RESIDENTIAL" for b in verdict["blockers"])
def test_verdict_nspd_zoning_no_rawprops_zh_zone_code_can_build():
"""raw_props отсутствует, zone_code=^Ж → tier-1 → Можно (регрессия: no crash)."""
nspd_zoning = {"zone_code": "Ж-1", "zone_name": None}
verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "КНС-1"}], {"available": True})
assert verdict["can_build_mkd"] is True
assert verdict["verdict_label"] == "Можно"
assert verdict["blockers"] == []
def test_verdict_nspd_zoning_no_rawprops_no_zone_blocks():
"""raw_props=None, zone_code=кадастровый (не ^Ж), zone_name=None → PZZ_NOT_RESIDENTIAL."""
nspd_zoning = {
"zone_code": "66:41-7.99",
"zone_name": None,
"raw_props": None,
}
verdict = compute_gate_verdict(nspd_zoning, [], [], {"available": True})
assert verdict["can_build_mkd"] is False
assert verdict["verdict_label"] == "Нельзя"
assert any(b["code"] == "PZZ_NOT_RESIDENTIAL" for b in verdict["blockers"])