"""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)"