gendesign/backend/tests/api/v1/test_analyze_parcel_meta.py
bot-backend 959246a6ec
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Successful in 16m57s
fix(ptica): шумовой скорер перестаёт считать водоёмы источниками шума (#2464-G)
Запрос шума брал из osm_noise_sources_ekb ВСЕ типы. В таблице лежат и нешумовые
слои — water (870 строк) и utility (1487), их отдельно читает блок гидрологии.
Для скорера они мусор: ключа в NOISE_L_BASE нет, поэтому .get(key, 50.0) выдавал
им ровно 50 дБ — значение, совпадающее с порогом попадания в список источников.

Замер 19.08 на 1000 участков (детерминированная выборка по cad_num): 19755
занятых слотов LIMIT 30, из них 8797 (44.5%) — вода и коммуникации; 562 участка
теряли из-за этого хотя бы один настоящий источник.

Честно про эффект: сегодня пользователь этого почти не видит. Все вытесненные
источники оказались тише порога 50 дБ (участков, теряющих ВИДИМЫЙ источник: 0 из
729), максимум дБ не меняется ни у одного. Видимый дефект — только водоём ближе
10 м, попадающий в список как источник на 50 дБ. Правка убирает не столько
видимую поломку, сколько скрытый потолок: почти половина бюджета LIMIT уходила
на строки, которые скорер не умеет оценивать.

Отдельный запрос на покрытие шумовой карты — не педантизм, а условие
корректности. _compute_confidence спрашивает «загружена ли карта» (при нуле пишет
«Шумовая карта не загружена — noise score = stub»), а у 345 участков из 1000 в
радиусе 2 км нет НИ ОДНОГО шумового источника при наличии воды. Наивный вариант
фикса (фильтр + len(noise_rows)) соврал бы этой трети. До правки верный ответ там
получался случайно — ровно потому, что в счёт шли нешумовые строки. Параметр
переименован в noise_map_rows_nearby, чтобы смысл не приходилось угадывать.

Тест на эту ловушку зелёный по обе стороны, но краснеет на наивном варианте —
проверено подстановкой.

Мок в test_analyze_parcel_meta.py переведён с позиционной нумерации db.execute на
матчинг по сигнатуре SQL: добавление любого запроса выше по коду ломало его
(следы в истории — #29 G2, #2464 cluster B), давая красный CI без отношения к
предмету теста. Тот же приём уже применён в test_analyze_market_price.py.

Переформатирование assert-сообщения — от pre-commit ruff 0.7.4 (#2864), не моё.
2026-08-19 17:08:50 +05:00

202 lines
8.1 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.

"""Тесты для parcel_meta в POST /api/v1/parcels/{cad_num}/analyze (#29 G2).
Покрывает:
1. analyze когда cad_parcels has row → parcel_meta содержит permitted_use/land_category/cad_cost
2. analyze когда cad_parcels row отсутствует → parcel_meta == None
Стратегия mock: аналогична test_analyze_market_price.py — DB mock через
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
Диспетчеризация мока — ПО СИГНАТУРЕ SQL, а не по порядковому номеру вызова.
Позиционный вариант ломался при каждом добавлении запроса в analyze_parcel
(следы в истории: #29 G2, #2464 cluster B, затем #2464-G с запросом покрытия
шумовой карты). Такое падение говорит не о parcel_meta, ради которого написан
файл, а о том, что кто-то добавил блок выше по коду — красный CI без отношения
к предмету теста. Матчинг по сигнатуре к порядку нечувствителен; всё, что не
распознано, отдаёт пустой результат (обработчик это переживает — прочие блоки
обёрнуты в try/except SAVEPOINT).
"""
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]]]}'
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(
parcel_meta_row: dict[str, Any] | None = None,
) -> MagicMock:
"""Сконструировать mock DB Session для analyze_parcel с фокусом на parcel_meta.
parcel_meta_row=None → имитирует "строки нет в cad_parcels" → parcel_meta=None.
parcel_meta_row={...} → имитирует найденную строку.
"""
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": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
pm_mock = _make_mapping(parcel_meta_row) if parcel_meta_row is not None else None
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
sql = " ".join(str(args[0]).split()) if args else ""
first_val: Any = None
all_val: list[Any] = []
if "AS geom_geojson" in sql:
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row
# Предмет файла: parcel_meta из cad_parcels (#29 G2).
elif "AS permitted_use" in sql and "FROM cad_parcels" in sql:
first_val = pm_mock
# _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B).
elif "neighbors_total_count" in sql or "json_agg" in sql:
first_val = {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}
r = MagicMock()
r.mappings.return_value.first.return_value = first_val
r.mappings.return_value.all.return_value = all_val
r.scalar.return_value = 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
_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={
"nspd_zoning": None,
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_risk_zones": [],
"nspd_opportunity_parcels": [],
"nspd_red_lines": [],
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
},
),
patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
# enable_ird_analyze=True by default — мокаем чтобы не делать сетевые вызовы в тестах
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,
},
),
]
def _start_patches() -> None:
for p in _PATCHES:
p.start()
def _stop_patches() -> None:
for p in _PATCHES:
p.stop()
def test_parcel_meta_found_in_cad_parcels() -> None:
"""analyze → cad_parcels has row → parcel_meta содержит permitted_use/land_category/cad_cost."""
from app.core.db import get_db
pm_data: dict[str, Any] = {
"permitted_use": "многоквартирный дом",
"land_category": "Земли населённых пунктов",
"land_subtype": "жилая застройка",
"cad_cost": 5_000_000.0,
}
db = _make_db_for_analyze(parcel_meta_row=pm_data)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
assert "parcel_meta" in body, "parcel_meta отсутствует в ответе"
pm = body["parcel_meta"]
assert pm is not None, "parcel_meta должен быть не None при наличии строки"
assert pm["permitted_use"] == "многоквартирный дом"
assert pm["land_category"] == "Земли населённых пунктов"
assert pm["land_subtype"] == "жилая застройка"
assert pm["cad_cost"] == 5_000_000.0
assert pm["source"] == "cad_parcels"
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_parcel_meta_none_when_cad_parcels_missing() -> None:
"""analyze → cad_parcels row отсутствует → parcel_meta == None."""
from app.core.db import get_db
db = _make_db_for_analyze(parcel_meta_row=None)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
assert "parcel_meta" in body, "parcel_meta ключ должен присутствовать в ответе"
assert body["parcel_meta"] is None, "parcel_meta должен быть None при отсутствии строки"
finally:
app.dependency_overrides.clear()
_stop_patches()