"""Unit-тесты monthly макро-ряда + классификатора режима ставки (#951b, ТЗ §9.5/§9.6).

Чистые тесты (без живой БД):
  • classify_regime — синтетический путь down→stable→up, пороги eps, None-дыры.
  • macro_at_lag — точный месяц, отсутствующий месяц → None, лаг за пределами → None.
  • is_confounded_window — пересекает / не пересекает confounder-даты.
  • MonthlyMacro.as_dict — округление + None survive.
  • get_monthly_macro через MagicMock-сессию: форма SQL (DISTINCT ON ресэмпл,
    CAST(:x AS type) не :x::type), carry-forward, graceful empty → [].

psycopg v3 правило проверяется явно: bind-параметры — CAST(:x AS type).
"""

from __future__ import annotations

import contextlib
import datetime as dt
import os
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from app.services.forecasting.macro_series import (
    _CONFOUNDER_DATES,
    _MORTGAGE_FIELDS,
    _REGIME_EPS_PP,
    MonthlyMacro,
    _carry_forward,
    _month_grid,
    _query_inflation_monthly,
    _query_key_rate_monthly,
    _query_mortgage_monthly,
    _shift_months,
    classify_regime,
    get_monthly_macro,
    is_confounded_window,
    macro_at_lag,
)

_GET_SERIES = "app.services.forecasting.macro_series.get_macro_series"


def _macro(month: dt.date, **kw: float | None) -> MonthlyMacro:
    """Хелпер: MonthlyMacro с None-полями по умолчанию (переопределяем нужные)."""
    defaults: dict[str, float | None] = {
        "key_rate": None,
        "mortgage_rate_weighted": None,
        "mortgage_issued_count": None,
        "mortgage_issued_volume": None,
        "mortgage_debt": None,
        "mortgage_overdue": None,
    }
    defaults.update(kw)
    return MonthlyMacro(month=month, **defaults)


# ── pure: _carry_forward (LOCF) ───────────────────────────────────────────────


class TestCarryForward:
    def test_fills_gaps_with_last_known(self) -> None:
        assert _carry_forward([1.0, None, None, 2.0, None]) == [1.0, 1.0, 1.0, 2.0, 2.0]

    def test_leading_none_stays_none(self) -> None:
        # До первой известной точки carry невозможен.
        assert _carry_forward([None, None, 5.0, None]) == [None, None, 5.0, 5.0]

    def test_all_none(self) -> None:
        assert _carry_forward([None, None]) == [None, None]

    def test_empty(self) -> None:
        assert _carry_forward([]) == []

    def test_does_not_mutate_input(self) -> None:
        src: list[float | None] = [1.0, None]
        _carry_forward(src)
        assert src == [1.0, None]


# ── pure: classify_regime ─────────────────────────────────────────────────────


class TestClassifyRegime:
    def test_down_stable_up_path(self) -> None:
        # Синтетический путь: смягчение → плато → ужесточение.
        # 9.0 → 8.5 (down) → 8.5 (stable) → 8.5 (stable) → 9.0 (up) → 9.5 (up)
        rates: list[float | None] = [9.0, 8.5, 8.5, 8.5, 9.0, 9.5]
        out = classify_regime(rates)
        assert out == ["stable", "down", "stable", "stable", "up", "up"]

    def test_first_month_stable(self) -> None:
        # i=0 нет базы сравнения → 'stable' (нейтрально, не выдумываем тренд).
        assert classify_regime([10.0])[0] == "stable"

    def test_eps_threshold_exact_down(self) -> None:
        # Δ ровно -eps → 'down' (граница включительно: delta <= -eps).
        assert classify_regime([10.0, 10.0 - _REGIME_EPS_PP]) == ["stable", "down"]

    def test_eps_threshold_exact_up(self) -> None:
        # Δ ровно +eps → 'up' (delta >= +eps).
        assert classify_regime([10.0, 10.0 + _REGIME_EPS_PP]) == ["stable", "up"]

    def test_just_below_eps_is_stable(self) -> None:
        # |Δ| < eps → шум → 'stable'.
        below = _REGIME_EPS_PP - 0.01
        assert classify_regime([10.0, 10.0 + below]) == ["stable", "stable"]
        assert classify_regime([10.0, 10.0 - below]) == ["stable", "stable"]

    def test_custom_eps(self) -> None:
        # При eps=1.0 шаг 0.5 п.п. больше не тренд.
        assert classify_regime([10.0, 10.5], eps_pp=1.0) == ["stable", "stable"]
        assert classify_regime([10.0, 11.0], eps_pp=1.0) == ["stable", "up"]

    def test_none_gap_carried_then_classified(self) -> None:
        # 9.0 → None(carry 9.0, stable) → 9.5 (up vs carried 9.0).
        out = classify_regime([9.0, None, 9.5])
        assert out == ["stable", "stable", "up"]

    def test_leading_none_is_stable(self) -> None:
        # None до первой точки: нет ни cur, ни базы → 'stable'.
        out = classify_regime([None, None, 7.0, 8.0])
        assert out == ["stable", "stable", "stable", "up"]

    def test_empty(self) -> None:
        assert classify_regime([]) == []

    def test_window_months_two(self) -> None:
        # window=2: сравниваем с точкой 2 месяца назад.
        # i=2: 9.0 vs rates[0]=8.0 → +1.0 → up; i=3: 9.5 vs rates[1]=8.5 → up.
        out = classify_regime([8.0, 8.5, 9.0, 9.5], window_months=2)
        assert out == ["stable", "stable", "up", "up"]


# ── pure: macro_at_lag ────────────────────────────────────────────────────────


class TestMacroAtLag:
    def _series(self) -> list[MonthlyMacro]:
        return [
            _macro(dt.date(2024, 1, 1), key_rate=16.0),
            _macro(dt.date(2024, 2, 1), key_rate=16.0),
            _macro(dt.date(2024, 3, 1), key_rate=16.0),
            _macro(dt.date(2024, 4, 1), key_rate=16.0),
        ]

    def test_lag_zero_returns_target(self) -> None:
        m = macro_at_lag(self._series(), dt.date(2024, 3, 1), 0)
        assert m is not None and m.month == dt.date(2024, 3, 1)

    def test_lag_two_months(self) -> None:
        m = macro_at_lag(self._series(), dt.date(2024, 3, 1), 2)
        assert m is not None and m.month == dt.date(2024, 1, 1)

    def test_target_normalised_to_first_of_month(self) -> None:
        # Передаём середину месяца — нормализуется к 1-му числу.
        m = macro_at_lag(self._series(), dt.date(2024, 3, 17), 0)
        assert m is not None and m.month == dt.date(2024, 3, 1)

    def test_lag_beyond_series_returns_none(self) -> None:
        # 2024-03 минус 6 мес = 2023-09 — нет в series.
        assert macro_at_lag(self._series(), dt.date(2024, 3, 1), 6) is None

    def test_missing_intermediate_month_returns_none(self) -> None:
        # Дыра в ряду: лаг указывает на отсутствующий месяц → None.
        sparse = [
            _macro(dt.date(2024, 1, 1), key_rate=16.0),
            _macro(dt.date(2024, 3, 1), key_rate=16.0),
        ]
        assert macro_at_lag(sparse, dt.date(2024, 3, 1), 1) is None  # 2024-02 отсутствует

    def test_empty_series(self) -> None:
        assert macro_at_lag([], dt.date(2024, 3, 1), 0) is None

    def test_lag_six_present(self) -> None:
        # Полугодовой лаг, когда месяц есть.
        long_series = [_macro(_shift_months(dt.date(2024, 7, 1), -k)) for k in range(8)]
        m = macro_at_lag(long_series, dt.date(2024, 7, 1), 6)
        assert m is not None and m.month == dt.date(2024, 1, 1)


# ── pure: is_confounded_window ────────────────────────────────────────────────


class TestIsConfoundedWindow:
    def test_straddles_covid_2020_03(self) -> None:
        assert is_confounded_window(dt.date(2020, 1, 1), dt.date(2020, 6, 1)) is True

    def test_straddles_lgota_cut_2024_07(self) -> None:
        assert is_confounded_window(dt.date(2024, 5, 1), dt.date(2024, 9, 1)) is True

    def test_straddles_geopolitics_2022_02(self) -> None:
        assert is_confounded_window(dt.date(2022, 1, 1), dt.date(2022, 4, 1)) is True

    def test_clean_window_not_confounded(self) -> None:
        # 2023-Q1 — между шоками, чисто.
        assert is_confounded_window(dt.date(2023, 1, 1), dt.date(2023, 4, 1)) is False

    def test_boundary_inclusive_start(self) -> None:
        # confounder ровно на start → попадает (включительно).
        assert is_confounded_window(dt.date(2024, 7, 1), dt.date(2024, 8, 1)) is True

    def test_boundary_inclusive_end(self) -> None:
        # confounder ровно на end → попадает.
        assert is_confounded_window(dt.date(2024, 6, 1), dt.date(2024, 7, 1)) is True

    def test_mid_month_dates_normalised(self) -> None:
        # Даты не на 1-м числе нормализуются к месяцу; шок 2024-07 внутри.
        assert is_confounded_window(dt.date(2024, 6, 15), dt.date(2024, 7, 20)) is True

    def test_reversed_args_normalised(self) -> None:
        # start>end → меняем местами, не падаем.
        assert is_confounded_window(dt.date(2020, 6, 1), dt.date(2020, 1, 1)) is True

    def test_confounder_dates_have_rationale(self) -> None:
        # Каждая hardcoded дата несёт «почему» (sql.md «нужен why»).
        assert len(_CONFOUNDER_DATES) >= 3
        for cdate, why in _CONFOUNDER_DATES:
            assert isinstance(cdate, dt.date)
            assert isinstance(why, str) and len(why) > 10


# ── pure: _shift_months / _month_grid ─────────────────────────────────────────


class TestShiftMonths:
    def test_forward(self) -> None:
        assert _shift_months(dt.date(2024, 1, 1), 2) == dt.date(2024, 3, 1)

    def test_backward(self) -> None:
        assert _shift_months(dt.date(2024, 1, 1), -1) == dt.date(2023, 12, 1)

    def test_year_wrap_back(self) -> None:
        assert _shift_months(dt.date(2024, 3, 1), -6) == dt.date(2023, 9, 1)

    def test_normalises_day(self) -> None:
        assert _shift_months(dt.date(2024, 3, 17), 0) == dt.date(2024, 3, 1)


class TestMonthGrid:
    def test_inclusive_range(self) -> None:
        grid = _month_grid(dt.date(2024, 1, 1), dt.date(2024, 4, 1))
        assert grid == [
            dt.date(2024, 1, 1),
            dt.date(2024, 2, 1),
            dt.date(2024, 3, 1),
            dt.date(2024, 4, 1),
        ]

    def test_single_month(self) -> None:
        assert _month_grid(dt.date(2024, 5, 1), dt.date(2024, 5, 1)) == [dt.date(2024, 5, 1)]

    def test_start_after_end_empty(self) -> None:
        assert _month_grid(dt.date(2024, 5, 1), dt.date(2024, 1, 1)) == []


# ── MonthlyMacro.as_dict ──────────────────────────────────────────────────────


class TestAsDict:
    def test_rounds_and_serialises(self) -> None:
        m = MonthlyMacro(
            month=dt.date(2024, 3, 1),
            key_rate=16.123456,
            mortgage_rate_weighted=7.987654,
            mortgage_issued_count=1234.6,
            mortgage_issued_volume=98765.4321,
            mortgage_debt=123456.789,
            mortgage_overdue=234.5678,
            inflation_yoy=5.876543,
        )
        d = m.as_dict()
        assert d["month"] == "2024-03-01"
        assert d["key_rate"] == 16.12
        assert d["inflation_yoy"] == 5.88  # round to 2 digits
        assert d["mortgage_rate_weighted"] == 7.99
        assert d["mortgage_issued_count"] == 1235  # round to 0 digits
        assert d["mortgage_issued_volume"] == 98765.4
        assert d["mortgage_debt"] == 123456.8
        assert d["mortgage_overdue"] == 234.6

    def test_none_fields_survive(self) -> None:
        m = _macro(dt.date(2024, 3, 1))
        d = m.as_dict()
        assert d["month"] == "2024-03-01"
        assert d["key_rate"] is None
        assert d["inflation_yoy"] is None
        assert d["mortgage_rate_weighted"] is None
        assert d["mortgage_debt"] is None


# ── get_monthly_macro: MagicMock-сессия (форма SQL + carry + graceful) ────────


def _key_rate_result(rows: list[tuple[dt.date, float]]) -> MagicMock:
    """Результат db.execute(_KEY_RATE_MONTHLY_SQL): .all() → [(month, value), ...]."""
    res = MagicMock()
    res.all.return_value = rows
    return res


class TestGetMonthlyMacroShape:
    def test_key_rate_sql_uses_distinct_on_resample(self) -> None:
        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, return_value=[]):
            get_monthly_macro(db, months_back=3)
        sql = str(db.execute.call_args_list[0].args[0])
        # Ресэмпл daily→monthly: DISTINCT ON по месяцу + последняя точка месяца.
        assert "DISTINCT ON (date_trunc('month', obs_date))" in sql
        assert "ORDER BY date_trunc('month', obs_date), obs_date DESC" in sql

    def test_key_rate_sql_uses_cast_not_double_colon(self) -> None:
        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, return_value=[]):
            get_monthly_macro(db, months_back=3)
        sql = str(db.execute.call_args_list[0].args[0])
        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_key_rate_query_params(self) -> None:
        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, return_value=[]):
            get_monthly_macro(db, months_back=12)
        params = db.execute.call_args_list[0].args[1]
        assert params["itype"] == "key_rate"
        assert params["region"] == "rf"
        assert isinstance(params["since"], dt.date)

    def test_mortgage_series_read_from_sverdl(self) -> None:
        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, return_value=[]) as gs:
            get_monthly_macro(db, months_back=3)
        # MORTGAGE-ряды читаются регионом 'sverdl' (inflation_yoy — отдельно 'rf', ниже).
        assert gs.called
        region_by_type = {call.args[1]: call.kwargs["region"] for call in gs.call_args_list}
        assert region_by_type.get("mortgage_rate_weighted") == "sverdl"
        assert region_by_type.get("mortgage_issued_count") == "sverdl"

    def test_inflation_series_read_from_rf(self) -> None:
        # #946: inflation_yoy — национальный ряд (region 'rf'), читается через get_macro_series.
        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, return_value=[]) as gs:
            get_monthly_macro(db, months_back=3)
        region_by_type = {call.args[1]: call.kwargs["region"] for call in gs.call_args_list}
        assert region_by_type.get("inflation_yoy") == "rf"


class TestGetMonthlyMacroLogic:
    def test_grid_is_continuous_and_carries_key_rate(self) -> None:
        # key_rate известен только в первом и последнем месяце сетки → carry между.
        today = dt.date.today()
        first = _shift_months(today, -3)
        last = _shift_months(today, -1)
        db = MagicMock()
        db.execute.return_value = _key_rate_result([(first, 16.0), (last, 18.0)])
        with patch(_GET_SERIES, return_value=[]):
            out = get_monthly_macro(db, months_back=3)
        by_month = {m.month: m for m in out}
        # Сетка непрерывна (4 месяца: -3..0).
        assert len(out) == 4
        assert out == sorted(out, key=lambda m: m.month)
        # carry-forward: месяц между first и last наследует 16.0; последний = 18.0.
        mid = _shift_months(today, -2)
        assert by_month[first].key_rate == 16.0
        assert by_month[mid].key_rate == 16.0  # LOCF из first
        assert by_month[last].key_rate == 18.0

    def test_leading_months_without_key_rate_are_none(self) -> None:
        # key_rate появляется только в последнем месяце → ранние = None (carry невозможен).
        today = dt.date.today()
        last = _shift_months(today, -1)
        db = MagicMock()
        db.execute.return_value = _key_rate_result([(last, 20.0)])
        with patch(_GET_SERIES, return_value=[]):
            out = get_monthly_macro(db, months_back=3)
        assert out[0].key_rate is None
        assert out[-2].key_rate == 20.0  # last месяц сетки (idx -2 т.к. -1 = today)

    def test_mortgage_joined_by_month(self) -> None:
        today = dt.date.today()
        target = _shift_months(today, -2)

        def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
            if indicator_type == "mortgage_rate_weighted":
                return [(target, 7.5)]
            if indicator_type == "mortgage_issued_count":
                return [(target, 1200.0)]
            return []

        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, side_effect=fake_series):
            out = get_monthly_macro(db, months_back=3)
        row = next(m for m in out if m.month == target)
        assert row.mortgage_rate_weighted == 7.5
        assert row.mortgage_issued_count == 1200.0
        # Месяц без mortgage-данных → None по этим полям.
        other = next(m for m in out if m.month != target)
        assert other.mortgage_rate_weighted is None

    def test_inflation_carries_forward_on_grid(self) -> None:
        # #946: inflation_yoy известен в первом месяце сетки → carry-forward вперёд
        # (медленный месячный ряд, последняя известная точка = текущий режим).
        today = dt.date.today()
        first = _shift_months(today, -3)

        def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
            if indicator_type == "inflation_yoy":
                return [(first, 6.0)]
            return []

        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, side_effect=fake_series):
            out = get_monthly_macro(db, months_back=3)
        by_month = {m.month: m for m in out}
        assert by_month[first].inflation_yoy == 6.0
        # LOCF: последующий месяц наследует 6.0 (нет своей точки).
        mid = _shift_months(today, -2)
        assert by_month[mid].inflation_yoy == 6.0

    def test_leading_months_without_inflation_are_none(self) -> None:
        # inflation появляется только в последнем месяце → ранние = None (carry невозможен).
        today = dt.date.today()
        last = _shift_months(today, -1)

        def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
            if indicator_type == "inflation_yoy":
                return [(last, 5.5)]
            return []

        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, side_effect=fake_series):
            out = get_monthly_macro(db, months_back=3)
        assert out[0].inflation_yoy is None
        assert out[-2].inflation_yoy == 5.5


class TestGetMonthlyMacroGraceful:
    def test_empty_table_returns_rows_with_none(self) -> None:
        # Пустые данные, но months_back>0 → сетка строится, поля None (не crash).
        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, return_value=[]):
            out = get_monthly_macro(db, months_back=2)
        assert len(out) == 3  # -2..0
        assert all(m.key_rate is None for m in out)
        assert all(m.mortgage_debt is None for m in out)

    def test_months_back_zero_single_month(self) -> None:
        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, return_value=[]):
            out = get_monthly_macro(db, months_back=0)
        assert len(out) == 1  # только текущий месяц

    def test_key_rate_query_exception_graceful(self) -> None:
        # Сбой key_rate-запроса → пустой ресэмпл, но ряд строится (НЕ crash).
        db = MagicMock()
        db.execute.side_effect = RuntimeError("db down")
        with patch(_GET_SERIES, return_value=[]):
            out = get_monthly_macro(db, months_back=2)
        assert len(out) == 3
        assert all(m.key_rate is None for m in out)

    def test_mortgage_series_exception_graceful(self) -> None:
        # Сбой одного mortgage-ряда не валит весь сбор.
        db = MagicMock()
        db.execute.return_value = _key_rate_result([])
        with patch(_GET_SERIES, side_effect=RuntimeError("boom")):
            out = get_monthly_macro(db, months_back=1)
        assert len(out) == 2
        assert all(m.mortgage_rate_weighted is None for m in out)


# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #2, `_query_mortgage_monthly`): сбой ОДНОГО
# mortgage-поля НЕ должен «отравить» общую §22-сессию для остальных 4 полей / для
# последующих §9.x-слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()`
# сбойный `db.execute` (внутри `get_macro_series`) оставляет транзакцию Postgres
# aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────


class _FakeMortgageResult:
    """Мини-результат db.execute() — покрывает `.all()` (см. `get_macro_series`)."""

    def all(self) -> list[Any]:
        return []


class _PoisonableSession:
    """Fake Session, симулирующая Postgres 'aborted transaction' семантику.

    `execute()` бросает РОВНО один раз (на первое поле) и, как реальный Postgres,
    «отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже
    несвязанный) тоже бросает — `current transaction is aborted`. `begin_nested()`
    зеркалит `Session.begin_nested()`: при исключении откатывает ТОЛЬКО SAVEPOINT
    (сбрасывает poison-флаг) и пробрасывает исключение дальше — как настоящий
    `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (откатил бы всю внешнюю
    транзакцию).
    """

    def __init__(self) -> None:
        self.poisoned = False
        self._raised = False
        self.calls = 0

    def execute(self, *args: Any, **kwargs: Any) -> _FakeMortgageResult:
        self.calls += 1
        if self.poisoned:
            raise RuntimeError(
                "current transaction is aborted, commands ignored until end of transaction block"
            )
        if not self._raised:
            self._raised = True
            self.poisoned = True
            raise RuntimeError("simulated mortgage field query failure")
        return _FakeMortgageResult()

    @contextlib.contextmanager
    def begin_nested(self) -> Iterator[None]:
        try:
            yield
        except Exception:
            # ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
            # сессия остаётся рабочей для следующего поля.
            self.poisoned = False
            raise


class TestQueryMortgageMonthlySavepoint:
    """`_query_mortgage_monthly` оборачивает КАЖДОЕ поле в `db.begin_nested()`."""

    def test_first_field_failure_leaves_session_usable_for_later_fields(self) -> None:
        db = _PoisonableSession()
        out = _query_mortgage_monthly(db, since=dt.date(2024, 1, 1))
        # все 5 полей присутствуют в результате (контракт функции не изменён).
        assert set(out.keys()) == {field for _itype, field in _MORTGAGE_FIELDS}
        # сбойное (первое) поле → пустой fallback-словарь, НЕ crash.
        first_field = _MORTGAGE_FIELDS[0][1]
        assert out[first_field] == {}
        # КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
        # сбойного поля проходит без 'current transaction aborted' (не poisoned).
        db.execute("SELECT 1")


class TestQueryKeyRateMonthlySavepoint:
    """`_query_key_rate_monthly` оборачивает db.execute в `db.begin_nested()`.

    Тот же poisoning-класс, что и mortgage-цикл: key_rate — ПЕРВЫЙ запрос
    get_monthly_macro; без SAVEPOINT его сбой отравил бы транзакцию для inflation +
    mortgage + всех последующих §9.x-слоёв на общей §22-сессии.
    """

    def test_db_failure_leaves_session_usable(self) -> None:
        db = _PoisonableSession()
        out = _query_key_rate_monthly(db, since=dt.date(2024, 1, 1))
        # сбой запроса → пустой ресэмпл (fallback, НЕ crash).
        assert out == {}
        # КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
        # сбоя проходит без 'current transaction aborted' (не poisoned).
        db.execute("SELECT 1")


class TestQueryInflationMonthlySavepoint:
    """`_query_inflation_monthly` оборачивает db.execute (через get_macro_series) в SAVEPOINT.

    Тот же poisoning-класс: inflation — ВТОРОЙ запрос get_monthly_macro; без SAVEPOINT
    его сбой отравил бы транзакцию для mortgage-полей + последующих §9.x-слоёв.
    """

    def test_db_failure_leaves_session_usable(self) -> None:
        db = _PoisonableSession()
        out = _query_inflation_monthly(db, since=dt.date(2024, 1, 1))
        # сбой запроса → пустой ряд (fallback, НЕ crash).
        assert out == {}
        # КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
        # сбоя проходит без 'current transaction aborted' (не poisoned).
        db.execute("SELECT 1")
