deficit_index pins to -1.0 for every ЕКБ segment (12mo demand flow vs multi-year supply stock → log-ratio clamps) → zero discriminating power, though the oversupply is partly real. Add MOI (gross competing supply / demand_per_mo), the real-estate absorption standard, as an additive non-saturating companion that DISCRIMINATES (Уралмаш 42mo … Чермет 109mo) where deficit cannot. deficit_index math kept exactly as-is (honest absolute: -1 = genuinely oversupplied); docstrings clarify -1 is common and MOI is the discriminating companion (no recalibration). _gross_supply extract-method (single source of truth; _project_supply behavior byte-identical, code-review-verified). Surface MOI in §22 future_market (passthrough) + exec_summary key_numbers/verdict. Guards: no demand → None, no supply → 0. Prod: MOI varies 42→109mo, deficit stays -1. Discrimination test pins MOI separating two segments both at deficit -1. Refs #952.
678 lines
29 KiB
Python
678 lines
29 KiB
Python
"""Unit-тесты §13 сборщика отчёта (#988, 955-A2) — ЧИСТОЕ маппирование, БЕЗ БД.
|
||
|
||
Сборщик PURE (берёт уже-посчитанные входы, в БД не ходит, §9.x не пересобирает) →
|
||
тесты чистые, без фикстур БД:
|
||
• полный сбор (sample analyze dict + sample advisory dict'ы всех под-сервисов) →
|
||
SiteFinderReport со ВСЕМИ восемью секциями заполненными, `as_dict()` JSON-сериализуем,
|
||
confidence посчитана через #990 (структурная rationale присутствует), exec_summary
|
||
headline непустой, advisory True;
|
||
• частичный сбор (ТОЛЬКО analyze dict) → валидный частичный отчёт (market_now заполнен,
|
||
future-секции пусты, confidence 'low');
|
||
• вход КАК dataclass-инстанс, ТАК и как `.as_dict()`-словарь — оба нормализуются
|
||
(`_as_dict_or`), отчёт идентичен;
|
||
• извлечение сигналов качества данных для #990 (deal_count/analog_count/domrf_coverage/
|
||
history_months/confounded) + exec_summary-синтез;
|
||
• graceful: пустой analyze {} → отчёт всё равно валиден (8 секций, JSON-safe).
|
||
|
||
Детерминированно, без LLM, без сети. DATABASE_URL до импорта app-модулей (зеркало
|
||
соседних тестов — на случай side-effect'ов импорта пакета forecasting).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.forecasting.report import SiteFinderReport
|
||
from app.services.forecasting.report_assembler import (
|
||
_analog_count,
|
||
_as_dict_or,
|
||
_confounded,
|
||
_deal_count,
|
||
_domrf_coverage,
|
||
_history_months,
|
||
_primary_deficit_index,
|
||
_primary_months_of_inventory,
|
||
assemble_report,
|
||
)
|
||
|
||
# Восемь обязательных секций §13 — стабильный контракт `as_dict()`.
|
||
_SECTION_KEYS: tuple[str, ...] = (
|
||
"exec_summary",
|
||
"market_now",
|
||
"future_market",
|
||
"product_tz",
|
||
"scenarios",
|
||
"scoring",
|
||
"confidence",
|
||
"meta",
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Sample-данные: форма `as_dict()` под-сервисов (плоские, JSON-safe). НЕ зовём живые
|
||
# сервисы (сборщик их не считает — берёт готовые входы).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _sample_analyze() -> dict[str, Any]:
|
||
"""Реалистичный (усечённый) dict вывода analyze_parcel — только релевантные ключи."""
|
||
return {
|
||
"cad_num": "66:41:0000000:1",
|
||
"district": {"district_name": "Верх-Исетский"},
|
||
"competitors": [
|
||
{"obj_id": 1, "comm_name": "ЖК Альфа", "distance_m": 320.0},
|
||
{"obj_id": 2, "comm_name": "ЖК Бета", "distance_m": 540.0},
|
||
],
|
||
"market_pulse": {
|
||
"competitors_total": 12,
|
||
"competitors_with_price": 9,
|
||
"coverage_pct": 75.0,
|
||
"market_avg_price_per_m2": 145000,
|
||
},
|
||
"market_avg_price_per_m2": 145000,
|
||
"market_data_coverage_pct": 75.0,
|
||
"poi_count": 18,
|
||
}
|
||
|
||
|
||
def _sample_market_metrics() -> dict[str, Any]:
|
||
"""Форма MarketMetrics.as_dict() (#950, §9.2) — несёт счётчики качества данных."""
|
||
return {
|
||
"district": "Верх-Исетский",
|
||
"obj_count": 7,
|
||
"n_lots": 240,
|
||
"n_sold": 88,
|
||
"n_available": 152,
|
||
"window_months": 18,
|
||
"premise_kind": "квартира",
|
||
"confidence": "medium",
|
||
"unit_velocity": 8.2,
|
||
"overstock_index": 0.21,
|
||
"absorption_rate": 0.05,
|
||
}
|
||
|
||
|
||
def _sample_supply_layers() -> dict[str, Any]:
|
||
"""Форма supply-слоёв (#950, §9.3) — несёт domrf_coverage."""
|
||
return {"open_units": 1200, "hidden_units": 800, "domrf_coverage": 0.55}
|
||
|
||
|
||
def _sample_forecasts() -> list[dict[str, Any]]:
|
||
"""Per-горизонт DemandSupplyForecast.as_dict() (#952) — деривируем дефицит/конкурентов."""
|
||
return [
|
||
{
|
||
"horizon_months": 6,
|
||
"deficit_index": 0.21,
|
||
"months_of_inventory": 18.5,
|
||
"confidence": "medium",
|
||
"future_competitors": [],
|
||
"confounded": False,
|
||
},
|
||
{
|
||
"horizon_months": 12,
|
||
"deficit_index": 0.34,
|
||
"months_of_inventory": 24.2,
|
||
"confidence": "medium",
|
||
"future_competitors": [{"obj_id": 3, "comm_name": "ЖК Гамма", "relevance_weight": 0.6}],
|
||
"confounded": False,
|
||
},
|
||
{
|
||
"horizon_months": 18,
|
||
"deficit_index": 0.28,
|
||
"months_of_inventory": 31.0,
|
||
"confidence": "low",
|
||
"future_competitors": [],
|
||
"confounded": False,
|
||
},
|
||
{
|
||
"horizon_months": 24,
|
||
"deficit_index": 0.19,
|
||
"months_of_inventory": 40.7,
|
||
"confidence": "low",
|
||
"future_competitors": [],
|
||
"confounded": False,
|
||
},
|
||
]
|
||
|
||
|
||
def _sample_future_supply() -> dict[str, Any]:
|
||
"""Форма FutureSupplyPressure.as_dict() (#950, §9.3)."""
|
||
return {
|
||
"district": "Верх-Исетский",
|
||
"horizon_months": 12,
|
||
"confidence": "low",
|
||
"index": 0.42,
|
||
"breakdown": {"open_units": 1200, "hidden_units": 800},
|
||
}
|
||
|
||
|
||
def _sample_scenarios() -> list[dict[str, Any]]:
|
||
"""Три ScenarioForecast.as_dict() (#984) — conservative/base/aggressive."""
|
||
return [
|
||
{
|
||
"scenario": "conservative",
|
||
"advisory": True,
|
||
"rate_path": {12: 18.0},
|
||
"forecasts": [{"horizon_months": 12, "deficit_index": 0.18, "confidence": "medium"}],
|
||
},
|
||
{
|
||
"scenario": "base",
|
||
"advisory": True,
|
||
"rate_path": {12: 16.0},
|
||
"forecasts": [{"horizon_months": 12, "deficit_index": 0.34, "confidence": "medium"}],
|
||
},
|
||
{
|
||
"scenario": "aggressive",
|
||
"advisory": True,
|
||
"rate_path": {12: 13.0},
|
||
"forecasts": [{"horizon_months": 12, "deficit_index": 0.49, "confidence": "medium"}],
|
||
},
|
||
]
|
||
|
||
|
||
def _sample_overlay() -> dict[str, Any]:
|
||
"""Форма build_forecast_overlay (#983) — класс §10.2 / ranked_segments / USP / commercial."""
|
||
return {
|
||
"horizon_months": 12,
|
||
"mode": "demand_supply",
|
||
"advisory": True,
|
||
"ranked_segments": [
|
||
{
|
||
"bucket": "1-Студия",
|
||
"obj_class": "комфорт",
|
||
"deficit_index": 0.34,
|
||
"confidence": "medium",
|
||
},
|
||
{
|
||
"bucket": "2-1-к",
|
||
"obj_class": "комфорт",
|
||
"deficit_index": 0.22,
|
||
"confidence": "medium",
|
||
},
|
||
],
|
||
"warnings": [],
|
||
"class_reco": {
|
||
"obj_class": "комфорт",
|
||
"mean_deficit_index": 0.28,
|
||
"n_segments": 2,
|
||
"reason": {
|
||
"why": "Класс «комфорт»: сильнейший средний дефицит.",
|
||
"drivers": [{"factor": "deficit_index", "value": 0.28, "direction": "+"}],
|
||
"rejected": [],
|
||
"what_would_change": ["Рост ставки → спрос мягче."],
|
||
"confidence": "medium",
|
||
"advisory": True,
|
||
},
|
||
},
|
||
"usp": [
|
||
{
|
||
"segment": "1-Студия",
|
||
"obj_class": "комфорт",
|
||
"deficit_index": 0.34,
|
||
"usp_text": "Дефицит формата «1-Студия (комфорт)» — стройте его.",
|
||
}
|
||
],
|
||
"commercial": {"available": False, "caveat": "коммерция: нет достаточных данных"},
|
||
}
|
||
|
||
|
||
def _sample_product_scores() -> dict[str, Any]:
|
||
"""Форма ProductScoreCard.as_dict() (#985, 10 скоров + overall)."""
|
||
return {
|
||
"segment": {"obj_class": "комфорт", "room_bucket": "1-к 30-45"},
|
||
"horizon_months": 12,
|
||
"scores": {
|
||
"market_fit": {"key": "market_fit", "value": 0.67, "confidence": "medium"},
|
||
"demand": {"key": "demand", "value": 0.51, "confidence": "medium"},
|
||
},
|
||
"overall": 0.62,
|
||
"advisory": True,
|
||
"confidence": "medium",
|
||
}
|
||
|
||
|
||
def _sample_special_indices() -> dict[str, Any]:
|
||
"""Форма SpecialIndices.as_dict() (#986, 6 индексов)."""
|
||
return {
|
||
"segment": {"obj_class": "комфорт"},
|
||
"district": "Верх-Исетский",
|
||
"indices": {
|
||
"launch_window": {"key": "launch_window", "value": 0.6, "label": "12 мес"},
|
||
"product_void": {"key": "product_void", "value": 0.4, "label": "2 белых пятна"},
|
||
},
|
||
"advisory": True,
|
||
"confidence": "medium",
|
||
}
|
||
|
||
|
||
def _full_assemble() -> SiteFinderReport:
|
||
"""Полный сбор отчёта из sample analyze dict + sample advisory dict'ов всех секций."""
|
||
return assemble_report(
|
||
_sample_analyze(),
|
||
market_metrics=_sample_market_metrics(),
|
||
supply_layers=_sample_supply_layers(),
|
||
forecasts=_sample_forecasts(),
|
||
future_supply=_sample_future_supply(),
|
||
scenarios=_sample_scenarios(),
|
||
recommendation_overlay=_sample_overlay(),
|
||
product_scores=_sample_product_scores(),
|
||
special_indices=_sample_special_indices(),
|
||
segment={"obj_class": "комфорт", "room_bucket": "1-к 30-45"},
|
||
cad_num="66:41:0000000:1",
|
||
district="Верх-Исетский",
|
||
)
|
||
|
||
|
||
# ── Полный сбор: восемь секций + JSON-serializability + advisory ──────────────
|
||
|
||
|
||
class TestFullAssemble:
|
||
def test_returns_site_finder_report(self) -> None:
|
||
assert isinstance(_full_assemble(), SiteFinderReport)
|
||
|
||
def test_all_eight_sections_present_and_dicts(self) -> None:
|
||
payload = _full_assemble().as_dict()
|
||
for key in _SECTION_KEYS:
|
||
assert key in payload, f"отсутствует секция {key}"
|
||
assert isinstance(payload[key], dict)
|
||
|
||
def test_as_dict_is_json_serializable(self) -> None:
|
||
# Главный контракт #987: as_dict() проходит json.dumps без default= (ничего
|
||
# сырого/dataclass'ов в выдаче). Полный round-trip (json.loads == payload) на
|
||
# частичном/пустом отчёте ниже — здесь scenarios.rate_path несёт int-ключи
|
||
# (как ScenarioForecast.as_dict()), которые JSON приводит к строкам, поэтому
|
||
# строгое равенство неинформативно — важна именно сериализуемость.
|
||
payload = _full_assemble().as_dict()
|
||
dumped = json.dumps(payload, ensure_ascii=False)
|
||
assert isinstance(dumped, str)
|
||
assert json.loads(dumped) is not None
|
||
|
||
def test_advisory_true_everywhere(self) -> None:
|
||
payload = _full_assemble().as_dict()
|
||
assert payload["advisory"] is True
|
||
assert payload["meta"]["generated_advisory"] is True
|
||
|
||
def test_schema_version_present(self) -> None:
|
||
payload = _full_assemble().as_dict()
|
||
assert payload["schema_version"] == payload["meta"]["schema_version"]
|
||
|
||
|
||
# ── market_now ← analyze + §9.2/§9.3 ──────────────────────────────────────────
|
||
|
||
|
||
class TestMarketNow:
|
||
def test_market_metrics_and_supply_passed_through(self) -> None:
|
||
market_now = _full_assemble().as_dict()["market_now"]
|
||
assert market_now["market_metrics"]["unit_velocity"] == 8.2
|
||
assert market_now["supply_layers"]["open_units"] == 1200
|
||
|
||
def test_competitors_from_analyze(self) -> None:
|
||
market_now = _full_assemble().as_dict()["market_now"]
|
||
assert len(market_now["competitors"]) == 2
|
||
assert market_now["competitors"][0]["comm_name"] == "ЖК Альфа"
|
||
|
||
def test_summary_synthesized(self) -> None:
|
||
market_now = _full_assemble().as_dict()["market_now"]
|
||
assert market_now["summary"] is not None
|
||
assert "абсорбция" in market_now["summary"]
|
||
|
||
|
||
# ── future_market ← forecasts / future_supply / scenarios ─────────────────────
|
||
|
||
|
||
class TestFutureMarket:
|
||
def test_forecasts_by_horizon(self) -> None:
|
||
fm = _full_assemble().as_dict()["future_market"]
|
||
assert len(fm["forecasts_by_horizon"]) == 4
|
||
assert fm["forecasts_by_horizon"][1]["deficit_index"] == 0.34
|
||
|
||
def test_months_of_inventory_flows_through(self) -> None:
|
||
# MOI прокидывается per-горизонт автоматически (passthrough as_dict()).
|
||
fm = _full_assemble().as_dict()["future_market"]
|
||
assert fm["forecasts_by_horizon"][1]["months_of_inventory"] == 24.2
|
||
|
||
def test_summary_mentions_months_of_inventory(self) -> None:
|
||
# future_market.summary несёт ДИСКРИМИНИРУЮЩИЙ MOI-headline рядом с дефицитом.
|
||
fm = _full_assemble().as_dict()["future_market"]
|
||
assert fm["summary"] is not None
|
||
assert "мес конкурирующего предложения" in fm["summary"]
|
||
|
||
def test_future_supply_passed_through(self) -> None:
|
||
fm = _full_assemble().as_dict()["future_market"]
|
||
assert fm["future_supply"]["index"] == 0.42
|
||
|
||
def test_future_competitors_from_primary_horizon(self) -> None:
|
||
fm = _full_assemble().as_dict()["future_market"]
|
||
# Будущие конкуренты подняты с горизонта 12 мес (там список непуст).
|
||
assert len(fm["future_competitors"]) == 1
|
||
assert fm["future_competitors"][0]["comm_name"] == "ЖК Гамма"
|
||
|
||
def test_scenarios_summary_deficit_spread(self) -> None:
|
||
fm = _full_assemble().as_dict()["future_market"]
|
||
assert fm["scenarios_summary"]["conservative"] == 0.18
|
||
assert fm["scenarios_summary"]["aggressive"] == 0.49
|
||
|
||
|
||
# ── product_tz ← overlay #983 ─────────────────────────────────────────────────
|
||
|
||
|
||
class TestProductTz:
|
||
def test_class_from_overlay(self) -> None:
|
||
pt = _full_assemble().as_dict()["product_tz"]
|
||
assert pt["obj_class"] == "комфорт"
|
||
|
||
def test_mix_from_ranked_segments(self) -> None:
|
||
pt = _full_assemble().as_dict()["product_tz"]
|
||
assert len(pt["mix"]) == 2
|
||
assert pt["mix"][0]["bucket"] == "1-Студия"
|
||
|
||
def test_usp_and_commercial(self) -> None:
|
||
pt = _full_assemble().as_dict()["product_tz"]
|
||
assert len(pt["usp"]) == 1
|
||
assert pt["commercial"]["available"] is False
|
||
|
||
def test_reasons_lifted_from_class_reco(self) -> None:
|
||
pt = _full_assemble().as_dict()["product_tz"]
|
||
assert len(pt["reasons"]) == 1
|
||
assert "комфорт" in pt["reasons"][0]["why"]
|
||
|
||
|
||
# ── scenarios ← #984 by_scenario ──────────────────────────────────────────────
|
||
|
||
|
||
class TestScenarios:
|
||
def test_by_scenario_keyed(self) -> None:
|
||
sc = _full_assemble().as_dict()["scenarios"]
|
||
assert set(sc["by_scenario"].keys()) == {"conservative", "base", "aggressive"}
|
||
|
||
def test_summary_present(self) -> None:
|
||
sc = _full_assemble().as_dict()["scenarios"]
|
||
assert sc["summary"] is not None
|
||
|
||
|
||
# ── scoring ← #985 + #986 ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestScoring:
|
||
def test_product_scores_and_overall(self) -> None:
|
||
scoring = _full_assemble().as_dict()["scoring"]
|
||
assert scoring["product_scores"]["overall"] == 0.62
|
||
assert scoring["overall"] == 0.62
|
||
|
||
def test_special_indices_passed_through(self) -> None:
|
||
scoring = _full_assemble().as_dict()["scoring"]
|
||
assert scoring["special_indices"]["indices"]["launch_window"]["value"] == 0.6
|
||
|
||
|
||
# ── confidence ← compute_report_confidence (#990) ─────────────────────────────
|
||
|
||
|
||
class TestConfidence:
|
||
def test_level_computed(self) -> None:
|
||
conf = _full_assemble().as_dict()["confidence"]
|
||
assert conf["level"] in ("high", "medium", "low")
|
||
|
||
def test_advisory_never_high(self) -> None:
|
||
# advisory=True → #990 cap 'medium' (НИКОГДА 'high').
|
||
conf = _full_assemble().as_dict()["confidence"]
|
||
assert conf["level"] != "high"
|
||
|
||
def test_structural_rationale_present(self) -> None:
|
||
conf = _full_assemble().as_dict()["confidence"]
|
||
assert isinstance(conf["rationale"], str) and conf["rationale"]
|
||
|
||
def test_factors_carry_advisory_capped(self) -> None:
|
||
conf = _full_assemble().as_dict()["confidence"]
|
||
assert "advisory_capped" in conf["factors"]
|
||
|
||
def test_data_quality_factors_extracted(self) -> None:
|
||
# deal_count (n_sold=88) / analog_count (obj_count=7) / domrf_coverage (0.55)
|
||
# извлечены и попали в факторы #990.
|
||
conf = _full_assemble().as_dict()["confidence"]
|
||
factors = conf["factors"]
|
||
assert "deal_count" in factors
|
||
assert "analog_count" in factors
|
||
assert "domrf_coverage" in factors
|
||
|
||
|
||
# ── exec_summary — синтез ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestExecSummary:
|
||
def test_headline_non_empty(self) -> None:
|
||
es = _full_assemble().as_dict()["exec_summary"]
|
||
assert isinstance(es["headline"], str) and es["headline"]
|
||
|
||
def test_headline_mentions_class(self) -> None:
|
||
es = _full_assemble().as_dict()["exec_summary"]
|
||
assert "комфорт" in es["headline"]
|
||
|
||
def test_verdict_and_key_numbers(self) -> None:
|
||
es = _full_assemble().as_dict()["exec_summary"]
|
||
assert es["verdict"] is not None
|
||
assert es["key_numbers"]["deficit_index"] == 0.34
|
||
assert es["key_numbers"]["months_of_inventory"] == 24.2
|
||
assert es["key_numbers"]["overall_score"] == 0.62
|
||
|
||
def test_verdict_mentions_months_of_inventory(self) -> None:
|
||
# ДИСКРИМИНИРУЮЩИЙ MOI-headline присутствует в вердикте exec_summary.
|
||
es = _full_assemble().as_dict()["exec_summary"]
|
||
assert "мес конкурирующего предложения" in es["verdict"]
|
||
|
||
def test_overall_confidence_matches_section(self) -> None:
|
||
payload = _full_assemble().as_dict()
|
||
assert payload["exec_summary"]["overall_confidence"] == payload["confidence"]["level"]
|
||
|
||
|
||
# ── meta ──────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestMeta:
|
||
def test_context_fields(self) -> None:
|
||
meta = _full_assemble().as_dict()["meta"]
|
||
assert meta["cad_num"] == "66:41:0000000:1"
|
||
assert meta["district"] == "Верх-Исетский"
|
||
assert meta["segment"]["obj_class"] == "комфорт"
|
||
assert meta["horizons"] == [6, 12, 18, 24]
|
||
|
||
|
||
# ── Частичный сбор: ТОЛЬКО analyze dict → валидный частичный отчёт ─────────────
|
||
|
||
|
||
class TestPartialAssemble:
|
||
def _partial(self) -> SiteFinderReport:
|
||
return assemble_report(_sample_analyze(), cad_num="66:41:0000000:1")
|
||
|
||
def test_valid_partial_report(self) -> None:
|
||
report = self._partial()
|
||
assert isinstance(report, SiteFinderReport)
|
||
payload = report.as_dict()
|
||
# Все восемь секций присутствуют (контракт стабилен), JSON-safe.
|
||
for key in _SECTION_KEYS:
|
||
assert key in payload
|
||
assert json.loads(json.dumps(payload, ensure_ascii=False)) == payload
|
||
|
||
def test_market_now_filled_from_analyze(self) -> None:
|
||
payload = self._partial().as_dict()
|
||
# competitors из analyze есть; market_metrics/supply_layers None (не переданы).
|
||
assert len(payload["market_now"]["competitors"]) == 2
|
||
assert payload["market_now"]["market_metrics"] is None
|
||
assert payload["market_now"]["supply_layers"] is None
|
||
|
||
def test_future_sections_empty(self) -> None:
|
||
payload = self._partial().as_dict()
|
||
assert payload["future_market"]["forecasts_by_horizon"] == []
|
||
assert payload["future_market"]["future_supply"] is None
|
||
assert payload["scenarios"]["by_scenario"] == {}
|
||
assert payload["scoring"]["product_scores"] is None
|
||
assert payload["product_tz"]["obj_class"] is None
|
||
|
||
def test_confidence_capped_advisory_on_partial(self) -> None:
|
||
# Без forecasts/metrics #990 видит из analyze только analog_count (12 →
|
||
# high) + domrf_coverage (75% → 0.75 → high); None-сигналы (deal_count/
|
||
# history) #990 в факторы НЕ добавляет (None ≠ low-фактор). Weakest-link →
|
||
# high, но advisory-cap опускает до 'medium' (НИКОГДА не 'high').
|
||
payload = self._partial().as_dict()
|
||
assert payload["confidence"]["level"] == "medium"
|
||
assert payload["confidence"]["level"] != "high"
|
||
assert payload["confidence"]["rationale"]
|
||
|
||
def test_exec_summary_still_has_headline(self) -> None:
|
||
payload = self._partial().as_dict()
|
||
assert payload["exec_summary"]["headline"]
|
||
|
||
def test_advisory_true(self) -> None:
|
||
assert self._partial().as_dict()["advisory"] is True
|
||
|
||
|
||
# ── Graceful: пустой analyze {} → отчёт всё равно валиден ──────────────────────
|
||
|
||
|
||
class TestGracefulEmpty:
|
||
def test_empty_analyze_valid_report(self) -> None:
|
||
report = assemble_report({})
|
||
payload = report.as_dict()
|
||
for key in _SECTION_KEYS:
|
||
assert key in payload
|
||
assert json.loads(json.dumps(payload, ensure_ascii=False)) == payload
|
||
assert payload["advisory"] is True
|
||
# Всё тонко → confidence 'low', headline честный «недостаточно данных».
|
||
assert payload["confidence"]["level"] == "low"
|
||
assert payload["exec_summary"]["headline"]
|
||
|
||
def test_all_none_inputs(self) -> None:
|
||
# Явные None всех под-сервисов — не должно бросать, отчёт валиден.
|
||
report = assemble_report(
|
||
{},
|
||
market_metrics=None,
|
||
supply_layers=None,
|
||
forecasts=None,
|
||
future_supply=None,
|
||
scenarios=None,
|
||
recommendation_overlay=None,
|
||
product_scores=None,
|
||
special_indices=None,
|
||
)
|
||
assert isinstance(report, SiteFinderReport)
|
||
|
||
|
||
# ── Вход КАК dataclass, ТАК и as_dict-словарь → нормализуется ──────────────────
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _FakeMetrics:
|
||
"""Минимальный stand-in под-сервиса с `as_dict()` (как реальный MarketMetrics)."""
|
||
|
||
unit_velocity: float
|
||
obj_count: int
|
||
n_sold: int
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
return {
|
||
"unit_velocity": self.unit_velocity,
|
||
"obj_count": self.obj_count,
|
||
"n_sold": self.n_sold,
|
||
"confidence": "medium",
|
||
"window_months": 18,
|
||
}
|
||
|
||
|
||
class TestInputNormalization:
|
||
def test_dataclass_instance_accepted(self) -> None:
|
||
# Передаём ОБЪЕКТ (не as_dict) — сборщик нормализует через _as_dict_or.
|
||
report = assemble_report(_sample_analyze(), market_metrics=_FakeMetrics(8.2, 7, 88))
|
||
market_now = report.as_dict()["market_now"]
|
||
assert market_now["market_metrics"]["unit_velocity"] == 8.2
|
||
|
||
def test_dataclass_and_dict_give_same_market_metrics(self) -> None:
|
||
from_obj = assemble_report(
|
||
_sample_analyze(), market_metrics=_FakeMetrics(8.2, 7, 88)
|
||
).as_dict()
|
||
from_dict = assemble_report(
|
||
_sample_analyze(), market_metrics=_FakeMetrics(8.2, 7, 88).as_dict()
|
||
).as_dict()
|
||
assert from_obj["market_now"]["market_metrics"] == from_dict["market_now"]["market_metrics"]
|
||
|
||
|
||
# ── Pure-хелперы извлечения сигналов (юнит, без БД) ────────────────────────────
|
||
|
||
|
||
class TestSignalExtractionHelpers:
|
||
def test_as_dict_or_normalizes(self) -> None:
|
||
assert _as_dict_or(None) is None
|
||
assert _as_dict_or({"a": 1}) == {"a": 1}
|
||
assert _as_dict_or(_FakeMetrics(1.0, 2, 3))["obj_count"] == 2
|
||
assert _as_dict_or(42) is None # мусор → None (graceful)
|
||
|
||
def test_deal_count_from_market_metrics(self) -> None:
|
||
assert _deal_count({}, {"n_sold": 88}) == 88
|
||
assert _deal_count({}, None) is None
|
||
|
||
def test_analog_count_prefers_obj_count(self) -> None:
|
||
assert _analog_count({"market_pulse": {"competitors_total": 12}}, {"obj_count": 7}) == 7
|
||
|
||
def test_analog_count_fallback_to_analyze(self) -> None:
|
||
assert _analog_count({"market_pulse": {"competitors_total": 12}}, None) == 12
|
||
assert _analog_count({"competitors": [{}, {}, {}]}, None) == 3
|
||
assert _analog_count({}, None) is None
|
||
|
||
def test_domrf_coverage_fraction_and_percent(self) -> None:
|
||
# supply_layers доля (0.55) → как есть; analyze проценты (75.0) → /100.
|
||
assert _domrf_coverage({}, {"domrf_coverage": 0.55}) == 0.55
|
||
assert _domrf_coverage({"market_data_coverage_pct": 75.0}, None) == 0.75
|
||
assert _domrf_coverage({}, None) is None
|
||
|
||
def test_domrf_coverage_sub_one_percent_not_read_as_fraction(self) -> None:
|
||
# BUG #3: настоящий sub-1% процент (0.8% покрытия) — percent-ветка делит на 100
|
||
# → 0.008, НЕ 0.8 (что было бы 80% и инфлировало бы confidence в exactly
|
||
# near-zero кейсе, который §15 призван флагать).
|
||
assert _domrf_coverage({"market_data_coverage_pct": 0.8}, None) == 0.008
|
||
# Каноничный sparse-сигнал проекта ~2.5%.
|
||
assert _domrf_coverage({"market_data_coverage_pct": 2.5}, None) == 0.025
|
||
|
||
def test_domrf_coverage_fraction_source_unchanged(self) -> None:
|
||
# supply_layers — уже доля: 0.025 остаётся 0.025 (НЕ делим на 100).
|
||
assert _domrf_coverage({}, {"domrf_coverage": 0.025}) == 0.025
|
||
|
||
def test_history_months_from_window(self) -> None:
|
||
assert _history_months({"window_months": 18}, []) == 18
|
||
assert _history_months(None, []) is None
|
||
|
||
def test_confounded_any_horizon(self) -> None:
|
||
assert _confounded([{"confounded": False}, {"confounded": True}]) is True
|
||
assert _confounded([{"is_confounded_window": True}]) is True
|
||
assert _confounded([{"confounded": False}]) is False
|
||
assert _confounded([]) is False
|
||
|
||
def test_primary_deficit_prefers_12mo(self) -> None:
|
||
forecasts = [
|
||
{"horizon_months": 6, "deficit_index": 0.21},
|
||
{"horizon_months": 12, "deficit_index": 0.34},
|
||
]
|
||
assert _primary_deficit_index(forecasts) == 0.34
|
||
|
||
def test_primary_deficit_fallback_first(self) -> None:
|
||
forecasts = [{"horizon_months": 6, "deficit_index": 0.21}]
|
||
assert _primary_deficit_index(forecasts) == 0.21
|
||
assert _primary_deficit_index([]) is None
|
||
|
||
def test_primary_moi_prefers_12mo(self) -> None:
|
||
forecasts = [
|
||
{"horizon_months": 6, "months_of_inventory": 18.5},
|
||
{"horizon_months": 12, "months_of_inventory": 24.2},
|
||
]
|
||
assert _primary_months_of_inventory(forecasts) == 24.2
|
||
|
||
def test_primary_moi_fallback_first(self) -> None:
|
||
forecasts = [{"horizon_months": 6, "months_of_inventory": 18.5}]
|
||
assert _primary_months_of_inventory(forecasts) == 18.5
|
||
assert _primary_months_of_inventory([]) is None
|
||
|
||
def test_primary_moi_none_when_absent(self) -> None:
|
||
# forecast без MOI-ключа → None (graceful, не KeyError).
|
||
assert _primary_months_of_inventory([{"horizon_months": 12, "deficit_index": -1.0}]) is None
|