175 lines
7 KiB
Python
175 lines
7 KiB
Python
"""Тесты секционных 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",
|
||
"get_parcel_info",
|
||
}
|
||
|
||
|
||
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": {}}},
|
||
"parcel_context": {
|
||
"address": "Екатеринбург, ул. Ленина, 1",
|
||
"zone_code": "Ж-4",
|
||
"zone_name": "Зона многоэтажной застройки",
|
||
"max_floors": 25,
|
||
"has_zouit": True,
|
||
"zouit_zone_names": ["Приаэродромная территория"],
|
||
},
|
||
}
|
||
|
||
|
||
# ── Спеки ───────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_tool_specs_cover_all_six() -> None:
|
||
specs = tools.tool_specs()
|
||
assert len(specs) == 6
|
||
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"}
|
||
|
||
|
||
def test_parcel_info_slice_is_verbatim() -> None:
|
||
out = tools.get_parcel_info(_report())
|
||
assert out["zone_code"] == "Ж-4"
|
||
assert out["zone_name"] == "Зона многоэтажной застройки"
|
||
assert out["max_floors"] == 25
|
||
assert out["zouit_zone_names"] == ["Приаэродромная территория"]
|
||
|
||
|
||
def test_parcel_info_missing_returns_marker() -> None:
|
||
# Паспорт участка не влит (analyze-рана не было) → маркер «недоступно», не KeyError.
|
||
assert tools.get_parcel_info({}) == {"available": False}
|
||
assert tools.get_parcel_info({"parcel_context": {}}) == {"available": False}
|
||
|
||
|
||
# ── 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_parcel_info") == ("parcel_context",)
|
||
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
|