gendesign/backend/tests/services/generative/test_program_placement.py
Light1YT e8b05d821e
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m3s
CI / openapi-codegen-check (pull_request) Successful in 1m51s
CI / backend-tests (pull_request) Successful in 14m7s
test(concept): turn greedy backward-compat into a real golden pin (#1965 Stage 3a)
The old test_no_program_reproduces_greedy_output_unchanged only compared
building_program=None (default) vs explicit None — both through the new
_Placer code — so it proved the two None branches agree but did NOT pin
the greedy geometry; it would still pass if the _Placer extraction had
drifted the output. test_placement.py only checks invariants, never
concrete counts/TEAP, so there was no anti-regression guard that the
greedy path is byte-identical after the refactor.

Replace it with two tests:
- test_greedy_output_matches_golden_pin: hard-coded literals per strategy
  on the fixed _BIG_PARCEL — (features, built_area_sqm, total_floor_area_sqm,
  apartments_count) — frozen from the current (== pre-refactor) output, so
  any future deterministic drift in greedy placement FAILS.
- test_explicit_none_program_equals_default_greedy: keeps the None-branch
  equivalence check (default vs explicit None go one greedy path).
2026-06-28 02:23:55 +05:00

240 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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: greedy output is byte-identical after the _Placer refactor ──
# GOLDEN PIN — детерминированный жадный выход на фиксированном участке _BIG_PARCEL
# (comfort / 9 эт. / mid_rise). Захвачен ПОСЛЕ извлечения _Placer и сверен ревьювером
# как идентичный до-рефактора. Любой будущий ДРЕЙФ жадной геометрии (число секций /
# пятно / GFA / квартиры) уронит этот тест — это и есть anti-regression guard, которого
# не давали инвариантные тесты в test_placement.py. Формат на стратегию:
# (features, built_area_sqm, total_floor_area_sqm, apartments_count).
_GREEDY_GOLDEN: dict[str, tuple[int, float, float, int]] = {
"max_area": (83, 35856.0, 358560.0, 4830),
"max_insolation": (70, 15120.0, 120960.0, 1629),
"balanced": (88, 27720.0, 249480.0, 3361),
}
def test_greedy_output_matches_golden_pin() -> None:
# Реальный golden-pin: жадный путь (building_program не задан) должен выдать РОВНО
# замороженные литералы. Так фиксируем, что _Placer-рефактор не сдвинул раскладку.
payload = _payload(_BIG_PARCEL)
parcel = geometry.parse_parcel(payload)
variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)}
assert set(variants) == set(_GREEDY_GOLDEN)
for name, (features, built, gfa, apts) in _GREEDY_GOLDEN.items():
v = variants[name]
assert len(v.buildings_geojson["features"]) == features, name
assert round(v.teap.built_area_sqm, 1) == built, name
assert round(v.teap.total_floor_area_sqm, 1) == gfa, name
assert v.teap.apartments_count == apts, name
# Greedy-режим не выставляет partial-fit сигнал (остаётся None).
assert v.placed_count is None
assert v.requested_count is None
def test_explicit_none_program_equals_default_greedy() -> None:
# building_program=None (явно) и дефолт (поле опущено) идут одним greedy-путём →
# должны совпасть покомпонентно. Дополняет golden-pin: ветка None не разветвляется.
payload_default = _payload(_BIG_PARCEL)
payload_none = _payload(_BIG_PARCEL, building_program=None)
variants_default = placement.place_all_strategies(
geometry.parse_parcel(payload_default), payload_default
)
variants_none = placement.place_all_strategies(
geometry.parse_parcel(payload_none), payload_none
)
by_default = {v.strategy: v for v in variants_default}
by_none = {v.strategy: v for v in variants_none}
assert set(by_default) == set(by_none)
for name, dv in by_default.items():
nv = by_none[name]
assert dv.teap == nv.teap
assert dv.financial == nv.financial
assert len(dv.buildings_geojson["features"]) == len(nv.buildings_geojson["features"])
# ── (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<M.
program = [BuildingProgramItem(section_type="monolith_comfort", floors=14, count=20)]
payload = _payload(_SMALL_PARCEL, building_program=program)
parcel = geometry.parse_parcel(payload)
placed = placement.place_program(parcel, program)
assert placed.requested_count == 20
assert 0 < placed.placed_count < 20 # разместилось N из M
# И через полный конвейер: вариант строится, partial-fit сигнал проброшен, без raise.
variants = placement.place_all_strategies(parcel, payload)
assert len(variants) == 1
v = variants[0]
assert v.requested_count == 20
assert 0 < v.placed_count < 20
assert v.teap.apartments_count > 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)