"""Юнит-тесты роутинга intent'ов + рендеринга RU-ответов чата (#957, Step 1, БЕЗ LLM). Покрывает: • route_intent — явный intent уважается; keyword-матч RU-вопроса; нет матча → unknown. • render_answer — корректная секция(и) на intent; числа ВЕРБАТИМ из отчёта (не фабрикуются); advisory-оговорка в каждом ответе; graceful на отсутствующей секции («данных по X нет», без KeyError); unknown → меню тем. Никакой БД — работаем с фикстурой-dict (форма SiteFinderReport.as_dict()). """ from __future__ import annotations from typing import Any import pytest from app.schemas.chat import ChatIntent from app.services.chat.intents import _ADVISORY_NOTE, render_answer, route_intent # Разделитель тысяч — неразрывный пробел (зеркало ru-locale, _NBSP в intents.py). _NBSP = " " def _full_report() -> dict[str, Any]: """Полный 8-секционный отчёт (форма SiteFinderReport.as_dict()) с числами-маркерами. Числа подобраны узнаваемыми (7654321, 250000), чтобы тест мог утверждать: они приходят ИМЕННО из отчёта, а не выдуманы рендерером. """ return { "schema_version": "1.0", "advisory": True, "exec_summary": { "headline": "Комфорт-класс, 2-3-комнатные", "verdict": "Участок подходит под жильё комфорт-класса.", "key_numbers": {"площадь_м2": 7654321, "цена_руб_м2": 250000}, "overall_confidence": "medium", }, "market_now": { "market_metrics": {"absorption": 12.0}, "competitors": [{"name": "ЖК А"}], "supply_layers": None, "summary": "Рынок активен.", }, "future_market": { "forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}], "future_supply": {"pressure": 0.4}, "future_competitors": [], "scenarios_summary": {"spread": "wide"}, "summary": "Ожидается дефицит на горизонте 12 мес.", }, "product_tz": { "obj_class": "комфорт", "mix": [{"fmt": "1k"}, {"fmt": "2k"}, {"fmt": "3k"}], "commercial": {"gla_m2": 1200}, "usp": [{"usp": "видовые квартиры"}], "reasons": [{"why": "дефицит формата"}, {"why": "ценовое окно"}], "summary": "Рекомендуется комфорт-класс с акцентом на 2-3-комнатные.", }, "scenarios": { "by_scenario": { "conservative": {"npv": 1}, "base": {"npv": 2}, "aggressive": {"npv": 3}, }, "summary": "Разброс сценариев умеренный.", }, "scoring": { "product_scores": {"overall": 0.72}, "special_indices": { "indices": { "launch_window": {"value": None, "label": "18 мес"}, "cannibalization": {"value": 0.31, "label": None}, "competitor_strength": {"value": None, "label": None}, } }, "overall": 0.72, }, "confidence": { "level": "medium", "rationale": "Данные по сделкам полные, прогноз спроса — прокси.", "factors": {"deals": "ok", "supply": "proxy"}, }, "meta": { "cad_num": "66:41:0204016:10", "district": "Екатеринбург", "segment": {}, "horizons": [12, 24], "schema_version": "1.0", }, } # ── route_intent ───────────────────────────────────────────────────────────────── def test_route_intent_explicit_wins_over_text() -> None: """Явный intent уважается, даже если текст намекает на другое.""" assert route_intent("какие тут риски?", ChatIntent.summary) == ChatIntent.summary @pytest.mark.parametrize( ("message", "expected"), [ ("Что здесь строить?", ChatIntent.what_to_build), ("Какой продукт рекомендуете?", ChatIntent.what_to_build), ("Почему такой прогноз?", ChatIntent.why_forecast), ("На чём основан прогноз?", ChatIntent.why_forecast), ("Какие риски у участка?", ChatIntent.risks), ("Есть ли каннибализация портфеля?", ChatIntent.risks), ("Покажи сценарии развития", ChatIntent.scenarios), ("Дай краткое резюме по участку", ChatIntent.summary), ("Какой вердикт?", ChatIntent.summary), ], ) def test_route_intent_keyword_match(message: str, expected: ChatIntent) -> None: """Keyword/regex-матч RU-вопроса → правильный intent.""" assert route_intent(message, None) == expected def test_route_intent_no_match_returns_unknown() -> None: """Нераспознанный вопрос → unknown.""" assert route_intent("Какая сегодня погода в Москве?", None) == ChatIntent.unknown def test_route_intent_empty_message_unknown() -> None: """Пустое сообщение → unknown (не падаем).""" assert route_intent(" ", None) == ChatIntent.unknown # ── render_answer: секции + числа из отчёта ────────────────────────────────────── def test_render_summary_uses_exec_summary_and_verbatim_numbers() -> None: """summary тянет exec_summary; числа приходят ВЕРБАТИМ из отчёта (форматированы).""" answer, sections = render_answer(ChatIntent.summary, _full_report()) assert sections == ["exec_summary"] assert "Комфорт-класс, 2-3-комнатные" in answer # Числа приходят ВЕРБАТИМ из отчёта (тысячи — неразрывным пробелом, ru-locale). assert f"7{_NBSP}654{_NBSP}321" in answer assert f"250{_NBSP}000" in answer assert "средняя" in answer # overall_confidence=medium → RU assert _ADVISORY_NOTE in answer def test_render_what_to_build_uses_product_tz_and_headline() -> None: """what_to_build тянет product_tz + exec_summary.headline.""" answer, sections = render_answer(ChatIntent.what_to_build, _full_report()) assert "product_tz" in sections assert "exec_summary" in sections assert "комфорт" in answer assert _ADVISORY_NOTE in answer def test_render_why_forecast_uses_future_and_reasons_and_confidence() -> None: """why_forecast тянет future_market + product_tz.reasons + confidence.rationale.""" answer, sections = render_answer(ChatIntent.why_forecast, _full_report()) assert "future_market" in sections assert "product_tz" in sections assert "confidence" in sections assert "2 горизонт" in answer # 2 forecasts_by_horizon assert "прокси" in answer # из confidence.rationale assert _ADVISORY_NOTE in answer def test_render_risks_uses_special_indices_and_confidence() -> None: """risks тянет scoring.special_indices (вкл. каннибализацию) + confidence.""" answer, sections = render_answer(ChatIntent.risks, _full_report()) assert "scoring" in sections assert "confidence" in sections assert "каннибализация портфеля" in answer assert "0,31" in answer # value=0.31 → RU-десятичная из отчёта assert "18 мес" in answer # launch_window label из отчёта assert _ADVISORY_NOTE in answer def test_render_risks_skips_uncomputed_index() -> None: """Индекс с value=None и label=None пропускается честно (не фабрикуем).""" answer, _ = render_answer(ChatIntent.risks, _full_report()) # competitor_strength был None/None → его RU-метки в ответе быть не должно. assert "сила конкурентов" not in answer def test_render_scenarios_uses_scenarios_section() -> None: """scenarios тянет by_scenario + summary, метки сценариев → RU.""" answer, sections = render_answer(ChatIntent.scenarios, _full_report()) assert sections == ["scenarios"] assert "консервативный" in answer assert "базовый" in answer assert "агрессивный" in answer assert _ADVISORY_NOTE in answer def test_render_unknown_returns_menu() -> None: """unknown → меню поддерживаемых тем, секции не используются.""" answer, sections = render_answer(ChatIntent.unknown, _full_report()) assert sections == [] assert "что здесь строить" in answer.lower() assert "сценарии" in answer.lower() assert _ADVISORY_NOTE in answer # ── render_answer: graceful на частичном/пустом отчёте ─────────────────────────── @pytest.mark.parametrize( "intent", [ ChatIntent.summary, ChatIntent.what_to_build, ChatIntent.why_forecast, ChatIntent.risks, ChatIntent.scenarios, ChatIntent.unknown, ], ) def test_render_empty_report_never_raises_and_has_advisory(intent: ChatIntent) -> None: """Пустой отчёт ({}) — никакого KeyError, ответ непустой + advisory-оговорка.""" answer, sections = render_answer(intent, {}) assert isinstance(answer, str) assert answer.strip() assert _ADVISORY_NOTE in answer assert isinstance(sections, list) def test_render_summary_missing_section_says_honestly() -> None: """Нет exec_summary → честное «данных по разделу резюме нет», не выдумка.""" answer, sections = render_answer(ChatIntent.summary, {"market_now": {}}) assert sections == [] assert "Данных по разделу «резюме» в отчёте нет." in answer def test_render_risks_missing_special_indices_says_honestly() -> None: """Нет scoring.special_indices → честное сообщение об отсутствии §25-индексов.""" answer, _ = render_answer(ChatIntent.risks, {"scoring": {"product_scores": {}}}) assert "специальные индексы §25" in answer.lower() def test_render_does_not_fabricate_numbers_on_empty() -> None: """На пустом отчёте в ответе нет «выдуманных» числовых маркеров фикстуры.""" answer, _ = render_answer(ChatIntent.summary, {}) assert f"7{_NBSP}654{_NBSP}321" not in answer assert f"250{_NBSP}000" not in answer