"""Unit-тесты §15 движка отчётной уверенности (#990, 955-A4). Чистые тесты — БЕЗ БД (движок PURE: берёт уже-посчитанные входы, ничего не считает из §9.x и не ходит в БД): • _level_from_value / _factor_from_count — каждый порог-бэнд (high/medium/low) + RU-нота с РЕАЛЬНЫМ числом и оценочным словом; None → low + «нет данных». • _coverage_factor — покрытие domrf↔objective в % в ноте (структурный §15-пример). • _aggregate — weakest-link MIN (один low → общий low); пустой → low. • _cap — advisory-потолок опускает high → medium. • _build_rationale — называет тянущий-вниз фактор с его числом, «Low потому что N…»; advisory-cap проговаривается; пусто → «недостаточно данных». • compute_report_confidence — смешанные факторы → правильный level + структурная причина; advisory НИКОГДА не отдаёт high; all-None → low + «недостаточно данных»; as_dict() ложится в слот ReportConfidence (#987) и JSON-сериализуем. Детерминированно, без LLM, без сети. DATABASE_URL до импорта app-модулей (зеркало соседних тестов — на случай side-effect'ов импорта пакета forecasting). """ from __future__ import annotations import json import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services.forecasting.confidence_engine import ( _ANALOG_COUNT_HIGH, _DEAL_COUNT_HIGH, _DEAL_COUNT_LOW, _HISTORY_MONTHS_HIGH, ConfidenceFactor, ReportConfidenceResult, _aggregate, _build_rationale, _cap, _coverage_factor, _factor_from_count, _level_from_value, compute_report_confidence, ) def _factor(name: str, level: str) -> ConfidenceFactor: """Лёгкий фактор фиксированного уровня (для агрегатных/причинных тестов).""" return ConfidenceFactor(name=name, value=None, level=level, note=f"{name}:{level}") # type: ignore[arg-type] # ── _level_from_value — пороговые бэнды ──────────────────────────────────────── class TestLevelFromValue: def test_high_band(self) -> None: assert _level_from_value(50, high_at=50, low_below=15) == "high" assert _level_from_value(120, high_at=50, low_below=15) == "high" def test_medium_band(self) -> None: # Между low_below (вкл.) и high_at (искл.) → medium. assert _level_from_value(15, high_at=50, low_below=15) == "medium" assert _level_from_value(30, high_at=50, low_below=15) == "medium" def test_low_band(self) -> None: assert _level_from_value(14, high_at=50, low_below=15) == "low" assert _level_from_value(0, high_at=50, low_below=15) == "low" def test_none_is_low(self) -> None: # Нет сигнала ≠ хороший сигнал. assert _level_from_value(None, high_at=50, low_below=15) == "low" # ── _factor_from_count — бэнды + RU-нота с числом ────────────────────────────── class TestFactorFromCount: def test_low_count_note_has_number_and_word(self) -> None: f = _factor_from_count( "deal_count", 7, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок", suffix="за 6 мес", ) assert f.level == "low" assert f.value == 7 # Структурный §15-стиль: реальное число + контекст + оценка. assert "7 сделок" in f.note assert "за 6 мес" in f.note assert "мало" in f.note def test_medium_count_band(self) -> None: f = _factor_from_count( "deal_count", 20, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок" ) assert f.level == "medium" assert "20 сделок" in f.note assert "умеренно" in f.note def test_high_count_band(self) -> None: f = _factor_from_count( "deal_count", 80, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок" ) assert f.level == "high" assert "80 сделок" in f.note assert "достаточно" in f.note def test_one_analog_is_low_with_number(self) -> None: # ТЗ §15-пример «только 1 ЖК» — 1 аналог обязан давать low с числом в ноте. f = _factor_from_count( "analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога" ) assert f.level == "low" assert "1 ЖК-аналога" in f.note def test_none_count_low_no_data_note(self) -> None: f = _factor_from_count( "deal_count", None, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок" ) assert f.level == "low" assert f.value is None assert "нет данных" in f.note def test_float_count_rendered_compactly(self) -> None: # Целое-как-float → без хвоста; дробное → 1 знак. f_int = _factor_from_count( "history_months", 12.0, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории" ) assert "12 мес истории" in f_int.note # не "12.0" f_frac = _factor_from_count( "history_months", 12.5, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории" ) assert "12.5 мес истории" in f_frac.note # ── _coverage_factor — покрытие domrf↔objective в % ──────────────────────────── class TestCoverageFactor: def test_low_coverage_percent_in_note(self) -> None: # Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15). f = _coverage_factor(0.025) assert f.level == "low" assert f.value == 0.025 assert "2.5%" in f.note assert "domrf↔objective" in f.note def test_high_coverage(self) -> None: f = _coverage_factor(0.75) assert f.level == "high" assert "75.0%" in f.note def test_none_coverage_low(self) -> None: f = _coverage_factor(None) assert f.level == "low" assert "неизвестно" in f.note def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None: # BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден). # До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) — # инфляция confidence в exactly near-zero кейсе, который §15 призван флагать. f = _coverage_factor(0.008) assert f.level == "low" assert "0.8%" in f.note # ── _aggregate — weakest-link MIN ────────────────────────────────────────────── class TestAggregate: def test_one_low_drags_overall_to_low(self) -> None: factors = [_factor("a", "high"), _factor("b", "medium"), _factor("c", "low")] assert _aggregate(factors) == "low" def test_all_high_stays_high(self) -> None: assert _aggregate([_factor("a", "high"), _factor("b", "high")]) == "high" def test_medium_floor_when_no_low(self) -> None: assert _aggregate([_factor("a", "high"), _factor("b", "medium")]) == "medium" def test_empty_is_low(self) -> None: # Нет ни одного сигнала → low (graceful). assert _aggregate([]) == "low" # ── _cap — advisory-потолок ──────────────────────────────────────────────────── class TestCap: def test_high_capped_to_medium(self) -> None: assert _cap("high", "medium") == "medium" def test_low_unchanged_below_ceiling(self) -> None: assert _cap("low", "medium") == "low" def test_medium_unchanged_at_ceiling(self) -> None: assert _cap("medium", "medium") == "medium" # ── _build_rationale — структурная §15-причина ───────────────────────────────── class TestBuildRationale: def test_low_names_dragging_factor_with_number(self) -> None: # «Low потому что 7 сделок …» — называет виновника с его числом. deal = _factor_from_count( "deal_count", 7, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок", suffix="за 6 мес", ) good = _factor("history_months", "high") text = _build_rationale("low", [deal, good], advisory_capped=False) assert text.startswith("Low потому что") assert "7 сделок" in text # Не-тянущий (high) фактор НЕ называется. assert "history_months" not in text def test_low_lists_multiple_dragging_factors(self) -> None: deal = _factor_from_count( "deal_count", 7, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок" ) analog = _factor_from_count( "analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога" ) cov = _coverage_factor(0.025) text = _build_rationale("low", [deal, analog, cov], advisory_capped=False) assert "7 сделок" in text assert "1 ЖК-аналога" in text assert "2.5%" in text assert " / " in text # перечисление виновников def test_advisory_cap_spoken_in_rationale(self) -> None: # Уровень упёрся в advisory-потолок (не данные) → причина это проговаривает. text = _build_rationale("medium", [_factor("x", "medium")], advisory_capped=True) assert "советующий" in text assert "medium" in text def test_empty_factors_insufficient(self) -> None: assert "недостаточно данных" in _build_rationale("low", [], advisory_capped=False) # ── compute_report_confidence — интеграция (PURE, без БД) ────────────────────── class TestComputeReportConfidence: def test_mixed_factors_weakest_link_low(self) -> None: # Сильная история, но 7 сделок и 1 ЖК → weakest-link low; причина структурная. res = compute_report_confidence( component_confidences=["medium", "high"], deal_count=7, analog_count=1, domrf_coverage=0.4, history_months=36, confounded=False, advisory=True, ) assert isinstance(res, ReportConfidenceResult) assert res.level == "low" assert res.rationale.startswith("Low потому что") assert "7 сделок" in res.rationale assert "1 ЖК-аналога" in res.rationale def test_advisory_never_high(self) -> None: # Все сигналы отличные, но advisory=True → НИКОГДА не high (потолок medium). res = compute_report_confidence( component_confidences=["high", "high"], deal_count=200, analog_count=10, domrf_coverage=0.9, history_months=48, confounded=False, advisory=True, ) assert res.level == "medium" assert res.advisory_capped is True assert "советующий" in res.rationale def test_non_advisory_allows_high(self) -> None: # advisory=False (гипотетически провалидированный стек) → high достижим. res = compute_report_confidence( component_confidences=["high"], deal_count=200, analog_count=10, domrf_coverage=0.9, history_months=48, confounded=False, advisory=False, ) assert res.level == "high" assert res.advisory_capped is False def test_confounded_window_drags_down(self) -> None: # Шок-окно → low даже при прочих хороших сигналах; названо в причине. res = compute_report_confidence( component_confidences=["high"], deal_count=200, analog_count=10, domrf_coverage=0.9, history_months=48, confounded=True, advisory=True, ) assert res.level == "low" assert "шок" in res.rationale def test_all_none_is_low_insufficient(self) -> None: # Граничный graceful: ни одного входа → low + «недостаточно данных». res = compute_report_confidence() assert res.level == "low" assert "недостаточно данных" in res.rationale assert res.factors == [] assert res.advisory_capped is False def test_clean_inputs_medium_under_advisory(self) -> None: # Рабочие (medium-бэнд) счётчики без low-факторов → medium под advisory-cap. res = compute_report_confidence( component_confidences=["medium"], deal_count=20, analog_count=2, domrf_coverage=0.4, history_months=18, advisory=True, ) assert res.level == "medium" # Здесь потолок не понижал (raw уже medium) → не помечаем capped. assert res.advisory_capped is False def test_as_dict_fits_report_confidence_slot(self) -> None: # as_dict() ложится в слот ReportConfidence (#987): {level, rationale, factors}. res = compute_report_confidence(deal_count=7, analog_count=1, advisory=True) d = res.as_dict() assert set(d.keys()) == {"level", "rationale", "factors"} assert d["level"] == "low" assert isinstance(d["rationale"], str) assert isinstance(d["factors"], dict) # Факторы по именам + флаг advisory_capped внутри. assert "deal_count" in d["factors"] assert d["factors"]["deal_count"]["value"] == 7 assert "advisory_capped" in d["factors"] # Полностью JSON-сериализуем (контракт для экспортёров/чата). assert json.loads(json.dumps(d, ensure_ascii=False)) == d def test_deal_count_note_carries_window_months(self) -> None: # #1637: deal_count_months → нота «за N мес» в факторе (и в rationale). res = compute_report_confidence( deal_count=7, deal_count_months=6, advisory=False, ) dc_factor = next(f for f in res.factors if f.name == "deal_count") assert "за 6 мес" in dc_factor.note assert "7 сделок" in dc_factor.note # Структурная причина тоже содержит период (через ноту фактора-виновника). assert "за 6 мес" in res.rationale def test_deal_count_note_without_window_has_no_period(self) -> None: # deal_count_months=None (по умолчанию) → нота без «за N мес» (backward compat). res = compute_report_confidence(deal_count=7, advisory=False) dc_factor = next(f for f in res.factors if f.name == "deal_count") assert "7 сделок" in dc_factor.note assert "за" not in dc_factor.note def test_ignores_garbage_component_confidence(self) -> None: # Мусорный component-уровень не учитывается (whitelist), не роняет искусственно. res = compute_report_confidence( component_confidences=["high", "bogus"], # type: ignore[list-item] deal_count=200, analog_count=10, domrf_coverage=0.9, history_months=48, advisory=False, ) assert res.level == "high" def test_as_dict_keeps_all_component_factors(self) -> None: # Несколько component-факторов не теряются в плоском dict (суффиксуются). res = compute_report_confidence( component_confidences=["high", "medium", "low"], advisory=False ) d = res.as_dict() component_keys = [k for k in d["factors"] if k.startswith("component")] assert len(component_keys) == 3 # ни один не перезаписан assert json.loads(json.dumps(d, ensure_ascii=False)) == d