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
При 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.
230 lines
10 KiB
Python
230 lines
10 KiB
Python
"""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)"
|