272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""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
|