gendesign/backend/tests/api/v1/test_analyze_zoning_regulation.py
Light1YT 46f021e1c8
Some checks failed
CI / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Successful in 1m51s
CI / backend-tests (pull_request) Failing after 11m59s
CI / frontend-tests (pull_request) Has been skipped
fix(site-finder): разгейтить geoportal зон-резолвер от gappy NSPD-дампа
financial_estimate фаерил ~0% в проде (0/542 анализов, 90д): резолв ПЗЗ-зоны на
участок проваливался на 94%. Причина — в analyze ДВА резолвера зоны:
(1) get_quarter_dump_data/_get_zoning (nspd_quarter_dumps, точный ST_Intersects) —
терзоны НЕ замощают квартал плотно, центроид участка падает в 45-86м ЗАЗОР между
валидными зонами → nspd_zoning=None у 512/542; (2) get_or_fetch_zone_regulation →
EKBGeoportalClient.zone_index_at (живой геопортал, cache-first) — РАБОТАЕТ, резолвит
gap-участки. Но (2), дающий max_far, был ЗАГЕЙЧЕН за `_nspd_zoning is not None`, т.е.
зазор дампа глушил рабочий резолвер. Плюс: dump кладёт кадастровый рег-номер
("66:41-7.14") в zone_code, а gate.is_residential_zone матчит ^Ж → не срабатывал.

Фикс (PR-A): убран `_nspd_zoning is not None` из условия — резолвер геопортала
работает при наличии centroid (region-guard: только КН 66:41, геопортал ЕКБ-only).
Когда dump зону не дал — синтезируем минимальный nspd_zoning из geoportal:
zone_code = индекс зоны ("Ж-2") → is_residential_zone ^Ж срабатывает → can_build_mkd
резолвится для Ж-* зон. Запись обратно в nspd_dump_data["nspd_zoning"] доходит до
gate / финмоста / ответа (читают по ключу). Когда dump зону ДАЛ — zone_code не
перетираем (raw_props.subcategory детектит жильё), только добавляем regulation-поля.
Геопортал — authoritative источник (point-in-zone по ПОЛНОМУ слою ПЗЗ); «зазор»
только в нашем кэше-дампе, не в реальности. Хот-path-safe (try/except, timeout 3с,
cache-first). Расширение dump-резидентности на Ц-*/ЦС-* → PR-B.

Тесты: +7 (синтез при dump=None+geoportal; нет синтеза при обоих None; dump zone_code
не перетёрт; геопортал падает→деградация; is_residential_zone Ж-2=True, ПК-1/ЦС-3=False).
Кейс синтеза ассертит РЕАЛЬНЫЙ gate can_build_mkd=True. 47 passed (вкл. pre-existing
zoning+gate), ruff+mypy чисто. Схема не менялась (api-types regen не нужен).

Прод-замер: 66:41:0303006:930 (Ж-5 gap) сейчас zone_code/max_far/financial=None,
can_build=False → после деплоя ожидаем Ж-5/max_far=4/can_build=True/financial=PRESENT.

Refs #1881

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 03:25:06 +05:00

448 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.

"""Тесты для реального ПЗЗ-градрегламента в nspd_zoning ответа analyze (Route 2, #1067).
Покрывает merge числовых предельных параметров зоны (КСИТ/max_far, высота, этажность,
%застройки, min площадь ЗУ) в nspd_zoning через get_or_fetch_zone_regulation:
1. resolver вернул регламент → nspd_zoning несёт max_far/max_height_m/regulation_source
2. resolver кинул исключение → analyze всё равно 200 и nspd_zoning БЕЗ regulation-полей
(hot-path-safe, не падаем)
3. флаг enable_zoning_regulation_in_analyze=False → resolver НЕ вызывается, полей нет
PR-A (#financial-zoning-decouple) — резолвер РАЗГЕЙЧЕН от dump-зоны (раньше был
gated за `_nspd_zoning is not None`, из-за чего gap-участки без dump-зоны не получали
max_far → financial_estimate ~0%). Новые тесты на synth-путь:
(a) dump nspd_zoning=None + geoportal резолвит "Ж-2"+max_far → nspd_zoning
СИНТЕЗИРОВАН (zone_code="Ж-2", source="ekb-geoportal-urbancard", max_far),
gate видит зону как жилую (can_build_mkd=True).
(b) dump nspd_zoning=None + geoportal вернул None → nspd_zoning остаётся None,
analyze не падает, financial_estimate=None.
(c) dump ДАЛ nspd_zoning (рег-номер zone_code + raw_props) + geoportal резолвит →
regulation-поля добавлены, zone_code НЕ перетёрт (остаётся рег-номер).
(d) geoportal кинул → деградирует в None, analyze не падает.
Стратегия mock: как в test_analyze_parcel_meta.py — DB mock через dependency_overrides,
тяжёлые сервисы патчим через unittest.mock.patch.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
_CAD = "66:41:0204016:10"
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
_NSPD_ZONING = {
"zone_code": "Ц-1",
"zone_name": "Общественно-деловая зона",
"source": "nspd-quarter-dump",
"raw_props": {"type_zone": "Общественно-деловая зона"},
}
_REGULATION = {
"zone_index": "Ц-1",
"max_far": 4.0,
"max_height_m": 25.0,
"max_floors": 9,
"max_building_pct": 100.0,
"min_parcel_area_m2": 1500.0,
"source": "ekb_geoportal",
}
# PR-A synth fixtures: жилая зона Ж-2 (gate должен пустить МКД) с числами для финмодели.
_REGULATION_ZH2 = {
"zone_index": "Ж-2",
"max_far": 2.5,
"max_height_m": 75.0,
"max_floors": 25,
"max_building_pct": 40.0,
"min_parcel_area_m2": 600.0,
"source": "ekb_geoportal",
}
# Dump-provided зона с кадастровым рег-номером в zone_code (реальная форма dump).
_NSPD_ZONING_REGNUM = {
"zone_code": "66:41-7.14",
"zone_name": "66:41-7.14",
"source": "nspd-quarter-dump",
"raw_props": {"subcategory": 2, "descr": "66:41-7.14"},
}
def _make_mapping(data: dict[str, Any]) -> MagicMock:
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
m.get = lambda k, default=None: data.get(k, default)
return m
def _make_db_for_analyze() -> MagicMock:
"""Mock DB Session для analyze_parcel (минимальный — фокус на nspd_zoning merge)."""
db = MagicMock()
geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
wkt_row = _make_mapping({"wkt": _WKT})
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"dist_to_center": 1500.0,
}
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
call_idx = [0]
responses: list[Any] = [
("first", geom_row), # 0: geom UNION ALL
("first", wkt_row), # 1: WKT
("first", district_row), # 2: district
("all", []), # 3: POI rows
("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
# (далее nspd_zoning merge не ходит в db.execute — резолвер замокан)
("first", None), # 10: parcel_meta
("first", None), # 11: market trend
("first", None), # 12: zoning (begin_nested)
("all", []), # 13: success recommendation
("first", None), # 14: market price
("all", []), # 15: recent permits
("scalar", 0), # 16: geotech_risk
("all", []), # 17: neighbors
("first", None), # 18: overlap
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
idx = call_idx[0]
call_idx[0] += 1
if idx >= len(responses):
r = MagicMock()
r.mappings.return_value.first.return_value = None
r.mappings.return_value.all.return_value = []
r.scalar.return_value = 0
return r
kind, data = responses[idx]
r = MagicMock()
r.mappings.return_value.first.return_value = data
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
r.scalar.return_value = data if kind == "scalar" else 0
return r
db.execute.side_effect = _execute_side_effect
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = ctx
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
def _dump_result(
nspd_zoning: dict[str, Any] | None,
*,
dump_available: bool = False,
) -> dict[str, Any]:
"""Сборка результата get_quarter_dump_data. nspd_zoning — свежая копия (merge мутирует)."""
return {
"nspd_zoning": dict(nspd_zoning) if nspd_zoning is not None else None,
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_risk_zones": [],
"nspd_opportunity_parcels": [],
"nspd_red_lines": [],
"nspd_dump": {
"available": dump_available,
"stale": False,
"harvest_triggered": False,
},
}
def _base_patches(
*,
dump_zoning: dict[str, Any] | None = _NSPD_ZONING,
dump_available: bool = False,
mock_gate: bool = True,
) -> list[Any]:
"""Патчи тяжёлых сервисов.
dump_zoning — что вернёт get_quarter_dump_data в nspd_zoning (None = gap-участок).
dump_available — флаг свежести dump (gate evaluates зону ТОЛЬКО при available=True).
mock_gate — если False, реальный compute_gate_verdict (для проверки synth→gate пути).
"""
patches = [
patch("app.api.v1.parcels.get_air_quality_cached", return_value=None),
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
patch(
"app.api.v1.parcels.get_quarter_dump_data",
return_value=_dump_result(dump_zoning, dump_available=dump_available),
),
patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch(
"app.api.v1.parcels.build_ird_analyze_block",
return_value={
"ird_overlaps": [],
"ird_by_kind": {},
"opportunity_overlaps": [],
"planning_projects": [],
"functional_zone": None,
"krt": [],
"zone_regulation": None,
},
),
]
if mock_gate:
patches.append(
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"})
)
return patches
def test_zoning_regulation_merged_when_resolved() -> None:
"""resolver вернул регламент → nspd_zoning несёт max_far/max_height_m/regulation_source."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
patches = [
*_base_patches(),
patch(
"app.api.v1.parcels.get_or_fetch_zone_regulation",
return_value=dict(_REGULATION),
),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
zoning = body["nspd_zoning"]
assert zoning is not None
# существующие ключи сохранены
assert zoning["zone_code"] == "Ц-1"
assert zoning["source"] == "nspd-quarter-dump"
# regulation-поля добавлены
assert zoning["max_far"] == 4.0
assert zoning["max_height_m"] == 25.0
assert zoning["max_floors"] == 9
assert zoning["max_building_pct"] == 100.0
assert zoning["min_parcel_area_m2"] == 1500.0
assert zoning["regulation_zone_index"] == "Ц-1"
assert zoning["regulation_source"] == "ekb-geoportal-urbancard"
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()
def test_zoning_regulation_graceful_on_resolver_exception() -> None:
"""resolver кинул → analyze 200, nspd_zoning сохранён БЕЗ regulation-полей (не падаем)."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
patches = [
*_base_patches(),
patch(
"app.api.v1.parcels.get_or_fetch_zone_regulation",
side_effect=RuntimeError("geoportal timeout"),
),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
zoning = body["nspd_zoning"]
assert zoning is not None
assert zoning["zone_code"] == "Ц-1" # существующие ключи целы
# regulation-поля НЕ просочились (graceful degrade)
assert "max_far" not in zoning
assert "max_height_m" not in zoning
assert "regulation_source" not in zoning
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()
def test_zoning_regulation_skipped_when_flag_off() -> None:
"""флаг OFF → resolver НЕ вызывается, regulation-полей нет, nspd_zoning без изменений."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
resolver_mock = MagicMock(return_value=dict(_REGULATION))
patches = [
*_base_patches(),
patch("app.api.v1.parcels.settings.enable_zoning_regulation_in_analyze", False),
patch("app.api.v1.parcels.get_or_fetch_zone_regulation", resolver_mock),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
zoning = body["nspd_zoning"]
assert zoning is not None
assert zoning["zone_code"] == "Ц-1"
assert "max_far" not in zoning # резолв пропущен → полей нет
resolver_mock.assert_not_called() # флаг OFF → резолвер не дёрнут
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()
# ── PR-A: synth-путь (dump зону не дал, geoportal резолвит) ────────────────────
def test_synth_when_dump_zoning_none_and_geoportal_resolves() -> None:
"""(a) dump nspd_zoning=None + geoportal "Ж-2"+max_far → synth + gate видит жилую."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
patches = [
# dump_available=True + реальный gate → проверяем synth→gate путь end-to-end.
*_base_patches(dump_zoning=None, dump_available=True, mock_gate=False),
patch(
"app.api.v1.parcels.get_or_fetch_zone_regulation",
return_value=dict(_REGULATION_ZH2),
),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
zoning = body["nspd_zoning"]
# synth построил минимальный dict из geoportal-результата
assert zoning is not None
assert zoning["zone_code"] == "Ж-2" # zone_index → zone_code (для ^Ж regex)
assert zoning["source"] == "ekb-geoportal-urbancard"
assert zoning["zone_name"] is None
assert zoning["max_far"] == 2.5
assert zoning["regulation_zone_index"] == "Ж-2"
assert zoning["regulation_source"] == "ekb-geoportal-urbancard"
# gate увидел synth-зону как жилую → can_build_mkd=True
assert body["gate_verdict"]["can_build_mkd"] is True
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()
def test_no_synth_when_dump_none_and_geoportal_none() -> None:
"""(b) dump nspd_zoning=None + geoportal None → nspd_zoning None, financial None, не падаем."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
patches = [
*_base_patches(dump_zoning=None, dump_available=True, mock_gate=False),
patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["nspd_zoning"] is None # synth НЕ сработал (geoportal вернул None)
assert body["financial_estimate"] is None # нет чисел зоны → финмодель None
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()
def test_dump_provided_zone_code_not_overwritten_by_synth() -> None:
"""(c) dump ДАЛ зону (рег-номер) + geoportal резолвит → regulation добавлен, code цел."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
patches = [
*_base_patches(dump_zoning=_NSPD_ZONING_REGNUM, dump_available=True, mock_gate=True),
patch(
"app.api.v1.parcels.get_or_fetch_zone_regulation",
return_value=dict(_REGULATION_ZH2),
),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
zoning = body["nspd_zoning"]
assert zoning is not None
# zone_code НЕ перетёрт synth'ом — остаётся dump-рег-номер + raw_props
assert zoning["zone_code"] == "66:41-7.14"
assert zoning["source"] == "nspd-quarter-dump"
assert zoning["raw_props"]["subcategory"] == 2
# regulation-поля ДОБАВЛЕНЫ из geoportal
assert zoning["max_far"] == 2.5
assert zoning["regulation_zone_index"] == "Ж-2"
assert zoning["regulation_source"] == "ekb-geoportal-urbancard"
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()
def test_synth_path_graceful_when_geoportal_raises() -> None:
"""(d) dump None + geoportal кинул → деградирует в None, analyze не падает."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
patches = [
*_base_patches(dump_zoning=None, dump_available=True, mock_gate=False),
patch(
"app.api.v1.parcels.get_or_fetch_zone_regulation",
side_effect=RuntimeError("geoportal timeout"),
),
]
for p in patches:
p.start()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["nspd_zoning"] is None # synth НЕ сработал — резолвер кинул
assert body["financial_estimate"] is None
finally:
app.dependency_overrides.clear()
for p in patches:
p.stop()