"""Тесты §25 special_indices (#986/954-C) — pure-helpers + mocked-БД + оркестратор. Покрытие: • Pure-математика БЕЗ БД: Product Void (порог/доля/счёт), Launch Window (пик + tie-break + нормализация), Competitor Strength (топ-N среднее), Cannibalization (доля same-class), Artificial Demand (доля ипотеки), Cost-of-Error (монотонность + произведение), нормализации в [0,1], confidence-helpers, _avg_ticket. • Artificial-Demand SQL: MagicMock-сессия — форма SQL (CAST(:x AS type), не ::), параметры; сигнал есть → индекс; нет проданных → None + caveat. • compute_special_indices: @patch бэкенд-сервисов — 6 индексов присутствуют, advisory True, per-index graceful (сбой одного → unavailable, карточка цела). Всё детерминировано, без БД (сессия мокается). Зеркалит стиль test_what_to_build / test_market_metrics. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch import pytest from app.services.forecasting.sales_series import SegmentSpec from app.services.forecasting.special_indices import ( _INDEX_KEYS, _METHOD_UNAVAILABLE, KEY_ARTIFICIAL_DEMAND, KEY_CANNIBALIZATION, KEY_COMPETITOR_STRENGTH, KEY_COST_OF_ERROR, KEY_LAUNCH_WINDOW, KEY_PRODUCT_VOID, _artificial_demand_share, _avg_ticket_rub, _cannibalization_index, _cap_confidence, _clamp01, _competitor_strength, _cost_of_error_index, _count_void, _min_confidence, _oversupply_risk_from_deficit, _pick_launch_window, _query_artificial_demand, _void_index, compute_special_indices, ) # Patch-таргеты — имена, импортированные В модуль special_indices. _DSF используется # точечно (re-patch поверх _full_stack_patch); остальные сервисы патчатся через # patch.multiple(_MOD, ...) по kwargs-имени, поэтому отдельных констант им не нужно. _MOD = "app.services.forecasting.special_indices" _DSF = f"{_MOD}.compute_demand_supply_forecast" # ────────────────────────────────────────────────────────────────────────────── # Pure: _clamp01 / confidence-helpers # ────────────────────────────────────────────────────────────────────────────── class TestClamp01: def test_in_range_passthrough(self) -> None: assert _clamp01(0.4) == 0.4 def test_above_one_clamped(self) -> None: assert _clamp01(1.7) == 1.0 def test_below_zero_clamped(self) -> None: assert _clamp01(-0.3) == 0.0 class TestCapConfidence: def test_high_capped_to_medium(self) -> None: assert _cap_confidence("high") == "medium" def test_medium_unchanged(self) -> None: assert _cap_confidence("medium") == "medium" def test_low_unchanged(self) -> None: assert _cap_confidence("low") == "low" class TestMinConfidence: def test_worst_wins(self) -> None: assert _min_confidence(["high", "low", "medium"]) == "low" def test_none_ignored(self) -> None: assert _min_confidence(["medium", None, "high"]) == "medium" def test_all_none_low(self) -> None: assert _min_confidence([None, None]) == "low" def test_empty_low(self) -> None: assert _min_confidence([]) == "low" # ────────────────────────────────────────────────────────────────────────────── # Pure: Product Void (порог / доля / счёт) # ────────────────────────────────────────────────────────────────────────────── class TestVoidIndex: def test_share_above_threshold(self) -> None: # 2 из 4 измеренных ≥ 0.25 → 0.5. assert _void_index([0.3, 0.1, 0.9, -0.2]) == 0.5 def test_threshold_is_inclusive(self) -> None: # ровно на пороге 0.25 → считается пустотой. assert _void_index([0.25]) == 1.0 def test_below_threshold_excluded(self) -> None: # 0.24 < 0.25 → не пустота. assert _void_index([0.24]) == 0.0 def test_none_cells_excluded_from_denominator(self) -> None: # None не входит ни в числитель, ни в знаменатель: 1 из 2 измеренных. assert _void_index([0.9, None, 0.0]) == 0.5 def test_empty_yields_zero(self) -> None: assert _void_index([]) == 0.0 def test_all_none_yields_zero(self) -> None: assert _void_index([None, None]) == 0.0 def test_in_range(self) -> None: v = _void_index([0.9, 0.8, 0.7]) assert 0.0 <= v <= 1.0 assert v == 1.0 def test_custom_threshold(self) -> None: assert _void_index([0.5, 0.4], threshold=0.45) == 0.5 class TestCountVoid: def test_counts_at_or_above_threshold(self) -> None: assert _count_void([0.3, 0.25, 0.1, 0.99]) == 3 def test_none_skipped(self) -> None: assert _count_void([None, 0.9, None]) == 1 def test_empty_zero(self) -> None: assert _count_void([]) == 0 # ────────────────────────────────────────────────────────────────────────────── # Pure: Launch Window (пик + tie-break + нормализация) # ────────────────────────────────────────────────────────────────────────────── class TestPickLaunchWindow: def test_picks_peak_horizon(self) -> None: h, strength = _pick_launch_window({6: 0.1, 12: 0.4, 18: 0.2, 24: -0.1}) assert h == 12 # 0.4 / 0.5 (saturation) = 0.8. assert strength == pytest.approx(0.8) def test_strength_in_range(self) -> None: _, strength = _pick_launch_window({6: 0.9, 12: 0.95}) assert strength is not None assert 0.0 <= strength <= 1.0 assert strength == 1.0 # 0.95/0.5 clamp → 1.0 def test_tie_break_prefers_earlier_horizon(self) -> None: # равный пиковый deficit на 6 и 18 → берём меньший горизонт (раньше выгоднее). h, _ = _pick_launch_window({6: 0.5, 18: 0.5}) assert h == 6 def test_none_horizons_ignored(self) -> None: # 12 — None (тонко), пик среди измеренных = 24. h, _ = _pick_launch_window({6: 0.1, 12: None, 24: 0.3}) assert h == 24 def test_all_none_degrades(self) -> None: assert _pick_launch_window({6: None, 12: None}) == (None, None) def test_empty_degrades(self) -> None: assert _pick_launch_window({}) == (None, None) def test_nonpositive_peak_zero_strength_but_horizon_returned(self) -> None: # все горизонты ≤0 (баланс/затоварка): окна «строить» нет (strength 0), # но аргмакс-горизонт возвращаем (наименее плохой) для explainability. h, strength = _pick_launch_window({6: -0.3, 12: -0.1, 24: -0.5}) assert h == 12 # наибольший (наименее отрицательный) deficit assert strength == 0.0 def test_saturation_nonpositive_degrades_to_sign(self) -> None: h, strength = _pick_launch_window({6: 0.2}, saturation=0.0) assert h == 6 assert strength == 1.0 # ────────────────────────────────────────────────────────────────────────────── # Pure: Competitor Strength (топ-N среднее) # ────────────────────────────────────────────────────────────────────────────── class TestCompetitorStrength: def test_mean_of_top_n(self) -> None: # топ-5 по убыванию из 6: 0.9,0.8,0.7,0.6,0.5 → mean 0.7. v = _competitor_strength([0.1, 0.5, 0.9, 0.6, 0.8, 0.7]) assert v == pytest.approx(0.7) def test_fewer_than_top_n(self) -> None: v = _competitor_strength([0.4, 0.6]) assert v == pytest.approx(0.5) def test_none_weights_skipped(self) -> None: v = _competitor_strength([None, 0.8, None, 0.4]) assert v == pytest.approx(0.6) def test_empty_is_none(self) -> None: assert _competitor_strength([]) is None def test_all_none_is_none(self) -> None: assert _competitor_strength([None, None]) is None def test_in_range(self) -> None: v = _competitor_strength([0.9, 0.95, 1.0]) assert v is not None and 0.0 <= v <= 1.0 def test_custom_top_n(self) -> None: v = _competitor_strength([0.9, 0.8, 0.1], top_n=2) assert v == pytest.approx(0.85) # ────────────────────────────────────────────────────────────────────────────── # Pure: Cannibalization (доля same-class) # ────────────────────────────────────────────────────────────────────────────── class TestCannibalizationIndex: def test_share_of_same_class(self) -> None: # same = 0.6+0.4 = 1.0; all = 0.6+0.4+0.5+0.5 = 2.0 → 0.5. v = _cannibalization_index([0.6, 0.4], [0.6, 0.4, 0.5, 0.5]) assert v == pytest.approx(0.5) def test_no_same_class_is_zero(self) -> None: # конкуренты есть, но ни одного в нашем классе → 0.0 (валидно). v = _cannibalization_index([], [0.5, 0.5]) assert v == 0.0 def test_no_competitors_is_none(self) -> None: # окружения нет вообще → None (неизмеримо). assert _cannibalization_index([], []) is None def test_all_same_class_is_one(self) -> None: v = _cannibalization_index([0.5, 0.5], [0.5, 0.5]) assert v == 1.0 def test_none_weights_skipped(self) -> None: v = _cannibalization_index([0.5], [0.5, None, 0.5]) assert v == pytest.approx(0.5) def test_in_range(self) -> None: v = _cannibalization_index([0.3], [0.3, 0.7]) assert v is not None and 0.0 <= v <= 1.0 # ────────────────────────────────────────────────────────────────────────────── # Pure: Artificial Demand (доля ипотеки) # ────────────────────────────────────────────────────────────────────────────── class TestArtificialDemandShare: def test_basic_share(self) -> None: assert _artificial_demand_share(30, 100) == pytest.approx(0.3) def test_full_mortgage(self) -> None: assert _artificial_demand_share(50, 50) == 1.0 def test_zero_mortgage(self) -> None: assert _artificial_demand_share(0, 80) == 0.0 def test_no_sold_is_none(self) -> None: # нет проданных лотов → None (НЕ фабрикуем 0). assert _artificial_demand_share(0, 0) is None def test_none_mortgage_is_none(self) -> None: assert _artificial_demand_share(None, 50) is None def test_clamped_to_one_on_dirty_data(self) -> None: # грязные данные (mortgage > sold) → clamp 1.0, не >1. assert _artificial_demand_share(120, 100) == 1.0 def test_in_range(self) -> None: v = _artificial_demand_share(17, 23) assert v is not None and 0.0 <= v <= 1.0 # ────────────────────────────────────────────────────────────────────────────── # Pure: Cost-of-Error (монотонность + произведение + чек) # ────────────────────────────────────────────────────────────────────────────── class TestAvgTicket: def test_price_times_area(self) -> None: # 200_000 ₽/м² × 50 м² = 10 млн. assert _avg_ticket_rub(200_000.0) == 10_000_000.0 def test_none_price_is_none(self) -> None: assert _avg_ticket_rub(None) is None def test_nonpositive_price_is_none(self) -> None: assert _avg_ticket_rub(0.0) is None def test_custom_area(self) -> None: assert _avg_ticket_rub(100_000.0, area_m2=80.0) == 8_000_000.0 class TestOversupplyRiskFromDeficit: def test_negative_deficit_is_magnitude(self) -> None: assert _oversupply_risk_from_deficit(-0.4) == pytest.approx(0.4) def test_positive_deficit_zero_risk(self) -> None: assert _oversupply_risk_from_deficit(0.6) == 0.0 def test_zero_deficit_zero_risk(self) -> None: assert _oversupply_risk_from_deficit(0.0) == 0.0 def test_none_is_none(self) -> None: assert _oversupply_risk_from_deficit(None) is None class TestCostOfErrorIndex: def test_product_of_risk_and_ticket(self) -> None: # risk 0.5 × (10млн/15млн clamp=0.6667) ≈ 0.3333. v = _cost_of_error_index(0.5, 10_000_000.0) assert v == pytest.approx(0.5 * (10_000_000.0 / 15_000_000.0)) def test_monotonic_in_risk(self) -> None: low = _cost_of_error_index(0.2, 10_000_000.0) high = _cost_of_error_index(0.8, 10_000_000.0) assert low is not None and high is not None assert high > low def test_monotonic_in_ticket(self) -> None: cheap = _cost_of_error_index(0.5, 5_000_000.0) pricey = _cost_of_error_index(0.5, 12_000_000.0) assert cheap is not None and pricey is not None assert pricey > cheap def test_in_range(self) -> None: v = _cost_of_error_index(0.9, 30_000_000.0) # ticket clamps to 1.0 assert v is not None and 0.0 <= v <= 1.0 assert v == pytest.approx(0.9) def test_none_risk_is_none(self) -> None: assert _cost_of_error_index(None, 10_000_000.0) is None def test_none_ticket_is_none(self) -> None: assert _cost_of_error_index(0.5, None) is None def test_nonpositive_norm_degrades_to_risk_only(self) -> None: v = _cost_of_error_index(0.4, 10_000_000.0, ticket_norm_rub=0.0) assert v == pytest.approx(0.4) # ────────────────────────────────────────────────────────────────────────────── # Artificial-Demand SQL: MagicMock-сессия (форма SQL + параметры + сигнал/деградация) # ────────────────────────────────────────────────────────────────────────────── def _mock_db_one(row: dict[str, Any] | None) -> MagicMock: """Сессия, чей единственный execute().mappings().first() вернёт row.""" db = MagicMock() result = MagicMock() result.mappings.return_value.first.return_value = row db.execute.return_value = result return db def _executed_sql(db: MagicMock, call_index: int = 0) -> str: args, _ = db.execute.call_args_list[call_index] return str(args[0]) def _executed_params(db: MagicMock, call_index: int = 0) -> dict[str, Any]: args, _ = db.execute.call_args_list[call_index] return args[1] class TestArtificialDemandSQL: def test_sql_uses_cast_not_double_colon(self) -> None: db = _mock_db_one({"n_sold": 10, "n_mortgage": 4}) _query_artificial_demand( db, district="Академический", obj_class="комфорт", premise_kind="квартира" ) sql = _executed_sql(db) assert "CAST(:premise_kind AS text)" in sql assert "CAST(:district AS text)" in sql assert "CAST(:obj_class AS text)" in sql # psycopg v3: никогда :x::type assert ":premise_kind::" not in sql assert ":district::" not in sql assert ":obj_class::" not in sql def test_sql_reads_mortgage_signal_columns(self) -> None: db = _mock_db_one({"n_sold": 10, "n_mortgage": 4}) _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира") sql = _executed_sql(db) # сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots). assert "encumbrance_type" in sql assert "bank_name" in sql assert "objective_lots" in sql def test_params_passed(self) -> None: db = _mock_db_one({"n_sold": 1, "n_mortgage": 0}) _query_artificial_demand( db, district="Пионерский", obj_class="бизнес", premise_kind="квартира" ) p = _executed_params(db) assert p["district"] == "Пионерский" assert p["obj_class"] == "бизнес" assert p["premise_kind"] == "квартира" def test_signal_present_returns_counts(self) -> None: db = _mock_db_one({"n_sold": 80, "n_mortgage": 52}) out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира") assert out == {"n_sold": 80, "n_mortgage": 52} def test_empty_row_degrades_to_zeros(self) -> None: db = _mock_db_one(None) out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира") assert out == {"n_sold": 0, "n_mortgage": 0} def test_null_counts_coerced_to_zero(self) -> None: db = _mock_db_one({"n_sold": None, "n_mortgage": None}) out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира") assert out == {"n_sold": 0, "n_mortgage": 0} # ────────────────────────────────────────────────────────────────────────────── # Artificial-Demand builder via mocked rows: signal → index; no signal → None+caveat # ────────────────────────────────────────────────────────────────────────────── _SPEC = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический") def _patch_all_unavailable() -> Any: """Контекст: все 5 НЕ-Artificial бэкенда брошены/пусты (изолируем Artificial).""" return patch.multiple( _MOD, compute_demand_supply_forecast=MagicMock(return_value=[]), rank_segments=MagicMock(return_value=MagicMock(ranked=[], n_cells_ranked=0)), get_competitors=MagicMock(side_effect=ValueError("no geom")), compute_market_metrics=MagicMock( return_value=MagicMock(overstock_index=None, confidence="low") ), compute_affordability=MagicMock(return_value=MagicMock(price_per_m2=None)), ) class TestArtificialDemandBuilder: def test_signal_present_yields_index(self) -> None: db = MagicMock() with ( _patch_all_unavailable(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 65}, ), ): card = compute_special_indices(db, spec=_SPEC, district="Академический") idx = card.indices[KEY_ARTIFICIAL_DEMAND] assert idx.value == pytest.approx(0.65) assert idx.method == "mortgage_share_objective_lots" assert idx.detail["n_sold"] == 100 assert idx.detail["n_mortgage"] == 65 assert idx.advisory is True def test_no_signal_yields_none_with_caveat(self) -> None: db = MagicMock() with ( _patch_all_unavailable(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 0, "n_mortgage": 0}, ), ): card = compute_special_indices(db, spec=_SPEC, district="Академический") idx = card.indices[KEY_ARTIFICIAL_DEMAND] assert idx.value is None assert idx.method == _METHOD_UNAVAILABLE assert "reason" in idx.detail assert "фабрикуем" in idx.detail["reason"] # явный caveat «не фабрикуем» def test_small_sample_low_confidence(self) -> None: db = MagicMock() with ( _patch_all_unavailable(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 5, "n_mortgage": 3}, ), ): card = compute_special_indices(db, spec=_SPEC, district="Академический") idx = card.indices[KEY_ARTIFICIAL_DEMAND] assert idx.value == pytest.approx(0.6) assert idx.confidence == "low" # n_sold < 30 # ────────────────────────────────────────────────────────────────────────────── # compute_special_indices: @patch бэкенда — 6 индексов, advisory, graceful per-index # ────────────────────────────────────────────────────────────────────────────── def _forecast_stub(deficit: float | None, *, horizon: int, confidence: str = "medium") -> MagicMock: f = MagicMock() f.deficit_index = deficit f.horizon_months = horizon f.confidence = confidence return f def _ranked_stub(deficit: float, *, confidence: str = "medium") -> MagicMock: seg = MagicMock() seg.deficit_index = deficit seg.confidence = confidence seg.as_dict.return_value = {"deficit_index": deficit} return seg def _competitor_stub(relevance: float | None, obj_class: str | None) -> MagicMock: c = MagicMock() c.relevance_weight = relevance c.obj_class = obj_class return c def _full_stack_patch() -> Any: """Все 5 бэкенд-сервисов отдают здоровые данные → все 6 индексов считаются.""" forecasts = [_forecast_stub(0.1 * h, horizon=h) for h in (6, 12, 18, 24)] ranking = MagicMock() ranking.ranked = [_ranked_stub(0.5), _ranked_stub(0.3), _ranked_stub(-0.1)] ranking.n_cells_ranked = 3 competitors_resp = MagicMock() competitors_resp.competitors = [ _competitor_stub(0.8, "комфорт"), _competitor_stub(0.6, "комфорт"), _competitor_stub(0.5, "бизнес"), ] metrics = MagicMock() metrics.overstock_index = 0.4 metrics.confidence = "medium" afford = MagicMock() afford.price_per_m2 = 200_000.0 return patch.multiple( _MOD, compute_demand_supply_forecast=MagicMock(return_value=forecasts), rank_segments=MagicMock(return_value=ranking), get_competitors=MagicMock(return_value=competitors_resp), compute_market_metrics=MagicMock(return_value=metrics), compute_affordability=MagicMock(return_value=afford), ) class TestComputeSpecialIndicesShape: def test_all_six_indices_present(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) assert set(card.indices.keys()) == set(_INDEX_KEYS) assert len(card.indices) == 6 def test_advisory_always_true(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) assert card.advisory is True assert all(idx.advisory is True for idx in card.indices.values()) def test_all_values_in_range_when_present(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) for idx in card.indices.values(): if idx.value is not None: assert 0.0 <= idx.value <= 1.0, idx.key def test_confidence_capped_at_medium(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) assert card.confidence in ("low", "medium") # никогда high for idx in card.indices.values(): assert idx.confidence in ("low", "medium") def test_as_dict_roundtrip(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) d = card.as_dict() assert d["advisory"] is True assert set(d["indices"].keys()) == set(_INDEX_KEYS) assert d["district"] == "Академический" # каждый индекс сериализуется с ключами контракта. for sub in d["indices"].values(): assert set(sub.keys()) == { "key", "value", "label", "confidence", "detail", "method", "advisory", } class TestComputeSpecialIndicesValues: def test_launch_window_picks_peak_horizon(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) lw = card.indices[KEY_LAUNCH_WINDOW] # deficit = 0.1*h → пик на h=24 (0.1*24=2.4 clamps), label «24 мес». assert lw.label == "24 мес" assert lw.detail["best_horizon_months"] == 24 def test_product_void_counts_segments(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) pv = card.indices[KEY_PRODUCT_VOID] # ranked deficits [0.5,0.3,-0.1]: 2 ≥ 0.25 → share 2/3. assert pv.value == pytest.approx(2 / 3) assert pv.detail["n_void"] == 2 def test_competitor_strength_mean_top_n(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) cs = card.indices[KEY_COMPETITOR_STRENGTH] # relevance [0.8,0.6,0.5] → mean 0.6333. assert cs.value == pytest.approx((0.8 + 0.6 + 0.5) / 3) def test_cannibalization_same_class_share(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) can = card.indices[KEY_CANNIBALIZATION] # same-class (комфорт) = 0.8+0.6=1.4; all=1.9 → 0.7368. assert can.value == pytest.approx((0.8 + 0.6) / (0.8 + 0.6 + 0.5)) def test_cost_of_error_product(self) -> None: db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) coe = card.indices[KEY_COST_OF_ERROR] # risk 0.4 × ticket(200k×50=10млн / 15млн = 0.6667) ≈ 0.2667. ticket_factor = (200_000.0 * 50.0) / 15_000_000.0 assert coe.value == pytest.approx(0.4 * ticket_factor) assert coe.detail["risk_source"] == "overstock_index" class TestComputeSpecialIndicesGraceful: def test_no_cad_num_degrades_competitor_indices(self) -> None: # без cad_num: Cannibalization + Competitor Strength → unavailable, остальные ок. db = MagicMock() with ( _full_stack_patch(), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices(db, spec=_SPEC, district="Академический") assert card.indices[KEY_CANNIBALIZATION].method == _METHOD_UNAVAILABLE assert card.indices[KEY_COMPETITOR_STRENGTH].method == _METHOD_UNAVAILABLE assert card.indices[KEY_CANNIBALIZATION].value is None # quick-win Artificial Demand всё равно посчитан. assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.4) def test_per_index_failure_isolated(self) -> None: # один сервис (rank_segments) бросает → Product Void unavailable, остальные 5 ок. forecasts = [_forecast_stub(0.2, horizon=h) for h in (6, 12, 18, 24)] metrics = MagicMock(overstock_index=0.3, confidence="medium") afford = MagicMock(price_per_m2=180_000.0) comp = MagicMock() comp.competitors = [_competitor_stub(0.7, "комфорт")] db = MagicMock() with ( patch.multiple( _MOD, compute_demand_supply_forecast=MagicMock(return_value=forecasts), rank_segments=MagicMock(side_effect=RuntimeError("boom")), get_competitors=MagicMock(return_value=comp), compute_market_metrics=MagicMock(return_value=metrics), compute_affordability=MagicMock(return_value=afford), ), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 50, "n_mortgage": 20}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) # сбойный индекс — unavailable; карточка цела (все 6 ключей). assert card.indices[KEY_PRODUCT_VOID].method == _METHOD_UNAVAILABLE assert card.indices[KEY_PRODUCT_VOID].value is None assert len(card.indices) == 6 # остальные посчитаны. assert card.indices[KEY_LAUNCH_WINDOW].value is not None assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.4) assert card.advisory is True def test_all_backends_fail_card_still_returns(self) -> None: # тотальный сбой: каждый индекс unavailable, но карточка возвращается (не crash). db = MagicMock() with ( patch.multiple( _MOD, compute_demand_supply_forecast=MagicMock(side_effect=RuntimeError("x")), rank_segments=MagicMock(side_effect=RuntimeError("x")), get_competitors=MagicMock(side_effect=RuntimeError("x")), compute_market_metrics=MagicMock(side_effect=RuntimeError("x")), compute_affordability=MagicMock(side_effect=RuntimeError("x")), ), patch( f"{_MOD}._query_artificial_demand", side_effect=RuntimeError("x"), ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) assert len(card.indices) == 6 assert all(idx.method == _METHOD_UNAVAILABLE for idx in card.indices.values()) assert all(idx.value is None for idx in card.indices.values()) assert card.advisory is True assert card.confidence == "low" def test_launch_window_all_none_degrades(self) -> None: # deficit None на всех горизонтах → Launch Window unavailable. forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)] db = MagicMock() with ( _full_stack_patch(), patch(_DSF, return_value=forecasts), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 100, "n_mortgage": 40}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) assert card.indices[KEY_LAUNCH_WINDOW].method == _METHOD_UNAVAILABLE assert card.indices[KEY_LAUNCH_WINDOW].value is None def test_cost_of_error_falls_back_to_negative_deficit(self) -> None: # overstock_index None → Cost-of-Error берёт магнитуду отрицательного дефицита. metrics = MagicMock(overstock_index=None, confidence="medium") afford = MagicMock(price_per_m2=200_000.0) # forecast на _VOID_HORIZON_MONTHS=12 (fallback) с отрицательным дефицитом. forecasts = [_forecast_stub(-0.5, horizon=12)] comp = MagicMock() comp.competitors = [_competitor_stub(0.7, "комфорт")] db = MagicMock() with ( patch.multiple( _MOD, compute_demand_supply_forecast=MagicMock(return_value=forecasts), rank_segments=MagicMock(return_value=MagicMock(ranked=[], n_cells_ranked=0)), get_competitors=MagicMock(return_value=comp), compute_market_metrics=MagicMock(return_value=metrics), compute_affordability=MagicMock(return_value=afford), ), patch( f"{_MOD}._query_artificial_demand", return_value={"n_sold": 50, "n_mortgage": 20}, ), ): card = compute_special_indices( db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123" ) coe = card.indices[KEY_COST_OF_ERROR] assert coe.detail["risk_source"] == "negative_deficit" # risk |−0.5|=0.5 × ticket(10млн/15млн=0.6667) ≈ 0.3333. assert coe.value == pytest.approx(0.5 * ((200_000.0 * 50.0) / 15_000_000.0)) def test_aggregate_spec_no_class_param_is_none(self) -> None: # spec без obj_class → Artificial-Demand SQL получает obj_class=None (агрегат). db = MagicMock() captured: dict[str, Any] = {} def _capture(_db: Any, *, district: Any, obj_class: Any, premise_kind: Any) -> dict: captured["obj_class"] = obj_class captured["district"] = district return {"n_sold": 40, "n_mortgage": 10} spec_no_class = SegmentSpec(room_bucket="2-к 45-60") with _full_stack_patch(), patch(f"{_MOD}._query_artificial_demand", side_effect=_capture): card = compute_special_indices(db, spec=spec_no_class, district=None) assert captured["obj_class"] is None assert captured["district"] is None assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.25)