gendesign/backend/tests/services/exporters/test_report_maps.py
bot-backend 0b3ec7f91b
All checks were successful
Deploy / deploy (push) Successful in 1m59s
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 3m47s
Deploy / build-worker (push) Successful in 4m45s
feat(exporters): серверный статик-рендер карт для PDF-отчёта (#2259 PR-C Maps) (#2286)
2026-07-03 10:29:23 +00:00

272 lines
12 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.

"""Unit-тесты серверного рендера карт отчёта — `report_maps` (эпик #2259 PR-C).
Чистые тесты БЕЗ сети: OSM basemap мокается на каждом уровне (contextily.add_basemap
и/или сам `_add_basemap`), чтобы ни один кейс не тянул тайлы. Проверяем:
• синтетика (участок + риск-зона/ЗОУИТ/красная линия/конкуренты) → валидный PNG (PIL verify);
• basemap-no-op И basemap-exception → PNG всё равно рендерится (graceful fallback);
• нет `geom_geojson` → None (нечего рисовать);
• embed обе ветки (png → <img data-uri>, None → плашка «карта недоступна»);
• концепция: 2 корпуса с подписью этажности → валидный PNG;
• битая геометрия отдельного слоя пропускается, не валит всю карту;
• неожиданное исключение в середине рендера НЕ течёт фигурой (get_fignums без изменений).
Детерминированно, headless (Agg). DATABASE_URL/TESTING выставлены в tests/conftest.py.
"""
from __future__ import annotations
import io
from typing import Any
from unittest import mock
import pytest
from PIL import Image
from app.services.exporters import report_maps as rm
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
# ── Синтетика (окрестность ЕКБ, EPSG:4326) ─────────────────────────────────────
def _parcel_geojson() -> dict[str, Any]:
return {
"type": "Polygon",
"coordinates": [
[
[60.600, 56.830],
[60.602, 56.830],
[60.602, 56.831],
[60.600, 56.831],
[60.600, 56.830],
]
],
}
def _full_result() -> dict[str, Any]:
return {
"geom_geojson": _parcel_geojson(),
"nspd_risk_zones": [
{
"geom_wkt": (
"POLYGON((60.6005 56.8305,60.6015 56.8305,"
"60.6015 56.8312,60.6005 56.8312,60.6005 56.8305))"
)
}
],
"nspd_red_lines": [{"geom_wkt": "LINESTRING(60.600 56.8305, 60.602 56.8305)"}],
"nspd_opportunity_parcels": [
{
"geom_wkt": (
"POLYGON((60.6018 56.8302,60.6022 56.8302,"
"60.6022 56.8308,60.6018 56.8308,60.6018 56.8302))"
)
}
],
"nspd_zouit_overlaps": [
{
"geometry_geojson": {
"type": "Polygon",
"coordinates": [
[
[60.6008, 56.8302],
[60.6018, 56.8302],
[60.6018, 56.8309],
[60.6008, 56.8309],
[60.6008, 56.8302],
]
],
}
}
],
"competitors": [
{"lat": 56.8308, "lng": 60.6012},
{"lat": 56.8300, "lng": 60.6001},
],
}
def _concept_variant(*, floors: tuple[int, int] = (9, 16)) -> dict[str, Any]:
def _sq(x0: float, y0: float) -> dict[str, Any]:
return {
"type": "Polygon",
"coordinates": [
[
[x0, y0],
[x0 + 0.0004, y0],
[x0 + 0.0004, y0 + 0.0004],
[x0, y0 + 0.0004],
[x0, y0],
]
],
}
return {
"buildings_geojson": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": _sq(60.6002, 56.8302),
"properties": {"section_id": 1, "floors": floors[0], "footprint_sqm": 900.0},
},
{
"type": "Feature",
"geometry": _sq(60.6010, 56.8302),
"properties": {"section_id": 2, "floors": floors[1], "footprint_sqm": 1200.0},
},
],
}
}
def _assert_valid_png(png: bytes | None) -> tuple[int, int]:
assert png is not None
assert png[:8] == _PNG_MAGIC
img = Image.open(io.BytesIO(png))
img.verify() # структурная валидация PNG
return img.size
# ── Кейс 1: полная синтетика + basemap no-op → валидный PNG ─────────────────────
def test_render_parcel_map_all_layers_basemap_noop() -> None:
"""Участок + все слои НСПД + конкуренты, contextily.add_basemap замокан no-op'ом."""
with mock.patch("contextily.add_basemap") as add_basemap:
png = rm.render_parcel_map_png(_full_result(), max_px=1200)
add_basemap.assert_called_once() # basemap пытались подложить (no-op мок)
w, h = _assert_valid_png(png)
assert w > 0 and h > 0
# ── Кейс 2: basemap кидает исключение → graceful fallback, PNG всё равно есть ────
def test_render_parcel_map_basemap_exception_fallback() -> None:
"""Тайлы недоступны (add_basemap raise) → белый фон + слои, PNG рендерится."""
with mock.patch("contextily.add_basemap", side_effect=RuntimeError("tiles down")):
png = rm.render_parcel_map_png(_full_result(), max_px=1000)
_assert_valid_png(png)
# ── Кейс 3: нет geom_geojson → None ─────────────────────────────────────────────
def test_render_parcel_map_no_geom_returns_none() -> None:
"""Пустой payload и payload без geom_geojson → None (нечего рисовать)."""
assert rm.render_parcel_map_png({}) is None
assert rm.render_parcel_map_png({"competitors": [{"lat": 56.8, "lng": 60.6}]}) is None
# битый geom_geojson → тоже None (shape() падает, _geom_from_geojson вернёт None)
assert rm.render_parcel_map_png({"geom_geojson": {"type": "Nonsense"}}) is None
# ── Кейс 4: битая геометрия отдельного слоя не валит карту ──────────────────────
def test_render_parcel_map_broken_layer_geom_survives() -> None:
"""Мусорный geom_wkt в риск-зоне пропускается, участок всё равно рендерится."""
result = _full_result()
result["nspd_risk_zones"] = [{"geom_wkt": "NOT A WKT"}, {"geom_wkt": None}]
with mock.patch("contextily.add_basemap"):
png = rm.render_parcel_map_png(result, max_px=900)
_assert_valid_png(png)
# ── Кейс 5: концепция — 2 корпуса с подписью этажности → валидный PNG ───────────
def test_render_concept_footprint_two_buildings() -> None:
"""buildings_geojson с 2 корпусами + участок-контур → валидный PNG."""
with mock.patch("contextily.add_basemap"):
png = rm.render_concept_footprint_png(_concept_variant(), _parcel_geojson(), max_px=1200)
_assert_valid_png(png)
def test_render_concept_footprint_no_parcel_uses_footprints_bbox() -> None:
"""parcel_geojson=None → bbox по корпусам, PNG всё равно рендерится."""
with mock.patch("contextily.add_basemap"):
png = rm.render_concept_footprint_png(_concept_variant(), None, max_px=800)
_assert_valid_png(png)
def test_render_concept_footprint_empty_returns_none() -> None:
"""Ни участка, ни корпусов → None."""
assert rm.render_concept_footprint_png({"buildings_geojson": {"features": []}}, None) is None
assert rm.render_concept_footprint_png({}, None) is None
# ── Кейс 6: embed обе ветки ─────────────────────────────────────────────────────
def test_embed_map_png_with_png_produces_data_uri() -> None:
"""png != None → <img src=data:image/png;base64,…> вместо плейсхолдера."""
html = "<section>{{MAP_PARCEL}}</section>"
out = rm.embed_map_png(html, "{{MAP_PARCEL}}", b"\x89PNG\r\n\x1a\nFAKE")
assert "{{MAP_PARCEL}}" not in out
assert "data:image/png;base64," in out
assert "<img" in out
def test_embed_map_png_none_produces_unavailable_placeholder() -> None:
"""png=None → плашка «карта недоступна» (класс map-placeholder)."""
html = "<section>{{MAP_CONCEPT}}</section>"
out = rm.embed_map_png(html, "{{MAP_CONCEPT}}", None)
assert "{{MAP_CONCEPT}}" not in out
assert "map-placeholder" in out
assert "недоступна" in out
# ── Кейс 7: basemap timeout → fallback (bounded, без реальной сети) ─────────────
def test_add_basemap_timeout_returns_false() -> None:
"""add_basemap висит дольше таймаута → _add_basemap возвращает False (fallback)."""
import time
def _slow(*_a: Any, **_k: Any) -> None:
time.sleep(5.0)
ax = object() # _add_basemap не трогает ax до вызова cx.add_basemap
with (
mock.patch("contextily.add_basemap", side_effect=_slow),
mock.patch.object(rm, "_BASEMAP_TIMEOUT_S", 0.1),
):
assert rm._add_basemap(ax) is False
def test_add_basemap_success_returns_true() -> None:
"""add_basemap отработал в срок → True (тайлы легли)."""
ax = object()
with mock.patch("contextily.add_basemap") as add_basemap:
assert rm._add_basemap(ax) is True
add_basemap.assert_called_once()
# ── max_px реально ограничивает размер PNG ─────────────────────────────────────
@pytest.mark.parametrize("max_px", [600, 1600])
def test_render_parcel_map_respects_max_px(max_px: int) -> None:
"""Длинная сторона PNG не превышает max_px (dpi подбирается под потолок)."""
with mock.patch("contextily.add_basemap"):
png = rm.render_parcel_map_png(_full_result(), max_px=max_px)
w, h = _assert_valid_png(png)
# tight-bbox может слегка ужать; проверяем именно верхнюю границу.
assert max(w, h) <= max_px + 2
# ── Регресс: неожиданное исключение НЕ течёт фигурой (residual-окно pyplot) ─────
# Инжектим raise в _apply_bbox — вызывается в ОБЕИХ функциях ПОСЛЕ создания фигуры
# (plt.figure уже отработал). Без try/finally в _managed_figure упавший рендер оставил
# бы фигуру в глобальном реестре pyplot (get_fignums 0→1) → рост RSS в Celery-воркере.
def test_render_parcel_map_no_figure_leak_on_exception() -> None:
"""Raise в середине parcel-рендера → фигура закрыта, get_fignums без изменений."""
import matplotlib.pyplot as plt
before = set(plt.get_fignums())
with (
mock.patch("contextily.add_basemap"),
mock.patch.object(rm, "_apply_bbox", side_effect=RuntimeError("boom mid-render")),
pytest.raises(RuntimeError, match="boom mid-render"),
):
rm.render_parcel_map_png(_full_result(), max_px=800)
assert set(plt.get_fignums()) == before # residual-окно закрыто finally
def test_render_concept_footprint_no_figure_leak_on_exception() -> None:
"""Raise в середине concept-рендера → фигура закрыта, get_fignums без изменений."""
import matplotlib.pyplot as plt
before = set(plt.get_fignums())
with (
mock.patch("contextily.add_basemap"),
mock.patch.object(rm, "_apply_bbox", side_effect=RuntimeError("boom mid-render")),
pytest.raises(RuntimeError, match="boom mid-render"),
):
rm.render_concept_footprint_png(_concept_variant(), _parcel_geojson(), max_px=800)
assert set(plt.get_fignums()) == before # residual-окно закрыто finally