gendesign/backend/tests/test_quarter_dump_lookup.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

551 lines
23 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.

"""Unit tests для quarter_dump_lookup helper (Sprint 1.1 item #4).
Mock-based — без реальной БД и Celery. Проверяет:
- derive_quarter_cad: 3/4/5-сегмент → правильный quarter
- get_quarter_dump_data: отсутствующий дамп → triggers harvest, available=False
- get_quarter_dump_data: устаревший дамп → stale=True, triggers harvest
- get_quarter_dump_data: свежий дамп с territorial_zones → nspd_zoning populated
- get_quarter_dump_data: свежий дамп с 2 zouit → nspd_zouit_overlaps length=2
- get_quarter_dump_data: parcel_wkt=None → metadata only, no spatial queries
- make_empty_result: mutation-safety — каждый вызов возвращает независимый dict
- early-exit: layer_counts == 0 → spatial query не вызывается
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import MagicMock, patch
from app.services.site_finder.quarter_dump_lookup import (
EMPTY_DUMP_RESULT,
_get_cad_zouit_overlaps,
derive_quarter_cad,
get_quarter_dump_data,
make_empty_result,
)
# ── derive_quarter_cad ────────────────────────────────────────────────────────
def test_derive_quarter_cad_3seg() -> None:
"""3-сегментный номер — квартал сам себе."""
assert derive_quarter_cad("66:41:0204016") == "66:41:0204016"
def test_derive_quarter_cad_4seg() -> None:
"""4-сегментный участок → 3-сегментный квартал."""
assert derive_quarter_cad("66:41:0204016:10") == "66:41:0204016"
def test_derive_quarter_cad_5seg() -> None:
"""5-сегментное здание → 3-сегментный квартал."""
assert derive_quarter_cad("66:41:0204016:10:1") == "66:41:0204016"
def test_derive_quarter_cad_invalid() -> None:
"""Невалидный формат → None."""
assert derive_quarter_cad("66:41") is None
assert derive_quarter_cad("abc:def:ghi") is None
assert derive_quarter_cad("") is None
def test_derive_quarter_cad_strips_whitespace() -> None:
"""Пробелы обрезаются."""
assert derive_quarter_cad(" 66:41:0204016 ") == "66:41:0204016"
# ── get_quarter_dump_data — no row ────────────────────────────────────────────
def _make_db_mock(row: Any = None) -> MagicMock:
"""Создаёт mock Session.execute().first() с заданным результатом."""
db = MagicMock()
execute_result = MagicMock()
execute_result.first.return_value = row
db.execute.return_value = execute_result
return db
def test_get_quarter_dump_no_row() -> None:
"""Дамп отсутствует в БД → harvest triggered, available=False."""
db = _make_db_mock(row=None)
with patch(
"app.services.site_finder.quarter_dump_lookup._trigger_harvest",
return_value=True,
) as mock_trigger:
result = get_quarter_dump_data(db, "66:41:0204016:10", parcel_wkt="POLYGON ((...))")
mock_trigger.assert_called_once_with("66:41:0204016")
assert result["nspd_dump"]["available"] is False
assert result["nspd_dump"]["harvest_triggered"] is True
assert result["nspd_dump"]["stale"] is False
assert result["nspd_zoning"] is None
assert result["nspd_zouit_overlaps"] == []
assert result["nspd_engineering_nearby"] == []
# ── get_quarter_dump_data — stale ─────────────────────────────────────────────
def test_get_quarter_dump_stale() -> None:
"""Дамп старше 180 дней → stale=True, harvest triggered, available=False."""
old_date = datetime.now(UTC) - timedelta(days=200)
# row: (quarter_cad, fetched_at_utc, total_features, harvest_error,
# territorial_zones_count, zouit_count, engineering_count,
# risks_count, opportunity_count, red_lines_count)
db = _make_db_mock(row=("66:41:0204016", old_date, 50, None, 5, 2, 1, 0, 0, 0))
with patch(
"app.services.site_finder.quarter_dump_lookup._trigger_harvest",
return_value=True,
) as mock_trigger:
result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt="POLYGON ((...))")
mock_trigger.assert_called_once_with("66:41:0204016")
assert result["nspd_dump"]["available"] is False
assert result["nspd_dump"]["stale"] is True
assert result["nspd_dump"]["harvest_triggered"] is True
assert result["nspd_dump"]["total_features"] == 50
def test_get_quarter_dump_with_harvest_error() -> None:
"""Дамп с harvest_error → harvest_triggered=True, available=False."""
fresh_date = datetime.now(UTC) - timedelta(days=10)
db = _make_db_mock(row=("66:41:0204016", fresh_date, 0, "WAF 429", 0, 0, 0, 0, 0, 0))
with patch(
"app.services.site_finder.quarter_dump_lookup._trigger_harvest",
return_value=True,
) as mock_trigger:
result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=None)
mock_trigger.assert_called_once_with("66:41:0204016")
assert result["nspd_dump"]["available"] is False
assert result["nspd_dump"]["harvest_triggered"] is True
# ── get_quarter_dump_data — no parcel_wkt ─────────────────────────────────────
def test_get_quarter_dump_no_parcel_geometry() -> None:
"""parcel_wkt=None → metadata only, нет spatial queries."""
fresh_date = datetime.now(UTC) - timedelta(days=5)
db = _make_db_mock(row=("66:41:0204016", fresh_date, 120, None, 10, 3, 2, 0, 0, 0))
result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=None)
# execute вызывался только один раз (для чтения строки дампа)
assert db.execute.call_count == 1
assert result["nspd_dump"]["available"] is True
assert result["nspd_dump"]["stale"] is False
assert result["nspd_zoning"] is None
assert result["nspd_zouit_overlaps"] == []
assert result["nspd_engineering_nearby"] == []
# ── get_quarter_dump_data — fresh dump with zoning ───────────────────────────
def _make_db_mock_with_spatial(
dump_row: Any,
zoning_row: Any = None,
zouit_rows: list[Any] | None = None,
engineering_rows: list[Any] | None = None,
) -> MagicMock:
"""Mock DB с разными результатами для разных запросов.
Порядок вызовов db.execute жёстко привязан к реализации get_quarter_dump_data:
0. dump lookup → .first()
1. zoning → .first() (пропускается если territorial_zones_count == 0)
2. zouit → .fetchall() (пропускается если zouit_count == 0)
3. engineering → .fetchall() (пропускается если engineering_count == 0)
Хрупко при реорганизации порядка запросов в production-коде. При изменении
порядка в get_quarter_dump_data — обновить индексы call_count здесь.
dump_row должен быть 10-элементным tuple:
(quarter_cad, fetched_at_utc, total_features, harvest_error,
territorial_zones_count, zouit_count, engineering_count,
risks_count, opportunity_count, red_lines_count).
"""
db = MagicMock()
call_count = 0
def side_effect(*args: Any, **kwargs: Any) -> MagicMock:
nonlocal call_count
result_mock = MagicMock()
if call_count == 0:
# dump lookup
result_mock.first.return_value = dump_row
elif call_count == 1:
# zoning
result_mock.first.return_value = zoning_row
elif call_count == 2:
# zouit
result_mock.fetchall.return_value = zouit_rows or []
elif call_count == 3:
# engineering
result_mock.fetchall.return_value = engineering_rows or []
call_count += 1
return result_mock
db.execute.side_effect = side_effect
return db
def test_get_quarter_dump_fresh_zoning() -> None:
"""Свежий дамп с territorial_zone → nspd_zoning populated.
Использует РЕАЛЬНУЮ форму props (verified на prod): зона несёт только
рег-номер границы, продублированный в descr/label/externalKey/options.
"""
fresh_date = datetime.now(UTC) - timedelta(days=30)
dump_row = ("66:41:0204016", fresh_date, 100, None, 5, 0, 0, 0, 0, 0)
# zone_props JSONB в виде dict (SQLAlchemy вернёт dict) — реальная форма НСПД.
zone_props: dict[str, Any] = {
"descr": "66:41-7.14",
"label": "66:41-7.14",
"options": {"reg_numb_border": "66:41-7.14"},
"category": 472819,
"externalKey": "66:41-7.14",
"subcategory": 1,
}
zoning_row_mock = MagicMock()
zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None
db = _make_db_mock_with_spatial(
dump_row=dump_row,
zoning_row=zoning_row_mock,
zouit_rows=[],
engineering_rows=[],
)
wkt = "POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))"
result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=wkt)
assert result["nspd_dump"]["available"] is True
assert result["nspd_zoning"] is not None
# Регресс #fix nspd-zoning-zone-code-keys: оба поля больше не None.
assert result["nspd_zoning"]["zone_code"] == "66:41-7.14"
assert result["nspd_zoning"]["zone_name"] == "66:41-7.14"
assert result["nspd_zoning"]["source"] == "nspd-quarter-dump"
# raw_props сохраняет полный объект для дебага.
assert result["nspd_zoning"]["raw_props"]["options"]["reg_numb_border"] == "66:41-7.14"
def test_get_quarter_dump_zoning_reads_real_nspd_keys() -> None:
"""Регресс: zone_code/zone_name резолвятся из РЕАЛЬНЫХ ключей дампа.
До фикла _get_zoning читал несуществующие top-level reg_numb_border /
zone_code / type_zone / name → оба поля были None на КАЖДОМ участке.
Здесь проверяем три варианта формы и nested-fallback на options.
"""
fresh_date = datetime.now(UTC) - timedelta(days=10)
def run(zone_props: dict[str, Any]) -> dict[str, Any] | None:
dump_row = ("66:41:0702048", fresh_date, 80, None, 2, 0, 0, 0, 0, 0)
zoning_row_mock = MagicMock()
zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None
db = _make_db_mock_with_spatial(
dump_row=dump_row,
zoning_row=zoning_row_mock,
zouit_rows=[],
engineering_rows=[],
)
result = get_quarter_dump_data(
db, "66:41:0702048", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))"
)
return result["nspd_zoning"]
# 1. Полная реальная форма — берём top-level descr.
full = run(
{
"descr": "66:41-7.19",
"label": "66:41-7.19",
"options": {"reg_numb_border": "66:41-7.19"},
"externalKey": "66:41-7.19",
"subcategory": 3,
}
)
assert full is not None
assert full["zone_code"] == "66:41-7.19"
assert full["zone_name"] == "66:41-7.19"
# 2. Top-level descr/label отсутствуют — narrow safely на options.reg_numb_border.
nested_only = run({"options": {"reg_numb_border": "66:01-7.114"}, "category": 472819})
assert nested_only is not None
assert nested_only["zone_code"] == "66:01-7.114"
assert nested_only["zone_name"] == "66:01-7.114"
# 3. options не-dict / отсутствует и нет descr — graceful, не KeyError, zone_code=None.
empty = run({"category": 472819, "subcategory": 6})
assert empty is not None
assert empty["zone_code"] is None
assert empty["zone_name"] is None
def test_get_quarter_dump_fresh_zoning_no_match() -> None:
"""Свежий дамп, centroid вне всех territorial_zones → nspd_zoning=None."""
fresh_date = datetime.now(UTC) - timedelta(days=30)
dump_row = ("66:41:0204016", fresh_date, 100, None, 3, 0, 0, 0, 0, 0)
db = _make_db_mock_with_spatial(
dump_row=dump_row,
zoning_row=None,
zouit_rows=[],
engineering_rows=[],
)
result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt="POLYGON ((...))")
assert result["nspd_zoning"] is None
assert result["nspd_dump"]["available"] is True
# ── get_quarter_dump_data — fresh dump with zouit overlaps ────────────────────
def test_get_quarter_dump_fresh_zouit_overlaps() -> None:
"""Свежий дамп с 2 zouit → nspd_zouit_overlaps length=2."""
fresh_date = datetime.now(UTC) - timedelta(days=10)
# territorial_zones_count > 0 чтобы все 3 spatial запроса вызывались
# (иначе call_count в mock смещается и zouit попадает не в тот слот)
dump_row = ("66:41:0204016", fresh_date, 200, None, 1, 5, 0, 0, 0, 0)
props1: dict[str, Any] = {"name": "ОКН Дом купца", "subcategory": "okn"}
props2: dict[str, Any] = {"name": "ОЗ газопровода", "subcategory": "engineering"}
def make_row(layer: str, props: dict[str, Any]) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, i: layer if i == 0 else props
return r
zouit_rows = [make_row("zouit_okn", props1), make_row("zouit_engineering", props2)]
db = _make_db_mock_with_spatial(
dump_row=dump_row,
zoning_row=None,
zouit_rows=zouit_rows,
engineering_rows=[],
)
result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt="POLYGON ((...))")
assert len(result["nspd_zouit_overlaps"]) == 2
keys = {r["group_key"] for r in result["nspd_zouit_overlaps"]}
assert "okn" in keys
assert "engineering" in keys
# ── get_quarter_dump_data — integration of nspd_dump in response ──────────────
def test_get_quarter_dump_includes_nspd_fields() -> None:
"""Базовая проверка что все 4 nspd-ключа присутствуют в возвращаемом dict."""
fresh_date = datetime.now(UTC) - timedelta(days=1)
dump_row = ("66:41:0204016", fresh_date, 55, None, 2, 1, 0, 0, 0, 0)
db = _make_db_mock_with_spatial(
dump_row=dump_row,
zoning_row=None,
zouit_rows=[],
engineering_rows=[],
)
result = get_quarter_dump_data(db, "66:41:0204016:5", parcel_wkt="POINT (60.6 56.8)")
assert "nspd_zoning" in result
assert "nspd_zouit_overlaps" in result
assert "nspd_engineering_nearby" in result
assert "nspd_dump" in result
dump = result["nspd_dump"]
assert "available" in dump
assert "fetched_at_utc" in dump
assert "stale" in dump
assert "harvest_triggered" in dump
assert "total_features" in dump
# ── _get_cad_zouit_overlaps — geom_geojson field (#255) ──────────────────────
def _make_zouit_row(
type_zone: str,
category_name: str,
name: str,
reg_numb_border: str | None,
zouit_id: int,
geom_geojson: str | None,
coverage_pct: float | None = None,
) -> tuple[Any, ...]:
"""Строка cad_zouit в порядке SELECT _get_cad_zouit_overlaps.
(type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson,
coverage_pct) — coverage_pct добавлен area-gate'ом (доля участка под ЗОУИТ).
"""
return (
type_zone,
category_name,
name,
reg_numb_border,
zouit_id,
geom_geojson,
coverage_pct,
)
def test_cad_zouit_overlaps_includes_geom_geojson() -> None:
"""#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON)."""
geojson_str = (
'{"type":"Polygon","coordinates":'
"[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}"
)
rows = [
_make_zouit_row(
"Охранная зона тепловой сети",
"ЗОУИТ general",
"Охранная зона тепловой сети с кад. № 66:41:0204016:57",
"66:41:0204016:57",
123,
geojson_str,
)
]
db = MagicMock()
execute_result = MagicMock()
execute_result.fetchall.return_value = rows
db.execute.return_value = execute_result
result = _get_cad_zouit_overlaps(db, "POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))")
assert len(result) == 1
overlap = result[0]
assert "geom_geojson" in overlap, "overlap должен нести geom_geojson для ZouitLayer"
assert overlap["geom_geojson"] == geojson_str
assert overlap["source"] == "cad_zouit"
assert overlap["type_zone"] == "Охранная зона тепловой сети"
assert overlap["raw_props"]["zouit_id"] == 123
def test_cad_zouit_overlaps_geom_geojson_nullable() -> None:
"""geom_geojson=None (битая/NULL geometry) не валит маппинг — поле = None."""
rows = [
_make_zouit_row("СЗЗ", "иные", "Санитарно-защитная зона", None, 7, None),
]
db = MagicMock()
execute_result = MagicMock()
execute_result.fetchall.return_value = rows
db.execute.return_value = execute_result
result = _get_cad_zouit_overlaps(db, "POLYGON ((...))")
assert len(result) == 1
assert result[0]["geom_geojson"] is None
def test_cad_zouit_overlaps_query_failure_returns_empty() -> None:
"""Сбой запроса (например, отсутствие cad_zouit) → graceful [] (не пробрасываем)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("relation cad_zouit does not exist")
result = _get_cad_zouit_overlaps(db, "POLYGON ((...))")
assert result == []
# ── make_empty_result — mutation safety ─────────────────────────────────────
def testmake_empty_result_returns_independent_copies() -> None:
"""Каждый вызов make_empty_result() возвращает независимый dict.
Мутация nspd_dump в одном результате не должна влиять на другой
и на модульный sentinel EMPTY_DUMP_RESULT.
"""
a = make_empty_result()
b = make_empty_result()
# Вложенный dict — независимый объект
assert a["nspd_dump"] is not b["nspd_dump"]
assert a["nspd_dump"] is not EMPTY_DUMP_RESULT["nspd_dump"]
# Мутируем один — другой не трогается
a["nspd_dump"]["harvest_triggered"] = True
a["nspd_zouit_overlaps"].append({"group_key": "test"})
assert b["nspd_dump"]["harvest_triggered"] is False
assert b["nspd_zouit_overlaps"] == []
assert EMPTY_DUMP_RESULT["nspd_dump"]["harvest_triggered"] is False
assert EMPTY_DUMP_RESULT["nspd_zouit_overlaps"] == []
def testmake_empty_result_overrides() -> None:
"""make_empty_result принимает override-аргументы."""
r = make_empty_result(harvest_triggered=True, stale=True, total_features=42)
assert r["nspd_dump"]["harvest_triggered"] is True
assert r["nspd_dump"]["stale"] is True
assert r["nspd_dump"]["total_features"] == 42
assert r["nspd_dump"]["available"] is False
# ── early-exit when layer_counts == 0 ────────────────────────────────────────
def test_early_exit_all_counts_zero_no_spatial_queries() -> None:
"""Если все denormalized counts == 0 — spatial queries не вызываются."""
fresh_date = datetime.now(UTC) - timedelta(days=5)
# territorial_zones_count=0, zouit_count=0, engineering_count=0
dump_row = ("66:41:0204016", fresh_date, 50, None, 0, 0, 0, 0, 0, 0)
db = _make_db_mock_with_spatial(dump_row=dump_row)
result = get_quarter_dump_data(
db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))"
)
# 2 execute-вызова: чтение dump row (0) + cad_zouit fallback (#232) — при
# zouit_count==0 _get_zouit_overlaps не early-exit'ит, а делает fallback на
# cad_zouit. Остальные spatial helpers (zoning/engineering/risk/opportunity/
# red_lines) пропущены по своим нулевым счётчикам.
assert db.execute.call_count == 2
assert result["nspd_zoning"] is None
assert result["nspd_zouit_overlaps"] == []
assert result["nspd_engineering_nearby"] == []
assert result["nspd_dump"]["available"] is True
def test_early_exit_partial_counts() -> None:
"""territorial_zones_count > 0, zouit_count == 0 → zoning query вызывается,
zouit и engineering — нет."""
fresh_date = datetime.now(UTC) - timedelta(days=5)
dump_row = ("66:41:0204016", fresh_date, 30, None, 3, 0, 0, 0, 0, 0)
# Реальная форма props НСПД (см. test_get_quarter_dump_zoning_reads_real_nspd_keys).
zone_props: dict[str, Any] = {
"descr": "66:41-7.2",
"label": "66:41-7.2",
"options": {"reg_numb_border": "66:41-7.2"},
}
zoning_row_mock = MagicMock()
zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None
db = _make_db_mock_with_spatial(
dump_row=dump_row,
zoning_row=zoning_row_mock,
zouit_rows=[],
engineering_rows=[],
)
result = get_quarter_dump_data(
db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))"
)
# dump lookup (0) + zoning (1) + cad_zouit fallback (2) = 3 вызова;
# engineering/risk/opportunity/red_lines — пропущены по нулевым счётчикам.
# zouit_count==0 → _get_zouit_overlaps делает fallback на cad_zouit (#232),
# который возвращает [] (mock fetchall пуст).
assert db.execute.call_count == 3
assert result["nspd_zoning"] is not None
assert result["nspd_zoning"]["zone_code"] == "66:41-7.2"
assert result["nspd_zouit_overlaps"] == []
assert result["nspd_engineering_nearby"] == []