"""Stage 3a tests (#1965) — program-driven placement (типовые дома каталога). Covers the three core guarantees of the ``building_program`` path: (a) ``building_program=None`` reproduces today's greedy output unchanged (backward-compat). (b) A 2-item program places EXACTLY the requested sections (when they fit) and the TEAP reflects them (GFA = Σ(площадь_i × floors_i), apartments tied to placed footprints). (c) An over-packed program reports partial placement (placed < requested) WITHOUT raising. Plus structural checks: program footprints respect the collision/setback machinery, the per-feature GeoJSON carries the section's own floors + catalog type, and an empty-fit program is rejected (degenerate parcel) rather than returning a lying zero variant. """ from __future__ import annotations import pytest from shapely.geometry import shape from app.schemas.concept import BuildingProgramItem, ConceptInput from app.services.generative import catalog, geometry, placement from app.services.generative.geometry import ParcelGeometryError # Крупный квартальный участок (~7.9 га buildable) — вмещает десятки секций. _BIG_PARCEL = [ [60.60, 56.830], [60.6045, 56.830], [60.6045, 56.8328], [60.60, 56.8328], [60.60, 56.830], ] # Маленький участок (~3.8 тыс. кв.м buildable) — вмещает лишь ~2 крупные секции. _SMALL_PARCEL = [ [60.600, 56.8300], [60.6010, 56.8300], [60.6010, 56.8308], [60.600, 56.8308], [60.600, 56.8300], ] def _payload(coords: list[list[float]], **overrides: object) -> ConceptInput: base: dict[str, object] = { "parcel_geojson": {"type": "Polygon", "coordinates": [coords]}, "housing_class": "comfort", "target_floors": 9, "development_type": "mid_rise", } base.update(overrides) return ConceptInput(**base) # type: ignore[arg-type] # ── (a) backward-compat: building_program=None reproduces greedy output ───────────── def test_no_program_reproduces_greedy_output_unchanged() -> None: # Тот же участок: с building_program=None раскладка ДОЛЖНА совпасть с жадной 1b # (три стратегии, те же ТЭП/число секций) — program-ветка не трогает greedy-путь. payload_default = _payload(_BIG_PARCEL) payload_none = _payload(_BIG_PARCEL, building_program=None) parcel = geometry.parse_parcel(payload_default) greedy = placement.place_all_strategies(parcel, payload_default) none_branch = placement.place_all_strategies(geometry.parse_parcel(payload_none), payload_none) assert {v.strategy for v in greedy} == {"max_area", "max_insolation", "balanced"} assert {v.strategy for v in none_branch} == {"max_area", "max_insolation", "balanced"} by_strategy_g = {v.strategy: v for v in greedy} by_strategy_n = {v.strategy: v for v in none_branch} for name, gv in by_strategy_g.items(): nv = by_strategy_n[name] assert gv.teap == nv.teap assert gv.financial == nv.financial assert len(gv.buildings_geojson["features"]) == len(nv.buildings_geojson["features"]) # Greedy-режим не выставляет partial-fit сигнал (остаётся None). assert gv.placed_count is None assert gv.requested_count is None # ── (b) 2-item program places EXACTLY the requested sections; TEAP reflects them ───── def test_two_item_program_places_exactly_requested() -> None: program = [ BuildingProgramItem(section_type="monolith_comfort", floors=14, count=3), BuildingProgramItem(section_type="tower_business", floors=20, count=2), ] payload = _payload(_BIG_PARCEL, building_program=program) parcel = geometry.parse_parcel(payload) placed = placement.place_program(parcel, program) # Все 5 секций влезли в крупный участок — разместилось ровно столько, сколько просили. assert placed.requested_count == 5 assert placed.placed_count == 5 assert len(placed.sections) == 5 # Каждая секция несёт свой тип/этажность из программы. monolith = [s for s in placed.sections if s.section_type == "monolith_comfort"] tower = [s for s in placed.sections if s.section_type == "tower_business"] assert len(monolith) == 3 and all(s.floors == 14 for s in monolith) assert len(tower) == 2 and all(s.floors == 20 for s in tower) def test_two_item_program_variant_teap_reflects_program() -> None: program = [ BuildingProgramItem(section_type="monolith_comfort", floors=14, count=3), BuildingProgramItem(section_type="tower_business", floors=20, count=2), ] payload = _payload(_BIG_PARCEL, building_program=program) parcel = geometry.parse_parcel(payload) variant = placement.place_program_variant(parcel, payload) assert variant is not None # Partial-fit сигнал заполнен (program-режим): разместилось 5 из 5. assert variant.placed_count == 5 assert variant.requested_count == 5 # ТЭП отражает именно размещённую программу: # built = Σ площадей пятен каталога (3×monolith 21×18 + 2×tower 18×18). mono = catalog.get_house_type("monolith_comfort") tower = catalog.get_house_type("tower_business") expected_built = 3 * mono.footprint_sqm + 2 * tower.footprint_sqm assert variant.teap.built_area_sqm == pytest.approx(expected_built, abs=1.0) # GFA = Σ(площадь_i × floors_i) — ТОЧНО (per-floor-group аггрегация, без дрейфа # от округления «средней» этажности). expected_gfa = 3 * mono.footprint_sqm * 14 + 2 * tower.footprint_sqm * 20 assert variant.teap.total_floor_area_sqm == pytest.approx(expected_gfa, abs=1.0) assert variant.teap.apartments_count > 0 assert variant.financial.revenue_rub > 0 # Один program-вариант (не три жадные стратегии) идёт через place_all_strategies. variants = placement.place_all_strategies(parcel, payload) assert len(variants) == 1 assert variants[0].placed_count == 5 def test_program_geojson_features_carry_own_floors_and_type() -> None: program = [ BuildingProgramItem(section_type="monolith_comfort", floors=14, count=2), BuildingProgramItem(section_type="townhouse", floors=3, count=2), ] payload = _payload(_BIG_PARCEL, building_program=program) parcel = geometry.parse_parcel(payload) variant = placement.place_program_variant(parcel, payload) assert variant is not None features = variant.buildings_geojson["features"] assert isinstance(features, list) floors_seen = set() types_seen = set() for feat in features: geom = shape(feat["geometry"]) assert geom.geom_type == "Polygon" and geom.is_valid props = feat["properties"] assert props["strategy"] == "program" assert props["section_type"] in {"monolith_comfort", "townhouse"} floors_seen.add(props["floors"]) types_seen.add(props["section_type"]) # Смешанная этажность/типы сохранены пер-секционно в GeoJSON. assert floors_seen == {14, 3} assert types_seen == {"monolith_comfort", "townhouse"} def test_program_footprints_non_overlapping_inside_buildable() -> None: program = [BuildingProgramItem(section_type="monolith_comfort", floors=14, count=6)] payload = _payload(_BIG_PARCEL, building_program=program) parcel = geometry.parse_parcel(payload) placed = placement.place_program(parcel, program) fps = [s.footprint for s in placed.sections] assert len(fps) > 1 for fp in fps: assert parcel.buildable_m.buffer(0.01).covers(fp) for i, a in enumerate(fps): for b in fps[i + 1 :]: assert not a.buffer(-0.01).intersects(b.buffer(-0.01)) # ── (c) over-packed program reports partial placement WITHOUT raising ─────────────── def test_overpacked_program_reports_partial_without_raising() -> None: # Маленький участок вмещает ~2 крупные секции, просим 20 — НЕ 422, честный N 0 def test_program_with_zero_fit_raises_parcel_error() -> None: # Участок настолько мал, что НИ ОДНА секция программы не влезает → ParcelGeometryError # (API мапит в 422) — лучше отказ, чем лживый нулевой вариант. tiny = [ [60.60, 56.830], [60.60015, 56.830], [60.60015, 56.83015], [60.60, 56.83015], [60.60, 56.830], ] program = [BuildingProgramItem(section_type="tower_business", floors=25, count=5)] payload = _payload(tiny, building_program=program) with pytest.raises(ParcelGeometryError): parcel = geometry.parse_parcel(payload) placement.place_all_strategies(parcel, payload)