gendesign/backend/tests/services/site_finder/test_macro.py
Light1YT 3945d54e3b
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m53s
Deploy / build-worker (push) Successful in 3m19s
Deploy / deploy (push) Successful in 1m10s
fix(#945): indicator-aware default region in macro reader + document debt data-quality
FOLLOW-UP (Leha: not a bug, follow-up).

get_macro_series / get_latest_macro defaulted region='rf', but CBR mortgage_*
series live under 'sverdl' in macro_indicator (key_rate under 'rf'). Only caller
passes region explicitly so no active leak, but a future caller omitting region
would silently get None — latent footgun. region now str|None; when None →
_canonical_region (mortgage set sourced from _CBR_SERIES_MAP, single source of
truth). Explicit region always wins → existing callers unchanged. Region-binding
tests added. 159 tests pass.

Documented (NOT code-fixed — upstream data-quality): mortgage_debt /
mortgage_overdue stay empty in macro_indicator because cbr_mortgage_series.period
holds value-like garbage (e.g. '10054588.0') for the Debt series — corruption
from the upstream CBR-XLSX scraper that built domrf.db (imported verbatim by
44_import_anton_db.py), NOT an ingest bug. 123_macro_indicator.sql correctly
skips unparseable periods. Needs a separate scraper re-ingest (idempotent
backfill once period is valid). Refs #945
2026-06-04 11:49:41 +05:00

304 lines
13 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,
_canonical_region,
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
# ── canonical region resolution (#945 footgun) ────────────────────────────────
class TestCanonicalRegion:
"""Indicator-aware дефолт региона: prod-layout = CBR-ипотека под 'sverdl',
key_rate / прочее под 'rf' (см. 123_macro_indicator.sql §2)."""
def test_all_six_mortgage_indicators_resolve_sverdl(self) -> None:
# Источник истины — _CBR_SERIES_MAP (зеркало миграции): все 6 mortgage_*.
mortgage_types = {itype for itype, _unit in _CBR_SERIES_MAP.values()}
assert len(mortgage_types) == 6 # guard: набор не «усох»
for itype in mortgage_types:
assert _canonical_region(itype) == "sverdl", itype
def test_key_rate_resolves_rf(self) -> None:
assert _canonical_region("key_rate") == "rf"
def test_unknown_indicator_defaults_rf(self) -> None:
# Любой не-ипотечный / неизвестный тип → 'rf' (национальный дефолт).
assert _canonical_region("cpi") == "rf"
assert _canonical_region("mortgage_rate_primary_domrf") == "rf" # это region='rf'
assert _canonical_region("totally_unknown") == "rf"
# ── reader region resolution end-to-end (mock Session) ─────────────────────────
class TestReaderRegionResolution:
"""Какой region реально биндится в SQL ридером при/без явного region.
Покрывает контракт #945: дефолт indicator-aware, явный region — выигрывает.
"""
def _bound_region(self, db: MagicMock) -> str:
_args, _kwargs = db.execute.call_args
return _args[1]["region"]
def test_series_mortgage_no_region_binds_sverdl(self) -> None:
db = _mock_db([])
get_macro_series(db, "mortgage_rate_weighted")
assert self._bound_region(db) == "sverdl"
def test_series_key_rate_no_region_binds_rf(self) -> None:
db = _mock_db([])
get_macro_series(db, "key_rate")
assert self._bound_region(db) == "rf"
def test_series_explicit_region_overrides_default(self) -> None:
# Явный 'rf' для ипотечного типа — caller override выигрывает над 'sverdl'.
db = _mock_db([])
get_macro_series(db, "mortgage_debt", region="rf")
assert self._bound_region(db) == "rf"
def test_series_existing_sverdl_caller_unchanged(self) -> None:
# Регресс на текущего вызывателя (macro_series.py:372 region="sverdl").
db = _mock_db([])
get_macro_series(db, "mortgage_issued_count", region="sverdl")
assert self._bound_region(db) == "sverdl"
def test_latest_mortgage_no_region_binds_sverdl(self) -> None:
db = _mock_db([])
get_latest_macro(db, "mortgage_overdue")
assert self._bound_region(db) == "sverdl"
def test_latest_key_rate_no_region_binds_rf(self) -> None:
db = _mock_db([])
get_latest_macro(db, "key_rate")
assert self._bound_region(db) == "rf"
def test_latest_explicit_region_overrides_default(self) -> None:
db = _mock_db([])
get_latest_macro(db, "mortgage_term", region="rf")
assert self._bound_region(db) == "rf"
# ── консистентность 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_is_indicator_aware(self) -> None:
"""No region → mortgage_* резолвится в 'sverdl' (его prod-layout), не 'rf'.
Регресс на #945-footgun: раньше дефолт был жёстко 'rf' → mortgage_*-ряд
молча возвращал пусто. Проверяем РЕАЛЬНО забинженный в запрос region.
"""
db = _mock_db([])
get_macro_series(db, "mortgage_debt")
_args, _kwargs = db.execute.call_args
params = _args[1]
assert params["itype"] == "mortgage_debt"
assert params["region"] == "sverdl"
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