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>
551 lines
23 KiB
Python
551 lines
23 KiB
Python
"""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"] == []
|