gendesign/backend/tests/services/test_quarter_dump_lookup.py
lekss361 1595157ebf feat(nspd): enable TIER 3 risk zones harvest + extract in analyze (#94 PR1)
- harvest_stale_quarters fanout passes include_risks=True to all queued tasks
- _get_risk_zones() with ST_Intersects + ST_Area intersection calc
- _extract_features_by_layer() generic helper for future PR2+ reuse
- RiskZone Pydantic schema; /analyze returns nspd_risk_zones list
- NspdRiskZonesBlock component with severity color-coding (red/yellow/orange)
- 11 unit tests for extract/intersect/null-area/db-exception paths

Part of #94 (TIER 3 risk layers — flooding, landslide, burns, erosion etc)
2026-05-16 16:11:59 +03:00

200 lines
7.2 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.

"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction.
Покрывает:
- _extract_features_by_layer: filter by prefix
- _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect,
no-risks-in-dump
Не требует реального 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 (
_extract_features_by_layer,
_get_risk_zones,
derive_quarter_cad,
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."""
db = MagicMock()
mock_result = MagicMock()
mock_result.fetchall.return_value = 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"] == []
# ── 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