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).
240 lines
12 KiB
Python
240 lines
12 KiB
Python
"""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)
|