gendesign/backend/tests/services/site_finder/test_program_optimizer.py
bot-backend ae483468c9
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m40s
Deploy / build-worker (push) Successful in 3m54s
Deploy / build-frontend (push) Successful in 4m55s
Deploy / deploy (push) Successful in 1m35s
feat(site-finder): оптимизатор программ — «Как участок сходится» при отрицательном вердикте (#2181) (#2222)
2026-07-02 20:57:12 +00:00

236 lines
10 KiB
Python
Raw 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.

"""Unit-тесты оптимизатора программы застройки (#2181).
Тестируем чистую функцию program_optimizer.optimize_program (без БД, без app.main):
• ранжир вариантов по NPV убыв. (тай-брейк ROI);
• any_viable=False когда все комбо в минусе — и всё равно топ-N «наименее убыточных»;
• кламп floors_grid по max_floors_allowed (10 → [5, 9]);
• ЕДИНАЯ откалиброванная цена во все комбо (price_per_sqm_used одинакова);
• сбой одного комбо не валит грид;
• полный вызов быстрый (грубый порог 1 с — защита от деградации на порядки).
Детерминированно, без LLM / БД.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import time
from unittest.mock import patch
from app.services.site_finder.program_optimizer import (
_build_floors_grid,
optimize_program,
)
def _big_profitable_kwargs() -> dict:
"""Крупный участок + высокая цена → как минимум часть комбо прибыльна."""
return {
"area_m2": 12_000.0,
"max_far": 3.0,
"max_building_pct": 40.0,
"max_floors_allowed": 25,
"land_cost_rub": 50_000_000.0,
"market_price_per_sqm": 180_000.0,
"price_source": "objective_geo_radius",
"velocity_sqm_per_month": 900.0,
}
# ── floors-grid кламп ─────────────────────────────────────────────────────────
def test_floors_grid_clamped_to_max_allowed() -> None:
"""max_floors_allowed=10 → [5, 9] (14 и 25 отсечены)."""
assert _build_floors_grid(10) == [5, 9]
def test_floors_grid_none_returns_full_set() -> None:
"""Предел неизвестен → весь опорный набор."""
assert _build_floors_grid(None) == [5, 9, 14, 25]
def test_floors_grid_below_min_falls_back_to_limit() -> None:
"""Предел ниже минимального опорного (3) → сам предел единственной этажностью."""
assert _build_floors_grid(3) == [3]
def test_optimize_respects_floors_clamp() -> None:
"""max_floors_allowed=10 → в вариантах только этажи из [5, 9]."""
kwargs = _big_profitable_kwargs()
kwargs["max_floors_allowed"] = 10
result = optimize_program(**kwargs)
assert result is not None
floors_seen = {v["floors"] for v in result["variants"]}
assert floors_seen <= {5, 9}
# 3 класса × 2 этажа = 6 комбо всего в гриде.
assert result["grid_size"] == 6
# ── ранжир по NPV ─────────────────────────────────────────────────────────────
def test_variants_sorted_by_npv_desc() -> None:
"""Топ-N отсортирован по npv_rub убыв."""
result = optimize_program(**_big_profitable_kwargs())
assert result is not None
npvs = [v["npv_rub"] for v in result["variants"]]
assert npvs == sorted(npvs, reverse=True)
assert len(result["variants"]) == 3 # top_n=3 по умолчанию
def test_profitable_site_any_viable_true() -> None:
"""Крупный участок + высокая цена → хотя бы один вариант в плюсе."""
result = optimize_program(**_big_profitable_kwargs())
assert result is not None
assert result["any_viable"] is True
assert any(v["npv_rub"] > 0 for v in result["variants"])
# ── all-negative: any_viable=False, но топ-N всё равно есть ────────────────────
def test_all_negative_any_viable_false_still_returns_top_n() -> None:
"""Крошечный участок + мизерная цена → все в минусе, any_viable=False, топ-3 есть."""
result = optimize_program(
area_m2=600.0,
max_far=1.5,
max_building_pct=30.0,
max_floors_allowed=9,
land_cost_rub=200_000_000.0, # неадекватно дорогая земля топит все комбо
market_price_per_sqm=40_000.0, # мизерная цена
price_source="objective_geo_radius",
velocity_sqm_per_month=100.0,
)
assert result is not None
assert result["any_viable"] is False
assert all(v["npv_rub"] <= 0 for v in result["variants"])
# «Наименее убыточные» — всё равно топ-3 и отсортированы по NPV убыв.
assert len(result["variants"]) == 3
npvs = [v["npv_rub"] for v in result["variants"]]
assert npvs == sorted(npvs, reverse=True)
# ── ЕДИНАЯ цена во все комбо ───────────────────────────────────────────────────
def test_fixed_price_across_all_combos() -> None:
"""market_price_per_sqm един во всех комбо: price_per_sqm_used одинакова везде."""
kwargs = _big_profitable_kwargs()
kwargs["market_price_per_sqm"] = 165_000.0
result = optimize_program(**kwargs)
assert result is not None
prices = {v["price_per_sqm_used"] for v in result["variants"]}
assert prices == {165_000.0}
sources = {v["price_source"] for v in result["variants"]}
assert sources == {"objective_geo_radius"}
def test_no_market_price_uses_class_norm() -> None:
"""Рынка нет → каждый комбо на нормативе своего класса (цены РАЗНЫЕ по классам)."""
kwargs = _big_profitable_kwargs()
kwargs["market_price_per_sqm"] = None
kwargs["price_source"] = "class_norm"
result = optimize_program(**kwargs)
assert result is not None
# Норматив зависит от класса → в гриде минимум 2 разных цены (эконом 110k vs бизнес 210k).
all_prices = {v["price_per_sqm_used"] for v in result["variants"]}
assert all(v["price_source"] == "class_norm" for v in result["variants"])
assert len(all_prices) >= 1 # top-3 может оказаться одним классом; проверяем норму ниже
for v in result["variants"]:
assert v["price_per_sqm_used"] in (110_000.0, 145_000.0, 210_000.0)
# ── устойчивость: сбой одного комбо не валит грид ─────────────────────────────
def test_one_combo_failure_does_not_break_grid() -> None:
"""compute_financial кидает на 'business' → остальные комбо считаются, грид не падает."""
real_compute = None
from app.services.site_finder import program_optimizer as mod
real_compute = mod.compute_financial
def _flaky(*args, **kwargs): # type: ignore[no-untyped-def]
if kwargs.get("housing_class") == "business":
raise ValueError("boom")
return real_compute(*args, **kwargs)
with patch.object(mod, "compute_financial", side_effect=_flaky):
result = optimize_program(**_big_profitable_kwargs())
assert result is not None
# business (× 4 этажа) выпал → в гриде остались только econom + comfort.
assert all(v["housing_class"] != "business" for v in result["variants"])
assert result["grid_size"] > 0 # грид выжил
def test_no_area_returns_none() -> None:
"""Нет площади → None (нечего считать)."""
kwargs = _big_profitable_kwargs()
kwargs["area_m2"] = None
assert optimize_program(**kwargs) is None
def test_no_zoning_numbers_returns_none() -> None:
"""Ни far, ни (pct+floors) → ни один комбо не даёт ТЭП → None."""
result = optimize_program(
area_m2=8000.0,
max_far=None,
max_building_pct=None,
max_floors_allowed=None,
land_cost_rub=0.0,
market_price_per_sqm=150_000.0,
price_source="objective_geo_radius",
velocity_sqm_per_month=None,
)
assert result is None
# ── caveat / метаданные ───────────────────────────────────────────────────────
def test_result_carries_caveat_and_metadata() -> None:
"""Результат несёт honest-caveat + grid_size + структуру каждого варианта."""
result = optimize_program(**_big_profitable_kwargs())
assert result is not None
assert isinstance(result["caveat"], str) and result["caveat"]
assert result["grid_size"] == 12 # 3 класса × 4 этажа
v = result["variants"][0]
for key in (
"housing_class",
"floors",
"development_type",
"npv_rub",
"irr",
"roi",
"net_profit_rub",
"residential_sqm",
"apartments_count",
"price_per_sqm_used",
"price_source",
"schedule_is_default",
):
assert key in v
# ── производительность ────────────────────────────────────────────────────────
def test_full_grid_fast_enough_for_sync_request() -> None:
"""Полный грид достаточно быстр для синхронного вызова в теле запроса.
Локально 12 комбо ≈ 15 мс; порог 1000 мс намеренно грубый — ловим
деградацию НА ПОРЯДКИ (случайный I/O, БД, квадратичный взрыв), а не
миллисекунды: на нагруженном shared CI-раннере тот же вызов занимал
199 мс и валил жёсткий порог 100 мс (flaky, run 3917).
"""
kwargs = _big_profitable_kwargs()
start = time.perf_counter()
result = optimize_program(**kwargs)
elapsed_ms = (time.perf_counter() - start) * 1000.0
assert result is not None
assert elapsed_ms < 1000.0, f"optimize_program took {elapsed_ms:.1f} ms (>1000 ms)"