gendesign/backend/tests/services/site_finder/test_macro.py
Light1YT dbae4b0bda
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m20s
Deploy / build-backend (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m14s
Deploy / changes (push) Successful in 6s
feat(site-finder): macro_indicator table + backfill + reader (#945 PR A) (#963)
2026-06-02 18:41:28 +00:00

226 lines
9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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