gendesign/backend/tests/services/site_finder/test_program_optimizer.py
Light1YT 71c7e6b5b5
Some checks failed
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m14s
CI / openapi-codegen-check (pull_request) Successful in 2m9s
CI / backend-tests (pull_request) Failing after 14m35s
feat(site-finder): оптимизатор программ — «Как участок сходится» при отрицательном вердикте (#2181)
При npv<0 или roi<0 /analyze отдаёт program_alternatives: топ-3 варианта
программы (класс × этажность), просчитанных той же compute_financial,
+ честный any_viable=false когда ни один не в плюсе.

Backend:
- новый program_optimizer.optimize_program: pure, синхронно (≈15мс на грид
  3 класса × [5,9,14,25] эт с клампом по регламенту зоны), без Celery/БД
- единая откалиброванная цена во все комбо (иначе «бизнес» ложно выигрывает
  за счёт норматива 210k); норматив класса — только когда рынка нет
- select_calibrated_price helper (DRY выбора цены, байт-в-байт прежнее
  поведение synthesize_parcel_financial)
- сбой комбо/оптимизатора не роняет analyze (пояс как у financial_estimate)

Frontend:
- SectionAlternatives «Как участок сходится» после §7: карточки топ-3
  (NPV крупно ±млн, ROI/IRR, жилая/квартиры, цена+источник), бейдж
  «лучший», honest-caveat; render-guard на позитивных участках
- objective_geo_radius в подписях источника цены (рыночный источник не
  падает в норматив-фразу — инверсия честности)
- условный пункт scrollspy «Альтернативы программы»

14 unit-тестов оптимизатора + 20 parcel_financial зелёные; tsc/eslint/vitest clean.
2026-07-03 01:13:02 +05:00

230 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 одинакова);
• сбой одного комбо не валит грид;
• полный вызов быстрый (<100 мс).
Детерминированно, без 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_under_100ms() -> None:
"""Полный вызов (12 комбо) укладывается в <100 мс (синхронно в теле запроса)."""
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 < 100.0, f"optimize_program took {elapsed_ms:.1f} ms (>100 ms)"