"""Stage 1a tests — parcel parsing, setback buffer, placement grid. Deterministic geometry: a known WGS84 rectangle near ЕКБ is parsed into metres, the setback shrinks it, and the grid covers the buildable area. No network / no DB. """ from __future__ import annotations import math import pytest from shapely.geometry import Polygon from app.schemas.concept import ConceptInput from app.services.generative import geometry from app.services.generative.geometry import ParcelGeometryError, build_placement_grid # ~450 m x 310 m rectangle near Екатеринбург (WGS84). Area ~ 0.86 ha. _PARCEL_COORDS = [ [60.60, 56.830], [60.6045, 56.830], [60.6045, 56.8328], [60.60, 56.8328], [60.60, 56.830], ] def _payload(**overrides: object) -> ConceptInput: base: dict[str, object] = { "parcel_geojson": {"type": "Polygon", "coordinates": [_PARCEL_COORDS]}, "housing_class": "comfort", "target_floors": 9, "development_type": "mid_rise", } base.update(overrides) return ConceptInput(**base) # type: ignore[arg-type] def test_parse_parcel_reprojects_to_metres() -> None: parcel = geometry.parse_parcel(_payload()) # Площадь в кв.м должна быть на масштабе квартала (десятки тысяч кв.м), не градусов. assert 50_000 < parcel.site_area_sqm < 150_000 # Buildable меньше участка ровно за счёт отступа. assert parcel.buildable_area_sqm < parcel.site_area_sqm assert parcel.setback_m == geometry.DEFAULT_SETBACK_M def test_setback_shrinks_area_by_expected_band() -> None: setback = 6.0 parcel = geometry.parse_parcel(_payload(), setback_m=setback) # Грубая нижняя граница убыли: периметр * setback (внутренний буфер). perimeter = parcel.polygon_m.length expected_loss = perimeter * setback actual_loss = parcel.site_area_sqm - parcel.buildable_area_sqm # Внутренний буфер срезает примерно полосу шириной setback по периметру (±50%). assert 0.5 * expected_loss < actual_loss < 1.5 * expected_loss def test_grid_cells_lie_inside_buildable() -> None: parcel = geometry.parse_parcel(_payload(), grid_step_m=6.0) assert len(parcel.grid) > 0 for cell in parcel.grid: assert parcel.buildable_m.covers(cell.as_polygon().centroid) def test_parse_is_deterministic() -> None: a = geometry.parse_parcel(_payload()) b = geometry.parse_parcel(_payload()) assert a.site_area_sqm == b.site_area_sqm assert a.buildable_area_sqm == b.buildable_area_sqm assert len(a.grid) == len(b.grid) assert [(c.cx, c.cy) for c in a.grid] == [(c.cx, c.cy) for c in b.grid] def test_feature_geojson_is_accepted() -> None: # GeoJSON Feature (а не голая geometry) тоже парсится. payload = _payload( parcel_geojson={ "type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [_PARCEL_COORDS]}, } ) parcel = geometry.parse_parcel(payload) assert parcel.site_area_sqm > 0 def test_tiny_parcel_rejected_after_setback() -> None: # ~16 m x 16 m: отступ 6 м с каждой стороны схлопывает пятно застройки. tiny = [ [60.60, 56.830], [60.60015, 56.830], [60.60015, 56.83015], [60.60, 56.83015], [60.60, 56.830], ] payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [tiny]}) with pytest.raises(ParcelGeometryError): geometry.parse_parcel(payload) def test_non_polygon_rejected() -> None: payload = _payload(parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]}) with pytest.raises(ParcelGeometryError): geometry.parse_parcel(payload) def test_linestring_rejected() -> None: # LineString не имеет площади — прежняя ошибка сохраняется. payload = _payload( parcel_geojson={ "type": "LineString", "coordinates": [[60.60, 56.830], [60.6045, 56.8328]], } ) with pytest.raises(ParcelGeometryError): geometry.parse_parcel(payload) def test_projected_coordinates_rejected_with_clear_message() -> None: # Регресс эпик #2445 B1: координаты в проекции (МСК-66/UTM-like метры, напр. # [500000, 6200000]) вместо WGS84 lon/lat — синтаксически валидный Polygon, но # centroid.y (~6_200_000) далеко за пределами ±90° -> раньше падало неперехваченным # pyproj.exceptions.CRSError внутри _metric_transformers (opaque 500). Теперь # ловится ДО построения проекции с понятным ParcelGeometryError (-> 422). projected = [ [500_000.0, 6_200_000.0], [500_450.0, 6_200_000.0], [500_450.0, 6_200_310.0], [500_000.0, 6_200_310.0], [500_000.0, 6_200_000.0], ] payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [projected]}) with pytest.raises(ParcelGeometryError, match="WGS84"): geometry.parse_parcel(payload) # Второй, заведомо меньший контур (~90 m x 45 m) поодаль от основного участка. _SMALL_CONTOUR = [ [60.610, 56.840], [60.6112, 56.840], [60.6112, 56.8404], [60.610, 56.8404], [60.610, 56.840], ] def test_multipolygon_picks_largest_contour() -> None: # Многоконтурный участок: основной прямоугольник + мелкое вкрапление поодаль. # Берётся крупнейший контур → площадь == площади одиночного основного полигона. multi = _payload( parcel_geojson={ "type": "MultiPolygon", "coordinates": [[_PARCEL_COORDS], [_SMALL_CONTOUR]], } ) single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]}) parcel_multi = geometry.parse_parcel(multi) parcel_single = geometry.parse_parcel(single) # Результат многоконтурного == основному контуру (крупнейший выбран). assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-6) # Крупнейший контур на масштабе квартала, мелкое вкрапление отброшено. assert parcel_multi.site_area_sqm > 50_000 def test_multipolygon_single_contour_equals_polygon() -> None: # MultiPolygon с одним контуром эквивалентен голому Polygon. multi = _payload( parcel_geojson={ "type": "MultiPolygon", "coordinates": [[_PARCEL_COORDS]], } ) single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]}) parcel_multi = geometry.parse_parcel(multi) parcel_single = geometry.parse_parcel(single) assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-9) assert math.isclose( parcel_multi.buildable_area_sqm, parcel_single.buildable_area_sqm, rel_tol=1e-9, ) def test_empty_multipolygon_rejected() -> None: payload = _payload(parcel_geojson={"type": "MultiPolygon", "coordinates": []}) with pytest.raises(ParcelGeometryError): geometry.parse_parcel(payload) def test_build_placement_grid_anchors_are_step_aligned() -> None: # Простой метрический квадрат 30x30 м, шаг 10 -> 3x3 = 9 ячеек. square = Polygon([(0, 0), (30, 0), (30, 30), (0, 30)]) cells = build_placement_grid(square, 10.0) assert len(cells) == 9 # Центры на полушаге от кратных шагу. for cell in cells: assert math.isclose((cell.cx - 5.0) % 10.0, 0.0, abs_tol=1e-6) assert math.isclose((cell.cy - 5.0) % 10.0, 0.0, abs_tol=1e-6)