gendesign/backend/tests/services/test_cadastre_bulk.py

490 lines
17 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.

"""Тесты для bulk_harvest.py и grid_geometry.py.
Использует mock DB (через unittest.mock) и mock NSPDBulkClient.
Не требует реального PostgreSQL или NSPD сети.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot
from app.services.cadastre.bulk_harvest import (
_derive_quarter_cad,
_safe_int,
_safe_numeric,
upsert_features,
)
from app.services.cadastre.grid_geometry import generate_grid_click_points
# ── Фикстуры ─────────────────────────────────────────────────────────────────
def _make_parcel_feature(cad_num: str = "66:41:0303161:1") -> NSPDBulkFeature:
"""Создать NSPDBulkFeature для ЗУ (category_id=36368)."""
return NSPDBulkFeature.model_validate(
{
"id": 1,
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[6090000, 7590000],
[6090100, 7590000],
[6090100, 7590100],
[6090000, 7590100],
[6090000, 7590000],
]
],
},
"properties": {
"categoryId": 36368,
"options": {
"cad_num": cad_num,
"quarter_cad_number": "66:41:0303161",
"land_record_area": 1234.56,
"cost_value": 5000000.0,
"readable_address": "г Екатеринбург, ул Тестовая",
"status": "Учтённый",
"ownership_type": "частная",
},
},
}
)
def _make_building_feature(cad_num: str = "66:41:0303161:1:1") -> NSPDBulkFeature:
"""Создать NSPDBulkFeature для здания (category_id=36369)."""
return NSPDBulkFeature.model_validate(
{
"id": 2,
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[6090000, 7590000],
[6090050, 7590000],
[6090050, 7590050],
[6090000, 7590050],
[6090000, 7590000],
]
],
},
"properties": {
"categoryId": 36369,
"options": {
"cad_num": cad_num,
"quarter_cad_number": "66:41:0303161",
"purpose": "Жилое",
"floors": "9",
"area": 5000.0,
"cost_value": 20000000.0,
"readable_address": "г Екатеринбург, ул Тестовая, д 1",
},
},
}
)
def _make_zouit_feature(reg_numb: str = "66-0.1-1.1-1234") -> NSPDBulkFeature:
"""Создать NSPDBulkFeature для ЗОУИТ (category_id=36940)."""
return NSPDBulkFeature.model_validate(
{
"id": 3,
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[6090000, 7590000],
[6091000, 7590000],
[6091000, 7591000],
[6090000, 7591000],
[6090000, 7590000],
]
]
],
},
"properties": {
"categoryId": 36940,
"categoryName": "ЗОУИТ",
"options": {
"reg_numb_border": reg_numb,
"type_zone": "Охранная зона ЛЭП",
"registration_date": "2020-01-01",
},
},
}
)
# ── Вспомогательные функции ───────────────────────────────────────────────────
def test_derive_quarter_cad_4_segments() -> None:
assert _derive_quarter_cad("66:41:0303161:1") == "66:41:0303161"
def test_derive_quarter_cad_5_segments() -> None:
assert _derive_quarter_cad("66:41:0303161:1:1") == "66:41:0303161"
def test_derive_quarter_cad_3_segments_unchanged() -> None:
assert _derive_quarter_cad("66:41:0303161") == "66:41:0303161"
def test_safe_numeric_valid() -> None:
assert _safe_numeric("1234.56") == pytest.approx(1234.56)
assert _safe_numeric(100) == pytest.approx(100.0)
def test_safe_numeric_none_returns_none() -> None:
assert _safe_numeric(None) is None
def test_safe_numeric_invalid_returns_none() -> None:
assert _safe_numeric("не число") is None
def test_safe_int_valid() -> None:
assert _safe_int("9") == 9
assert _safe_int(5.7) == 5
def test_safe_int_none_returns_none() -> None:
assert _safe_int(None) is None
# ── Grid geometry ────────────────────────────────────────────────────────────
def test_generate_grid_click_points_count() -> None:
"""15×15 grid → 225 точек."""
bbox = (6090000.0, 7590000.0, 6090750.0, 7590750.0)
points = generate_grid_click_points(bbox, grid_size=15)
assert len(points) == 225
def test_generate_grid_click_points_bbox_coverage() -> None:
"""Все sub-bbox покрывают исходный bbox без перекрытий."""
bbox = (0.0, 0.0, 1500.0, 1500.0)
points = generate_grid_click_points(bbox, grid_size=3)
# 3×3 = 9 ячеек
assert len(points) == 9
# Первая ячейка начинается с 0,0
first_bbox, first_click = points[0]
assert first_bbox[0] == pytest.approx(0.0)
assert first_bbox[1] == pytest.approx(0.0)
# click_xy в центре тайла (256, 256) для tile_size=512
assert first_click == (256, 256)
def test_generate_grid_click_points_custom_grid() -> None:
bbox = (0.0, 0.0, 1000.0, 1000.0)
points = generate_grid_click_points(bbox, grid_size=5, tile_size=256)
assert len(points) == 25
# Каждая ячейка имеет правильный размер 200м×200м
cell_bbox, _ = points[0]
assert cell_bbox[2] - cell_bbox[0] == pytest.approx(200.0)
assert cell_bbox[3] - cell_bbox[1] == pytest.approx(200.0)
# ── upsert_features routing ──────────────────────────────────────────────────
def test_upsert_features_routes_parcels_and_buildings() -> None:
"""upsert_features должен вызывать upsert_parcel и upsert_building."""
parcel = _make_parcel_feature()
building = _make_building_feature()
db = MagicMock()
db.execute = MagicMock(return_value=MagicMock())
db.begin_nested = MagicMock(return_value=MagicMock(__enter__=MagicMock(), __exit__=MagicMock()))
with (
patch("app.services.cadastre.bulk_harvest.upsert_parcel") as mock_parcel,
patch("app.services.cadastre.bulk_harvest.upsert_building") as mock_building,
):
counts = upsert_features(db, [parcel, building], source="search")
mock_parcel.assert_called_once_with(db, parcel, source="search")
mock_building.assert_called_once_with(db, building, source="search")
assert counts["parcels"] == 1
assert counts["buildings"] == 1
def test_upsert_features_routes_zouit() -> None:
"""ЗОУИТ (36940) маршрутируется в upsert_zouit."""
zouit = _make_zouit_feature()
db = MagicMock()
with patch("app.services.cadastre.bulk_harvest.upsert_zouit") as mock_zouit:
counts = upsert_features(db, [zouit], source="search")
mock_zouit.assert_called_once()
assert counts["zouit"] == 1
def test_upsert_features_unknown_category_counts_as_skipped() -> None:
"""Неизвестный category_id → skipped, не raise."""
unknown_feature = NSPDBulkFeature.model_validate(
{
"type": "Feature",
"geometry": None,
"properties": {"categoryId": 99999, "options": {}},
}
)
db = MagicMock()
counts = upsert_features(db, [unknown_feature], source="search")
assert counts["skipped"] == 1
def test_upsert_features_quarter_stats_not_counted() -> None:
"""category_id=36381 (quarter stats) — не считается ни в одну таблицу."""
stats_feature = NSPDBulkFeature.model_validate(
{
"type": "Feature",
"geometry": None,
"properties": {"categoryId": 36381, "options": {"cnt_land": 5}},
}
)
db = MagicMock()
counts = upsert_features(db, [stats_feature], source="search")
# Все счётчики должны быть 0, skipped тоже (36381 — особый случай)
assert counts["parcels"] == 0
assert counts["buildings"] == 0
assert counts["skipped"] == 0
# ── upsert_parcel SQL params ─────────────────────────────────────────────────
def test_upsert_parcel_uses_cast_not_double_colon() -> None:
"""Проверить что SQL использует CAST(:x AS type) а не :x::type (SQLAlchemy ловушка).
Используем inspect самого SQL через db.execute calls.
"""
from app.services.cadastre.bulk_harvest import upsert_parcel
db = MagicMock()
executed_sqls: list[str] = []
def capture_execute(stmt: Any, params: Any = None) -> MagicMock:
executed_sqls.append(str(stmt))
return MagicMock()
db.execute = capture_execute
feature = _make_parcel_feature()
upsert_parcel(db, feature, source="search")
assert len(executed_sqls) == 1
sql = executed_sqls[0]
# Проверяем что нет `:x::type` паттерна (только CAST(:x AS ...))
assert "::jsonb" not in sql, "SQL содержит ::jsonb — нужно CAST(:x AS jsonb)"
assert "::date" not in sql, "SQL содержит ::date — нужно CAST(:x AS date)"
assert "CAST(:raw_props AS jsonb)" in sql
# ── harvest_quarter integration (async, mocked) ───────────────────────────────
@pytest.mark.asyncio
async def test_harvest_quarter_phase_done_early_return() -> None:
"""Если phase_state='done' — harvest_quarter возвращается ранним выходом."""
from app.services.cadastre.bulk_harvest import harvest_quarter
db = MagicMock()
db.execute = MagicMock(
return_value=MagicMock(
mappings=MagicMock(
return_value=MagicMock(
first=MagicMock(return_value={"phase_state": {"phase": "done"}})
)
)
)
)
client = AsyncMock()
progress_calls: list[Any] = []
await harvest_quarter(
db=db,
client=client,
quarter="66:41:0303161",
job_id=1,
update_progress=lambda s: progress_calls.append(s),
)
# client.search_by_quarter не должен вызываться
client.search_by_quarter.assert_not_called()
# Нет progress calls
assert len(progress_calls) == 0
@pytest.mark.asyncio
async def test_harvest_quarter_calls_upsert_features() -> None:
"""harvest_quarter Phase 1: search_by_quarter → upsert_features."""
from app.services.cadastre.bulk_harvest import harvest_quarter
parcel = _make_parcel_feature()
snapshot = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T10:00:00+00:00",
features=[parcel],
meta_counts={36368: 5, 36369: 3}, # нет overflow
)
db = MagicMock()
# phase_state = None → начинаем с нуля
db.execute = MagicMock(
return_value=MagicMock(
mappings=MagicMock(
return_value=MagicMock(first=MagicMock(return_value={"phase_state": None}))
)
)
)
db.commit = MagicMock()
client = AsyncMock()
client.search_by_quarter = AsyncMock(return_value=snapshot)
progress_states: list[dict[str, Any]] = []
with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert:
mock_upsert.return_value = {
"parcels": 1,
"buildings": 0,
"constructions": 0,
"oncs": 0,
"enks": 0,
"zouit": 0,
"skipped": 0,
}
result = await harvest_quarter(
db=db,
client=client,
quarter="66:41:0303161",
job_id=1,
update_progress=lambda s: progress_states.append(s),
)
client.search_by_quarter.assert_awaited_once_with("66:41:0303161")
mock_upsert.assert_called()
assert result.parcels_upserted == 1
# Проверить что последний progress = done
assert progress_states[-1]["phase"] == "done"
# ── Dedup тест для grid-walk ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_grid_walk_dedup_same_cad_num() -> None:
"""Если wms_feature_info возвращает одинаковый cad_num дважды — 1 upsert."""
from app.services.cadastre.bulk_harvest import _grid_walk_category
parcel = _make_parcel_feature("66:41:0303161:1")
# Одинаковый feature для каждой grid-точки
db = MagicMock()
db.execute = MagicMock(
return_value=MagicMock(
mappings=MagicMock(
return_value=MagicMock(
first=MagicMock(
return_value={
"xmin": 6090000.0,
"ymin": 7590000.0,
"xmax": 6090750.0,
"ymax": 7590750.0,
}
)
)
)
)
)
db.begin_nested = MagicMock(
return_value=MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock())
)
db.commit = MagicMock()
client = AsyncMock()
# Каждый wms_feature_info вызов возвращает один и тот же парцел
client.wms_feature_info = AsyncMock(return_value=[parcel])
with patch("app.services.cadastre.bulk_harvest.quarter_bbox_3857") as mock_bbox:
mock_bbox.return_value = (6090000.0, 7590000.0, 6090750.0, 7590750.0)
with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert:
mock_upsert.return_value = {
"parcels": 1,
"buildings": 0,
"constructions": 0,
"oncs": 0,
"enks": 0,
"zouit": 0,
"skipped": 0,
}
# Используем grid_size=2 для скорости теста (4 ячейки)
upserted, requests = await _grid_walk_category(
db=db,
client=client,
quarter="66:41:0303161",
layer_id=36368,
grid_size=2,
)
# Несмотря на 4 запроса, upsert_features вызывается 1 раз (дедуп по cad_num)
assert mock_upsert.call_count == 1
assert upserted == 1
assert requests == 4 # 2×2 grid
# ── ЗОУИТ dedup по (reg_numb_border, category_id) ───────────────────────────
def test_zouit_dedup_different_category_ids() -> None:
"""Один reg_numb_border с разными category_id → 2 записи в upsert."""
zouit_1 = NSPDBulkFeature.model_validate(
{
"type": "Feature",
"geometry": None,
"properties": {
"categoryId": 36940,
"categoryName": "ЗОУИТ 1",
"options": {"reg_numb_border": "66-0.1-1.1-1234", "type_zone": "тип А"},
},
}
)
zouit_2 = NSPDBulkFeature.model_validate(
{
"type": "Feature",
"geometry": None,
"properties": {
"categoryId": 469039,
"categoryName": "ЗОУИТ 2",
"options": {"reg_numb_border": "66-0.1-1.1-1234", "type_zone": "тип Б"},
},
}
)
db = MagicMock()
calls: list[Any] = []
db.execute = MagicMock(
side_effect=lambda stmt, params=None: calls.append(params) or MagicMock()
)
with patch("app.services.cadastre.bulk_harvest.upsert_zouit") as mock_zouit:
counts = upsert_features(db, [zouit_1, zouit_2], source="search")
# Обе записи маршрутируются в upsert_zouit
assert mock_zouit.call_count == 2
assert counts["zouit"] == 2