"""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_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)