"""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 → , 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 → вместо плейсхолдера.""" html = "
{{MAP_PARCEL}}
" 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 " None: """png=None → плашка «карта недоступна» (класс map-placeholder).""" html = "
{{MAP_CONCEPT}}
" 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