ZOUIT_OVERLAP_SUB17 (единственный subcategory-blocker = охранная зона ЛЭП/газа/трубопровода) был ХАРД-блокером на ЛЮБОЕ пересечение → блокировал 434/574 (75.6%) участков как «нельзя строить МКД». Прод-замер: реальное покрытие участка этими зонами median=6%, p90=47%, только 1/17 >60% — тонкие инж-коридоры. По РФ охранная зона ограничивает застройку ВНУТРИ полосы, а не весь участок (МКД сажается на незанятой части). Хард-блок на 6%-покрытии неверен и (после фикса резолва зон #1891) прятал financial_estimate у ~90% участков. Фикс: area-gate. - quarter_dump_lookup._get_zouit_overlaps + _get_cad_zouit_overlaps: добавлен coverage_pct (доля площади участка под зоной, в geography м², NULLIF-guard). - gate_verdict.compute_gate_verdict: sub-17 (и cad network/blocker) АГРЕГИРУЮТСЯ (sum-capped, консервативно к блоку) → блокер ТОЛЬКО если покрытие > settings.gate_zouit_engineering_blocker_min_coverage (0.6, env-tunable), иначе WARNING ZOUIT_ENGINEERING_PARTIAL с % покрытия. Больше нет 4× дубль-блокеров. СЗЗ/OKN/прочие ЗОУИТ — без изменений (warnings). main_vri/резидентность — без изменений. - coverage_pct отсутствует (legacy/synthetic) → трактуем как 0 → warning (документировано: дамп всегда даёт coverage; ложный хард-блок дороже). Тесты: +помесь gate/aggregation/coverage (131 combined passed); правка _make_zouit_row (7-я колонка coverage_pct) в tests/test_quarter_dump_lookup.py. Полный бэкенд локально: 2 failed → fixed → перепроверка. ruff чисто. Схема не менялась (gate_verdict/overlaps — dict[str,Any], api-types regen не нужен). Refs #1881 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
676 lines
31 KiB
Python
676 lines
31 KiB
Python
"""Tests for gate_verdict aggregator — pure function, no DB."""
|
||
|
||
import pytest
|
||
|
||
from app.services.site_finder.gate_verdict import (
|
||
_aggregate_coverage,
|
||
_coverage_of,
|
||
compute_gate_verdict,
|
||
is_residential_zone,
|
||
mkd_permitted_from_vri,
|
||
)
|
||
|
||
# Реальные формы main_vri из zone_regulation_cache (JSON list of strings).
|
||
_VRI_IZHS_ONLY = [
|
||
"2.1 Для индивидуального жилищного строительства",
|
||
"2.2 Для ведения личного подсобного хозяйства (приусадебный земельный участок)",
|
||
]
|
||
_VRI_HAS_MKD_25 = ["2.5 Среднеэтажная жилая застройка"]
|
||
_VRI_HAS_MKD_26 = ["2.6 Многоэтажная жилая застройка (высотная застройка)"]
|
||
_VRI_HAS_MKD_211 = ["2.1.1\n Малоэтажная многоквартирная жилая застройка"]
|
||
_VRI_NO_MKD = ["1.13 Овощеводство / ИЖС", "2.7.1 Хранение автотранспорта"]
|
||
|
||
# ── 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
|
||
|
||
|
||
# PR-A synth-path lock: эти индексы геопортала кладёт synth в zone_code; gate-регекс
|
||
# ^Ж должен пускать только жилые. "ПК-1"/"ЦС-3" — реальные gap-зоны ЕКБ (#decouple).
|
||
def test_residential_synth_zh2_zone_index_only():
|
||
# synth кладёт zone_index в zone_code, zone_name=None, raw_props=None
|
||
assert is_residential_zone("Ж-2", None, None) is True
|
||
|
||
|
||
def test_non_residential_synth_pk1():
|
||
assert is_residential_zone("ПК-1", None, None) is False
|
||
|
||
|
||
def test_non_residential_synth_cs3():
|
||
assert is_residential_zone("ЦС-3", None, None) is False
|
||
|
||
|
||
# ── mkd_permitted_from_vri (authoritative main_vri matching, #1891) ───────────
|
||
|
||
|
||
def test_mkd_vri_true_for_2_5():
|
||
assert mkd_permitted_from_vri(_VRI_HAS_MKD_25) is True
|
||
|
||
|
||
def test_mkd_vri_true_for_2_6():
|
||
assert mkd_permitted_from_vri(_VRI_HAS_MKD_26) is True
|
||
|
||
|
||
def test_mkd_vri_true_for_2_1_1_with_newline():
|
||
"""Код может склеиться с \\n — split берёт первый токен '2.1.1'."""
|
||
assert mkd_permitted_from_vri(_VRI_HAS_MKD_211) is True
|
||
|
||
|
||
def test_mkd_vri_false_for_izhs_only():
|
||
assert mkd_permitted_from_vri(_VRI_IZHS_ONLY) is False
|
||
|
||
|
||
def test_mkd_vri_false_for_no_mkd_codes():
|
||
assert mkd_permitted_from_vri(_VRI_NO_MKD) is False
|
||
|
||
|
||
def test_mkd_vri_none_for_none():
|
||
assert mkd_permitted_from_vri(None) is None
|
||
|
||
|
||
def test_mkd_vri_none_for_empty_list():
|
||
assert mkd_permitted_from_vri([]) is None
|
||
|
||
|
||
def test_mkd_vri_no_false_prefix_2_50():
|
||
"""'2.50' НЕ должен матчить '2.5' (точный токен-матч, не prefix)."""
|
||
assert mkd_permitted_from_vri(["2.50 Какой-то ВРИ"]) is False
|
||
|
||
|
||
def test_mkd_vri_no_false_prefix_2_59():
|
||
assert mkd_permitted_from_vri(["2.59 Какой-то ВРИ"]) is False
|
||
|
||
|
||
def test_mkd_vri_no_false_prefix_12_0():
|
||
"""'12.0' НЕ должен матчить '2.6'/'2.5' (substring guard)."""
|
||
assert mkd_permitted_from_vri(["12.0 Земельные участки общего пользования"]) is False
|
||
|
||
|
||
def test_mkd_vri_code_only_element():
|
||
"""Элемент без названия (просто код) тоже матчит."""
|
||
assert mkd_permitted_from_vri(["2.6"]) is True
|
||
|
||
|
||
# ── is_residential_zone: main_vri authoritative override (#1891) ──────────────
|
||
|
||
|
||
def test_residential_main_vri_suppresses_izhs_false_positive():
|
||
"""Ж-2 ИЖС: ^Ж regex дал бы True, но main_vri (ИЖС-only) перебивает → False (FIX)."""
|
||
assert is_residential_zone("Ж-2", None, None, main_vri=_VRI_IZHS_ONLY) is False
|
||
|
||
|
||
def test_residential_main_vri_enables_ts2_false_negative():
|
||
"""Ц-2: нет префикса Ж (^Ж дал бы False), но main_vri содержит 2.6 → True (FIX)."""
|
||
assert is_residential_zone("Ц-2", None, None, main_vri=_VRI_HAS_MKD_26) is True
|
||
|
||
|
||
def test_residential_main_vri_zh5_with_mkd_true():
|
||
"""Ж-5 с main_vri 2.5 → True (authoritative совпадает с regex)."""
|
||
assert is_residential_zone("Ж-5", None, None, main_vri=_VRI_HAS_MKD_25) is True
|
||
|
||
|
||
def test_residential_main_vri_overrides_subcategory():
|
||
"""main_vri ИЖС перебивает даже raw_props subcategory=2 (authoritative wins)."""
|
||
assert (
|
||
is_residential_zone("Ж-2", None, {"subcategory": 2}, main_vri=_VRI_IZHS_ONLY) is False
|
||
)
|
||
|
||
|
||
def test_residential_empty_main_vri_falls_back_to_regex():
|
||
"""main_vri=[] (нет инфо) → fallback на ^Ж regex → True."""
|
||
assert is_residential_zone("Ж-2", None, None, main_vri=[]) is True
|
||
|
||
|
||
def test_residential_none_main_vri_fallback_regex_unchanged():
|
||
"""main_vri=None → regex fallback: Ж-2 остаётся True (backward-compat)."""
|
||
assert is_residential_zone("Ж-2", None, None) is True
|
||
|
||
|
||
def test_residential_none_main_vri_fallback_subcategory_unchanged():
|
||
"""main_vri=None → subcategory fallback продолжает работать."""
|
||
assert is_residential_zone(None, None, {"subcategory": 2}) is True
|
||
|
||
|
||
# ── 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_full_coverage_blocks():
|
||
"""ЗОУИТ sub-17 покрывающий весь участок (coverage 0.9 > порога) → blocker."""
|
||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.9}]
|
||
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 "90%" in verdict["blockers"][0]["detail"]
|
||
|
||
|
||
def test_zouit_subcategory_17_thin_strip_is_warning_not_blocker():
|
||
"""ЗОУИТ sub-17 тонкой полосой (coverage 0.06 < порога) → warning, НЕ blocker (THE FIX).
|
||
|
||
Охранная зона инж.сети на 6% участка ограничивает застройку внутри полосы, но МКД
|
||
сажается на остатке — раньше это был безусловный hard-block (исходный баг).
|
||
"""
|
||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.06}]
|
||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||
assert verdict["can_build_mkd"] is True
|
||
assert verdict["blockers"] == []
|
||
assert any(w["code"] == "ZOUIT_ENGINEERING_PARTIAL" for w in verdict["warnings"])
|
||
partial = next(w for w in verdict["warnings"] if w["code"] == "ZOUIT_ENGINEERING_PARTIAL")
|
||
assert "6%" in partial["detail"]
|
||
|
||
|
||
def test_zouit_subcategory_17_no_coverage_treated_as_zero_warning():
|
||
"""ЗОУИТ sub-17 БЕЗ coverage_pct (legacy/synthetic) → трактуется как 0 → warning.
|
||
|
||
Намеренно: dump-путь всегда проставляет coverage_pct, отсутствие ключа = синтетика
|
||
без гео-данных. Без площади не доказать стерилизацию → warning, не hard-block.
|
||
"""
|
||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578"}]
|
||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||
assert verdict["can_build_mkd"] is True
|
||
assert verdict["blockers"] == []
|
||
assert any(w["code"] == "ZOUIT_ENGINEERING_PARTIAL" for w in verdict["warnings"])
|
||
|
||
|
||
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():
|
||
"""Реальный блокер (охранная зона ЛЭП покрывает участок) при отсутствии сетей → «Нельзя»."""
|
||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.85}]
|
||
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_full_coverage_blocks():
|
||
"""cad_zouit газопровод покрывающий весь участок (coverage > порога) → blocker (#1070).
|
||
|
||
Трубопроводы/газ классифицируются как ZOUIT_NETWORK_OBREMENENIE, но теперь AREA-GATED:
|
||
полное покрытие → blocker.
|
||
"""
|
||
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||
overlaps = [
|
||
{
|
||
"source": "cad_zouit",
|
||
"type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)",
|
||
"layer": "Охранная зона трубопроводов",
|
||
"name": "Газопровод высокого давления",
|
||
"coverage_pct": 0.8,
|
||
}
|
||
]
|
||
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_ohranaya_zona_thin_strip_is_warning():
|
||
"""cad_zouit газопровод тонкой полосой (coverage 0.1 < порога) → warning, не blocker."""
|
||
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||
overlaps = [
|
||
{
|
||
"source": "cad_zouit",
|
||
"type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)",
|
||
"layer": "Охранная зона трубопроводов",
|
||
"name": "Газопровод высокого давления",
|
||
"coverage_pct": 0.1,
|
||
}
|
||
]
|
||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||
assert verdict["can_build_mkd"] is True
|
||
assert verdict["blockers"] == []
|
||
assert any(w["code"] == "ZOUIT_ENGINEERING_PARTIAL" for w in verdict["warnings"])
|
||
|
||
|
||
def test_cad_zouit_elektr_full_coverage_blocks():
|
||
"""cad_zouit электросетевая охранная зона с полным покрытием → blocker (#1070)."""
|
||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||
overlaps = [
|
||
{
|
||
"source": "cad_zouit",
|
||
"type_zone": "Охранная зона электросетевого объекта",
|
||
"layer": "Охранная зона электросетевого объекта",
|
||
"name": "ЛЭП 110 кВ",
|
||
"coverage_pct": 0.7,
|
||
}
|
||
]
|
||
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') sub-17 с полным покрытием → blocker."""
|
||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||
overlaps = [
|
||
{
|
||
"source": "nspd-quarter-dump",
|
||
"subcategory": 17,
|
||
"name": "ЛЭП 110кВ",
|
||
"layer": "37578",
|
||
"coverage_pct": 0.75,
|
||
}
|
||
]
|
||
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"])
|
||
|
||
|
||
# ── compute_gate_verdict: main_vri authoritative (#1891) ──────────────────────
|
||
|
||
|
||
def test_verdict_main_vri_izhs_blocks_despite_zh_code():
|
||
"""nspd_zoning Ж-2 + main_vri ИЖС-only → gate видит non-residential → Нельзя (FIX)."""
|
||
nspd_zoning = {
|
||
"zone_code": "Ж-2",
|
||
"zone_name": "Зона Ж-2",
|
||
"raw_props": None,
|
||
"main_vri": _VRI_IZHS_ONLY,
|
||
}
|
||
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_main_vri_enables_ts2_can_build():
|
||
"""nspd_zoning Ц-2 (нет ^Ж) + main_vri 2.6 → gate видит жилую → Можно (FIX)."""
|
||
nspd_zoning = {
|
||
"zone_code": "Ц-2",
|
||
"zone_name": "Общественно-деловая Ц-2",
|
||
"raw_props": None,
|
||
"main_vri": _VRI_HAS_MKD_26,
|
||
}
|
||
verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "ТП-7"}], {"available": True})
|
||
assert verdict["can_build_mkd"] is True
|
||
assert verdict["verdict_label"] == "Можно"
|
||
assert verdict["blockers"] == []
|
||
|
||
|
||
# ── ЗОУИТ engineering area-gate: coverage helpers + aggregation ────────────────
|
||
|
||
|
||
def test_coverage_of_present():
|
||
assert _coverage_of({"coverage_pct": 0.42}) == 0.42
|
||
|
||
|
||
def test_coverage_of_missing_key_is_zero():
|
||
"""Legacy/synthetic overlap без coverage_pct → 0.0 (намеренно → warning, не blocker)."""
|
||
assert _coverage_of({"name": "ЛЭП"}) == 0.0
|
||
|
||
|
||
def test_coverage_of_none_is_zero():
|
||
assert _coverage_of({"coverage_pct": None}) == 0.0
|
||
|
||
|
||
def test_coverage_of_non_numeric_is_zero():
|
||
assert _coverage_of({"coverage_pct": "n/a"}) == 0.0
|
||
|
||
|
||
def test_coverage_of_clamps_above_one():
|
||
assert _coverage_of({"coverage_pct": 1.0000001}) == 1.0
|
||
|
||
|
||
def test_aggregate_coverage_sum_below_threshold():
|
||
"""coverage [0.05, 0.04] → сумма 0.09 — ниже дефолтного порога 0.6."""
|
||
overlaps = [{"coverage_pct": 0.05}, {"coverage_pct": 0.04}]
|
||
assert _aggregate_coverage(overlaps) == pytest.approx(0.09)
|
||
|
||
|
||
def test_aggregate_coverage_single_big_strip():
|
||
assert _aggregate_coverage([{"coverage_pct": 0.7}]) == pytest.approx(0.7)
|
||
|
||
|
||
def test_aggregate_coverage_many_small_strips_sum_over_threshold():
|
||
"""coverage [0.4, 0.4] → сумма 0.8 > 0.6 (агрегация мелких полос в blocker)."""
|
||
overlaps = [{"coverage_pct": 0.4}, {"coverage_pct": 0.4}]
|
||
assert _aggregate_coverage(overlaps) == pytest.approx(0.8)
|
||
|
||
|
||
def test_aggregate_coverage_clamps_to_one():
|
||
"""Сумма > 1 клампится в 1.0 (overlapping strips двойной счёт)."""
|
||
overlaps = [{"coverage_pct": 0.7}, {"coverage_pct": 0.6}]
|
||
assert _aggregate_coverage(overlaps) == 1.0
|
||
|
||
|
||
def test_aggregate_coverage_none_treated_as_zero():
|
||
overlaps = [{"coverage_pct": None}, {"coverage_pct": None}]
|
||
assert _aggregate_coverage(overlaps) == 0.0
|
||
|
||
|
||
# ── ЗОУИТ engineering area-gate: verdict-level (THE FIX) ───────────────────────
|
||
|
||
|
||
def test_verdict_residential_sub17_thin_strip_can_build():
|
||
"""Жилая зона (main_vri МКД) + sub-17 покрытие 0.06 + нет др. блокеров →
|
||
can_build_mkd=True с warning ZOUIT_ENGINEERING_PARTIAL (THE FIX — раньше было False)."""
|
||
nspd_zoning = {
|
||
"zone_code": "Ж-3",
|
||
"zone_name": "Жилая Ж-3",
|
||
"raw_props": None,
|
||
"main_vri": _VRI_HAS_MKD_25,
|
||
}
|
||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.06}]
|
||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||
assert verdict["can_build_mkd"] is True
|
||
assert verdict["blockers"] == []
|
||
assert any(w["code"] == "ZOUIT_ENGINEERING_PARTIAL" for w in verdict["warnings"])
|
||
|
||
|
||
def test_verdict_residential_sub17_full_coverage_blocks():
|
||
"""Та же жилая зона + sub-17 покрытие 0.8 → can_build_mkd=False с blocker (стерилизован)."""
|
||
nspd_zoning = {
|
||
"zone_code": "Ж-3",
|
||
"zone_name": "Жилая Ж-3",
|
||
"raw_props": None,
|
||
"main_vri": _VRI_HAS_MKD_25,
|
||
}
|
||
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578", "coverage_pct": 0.8}]
|
||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"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"])
|
||
assert "80%" in verdict["blockers"][0]["detail"]
|
||
|
||
|
||
def test_verdict_sub17_many_small_strips_aggregate_blocks():
|
||
"""Несколько мелких sub-17 полос (0.4 + 0.4 = 0.8 > 0.6) → ОДИН blocker (агрегация)."""
|
||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||
overlaps = [
|
||
{"subcategory": 17, "name": "ЛЭП A", "layer": "37578", "coverage_pct": 0.4},
|
||
{"subcategory": 17, "name": "ЛЭП B", "layer": "37578", "coverage_pct": 0.4},
|
||
]
|
||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||
assert verdict["can_build_mkd"] is False
|
||
sub17_blockers = [b for b in verdict["blockers"] if b["code"] == "ZOUIT_OVERLAP_SUB17"]
|
||
assert len(sub17_blockers) == 1 # один агрегированный, не 4× дубля
|
||
|
||
|
||
def test_verdict_sub17_two_thin_strips_sum_below_threshold_warning():
|
||
"""Две тонкие sub-17 полосы (0.05 + 0.04 = 0.09 < 0.6) → ОДИН warning, не blocker."""
|
||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||
overlaps = [
|
||
{"subcategory": 17, "name": "ЛЭП A", "layer": "37578", "coverage_pct": 0.05},
|
||
{"subcategory": 17, "name": "ЛЭП B", "layer": "37578", "coverage_pct": 0.04},
|
||
]
|
||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||
assert verdict["can_build_mkd"] is True
|
||
assert verdict["blockers"] == []
|
||
partials = [w for w in verdict["warnings"] if w["code"] == "ZOUIT_ENGINEERING_PARTIAL"]
|
||
assert len(partials) == 1
|