226 lines
9 KiB
Python
226 lines
9 KiB
Python
"""Unit-тесты для reader макропоказателей (#945 PR A, ТЗ §8.8).
|
||
|
||
Чистые тесты (без живой БД):
|
||
• classify_cbr_series — pure mapping cbr series_id → indicator_type (+ skip unmapped).
|
||
• Консистентность Python-зеркала с SQL-миграцией 123_macro_indicator.sql.
|
||
• Форма SQL reader-а (CAST(:x AS type), ORDER BY, params) через MagicMock-сессию,
|
||
как в test_best_layouts.py — без подключения к Postgres.
|
||
|
||
psycopg v3 правило проверяется явно: bind-параметры должны быть CAST(:x AS type),
|
||
а не :x::type.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
import pathlib
|
||
from unittest.mock import MagicMock
|
||
|
||
from app.services.site_finder.macro import (
|
||
_CBR_SERIES_MAP,
|
||
classify_cbr_series,
|
||
get_latest_macro,
|
||
get_macro_series,
|
||
)
|
||
|
||
# Путь к миграции — для теста-консистентности зеркала.
|
||
_MIGRATION = (
|
||
pathlib.Path(__file__).resolve().parents[4]
|
||
/ "data"
|
||
/ "sql"
|
||
/ "123_macro_indicator.sql"
|
||
)
|
||
|
||
|
||
# ── pure: classify_cbr_series ─────────────────────────────────────────────────
|
||
|
||
|
||
class TestClassifyCbrSeries:
|
||
def test_weighted_rate(self) -> None:
|
||
assert classify_cbr_series("02_13_Rates_mortgage_ставка_в_рублях") == (
|
||
"mortgage_rate_weighted",
|
||
"%",
|
||
)
|
||
|
||
def test_term(self) -> None:
|
||
assert classify_cbr_series("02_13_Rates_mortgage_срок_в_руб") == (
|
||
"mortgage_term",
|
||
"мес",
|
||
)
|
||
|
||
def test_issued_count(self) -> None:
|
||
assert classify_cbr_series("02_10_Quantity_mortgage_в_рублях") == (
|
||
"mortgage_issued_count",
|
||
"шт",
|
||
)
|
||
|
||
def test_issued_volume(self) -> None:
|
||
assert classify_cbr_series("02_11_New_loans_mortgage_в_рублях") == (
|
||
"mortgage_issued_volume",
|
||
"млн руб",
|
||
)
|
||
|
||
def test_debt(self) -> None:
|
||
assert classify_cbr_series("02_14_Debt_mortgage_в_рублях") == (
|
||
"mortgage_debt",
|
||
"млн руб",
|
||
)
|
||
|
||
def test_overdue(self) -> None:
|
||
assert classify_cbr_series("02_14_Debt_mortgage_в_т.ч._просроч._в_рублях") == (
|
||
"mortgage_overdue",
|
||
"млн руб",
|
||
)
|
||
|
||
def test_none_and_empty(self) -> None:
|
||
assert classify_cbr_series(None) is None
|
||
assert classify_cbr_series("") is None
|
||
|
||
def test_itogo_variants_excluded(self) -> None:
|
||
"""_итого-варианты НЕ маппятся (иначе коллизия по PK с _в_рублях)."""
|
||
assert classify_cbr_series("02_13_Rates_mortgage_ставка_итого") is None
|
||
assert classify_cbr_series("02_10_Quantity_mortgage_итого") is None
|
||
assert classify_cbr_series("02_14_Debt_mortgage_итого") is None
|
||
|
||
def test_other_families_excluded(self) -> None:
|
||
"""_housing_ / _scpa_mortgage_ / _Share_ — шире/иное, чем _mortgage_ ИЖК-семья."""
|
||
assert classify_cbr_series("02_06_Quantity_housing_в_рублях") is None
|
||
assert classify_cbr_series("02_15_Rates_scpa_mortgage_ставка_в_рублях") is None
|
||
assert classify_cbr_series("02_44_Share_mortgage_в_рублях") is None
|
||
|
||
def test_unknown_series_id_skipped(self) -> None:
|
||
"""Неизвестный / посторонний series_id → None (в backfill не попадает)."""
|
||
assert classify_cbr_series("Доля ипотечных кредитов на ИЖС, %") is None
|
||
assert classify_cbr_series("02_99_unknown_series") is None
|
||
|
||
|
||
# ── консистентность Python-зеркала с SQL-миграцией ────────────────────────────
|
||
|
||
|
||
class TestMirrorConsistency:
|
||
def test_migration_file_exists(self) -> None:
|
||
assert _MIGRATION.is_file(), f"migration not found: {_MIGRATION}"
|
||
|
||
def test_all_indicator_types_present_in_migration(self) -> None:
|
||
"""Каждый indicator_type из Python-зеркала встречается в тексте миграции."""
|
||
sql = _MIGRATION.read_text(encoding="utf-8")
|
||
for indicator_type, _unit in _CBR_SERIES_MAP.values():
|
||
assert indicator_type in sql, (
|
||
f"indicator_type {indicator_type!r} есть в Python-зеркале, "
|
||
f"но отсутствует в миграции 123_macro_indicator.sql"
|
||
)
|
||
|
||
def test_all_series_ids_present_in_migration(self) -> None:
|
||
"""Каждый series_id-ключ Python-зеркала присутствует в CASE миграции."""
|
||
sql = _MIGRATION.read_text(encoding="utf-8")
|
||
for series_id in _CBR_SERIES_MAP:
|
||
assert series_id in sql, (
|
||
f"series_id {series_id!r} есть в Python-зеркале, "
|
||
f"но отсутствует в миграции 123_macro_indicator.sql"
|
||
)
|
||
|
||
def test_domrf_primary_type_present(self) -> None:
|
||
sql = _MIGRATION.read_text(encoding="utf-8")
|
||
assert "mortgage_rate_primary_domrf" in sql
|
||
|
||
def test_migration_balanced_transaction(self) -> None:
|
||
sql = _MIGRATION.read_text(encoding="utf-8")
|
||
assert sql.count("BEGIN;") == 1
|
||
assert sql.count("COMMIT;") == 1
|
||
assert "ON CONFLICT" in sql
|
||
assert "macro_indicator" in sql
|
||
|
||
|
||
# ── reader SQL shape (mock Session, без БД) ───────────────────────────────────
|
||
|
||
|
||
def _mock_db(rows: list) -> MagicMock:
|
||
"""MagicMock-сессия: db.execute(...).all() и .first() возвращают подготовленное."""
|
||
db = MagicMock()
|
||
result = MagicMock()
|
||
result.all.return_value = rows
|
||
result.first.return_value = rows[0] if rows else None
|
||
db.execute.return_value = result
|
||
return db
|
||
|
||
|
||
def _executed_sql(db: MagicMock) -> str:
|
||
"""Текст SQL первого db.execute (TextClause → str)."""
|
||
args, _kwargs = db.execute.call_args
|
||
return str(args[0])
|
||
|
||
|
||
class TestGetMacroSeries:
|
||
def test_returns_ordered_float_tuples(self) -> None:
|
||
rows = [(dt.date(2019, 2, 1), 9.5), (dt.date(2019, 3, 1), 9.4)]
|
||
db = _mock_db(rows)
|
||
out = get_macro_series(db, "mortgage_rate_weighted")
|
||
assert out == [(dt.date(2019, 2, 1), 9.5), (dt.date(2019, 3, 1), 9.4)]
|
||
assert all(isinstance(v, float) for _d, v in out)
|
||
|
||
def test_empty(self) -> None:
|
||
db = _mock_db([])
|
||
assert get_macro_series(db, "key_rate") == []
|
||
|
||
def test_default_region_and_params(self) -> None:
|
||
db = _mock_db([])
|
||
get_macro_series(db, "mortgage_debt")
|
||
_args, kwargs = db.execute.call_args
|
||
params = _args[1] if len(_args) > 1 else kwargs
|
||
assert params["itype"] == "mortgage_debt"
|
||
assert params["region"] == "rf"
|
||
assert params["since"] is None
|
||
|
||
def test_since_passed_through(self) -> None:
|
||
db = _mock_db([])
|
||
since = dt.date(2024, 1, 1)
|
||
get_macro_series(db, "mortgage_debt", region="sverdl", since=since)
|
||
_args, _kwargs = db.execute.call_args
|
||
params = _args[1]
|
||
assert params["region"] == "sverdl"
|
||
assert params["since"] == since
|
||
|
||
def test_sql_uses_cast_not_double_colon(self) -> None:
|
||
db = _mock_db([])
|
||
get_macro_series(db, "mortgage_debt")
|
||
sql = _executed_sql(db)
|
||
assert "CAST(:itype AS text)" in sql
|
||
assert "CAST(:region AS text)" in sql
|
||
assert "CAST(:since AS date)" in sql
|
||
# psycopg v3 trap: никаких :name::type
|
||
assert ":itype::" not in sql
|
||
assert ":region::" not in sql
|
||
assert ":since::" not in sql
|
||
|
||
def test_sql_orders_ascending(self) -> None:
|
||
db = _mock_db([])
|
||
get_macro_series(db, "mortgage_debt")
|
||
assert "ORDER BY obs_date ASC" in _executed_sql(db)
|
||
|
||
|
||
class TestGetLatestMacro:
|
||
def test_returns_tuple(self) -> None:
|
||
db = _mock_db([(dt.date(2026, 2, 1), 11.0)])
|
||
assert get_latest_macro(db, "mortgage_rate_weighted") == (dt.date(2026, 2, 1), 11.0)
|
||
|
||
def test_none_when_empty(self) -> None:
|
||
db = _mock_db([])
|
||
assert get_latest_macro(db, "key_rate") is None
|
||
|
||
def test_value_coerced_to_float(self) -> None:
|
||
from decimal import Decimal
|
||
|
||
db = _mock_db([(dt.date(2026, 2, 1), Decimal("11.0"))])
|
||
result = get_latest_macro(db, "mortgage_rate_weighted")
|
||
assert result is not None
|
||
_d, v = result
|
||
assert isinstance(v, float)
|
||
assert v == 11.0
|
||
|
||
def test_sql_orders_desc_limit_1(self) -> None:
|
||
db = _mock_db([])
|
||
get_latest_macro(db, "mortgage_debt")
|
||
sql = _executed_sql(db)
|
||
assert "ORDER BY obs_date DESC" in sql
|
||
assert "LIMIT 1" in sql
|
||
assert "CAST(:itype AS text)" in sql
|