"""Тесты секционных tool'ов чата (#957, Step 2) — PURE срезы report_dict, без БД/recompute. Покрываем: • tool_specs: 5 спек, валидная OpenAI-форма, без параметров. • executors: достают нужную секцию ВЕРБАТИМ; числа не пересчитываются. • robust: отсутствует секция → маркер «недоступно», НЕ KeyError. • execute_tool/sections_for_tool: реестр имя→executor + provenance-секции. • неизвестное имя tool'а: безопасный маркер + пустые секции (не падаем). """ from __future__ import annotations from typing import Any from app.services.chat import tools _EXPECTED_TOOLS = { "get_exec_summary", "get_product_recommendation", "get_forecast", "get_risks", "get_scenarios", } def _report() -> dict[str, Any]: """Узнаваемый отчёт (форма SiteFinderReport.as_dict()).""" return { "exec_summary": { "headline": "Комфорт-класс", "key_numbers": {"цена_руб_м2": 250000}, "overall_confidence": "medium", }, "future_market": { "forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}], "future_supply": {"pressure": 0.4}, }, "product_tz": {"obj_class": "комфорт", "mix": [{"fmt": "2k"}]}, "scoring": {"special_indices": {"indices": {"cannibalization": {"value": 0.31}}}}, "confidence": {"level": "medium", "factors": {"deals": "ok"}}, "scenarios": {"by_scenario": {"base": {}, "conservative": {}}}, } # ── Спеки ─────────────────────────────────────────────────────────────────────── def test_tool_specs_cover_all_five() -> None: specs = tools.tool_specs() assert len(specs) == 5 assert {s["function"]["name"] for s in specs} == _EXPECTED_TOOLS def test_tool_specs_are_valid_openai_function_shape() -> None: for spec in tools.tool_specs(): assert spec["type"] == "function" fn = spec["function"] assert isinstance(fn["name"], str) and fn["name"] assert isinstance(fn["description"], str) and fn["description"] params = fn["parameters"] assert params["type"] == "object" # Секционные tool'ы без аргументов (модель не управляет вычислениями). assert params["properties"] == {} assert params["additionalProperties"] is False # ── Executors: верные срезы, числа вербатим ────────────────────────────────────── def test_exec_summary_slice_is_verbatim() -> None: out = tools.get_exec_summary(_report()) assert out["headline"] == "Комфорт-класс" # Число берётся как есть, не пересчитывается. assert out["key_numbers"]["цена_руб_м2"] == 250000 def test_product_recommendation_slice() -> None: out = tools.get_product_recommendation(_report()) assert out["obj_class"] == "комфорт" assert out["mix"] == [{"fmt": "2k"}] def test_forecast_slice() -> None: out = tools.get_forecast(_report()) assert len(out["forecasts_by_horizon"]) == 2 assert out["future_supply"]["pressure"] == 0.4 def test_risks_merges_scoring_and_confidence() -> None: out = tools.get_risks(_report()) assert set(out) == {"scoring", "confidence"} indices = out["scoring"]["special_indices"]["indices"] assert indices["cannibalization"]["value"] == 0.31 assert out["confidence"]["level"] == "medium" def test_scenarios_slice() -> None: out = tools.get_scenarios(_report()) assert set(out["by_scenario"]) == {"base", "conservative"} # ── Robust: отсутствующая/пустая секция → маркер, НЕ KeyError ───────────────────── def test_missing_section_returns_not_available_marker() -> None: assert tools.get_exec_summary({}) == {"available": False} assert tools.get_forecast({}) == {"available": False} assert tools.get_scenarios({}) == {"available": False} def test_risks_robust_to_missing_subsections() -> None: out = tools.get_risks({}) assert out == { "scoring": {"available": False}, "confidence": {"available": False}, } def test_empty_section_dict_treated_as_unavailable() -> None: # Пустой dict секции → маркер (нечего показывать), а не «{}» как валидные данные. assert tools.get_product_recommendation({"product_tz": {}}) == {"available": False} # ── Реестр: execute_tool / sections_for_tool / is_known_tool ───────────────────── def test_execute_tool_dispatches_by_name() -> None: out = tools.execute_tool("get_exec_summary", _report()) assert out["headline"] == "Комфорт-класс" def test_execute_unknown_tool_returns_marker_not_raise() -> None: assert tools.execute_tool("get_nonexistent", _report()) == {"available": False} def test_sections_for_tool_provenance() -> None: assert tools.sections_for_tool("get_exec_summary") == ("exec_summary",) assert tools.sections_for_tool("get_risks") == ("scoring", "confidence") assert tools.sections_for_tool("get_nonexistent") == () def test_is_known_tool() -> None: assert tools.is_known_tool("get_forecast") is True assert tools.is_known_tool("nope") is False def test_executors_do_not_mutate_report() -> None: report = _report() before = dict(report["exec_summary"]) tools.get_exec_summary(report) assert report["exec_summary"] == before