gendesign/backend/tests/test_gate_verdict.py
Light1YT bc1da8eac9
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m52s
CI / backend-tests (pull_request) Successful in 11m34s
fix(gate): area-gate ЗОУИТ utility-easement blocker (sub-17) — снять over-block
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>
2026-06-24 14:45:59 +05:00

676 lines
31 KiB
Python
Raw Permalink 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."""
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