gendesign/backend/tests/api/v1/test_parcel_neighbor_buildings.py
Light1YT 964eb9008d feat(concept): 3D-макет окружения — соседние здания из кадастра в §7 (#2180)
Backend: GET /parcels/{cad}/neighbor-buildings?radius_m=300 → GeoJSON
footprint'ов cad_buildings вокруг участка (ST_DWithin geography, GIST,
EXPLAIN 28мс на плотном участке; NOT ST_Intersects с самим участком;
SimplifyPreserveTopology; LIMIT 300 по расстоянию + truncated; 404 только
при отсутствии геометрии участка). Свойства: floors, height_m (этаж 3 м),
building_name, year_built.

Frontend: hook useNeighborBuildings (TanStack) + серые низкополигональные
экструзии в Massing3DScene (0.55 opacity, fallback 6 м, cap 200, отдельная
Group с корректным dispose) + неинтерактивный слой в 2D-плане под
корпусами; cadNum прокинут из Section7Concept; standalone /concept без
кадастра работает как раньше. Подпись про НСПД-этажность при count>0.
2026-07-03 00:17:23 +05:00

244 lines
9.1 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.

"""Тесты GET /{cad_num}/neighbor-buildings (Forgejo #2180).
Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены §7 «Размещение
застройки»: GeoJSON FeatureCollection упрощённых полигонов cad_buildings вокруг
участка.
Механика моков: get_db переопределяем через app.dependency_overrides (FastAPI
Depends держит оригинальную ссылку — unittest.mock.patch неэффективен). Хендлер
делает один основной `db.execute(...).mappings().all()` (соседи), и ТОЛЬКО при
пустом результате — второй `db.execute(...).first()` (EXISTS-пробник участка,
404 vs пустой 200). Мок покрывает обе формы. Очистку dependency_overrides делает
autouse-фикстура в tests/conftest.py. RBAC-гейт в test-mode (settings.testing)
байпасится — auth-заголовок не нужен.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.core.db import get_db
from app.main import app
_VALID_CAD = "66:41:0204016:10"
# Валидный GeoJSON-полигон footprint'а — как отдаёт ST_AsGeoJSON (строка).
_POLYGON_GEOJSON = json.dumps(
{
"type": "Polygon",
"coordinates": [
[
[60.640, 56.830],
[60.641, 56.830],
[60.641, 56.831],
[60.640, 56.831],
[60.640, 56.830],
]
],
}
)
def _make_building_row(
cad_num: str = "66:41:0204016:100",
floors: int | None = 9,
building_name: str | None = "Жилой дом",
year_built: int | None = 1998,
distance_m: float = 42.0,
geojson: str | None = _POLYGON_GEOJSON,
) -> dict[str, Any]:
"""Одна строка результата _NEIGHBOR_BUILDINGS_SQL (mappings())."""
return {
"cad_num": cad_num,
"floors": floors,
"building_name": building_name,
"year_built": year_built,
"geojson": geojson,
"distance_m": distance_m,
}
def _build_mock_db(
rows: list[dict[str, Any]],
*,
parcel_exists: bool = True,
) -> MagicMock:
"""Mock Session.
Основной запрос соседей → .mappings().all() = rows.
Fallback EXISTS-пробник (вызывается лишь при пустом rows) → .first() =
(1,) если parcel_exists иначе None.
"""
mock_db = MagicMock()
mappings_mock = MagicMock()
mappings_mock.all.return_value = rows
execute_result = MagicMock()
execute_result.mappings.return_value = mappings_mock
# .first() дёргается только на EXISTS-пробнике (тот же execute_result — ок,
# т.к. пробник запускается только когда rows пуст).
execute_result.first.return_value = (1,) if parcel_exists else None
mock_db.execute.return_value = execute_result
return mock_db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
@pytest.fixture()
def client() -> TestClient:
return TestClient(app)
# ── happy path: фичи + floors → height_m ──────────────────────────────────
def test_neighbor_buildings_happy_path(client: TestClient) -> None:
rows = [
_make_building_row("66:41:0204016:100", floors=9, distance_m=30.0),
_make_building_row("66:41:0204016:101", floors=16, distance_m=85.0),
_make_building_row(
"66:41:0204016:102",
floors=None, # floors неизвестны → height_m null
building_name="", # пустое имя → null
year_built=None,
distance_m=120.0,
),
]
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
assert r.status_code == 200, r.text
body = r.json()
assert body["type"] == "FeatureCollection"
assert body["count"] == 3
assert body["radius_m"] == 300
assert body["truncated"] is False
assert len(body["features"]) == 3
f0 = body["features"][0]
assert f0["type"] == "Feature"
assert f0["geometry"]["type"] == "Polygon" # распарсен из строки, не escaped
props0 = f0["properties"]
assert props0["cad_num"] == "66:41:0204016:100"
assert props0["floors"] == 9
assert props0["height_m"] == pytest.approx(27.0) # 9 × 3.0
assert props0["building_name"] == "Жилой дом"
assert props0["year_built"] == 1998
assert props0["is_neighbor"] is True
# floors=16 → height_m 48.0
assert body["features"][1]["properties"]["height_m"] == pytest.approx(48.0)
# floors=None → height_m null, building_name пустой → null, year_built null
props2 = body["features"][2]["properties"]
assert props2["floors"] is None
assert props2["height_m"] is None
assert props2["building_name"] is None
assert props2["year_built"] is None
assert props2["is_neighbor"] is True
# ── пустой результат (участок есть, соседей нет) → 200 empty ───────────────
def test_neighbor_buildings_empty_ok(client: TestClient) -> None:
"""Участок в БД есть (EXISTS=true), но соседей в радиусе нет → 200 пустой."""
app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=True))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
assert r.status_code == 200, r.text
body = r.json()
assert body["type"] == "FeatureCollection"
assert body["features"] == []
assert body["count"] == 0
assert body["truncated"] is False
# ── участок не найден (нет geom) → 404 ─────────────────────────────────────
def test_neighbor_buildings_parcel_not_found_404(client: TestClient) -> None:
"""Пустой результат + EXISTS-пробник вернул None (нет geom) → 404."""
app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=False))
r = client.get("/api/v1/parcels/66:41:9999999:1/neighbor-buildings")
assert r.status_code == 404
assert "не найдена" in r.json()["detail"]
# ── truncated: >300 соседей → флаг + обрезка до 300 ────────────────────────
def test_neighbor_buildings_truncated(client: TestClient) -> None:
"""SQL LIMIT 301 → если пришёл 301, отдаём 300 + truncated=true."""
rows = [
_make_building_row(f"66:41:0204016:{i}", floors=(i % 20) + 1, distance_m=float(i))
for i in range(301)
]
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
assert r.status_code == 200, r.text
body = r.json()
assert body["count"] == 300
assert len(body["features"]) == 300
assert body["truncated"] is True
# ── radius валидация → 422 ─────────────────────────────────────────────────
def test_neighbor_buildings_radius_too_small_422(client: TestClient) -> None:
"""radius_m=10 (< min 50) → 422."""
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=10")
assert r.status_code == 422
def test_neighbor_buildings_radius_too_large_422(client: TestClient) -> None:
"""radius_m=2000 (> max 1000) → 422."""
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=2000")
assert r.status_code == 422
def test_neighbor_buildings_radius_passthrough(client: TestClient) -> None:
"""radius_m из query отражается в ответе (и внутри — в bind SQL)."""
app.dependency_overrides[get_db] = _override_db(_build_mock_db([_make_building_row()]))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=500")
assert r.status_code == 200, r.text
assert r.json()["radius_m"] == 500
# ── битая геометрия (ST_AsGeoJSON NULL) пропускается, не роняет сцену ───────
def test_neighbor_buildings_skips_null_geometry(client: TestClient) -> None:
rows = [
_make_building_row("66:41:0204016:100", geojson=None), # битый polygon
_make_building_row("66:41:0204016:101", geojson=_POLYGON_GEOJSON),
]
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings")
assert r.status_code == 200, r.text
body = r.json()
assert body["count"] == 1
assert body["features"][0]["properties"]["cad_num"] == "66:41:0204016:101"