933 lines
37 KiB
Python
933 lines
37 KiB
Python
"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction + TIER 4.
|
||
|
||
Покрывает:
|
||
- _extract_features_by_layer: filter by prefix
|
||
- _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect,
|
||
no-risks-in-dump
|
||
- _get_opportunity_parcels: auction/scheme/free/future/oopt layers, distance sort, empty
|
||
- _get_red_lines: intersecting, nearby-only, empty, early-exit
|
||
- EMPTY_DUMP_RESULT / make_empty_result: new keys присутствуют
|
||
Не требует реального PostgreSQL — мокает db.execute().
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
from app.services.site_finder.quarter_dump_lookup import (
|
||
EMPTY_DUMP_RESULT,
|
||
_build_structure_characteristics,
|
||
_clean_struct_str,
|
||
_extract_features_by_layer,
|
||
_get_cad_zouit_overlaps,
|
||
_get_engineering_structures_by_boundary,
|
||
_get_opportunity_parcels,
|
||
_get_red_lines,
|
||
_get_risk_zones,
|
||
_get_zouit_engineering_overlaps,
|
||
_get_zouit_overlaps,
|
||
_zouit_subcategory_type,
|
||
derive_quarter_cad,
|
||
get_quarter_dump_data,
|
||
make_empty_result,
|
||
)
|
||
|
||
# ── Fixtures & helpers ────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_feature(layer: str, geom: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
"""Минимальный feature dict в формате features_json."""
|
||
return {
|
||
"layer": layer,
|
||
"feature_id": "test-id",
|
||
"geometry": geom or {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]},
|
||
"properties": {"name": f"test-{layer}", "type_zone": f"zone-{layer}"},
|
||
}
|
||
|
||
|
||
# ── _extract_features_by_layer ────────────────────────────────────────────────
|
||
|
||
|
||
def test_extract_features_by_layer_prefix_match() -> None:
|
||
"""Возвращает только features с нужным prefix."""
|
||
features = [
|
||
_make_feature("risk_flooding"),
|
||
_make_feature("risk_burns"),
|
||
_make_feature("zouit_engineering"),
|
||
_make_feature("parcels"),
|
||
_make_feature("risk_landslide"),
|
||
]
|
||
result = _extract_features_by_layer(features, "risk_")
|
||
assert len(result) == 3
|
||
layers = {f["layer"] for f in result}
|
||
assert layers == {"risk_flooding", "risk_burns", "risk_landslide"}
|
||
|
||
|
||
def test_extract_features_by_layer_zouit_prefix() -> None:
|
||
"""Работает для zouit_ prefix — generic re-use."""
|
||
features = [
|
||
_make_feature("zouit_engineering"),
|
||
_make_feature("zouit_okn"),
|
||
_make_feature("risk_flooding"),
|
||
]
|
||
result = _extract_features_by_layer(features, "zouit_")
|
||
assert len(result) == 2
|
||
assert all(f["layer"].startswith("zouit_") for f in result)
|
||
|
||
|
||
def test_extract_features_by_layer_empty_list() -> None:
|
||
result = _extract_features_by_layer([], "risk_")
|
||
assert result == []
|
||
|
||
|
||
def test_extract_features_by_layer_no_match() -> None:
|
||
features = [_make_feature("parcels"), _make_feature("buildings")]
|
||
result = _extract_features_by_layer(features, "risk_")
|
||
assert result == []
|
||
|
||
|
||
def test_extract_features_by_layer_non_string_layer() -> None:
|
||
"""Gracefully skips features with non-string layer."""
|
||
features: list[dict[str, Any]] = [
|
||
{"layer": None, "feature_id": "x", "geometry": None, "properties": {}},
|
||
_make_feature("risk_flooding"),
|
||
]
|
||
result = _extract_features_by_layer(features, "risk_")
|
||
assert len(result) == 1
|
||
assert result[0]["layer"] == "risk_flooding"
|
||
|
||
|
||
# ── _get_risk_zones ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_mock_db_with_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
|
||
"""Мок Session где execute().fetchall() возвращает rows.
|
||
|
||
execute().scalar() тоже замокан на len(rows) — epic #2445 A1 добавил отдельный
|
||
honest-COUNT(*) запрос в _get_engineering_structures_by_boundary /
|
||
_get_zouit_engineering_overlaps ДО row-fetch; хелперы без true-count-запроса
|
||
(_get_risk_zones и т.п.) scalar() не вызывают, так что это безопасный default.
|
||
"""
|
||
db = MagicMock()
|
||
mock_result = MagicMock()
|
||
mock_result.fetchall.return_value = rows
|
||
mock_result.scalar.return_value = len(rows)
|
||
db.execute.return_value = mock_result
|
||
return db
|
||
|
||
|
||
def test_get_risk_zones_intersects() -> None:
|
||
"""Три risk features → три записи в результате."""
|
||
rows: list[tuple[Any, ...]] = [
|
||
# (layer, props, geom_wkt, intersection_area_sqm)
|
||
(
|
||
"risk_flooding",
|
||
{"type_zone": "Затопление 1%"},
|
||
"POLYGON((0 0,1 0,1 1,0 0))",
|
||
1234.5,
|
||
),
|
||
(
|
||
"risk_burns",
|
||
{"name": "Гарь"},
|
||
"POLYGON((0 0,2 0,2 2,0 0))",
|
||
500.0,
|
||
),
|
||
(
|
||
"risk_landslide",
|
||
{},
|
||
"POLYGON((0 0,3 0,3 3,0 0))",
|
||
999.9,
|
||
),
|
||
]
|
||
db = _make_mock_db_with_rows(rows)
|
||
|
||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
|
||
assert len(result) == 3
|
||
|
||
flooding = next(r for r in result if r["layer"] == "risk_flooding")
|
||
assert flooding["subtype"] == "Затопление 1%"
|
||
assert flooding["intersection_area_sqm"] == 1234.5
|
||
assert flooding["geom_wkt"] == "POLYGON((0 0,1 0,1 1,0 0))"
|
||
|
||
burns = next(r for r in result if r["layer"] == "risk_burns")
|
||
assert burns["subtype"] == "Гарь"
|
||
|
||
landslide = next(r for r in result if r["layer"] == "risk_landslide")
|
||
# No properties name/type_zone → falls back to _RISK_SUBTYPE_LABELS
|
||
assert landslide["subtype"] == "Обвально-осыпные процессы"
|
||
|
||
|
||
def test_get_risk_zones_no_intersect() -> None:
|
||
"""Пустой fetchall → пустой список."""
|
||
db = _make_mock_db_with_rows([])
|
||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
assert result == []
|
||
|
||
|
||
def test_get_risk_zones_no_risks_in_dump() -> None:
|
||
"""risks_count == 0 → early exit, db.execute не вызывается."""
|
||
db = MagicMock()
|
||
layer_counts = {"risks_count": 0}
|
||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts)
|
||
assert result == []
|
||
db.execute.assert_not_called()
|
||
|
||
|
||
def test_get_risk_zones_db_exception_returns_empty() -> None:
|
||
"""DB exception → пустой список (не propagate)."""
|
||
db = MagicMock()
|
||
db.execute.side_effect = Exception("DB connection lost")
|
||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
assert result == []
|
||
|
||
|
||
def test_get_risk_zones_null_area() -> None:
|
||
"""NULL intersection_area_sqm → intersection_area_sqm=None в результате."""
|
||
rows = [("risk_erosion_water", {}, "POINT(0 0)", None)]
|
||
db = _make_mock_db_with_rows(rows)
|
||
result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
assert len(result) == 1
|
||
assert result[0]["intersection_area_sqm"] is None
|
||
|
||
|
||
# ── make_empty_result includes nspd_risk_zones ────────────────────────────────
|
||
|
||
|
||
def test_make_empty_result_has_risk_zones_key() -> None:
|
||
"""make_empty_result возвращает nspd_risk_zones: []."""
|
||
result = make_empty_result()
|
||
assert "nspd_risk_zones" in result
|
||
assert result["nspd_risk_zones"] == []
|
||
|
||
|
||
# ── _get_opportunity_parcels ─────────────────────────────────────────────────
|
||
|
||
|
||
def test_get_opportunity_parcels_finds_auction() -> None:
|
||
"""3 features (1 auction + 2 other types) — все возвращаются, auction в нужном layer."""
|
||
rows: list[tuple[Any, ...]] = [
|
||
# (layer, props, geom_wkt, distance_m)
|
||
(
|
||
"opportunity_auction_parcels",
|
||
{"cad_num": "66:41:0204016:101"},
|
||
"POLYGON((0 0,1 0,1 1,0 0))",
|
||
50.0,
|
||
),
|
||
(
|
||
"opportunity_scheme_parcels",
|
||
{"cadastral_number": "66:41:0204016:102"},
|
||
"POLYGON((1 1,2 1,2 2,1 1))",
|
||
150.0,
|
||
),
|
||
(
|
||
"opportunity_oopt",
|
||
{},
|
||
"POLYGON((2 2,3 2,3 3,2 2))",
|
||
300.0,
|
||
),
|
||
]
|
||
db = _make_mock_db_with_rows(rows)
|
||
|
||
result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
|
||
assert len(result) == 3
|
||
|
||
auction = next(r for r in result if r["layer"] == "auction_parcels")
|
||
assert auction["cad_num"] == "66:41:0204016:101"
|
||
assert auction["distance_m"] == 50.0
|
||
|
||
scheme = next(r for r in result if r["layer"] == "scheme_parcels")
|
||
assert scheme["cad_num"] == "66:41:0204016:102"
|
||
|
||
oopt = next(r for r in result if r["layer"] == "oopt")
|
||
assert oopt["cad_num"] is None
|
||
assert oopt["distance_m"] == 300.0
|
||
|
||
|
||
def test_get_opportunity_parcels_distance_sort() -> None:
|
||
"""Результаты отсортированы по distance_m ASC (SQL ORDER BY передаётся DB)."""
|
||
rows: list[tuple[Any, ...]] = [
|
||
# Порядок: ближайший первым (SQL уже сортирует — тест проверяет что порядок сохраняется)
|
||
(
|
||
"opportunity_free_parcels",
|
||
{"cad_num": "66:41:0204016:10"},
|
||
"POINT(0 0)",
|
||
10.0,
|
||
),
|
||
(
|
||
"opportunity_auction_parcels",
|
||
{"cad_num": "66:41:0204016:20"},
|
||
"POINT(1 1)",
|
||
200.0,
|
||
),
|
||
(
|
||
"opportunity_future_parcels",
|
||
{},
|
||
"POINT(2 2)",
|
||
450.0,
|
||
),
|
||
]
|
||
db = _make_mock_db_with_rows(rows)
|
||
|
||
result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
|
||
assert len(result) == 3
|
||
# Порядок из DB (mock возвращает в порядке rows) — ближайший первый
|
||
assert result[0]["distance_m"] == 10.0
|
||
assert result[1]["distance_m"] == 200.0
|
||
assert result[2]["distance_m"] == 450.0
|
||
|
||
|
||
def test_get_opportunity_parcels_empty() -> None:
|
||
"""Нет opportunity features — пустой список."""
|
||
db = _make_mock_db_with_rows([])
|
||
result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
assert result == []
|
||
|
||
|
||
def test_get_opportunity_parcels_early_exit() -> None:
|
||
"""opportunity_count == 0 → early exit, db.execute не вызывается."""
|
||
db = MagicMock()
|
||
layer_counts = {"opportunity_count": 0}
|
||
result = _get_opportunity_parcels(
|
||
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts
|
||
)
|
||
assert result == []
|
||
db.execute.assert_not_called()
|
||
|
||
|
||
def test_get_opportunity_parcels_db_exception_returns_empty() -> None:
|
||
"""DB exception → пустой список (не propagate)."""
|
||
db = MagicMock()
|
||
db.execute.side_effect = Exception("connection lost")
|
||
result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
assert result == []
|
||
|
||
|
||
# ── _get_red_lines ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_get_red_lines_intersects() -> None:
|
||
"""Red line intersecting parcel → intersection_length_m filled, distance_m=None."""
|
||
rows: list[tuple[Any, ...]] = [
|
||
# (geom_wkt, does_intersect, intersection_length_m, distance_m)
|
||
(
|
||
"LINESTRING(0 0, 1 1)",
|
||
True,
|
||
45.3, # длина пересечения в метрах
|
||
0.0, # intersecting → distance_m becomes None in result
|
||
),
|
||
]
|
||
db = _make_mock_db_with_rows(rows)
|
||
|
||
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
|
||
assert len(result) == 1
|
||
r = result[0]
|
||
assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)"
|
||
assert r["intersection_length_m"] == 45.3
|
||
assert r["distance_m"] is None # null when intersecting
|
||
|
||
|
||
def test_get_red_lines_nearby_only() -> None:
|
||
"""Red line nearby only (не intersect) → distance_m filled, intersection_length_m=None."""
|
||
rows: list[tuple[Any, ...]] = [
|
||
# (geom_wkt, does_intersect, intersection_length_m, distance_m)
|
||
(
|
||
"LINESTRING(10 10, 20 20)",
|
||
False,
|
||
0.0, # нет пересечения
|
||
85.5,
|
||
),
|
||
]
|
||
db = _make_mock_db_with_rows(rows)
|
||
|
||
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
|
||
assert len(result) == 1
|
||
r = result[0]
|
||
assert r["intersection_length_m"] is None
|
||
assert r["distance_m"] == 85.5
|
||
|
||
|
||
def test_get_red_lines_db_exception_returns_empty() -> None:
|
||
"""DB exception → пустой список (не propagate)."""
|
||
db = MagicMock()
|
||
db.execute.side_effect = Exception("DB connection lost")
|
||
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
assert result == []
|
||
|
||
|
||
def test_get_red_lines_empty() -> None:
|
||
"""Нет red lines features — пустой список."""
|
||
db = _make_mock_db_with_rows([])
|
||
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
assert result == []
|
||
|
||
|
||
def test_get_red_lines_early_exit() -> None:
|
||
"""red_lines_count == 0 → early exit, db.execute не вызывается."""
|
||
db = MagicMock()
|
||
layer_counts = {"red_lines_count": 0}
|
||
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts)
|
||
assert result == []
|
||
db.execute.assert_not_called()
|
||
|
||
|
||
# ── EMPTY_DUMP_RESULT / make_empty_result includes new TIER 4 keys ────────────
|
||
|
||
|
||
def test_empty_dump_result_has_opportunity_key() -> None:
|
||
"""EMPTY_DUMP_RESULT содержит nspd_opportunity_parcels: []."""
|
||
assert "nspd_opportunity_parcels" in EMPTY_DUMP_RESULT
|
||
assert EMPTY_DUMP_RESULT["nspd_opportunity_parcels"] == []
|
||
|
||
|
||
def test_empty_dump_result_has_red_lines_key() -> None:
|
||
"""EMPTY_DUMP_RESULT содержит nspd_red_lines: []."""
|
||
assert "nspd_red_lines" in EMPTY_DUMP_RESULT
|
||
assert EMPTY_DUMP_RESULT["nspd_red_lines"] == []
|
||
|
||
|
||
def test_make_empty_result_has_tier4_keys() -> None:
|
||
"""make_empty_result() возвращает nspd_opportunity_parcels и nspd_red_lines."""
|
||
result = make_empty_result()
|
||
assert "nspd_opportunity_parcels" in result
|
||
assert result["nspd_opportunity_parcels"] == []
|
||
assert "nspd_red_lines" in result
|
||
assert result["nspd_red_lines"] == []
|
||
|
||
|
||
# ── Issue #234: harvest_eta_seconds + SETNX dedupe ────────────────────────────
|
||
|
||
|
||
def test_empty_dump_result_has_harvest_eta_seconds_key() -> None:
|
||
"""EMPTY_DUMP_RESULT.nspd_dump содержит harvest_eta_seconds (issue #234)."""
|
||
assert "harvest_eta_seconds" in EMPTY_DUMP_RESULT["nspd_dump"]
|
||
assert EMPTY_DUMP_RESULT["nspd_dump"]["harvest_eta_seconds"] is None
|
||
|
||
|
||
def test_make_empty_result_passes_harvest_eta_seconds() -> None:
|
||
"""make_empty_result(harvest_eta_seconds=60) → пробрасывает в nspd_dump."""
|
||
result = make_empty_result(harvest_triggered=True, harvest_eta_seconds=60)
|
||
assert result["nspd_dump"]["harvest_eta_seconds"] == 60
|
||
assert result["nspd_dump"]["harvest_triggered"] is True
|
||
|
||
|
||
def test_make_empty_result_default_eta_is_none() -> None:
|
||
"""Если harvest_eta_seconds не передан — поле None (для available=False случая)."""
|
||
result = make_empty_result()
|
||
assert result["nspd_dump"]["harvest_eta_seconds"] is None
|
||
|
||
|
||
def test_acquire_harvest_lock_succeeds_first_call(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""SETNX lock: первый вызов получает lock (True), второй — нет (False).
|
||
|
||
Issue #234: burst N concurrent analyze на один свежетриггеренный квартал
|
||
дедуплицируется через Redis SETNX. Мокаем redis.Redis.from_url.
|
||
"""
|
||
from app.services.site_finder import quarter_dump_lookup as qdl
|
||
|
||
# Mock redis: первый client.set возвращает True, второй — None (key existed).
|
||
calls = {"n": 0}
|
||
|
||
class _MockRedis:
|
||
def set(self, key: str, value: str, *, nx: bool, ex: int) -> bool | None:
|
||
calls["n"] += 1
|
||
return True if calls["n"] == 1 else None
|
||
|
||
monkeypatch.setattr(
|
||
"redis.Redis.from_url",
|
||
lambda _url: _MockRedis(),
|
||
)
|
||
|
||
assert qdl._acquire_harvest_lock("66:41:0204016") is True
|
||
assert qdl._acquire_harvest_lock("66:41:0204016") is False
|
||
assert calls["n"] == 2
|
||
|
||
|
||
def test_acquire_harvest_lock_redis_unavailable_returns_false(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Redis недоступен → _acquire_harvest_lock возвращает False (graceful).
|
||
|
||
Лучше не запустить дубль, чем нагрузить WAF при transient redis-fail.
|
||
"""
|
||
from app.services.site_finder import quarter_dump_lookup as qdl
|
||
|
||
def _boom(_url: str) -> None:
|
||
raise ConnectionError("redis down")
|
||
|
||
monkeypatch.setattr("redis.Redis.from_url", _boom)
|
||
assert qdl._acquire_harvest_lock("66:41:0204016") is False
|
||
|
||
|
||
# ── derive_quarter_cad (smoke tests) ──────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"cad,expected",
|
||
[
|
||
("66:41:0204016", "66:41:0204016"),
|
||
("66:41:0204016:10", "66:41:0204016"),
|
||
("66:41:0204016:10:1", "66:41:0204016"),
|
||
("invalid", None),
|
||
("66:41", None),
|
||
],
|
||
)
|
||
def test_derive_quarter_cad(cad: str, expected: str | None) -> None:
|
||
assert derive_quarter_cad(cad) == expected
|
||
|
||
|
||
# ── #243: cad_zouit fallback when dump absent ─────────────────────────────────
|
||
|
||
|
||
def _make_mock_db_no_dump_with_cad_zouit(
|
||
cad_zouit_rows: list[tuple[Any, ...]],
|
||
) -> MagicMock:
|
||
"""Мок Session: nspd_quarter_dumps.first() → None; cad_zouit.fetchall() → rows."""
|
||
db = MagicMock()
|
||
first_result = MagicMock()
|
||
first_result.first.return_value = None # нет дампа
|
||
|
||
fetchall_result = MagicMock()
|
||
fetchall_result.fetchall.return_value = cad_zouit_rows
|
||
|
||
# Первый вызов execute (SELECT nspd_quarter_dumps) → first()=None
|
||
# Второй вызов execute (SELECT cad_zouit) → fetchall()=rows
|
||
db.execute.side_effect = [first_result, fetchall_result]
|
||
return db
|
||
|
||
|
||
def test_cad_zouit_fallback_fires_when_no_dump() -> None:
|
||
"""#243: cad_zouit fallback срабатывает когда nspd_quarter_dumps пуст глобально.
|
||
|
||
Ожидаем: nspd_zouit_overlaps непустой, source='cad_zouit'.
|
||
"""
|
||
cad_zouit_rows: list[tuple[Any, ...]] = [
|
||
# (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson, coverage_pct)
|
||
(
|
||
"Охранная зона трубопровода",
|
||
"Трубопровод",
|
||
"ГВС ул. Ленина",
|
||
"66-66-00/123",
|
||
42,
|
||
'{"type":"Polygon","coordinates":[[[60,56],[61,56],[61,57],[60,56]]]}',
|
||
0.15,
|
||
),
|
||
]
|
||
db = _make_mock_db_no_dump_with_cad_zouit(cad_zouit_rows)
|
||
|
||
wkt = "POLYGON((60 56,61 56,61 57,60 57,60 56))"
|
||
result = get_quarter_dump_data(db, "66:41:0603016:194", wkt)
|
||
|
||
overlaps: list[dict[str, Any]] = result["nspd_zouit_overlaps"]
|
||
assert len(overlaps) == 1
|
||
assert overlaps[0]["source"] == "cad_zouit"
|
||
assert overlaps[0]["type_zone"] == "Охранная зона трубопровода"
|
||
assert overlaps[0]["name"] == "ГВС ул. Ленина"
|
||
assert overlaps[0]["coverage_pct"] == 0.15
|
||
# dump.available остаётся False (нет дампа)
|
||
assert result["nspd_dump"]["available"] is False
|
||
|
||
|
||
def test_cad_zouit_fallback_no_dump_no_parcel_wkt() -> None:
|
||
"""#243: dump=None + parcel_wkt=None → overlaps пустой, не вызываем cad_zouit."""
|
||
db = MagicMock()
|
||
first_result = MagicMock()
|
||
first_result.first.return_value = None
|
||
db.execute.return_value = first_result
|
||
|
||
result = get_quarter_dump_data(db, "66:41:0603016:194", None)
|
||
|
||
assert result["nspd_zouit_overlaps"] == []
|
||
# cad_zouit SELECT не должен вызываться (только один execute для dump lookup)
|
||
assert db.execute.call_count == 1
|
||
|
||
|
||
def test_cad_zouit_fallback_empty_when_no_overlaps() -> None:
|
||
"""#243: dump=None + cad_zouit возвращает 0 строк → nspd_zouit_overlaps пуст."""
|
||
db = _make_mock_db_no_dump_with_cad_zouit([])
|
||
|
||
wkt = "POLYGON((60 56,61 56,61 57,60 57,60 56))"
|
||
result = get_quarter_dump_data(db, "66:41:0603016:194", wkt)
|
||
|
||
assert result["nspd_zouit_overlaps"] == []
|
||
|
||
|
||
def test_get_cad_zouit_overlaps_returns_correct_schema() -> None:
|
||
"""_get_cad_zouit_overlaps: поля group_key/layer/source/type_zone корректны."""
|
||
rows: list[tuple[Any, ...]] = [
|
||
# (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson, coverage_pct)
|
||
(
|
||
"Охранная зона ЛЭП 110кВ",
|
||
"Электроснабжение",
|
||
"ВЛ-110",
|
||
"66-123",
|
||
7,
|
||
'{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
|
||
0.42,
|
||
),
|
||
("СЗЗ промышленного объекта", "Санитарная", "Завод", "66-456", 8, None, None),
|
||
]
|
||
db = _make_mock_db_with_rows(rows)
|
||
|
||
result = _get_cad_zouit_overlaps(db, "POLYGON((0 0,1 0,1 1,0 0))")
|
||
|
||
assert len(result) == 2
|
||
lep = result[0]
|
||
# #1957: group_key унифицирован на 'protected' (был сырой 'cad_zouit' без RU-метки).
|
||
assert lep["group_key"] == "protected"
|
||
# source остаётся 'cad_zouit' — gate_verdict.py ветвится по нему (keyword-классификация).
|
||
assert lep["source"] == "cad_zouit"
|
||
assert lep["type_zone"] == "Охранная зона ЛЭП 110кВ"
|
||
assert lep["subcategory"] is None # всегда NULL в cad_zouit
|
||
assert lep["name"] == "ВЛ-110"
|
||
# #1957: top-level reg_numb_border — единый shape с nspd-dump path.
|
||
assert lep["reg_numb_border"] == "66-123"
|
||
assert lep["raw_props"]["zouit_id"] == 7
|
||
# area-gate: coverage_pct проброшен (0..1) и нормализован
|
||
assert lep["coverage_pct"] == 0.42
|
||
# #255: geom_geojson проброшен для ZouitLayer (string из ST_AsGeoJSON)
|
||
assert "geom_geojson" in lep
|
||
assert lep["geom_geojson"].startswith('{"type":"Polygon"')
|
||
assert result[1]["geom_geojson"] is None # NULL geom не валит маппинг
|
||
assert result[1]["coverage_pct"] is None # NULL coverage (делитель 0) → None
|
||
|
||
|
||
# ── #1957: NSPD subcategory → human type map + dump-path enrichment ───────────
|
||
|
||
|
||
def test_zouit_subcategory_type_known_codes() -> None:
|
||
"""Известный subcategory-код → (int, RU-тип). #1957 СЗЗ=26 обязателен."""
|
||
assert _zouit_subcategory_type(26) == (26, "Санитарно-защитная зона")
|
||
assert _zouit_subcategory_type("26") == (26, "Санитарно-защитная зона") # str из dump
|
||
assert _zouit_subcategory_type(17) == (17, "Охранная зона инженерных коммуникаций")
|
||
|
||
|
||
def test_zouit_subcategory_type_unknown_and_invalid() -> None:
|
||
"""Неизвестный/невалидный код → тип None (graceful), int нормализован если возможно."""
|
||
assert _zouit_subcategory_type(9999) == (9999, None) # код вне карты
|
||
assert _zouit_subcategory_type("garbage") == (None, None) # не int
|
||
assert _zouit_subcategory_type(None) == (None, None)
|
||
|
||
|
||
def test_dump_zouit_overlap_maps_subcategory_26_to_szz() -> None:
|
||
"""#1957: dump-фича с subcategory=26 (СЗЗ) и БЕЗ name/type_zone больше не blank.
|
||
|
||
type_zone заполняется из карты, reg читается из props.options.reg_numb_border,
|
||
group_key унифицирован ('protected'), name fallback → type_zone.
|
||
"""
|
||
props = {
|
||
"subcategory": 26,
|
||
"descr": "66:41-6.15834",
|
||
"label": "66:41-6.15834",
|
||
"options": {"reg_numb_border": "66:41-6.15834"},
|
||
}
|
||
row = MagicMock()
|
||
row.__getitem__ = lambda self, i: {0: "zouit_protected", 1: props, 2: 0.12}[i]
|
||
|
||
db = MagicMock()
|
||
exec_result = MagicMock()
|
||
exec_result.fetchall.return_value = [row]
|
||
db.execute.return_value = exec_result
|
||
|
||
# layer_counts с zouit_count>0 → идём по dump-path (не cad_zouit fallback).
|
||
overlaps = _get_zouit_overlaps(
|
||
db, "66:41:0205010", "POLYGON((0 0,1 0,1 1,0 0))", {"zouit_count": 3}
|
||
)
|
||
assert len(overlaps) == 1
|
||
o = overlaps[0]
|
||
assert o["group_key"] == "protected"
|
||
assert o["subcategory"] == 26 # нормализован к int
|
||
assert o["type_zone"] == "Санитарно-защитная зона" # из карты (был null → blank)
|
||
assert o["name"] == "Санитарно-защитная зона" # name отсутствовал → fallback на тип
|
||
assert o["reg_numb_border"] == "66:41-6.15834" # из options
|
||
assert o["source"] == "nspd-quarter-dump"
|
||
|
||
|
||
def test_dump_zouit_overlap_unknown_subcategory_graceful() -> None:
|
||
"""Неизвестный subcategory-код → type_zone None, но фактор не теряется."""
|
||
props = {"subcategory": 9999, "options": {"reg_numb_border": "66:41-9.99"}}
|
||
row = MagicMock()
|
||
row.__getitem__ = lambda self, i: {0: "zouit_other", 1: props, 2: None}[i]
|
||
|
||
db = MagicMock()
|
||
exec_result = MagicMock()
|
||
exec_result.fetchall.return_value = [row]
|
||
db.execute.return_value = exec_result
|
||
|
||
overlaps = _get_zouit_overlaps(
|
||
db, "66:41:0205010", "POLYGON((0 0,1 0,1 1,0 0))", {"zouit_count": 1}
|
||
)
|
||
assert len(overlaps) == 1
|
||
assert overlaps[0]["group_key"] == "other"
|
||
assert overlaps[0]["subcategory"] == 9999
|
||
assert overlaps[0]["type_zone"] is None
|
||
assert overlaps[0]["reg_numb_border"] == "66:41-9.99"
|
||
|
||
|
||
def test_cad_zouit_overlaps_distinct_on_in_sql() -> None:
|
||
"""#1957: cad_zouit SELECT использует DISTINCT ON (reg_numb_border) для дедупа.
|
||
|
||
Дубль-строки (одна физ. зона 2× с разным category_name) схлопывает Postgres
|
||
через DISTINCT ON — проверяем, что SQL действительно его содержит (мок не
|
||
выполняет SQL, поэтому верифицируем форму запроса)."""
|
||
db = MagicMock()
|
||
exec_result = MagicMock()
|
||
exec_result.fetchall.return_value = []
|
||
db.execute.return_value = exec_result
|
||
|
||
_get_cad_zouit_overlaps(db, "POLYGON((0 0,1 0,1 1,0 0))")
|
||
|
||
sql_text = str(db.execute.call_args[0][0])
|
||
assert "DISTINCT ON (reg_numb_border)" in sql_text
|
||
assert "ORDER BY reg_numb_border, id" in sql_text
|
||
|
||
|
||
def test_nspd_path_used_when_zouit_count_nonzero() -> None:
|
||
"""#243 backward compat: когда dump существует с zouit_count>0 — NSPD path, не cad_zouit.
|
||
|
||
Мок: row.zouit_count=3, fetchall возвращает NSPD features.
|
||
Проверяем source='nspd-quarter-dump' (не cad_zouit).
|
||
"""
|
||
# Строка дампа: все поля через MagicMock
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
dump_row = MagicMock()
|
||
dump_row.__getitem__ = lambda self, i: [
|
||
"66:41:0603016", # quarter_cad
|
||
# Свежесть ОТНОСИТЕЛЬНО now: был хардкод datetime(2026, 1, 1) → date-bomb
|
||
# (с 2026-06-30 перевалил _DUMP_MAX_AGE_DAYS=180 → stale-ветка → _trigger_harvest
|
||
# → _empty_unavailable → overlaps=[] → assert 0 == 1). now − 1 день всегда свеж.
|
||
datetime.now(UTC) - timedelta(days=1), # fetched_at_utc (свежий)
|
||
100, # total_features
|
||
None, # harvest_error
|
||
1, # territorial_zones_count
|
||
3, # zouit_count > 0 → NSPD path
|
||
0, # engineering_count
|
||
0, # risks_count
|
||
0, # opportunity_count
|
||
0, # red_lines_count
|
||
][i]
|
||
|
||
nspd_zouit_rows: list[tuple[Any, ...]] = [
|
||
# (layer, props, coverage_pct)
|
||
("zouit_engineering", {"name": "ЛЭП", "subcategory": 17}, 0.33),
|
||
]
|
||
|
||
db = MagicMock()
|
||
first_result = MagicMock()
|
||
first_result.first.return_value = dump_row
|
||
|
||
# Последующие вызовы execute (spatial queries) — возвращаем пустые кроме zouit
|
||
zouit_result = MagicMock()
|
||
zouit_result.fetchall.return_value = nspd_zouit_rows
|
||
|
||
empty_result = MagicMock()
|
||
empty_result.fetchall.return_value = []
|
||
empty_result.first.return_value = None
|
||
|
||
# [0]=dumps lookup, [1]=zoning(first), [2]=zouit(fetchall), [3+]=others
|
||
db.execute.side_effect = [
|
||
first_result,
|
||
empty_result, # _get_zoning first()
|
||
zouit_result, # _get_zouit_overlaps fetchall()
|
||
empty_result, # _get_engineering_nearby fetchall()
|
||
empty_result, # _get_risk_zones fetchall()
|
||
empty_result, # _get_opportunity_parcels fetchall()
|
||
empty_result, # _get_red_lines fetchall()
|
||
]
|
||
|
||
result = get_quarter_dump_data(
|
||
db, "66:41:0603016:194", "POLYGON((60 56,61 56,61 57,60 57,60 56))"
|
||
)
|
||
|
||
overlaps: list[dict[str, Any]] = result["nspd_zouit_overlaps"]
|
||
assert len(overlaps) == 1
|
||
assert overlaps[0]["source"] == "nspd-quarter-dump"
|
||
# area-gate: coverage_pct проброшен из SQL (r[2]) и нормализован
|
||
assert overlaps[0]["coverage_pct"] == 0.33
|
||
# dump.available=True т.к. свежий дамп
|
||
assert result["nspd_dump"]["available"] is True
|
||
|
||
|
||
# ── Engineering-structure options.* extraction (cat 36328, network popups) ──────
|
||
|
||
|
||
def test_clean_struct_str_normalizes_and_drops_empty() -> None:
|
||
assert _clean_struct_str(" КЛ 10кВ ") == "КЛ 10кВ"
|
||
assert _clean_struct_str("-") is None
|
||
assert _clean_struct_str("") is None
|
||
assert _clean_struct_str(None) is None
|
||
assert _clean_struct_str(2006) == "2006"
|
||
|
||
|
||
def test_build_structure_characteristics_full() -> None:
|
||
# Год · протяжённость · собственность из options НСПД-дампа.
|
||
opts = {
|
||
"params_year_built": "2006",
|
||
"params_extension": 192,
|
||
"ownership_type": "Частная",
|
||
}
|
||
assert (
|
||
_build_structure_characteristics(opts)
|
||
== "2006 г. · протяжённость 192 м · частная собственность"
|
||
)
|
||
|
||
|
||
def test_build_structure_characteristics_partial_and_empty() -> None:
|
||
# Только год → одна часть; нулевая протяжённость и пустые поля отбрасываются.
|
||
assert _build_structure_characteristics({"params_year_built": "1971"}) == "1971 г."
|
||
assert (
|
||
_build_structure_characteristics(
|
||
{"params_extension": 0, "ownership_type": "-", "params_year_built": ""}
|
||
)
|
||
is None
|
||
)
|
||
assert _build_structure_characteristics({}) is None
|
||
|
||
|
||
# ── #2111: engineering-structure purpose (dedup-vs-type) ───────────────────────
|
||
|
||
|
||
def _eng_structures(opts: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""Прогнать _get_engineering_structures_by_boundary на одной строке с данными opts."""
|
||
geom = '{"type":"Point","coordinates":[0,0]}'
|
||
db = _make_mock_db_with_rows([({"options": opts}, geom, 12.3)])
|
||
items, _total = _get_engineering_structures_by_boundary(
|
||
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", 500
|
||
)
|
||
return items
|
||
|
||
|
||
def test_engineering_purpose_set_when_differs_from_type() -> None:
|
||
# params_name → type; params_purpose отличается → purpose проброшен.
|
||
result = _eng_structures(
|
||
{
|
||
"params_name": "КЛ 10кВ ТП 1077",
|
||
"params_purpose": "1.1. Сооружения электроэнергетики",
|
||
"object_type_value": "Сооружение",
|
||
}
|
||
)
|
||
assert len(result) == 1
|
||
s = result[0]
|
||
# type берётся из params_purpose (первый в fallback-цепочке ptype).
|
||
assert s["type"] == "1.1. Сооружения электроэнергетики"
|
||
# purpose == type → дедуп → None (purpose первым тоже params_purpose).
|
||
assert s["purpose"] is None
|
||
|
||
|
||
def test_engineering_purpose_dedup_when_equal_to_type() -> None:
|
||
# Нет params_name → type = params_purpose; purpose тоже = params_purpose → дедуп None.
|
||
result = _eng_structures({"params_purpose": "Сооружение", "object_type_value": "Сооружение"})
|
||
assert result[0]["type"] == "Сооружение"
|
||
assert result[0]["purpose"] is None
|
||
|
||
|
||
def test_engineering_purpose_from_object_type_value_distinct() -> None:
|
||
# Нет params_purpose → type = object_type_value; purpose тоже object_type_value → дедуп.
|
||
result = _eng_structures({"params_name": "ВЛ-110", "object_type_value": "Сооружение"})
|
||
s = result[0]
|
||
assert s["type"] == "Сооружение" # object_type_value (params_purpose отсутствует)
|
||
assert s["purpose"] is None # purpose == type → дедуп
|
||
|
||
|
||
def test_engineering_purpose_none_when_no_options() -> None:
|
||
result = _eng_structures({})
|
||
assert result[0]["purpose"] is None
|
||
|
||
|
||
# ── Epic #2445 A1: honest total_count vs capped items list ─────────────────────
|
||
|
||
|
||
def _make_mock_db_count_then_rows(total_count: int, rows: list[tuple[Any, ...]]) -> MagicMock:
|
||
"""Мок Session: 1-й execute() → COUNT(*).scalar(), 2-й execute() → .fetchall() capped rows."""
|
||
db = MagicMock()
|
||
count_result = MagicMock()
|
||
count_result.scalar.return_value = total_count
|
||
rows_result = MagicMock()
|
||
rows_result.fetchall.return_value = rows
|
||
db.execute.side_effect = [count_result, rows_result]
|
||
return db
|
||
|
||
|
||
def test_engineering_structures_total_count_exceeds_capped_list() -> None:
|
||
"""total_count (COUNT(*) без LIMIT) > len(items) (LIMIT 50 row-fetch) → structures_truncated."""
|
||
geom = '{"type":"Point","coordinates":[0,0]}'
|
||
# Мок отдаёт только 2 строки (симулирует LIMIT 50 сработавший в БД), но истинный
|
||
# count в радиусе — 137 (COUNT(*) без LIMIT видит больше, чем row-fetch отдал).
|
||
rows = [({"options": {}}, geom, 12.3), ({"options": {}}, geom, 45.6)]
|
||
db = _make_mock_db_count_then_rows(total_count=137, rows=rows)
|
||
|
||
items, total = _get_engineering_structures_by_boundary(
|
||
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", 2000
|
||
)
|
||
|
||
assert len(items) == 2
|
||
assert total == 137
|
||
assert total > len(items)
|
||
|
||
|
||
def test_engineering_structures_not_truncated_when_under_cap() -> None:
|
||
"""total_count == len(items) (нет капа) → честно нет усечения."""
|
||
geom = '{"type":"Point","coordinates":[0,0]}'
|
||
rows = [({"options": {}}, geom, 12.3)]
|
||
db = _make_mock_db_count_then_rows(total_count=1, rows=rows)
|
||
|
||
items, total = _get_engineering_structures_by_boundary(
|
||
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", 500
|
||
)
|
||
|
||
assert len(items) == 1
|
||
assert total == 1
|
||
|
||
|
||
def test_zouit_engineering_overlaps_total_count_exceeds_capped_list() -> None:
|
||
"""total_count (COUNT(*) без LIMIT) > len(items) (LIMIT 100) → protection_zones_truncated."""
|
||
geom = '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}'
|
||
rows = [
|
||
({"reg_numb_border": "RN-001", "subcategory": "17"}, geom),
|
||
({"reg_numb_border": "RN-002", "subcategory": "17"}, geom),
|
||
]
|
||
db = _make_mock_db_count_then_rows(total_count=243, rows=rows)
|
||
|
||
items, total = _get_zouit_engineering_overlaps(
|
||
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))"
|
||
)
|
||
|
||
assert len(items) == 2
|
||
assert total == 243
|
||
assert total > len(items)
|
||
|
||
|
||
def test_zouit_engineering_overlaps_order_by_is_present_in_sql() -> None:
|
||
"""Fix для non-determinism: ORDER BY обязателен перед LIMIT 100 (was missing)."""
|
||
geom = '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}'
|
||
db = _make_mock_db_count_then_rows(total_count=1, rows=[({"reg_numb_border": "RN-1"}, geom)])
|
||
|
||
_get_zouit_engineering_overlaps(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
|
||
|
||
# 2-й вызов execute() — row-fetch (1-й — COUNT). Проверяем что SQL содержит ORDER BY.
|
||
row_fetch_sql = str(db.execute.call_args_list[1][0][0])
|
||
assert "ORDER BY" in row_fetch_sql
|
||
assert "LIMIT" in row_fetch_sql
|
||
|
||
|
||
def test_zouit_engineering_overlaps_query_failure_returns_zero_total() -> None:
|
||
"""DB exception на COUNT/row-fetch → ([], 0), не крашится (existing graceful contract)."""
|
||
from sqlalchemy.exc import OperationalError
|
||
|
||
db = MagicMock()
|
||
db.execute.side_effect = OperationalError("stmt", {}, Exception("boom"))
|
||
|
||
items, total = _get_zouit_engineering_overlaps(
|
||
db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))"
|
||
)
|
||
|
||
assert items == []
|
||
assert total == 0
|