"""Unit-тесты market-metrics service (#949 PR A, ТЗ §9.2).

Чистые тесты (без живой БД):
  • pure-арифметика метрик (_absorption_rate, _months_of_supply, _sell_through_pct,
    _monthly_rate, _liquidity_index, _overstock_index, _demand_concentration,
    _confidence, _room_bucket) — hand-built фикстуры, включая thin-data /
    empty / zero-division кейсы → None (не 0, не crash) + confidence='low'.
  • compute_market_metrics через MagicMock-сессию — форма SQL (CAST(:x AS type),
    не :x::type), params, и graceful-on-empty (всё None, low confidence, no crash).

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

from __future__ import annotations

import datetime as _dt
import os
from collections.abc import Iterator
from unittest.mock import MagicMock, patch

import pytest

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

from app.services.site_finder.market_metrics import (
    MarketMetrics,
    OfferPriceTrend,
    _absorption_rate,
    _confidence,
    _demand_concentration,
    _liquidity_index,
    _monthly_rate,
    _months_of_supply,
    _overstock_index,
    _price_sensitivity,
    _query_sales_window,
    _query_stock,
    _room_bucket,
    _sell_through_pct,
    compute_market_metrics,
    compute_offer_price_trend,
)

_ELAST = "app.services.site_finder.market_metrics._elasticity_coef"
# Резолвер admin→micro (Step 2). В shape/thin-data тестах патчим его на identity-обёртку
# через _patch_resolver(), чтобы не ходить в БД за admin-names/aliases. Отдельный класс
# TestDistrictResolution проверяет, что РЕЗОЛВНУТЫЕ микро реально попадают в SQL-параметры.
_RESOLVE = "app.services.site_finder.market_metrics.resolve_objective_districts"


@pytest.fixture(autouse=True)
def _patch_resolver() -> Iterator[MagicMock]:
    """По умолчанию резолвер = identity-обёртка raw-микро (district→[district], None→None).

    Так _mock_db (stock+sales, 2 execute) остаётся валиден — резолвер не трогает db.
    Тесты, проверяющие admin→micro-разворот, переопределяют .side_effect у этого мока.
    """
    with patch(_RESOLVE) as m:
        m.side_effect = lambda _db, d: [d] if d is not None else None
        yield m


# ── pure: _monthly_rate ───────────────────────────────────────────────────────


class TestMonthlyRate:
    def test_basic(self) -> None:
        assert _monthly_rate(12, 6) == 2.0

    def test_zero_count(self) -> None:
        assert _monthly_rate(0, 6) == 0.0

    def test_none_count(self) -> None:
        assert _monthly_rate(None, 6) is None

    def test_zero_months_no_crash(self) -> None:
        assert _monthly_rate(10, 0) is None

    def test_negative_months(self) -> None:
        assert _monthly_rate(10, -3) is None


# ── pure: _absorption_rate ────────────────────────────────────────────────────


class TestAbsorptionRate:
    def test_basic(self) -> None:
        # 12 sold over 6 mo = 2/mo; / 40 available = 0.05
        assert _absorption_rate(12, 40, 6) == 0.05

    def test_no_available_returns_none(self) -> None:
        # распродано — не 0, а None (неизмеримо)
        assert _absorption_rate(12, 0, 6) is None

    def test_none_available(self) -> None:
        assert _absorption_rate(12, None, 6) is None

    def test_no_sales_is_zero_rate(self) -> None:
        # есть сток, продаж 0 → поглощение 0/мес = 0.0 (валидно, не None)
        assert _absorption_rate(0, 40, 6) == 0.0

    def test_none_sold(self) -> None:
        assert _absorption_rate(None, 40, 6) is None

    def test_zero_months_no_crash(self) -> None:
        assert _absorption_rate(12, 40, 0) is None


# ── pure: _months_of_supply ───────────────────────────────────────────────────


class TestMonthsOfSupply:
    def test_basic(self) -> None:
        # 40 available / (12 sold/6mo = 2/mo) = 20 мес
        assert _months_of_supply(40, 12, 6) == 20.0

    def test_no_sales_returns_none(self) -> None:
        # темп 0 → бесконечность неизмерима → None (не 0)
        assert _months_of_supply(40, 0, 6) is None

    def test_none_available(self) -> None:
        assert _months_of_supply(None, 12, 6) is None

    def test_zero_available(self) -> None:
        # сток распродан → 0 месяцев до распродажи
        assert _months_of_supply(0, 12, 6) == 0.0

    def test_zero_months_no_crash(self) -> None:
        assert _months_of_supply(40, 12, 0) is None


# ── pure: _sell_through_pct ───────────────────────────────────────────────────


class TestSellThroughPct:
    def test_basic(self) -> None:
        # 30 sold / (30 + 70) = 30%
        assert _sell_through_pct(30, 70) == 30.0

    def test_all_sold(self) -> None:
        assert _sell_through_pct(50, 0) == 100.0

    def test_empty_returns_none(self) -> None:
        assert _sell_through_pct(0, 0) is None

    def test_none_inputs(self) -> None:
        assert _sell_through_pct(None, 70) is None
        assert _sell_through_pct(30, None) is None


# ── pure: _liquidity_index ────────────────────────────────────────────────────


class TestLiquidityIndex:
    def test_relative_speed(self) -> None:
        # 3 бакета, fair_share = 1/3. {"1":50,"2":25,"3":25} total=100
        idx = _liquidity_index({"1": 50, "2": 25, "3": 25})
        assert idx is not None
        # 1-к: (50/100)/(1/3) = 1.5; остальные (0.25)/(1/3) = 0.75
        assert idx["1"] == 1.5
        assert idx["2"] == 0.75
        assert idx["3"] == 0.75

    def test_single_bucket_index_one(self) -> None:
        idx = _liquidity_index({"студия": 10})
        assert idx == {"студия": 1.0}

    def test_empty_returns_none(self) -> None:
        assert _liquidity_index({}) is None

    def test_all_zero_returns_none(self) -> None:
        assert _liquidity_index({"1": 0, "2": 0}) is None

    def test_none_values_skipped(self) -> None:
        idx = _liquidity_index({"1": 10, "2": None})  # type: ignore[dict-item]
        assert idx == {"1": 1.0}


# ── pure: _overstock_index ────────────────────────────────────────────────────


class TestOverstockIndex:
    def test_basic(self) -> None:
        # 15 long-unsold / 60 available = 0.25
        assert _overstock_index(15, 60) == 0.25

    def test_no_available_returns_none(self) -> None:
        assert _overstock_index(0, 0) is None

    def test_none_long_unsold(self) -> None:
        assert _overstock_index(None, 60) is None

    def test_zero_long_unsold(self) -> None:
        assert _overstock_index(0, 60) == 0.0


# ── pure: _demand_concentration ───────────────────────────────────────────────


class TestDemandConcentration:
    def test_single_format_hhi_one(self) -> None:
        assert _demand_concentration({"1": 100}) == 1.0

    def test_even_split_low(self) -> None:
        # 4 равных бакета → HHI = 4 * 0.25^2 = 0.25
        assert _demand_concentration({"1": 25, "2": 25, "3": 25, "4": 25}) == 0.25

    def test_empty_returns_none(self) -> None:
        assert _demand_concentration({}) is None

    def test_all_zero_returns_none(self) -> None:
        assert _demand_concentration({"1": 0, "2": 0}) is None

    def test_zeros_ignored(self) -> None:
        # нулевые бакеты не считаются за «формат»
        assert _demand_concentration({"1": 100, "2": 0}) == 1.0


# ── pure: _confidence ─────────────────────────────────────────────────────────


class TestConfidence:
    def test_high(self) -> None:
        assert _confidence(n_lots=300, obj_count=4, n_sold=80) == "high"

    def test_medium(self) -> None:
        assert _confidence(n_lots=80, obj_count=2, n_sold=20) == "medium"

    def test_low_few_lots(self) -> None:
        assert _confidence(n_lots=10, obj_count=1, n_sold=3) == "low"

    def test_low_no_sales_history_even_if_many_lots(self) -> None:
        # ТЗ §15 spirit: нет проданной истории → скорости ненадёжны → low
        assert _confidence(n_lots=500, obj_count=5, n_sold=0) == "low"

    def test_low_single_complex(self) -> None:
        # много лотов но 1 ЖК → не high (obj_count gate)
        assert _confidence(n_lots=500, obj_count=1, n_sold=80) == "low"


# ── pure: _room_bucket ────────────────────────────────────────────────────────


class TestRoomBucket:
    def test_studio(self) -> None:
        assert _room_bucket(0) == "студия"

    def test_one_room(self) -> None:
        assert _room_bucket(1) == "1"

    def test_five_plus(self) -> None:
        assert _room_bucket(5) == "5+"
        assert _room_bucket(7) == "5+"

    def test_none(self) -> None:
        assert _room_bucket(None) == "unknown"


# ── MarketMetrics.as_dict ─────────────────────────────────────────────────────


class TestAsDict:
    def test_rounds_and_serialises(self) -> None:
        m = MarketMetrics(
            district="Автовокзал",
            obj_count=3,
            n_lots=300,
            n_sold=90,
            n_available=210,
            window_months=6,
            premise_kind="квартира",
            confidence="high",
            absorption_rate=0.0476190476,
            months_of_supply=21.0,
            sell_through_pct=30.0,
            unit_velocity=2.0,
            area_velocity=95.5,
            liquidity_index={"1": 1.5, "2": 0.75},
            overstock_index=0.25,
            demand_concentration=0.3333333,
            price_sensitivity=-1.234567,
            price_sensitivity_source="regression",
            velocity_by_room={"1": 1.5, "2": 0.5},
        )
        d = m.as_dict()
        assert d["absorption_rate"] == 0.0476
        assert d["months_of_supply"] == 21.0
        assert d["demand_concentration"] == 0.333
        assert d["price_sensitivity"] == -1.2346
        assert d["liquidity_index"] == {"1": 1.5, "2": 0.75}
        assert d["confidence"] == "high"

    def test_none_metrics_survive(self) -> None:
        m = MarketMetrics(
            district=None,
            obj_count=0,
            n_lots=0,
            n_sold=0,
            n_available=0,
            window_months=6,
            premise_kind="квартира",
            confidence="low",
            absorption_rate=None,
            months_of_supply=None,
            sell_through_pct=None,
            unit_velocity=None,
            area_velocity=None,
            liquidity_index=None,
            overstock_index=None,
            demand_concentration=None,
            price_sensitivity=None,
            price_sensitivity_source=None,
            velocity_by_room=None,
        )
        d = m.as_dict()
        assert d["absorption_rate"] is None
        assert d["liquidity_index"] is None
        assert d["confidence"] == "low"


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


def _mock_db(stock_row: dict | None, sales_rows: list[dict]) -> MagicMock:
    """Сессия с двумя последовательными execute: stock (.first), sales (.all)."""
    db = MagicMock()
    stock_result = MagicMock()
    stock_result.mappings.return_value.first.return_value = stock_row
    sales_result = MagicMock()
    sales_result.mappings.return_value.all.return_value = sales_rows
    db.execute.side_effect = [stock_result, sales_result]
    return db


def _executed_sql(db: MagicMock, call_index: int) -> str:
    args, _kwargs = db.execute.call_args_list[call_index]
    return str(args[0])


def _executed_params(db: MagicMock, call_index: int) -> dict:
    args, _kwargs = db.execute.call_args_list[call_index]
    return args[1]


_FULL_STOCK = {
    "n_lots": 300,
    "n_sold": 90,
    "n_available": 210,
    "obj_count": 3,
    "n_long_unsold": 42,
}
# ROLLUP с GROUPING(rooms_int) AS is_total (#1214):
#   is_total=1 → grand-total строка; is_total=0 → разбивка по rooms_int.
_FULL_SALES = [
    {"units_sold_window": 60, "area_sold_window": 2880.0, "rooms_int": None, "is_total": 1},
    {"units_sold_window": 30, "area_sold_window": 900.0, "rooms_int": 1, "is_total": 0},
    {"units_sold_window": 20, "area_sold_window": 1200.0, "rooms_int": 2, "is_total": 0},
    {"units_sold_window": 10, "area_sold_window": 780.0, "rooms_int": 0, "is_total": 0},
]


class TestComputeMarketMetricsShape:
    def test_sql_uses_cast_not_double_colon(self) -> None:
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            compute_market_metrics(db, district="Автовокзал")
        stock_sql = _executed_sql(db, 0)
        sales_sql = _executed_sql(db, 1)
        # psycopg v3: CAST(:x AS type), НЕ :x::type. District-фильтр теперь массив микро.
        assert "CAST(:districts AS text[])" in stock_sql
        assert "CAST(:obj_ids AS bigint[])" in stock_sql
        assert "CAST(:overstock_interval AS interval)" in stock_sql
        assert "CAST(:districts AS text[])" in sales_sql
        assert "CAST(:window_interval AS interval)" in sales_sql
        for sql in (stock_sql, sales_sql):
            assert ":districts::" not in sql
            assert ":obj_ids::" not in sql
            assert ":window_interval::" not in sql
            assert ":overstock_interval::" not in sql

    def test_params_district_and_premise(self) -> None:
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            compute_market_metrics(db, district="Автовокзал", premise_kind="квартира")
        p = _executed_params(db, 0)
        # district резолвится в набор микро (тут identity → ['Автовокзал']) и идёт в
        # ANY(:districts); прежнего скалярного :district больше нет.
        assert p["has_district"] is True
        assert p["districts"] == ["Автовокзал"]
        assert "district" not in p
        assert p["premise_kind"] == "квартира"
        assert p["has_obj_ids"] is False
        assert p["obj_ids"] == []

    def test_params_obj_ids_filter(self) -> None:
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            compute_market_metrics(db, obj_ids=[101, 202, 303])
        p = _executed_params(db, 0)
        assert p["has_obj_ids"] is True
        assert p["obj_ids"] == [101, 202, 303]

    def test_full_metrics_computed(self) -> None:
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            m = compute_market_metrics(db, district="Автовокзал", window_months=6)
        assert m.n_lots == 300
        assert m.n_sold == 90
        assert m.n_available == 210
        assert m.obj_count == 3
        # unit_velocity: 60 / 6 = 10/mo
        assert m.unit_velocity == 10.0
        # area_velocity: 2880 / 6 = 480/mo
        assert m.area_velocity == 480.0
        # absorption: 10/mo / 210 ≈ 0.0476
        assert m.absorption_rate is not None and round(m.absorption_rate, 4) == 0.0476
        # months_of_supply: 210 / 10 = 21
        assert m.months_of_supply == 21.0
        # sell_through: 90 / 300 = 30%
        assert m.sell_through_pct == 30.0
        # overstock: 42 / 210 = 0.2
        assert m.overstock_index == 0.2
        # liquidity / demand_concentration computed from per-room buckets
        assert m.liquidity_index is not None
        assert set(m.liquidity_index.keys()) == {"1", "2", "студия"}
        assert m.demand_concentration is not None
        # price_sensitivity from reused elasticity
        assert m.price_sensitivity == -1.4
        assert m.price_sensitivity_source == "regression"
        assert m.confidence == "high"
        # #1593: velocity_by_room — per-bucket ед./мес из sold_by_room.
        # _FULL_SALES: rooms_int=1 (30 ед.), rooms_int=2 (20 ед.), rooms_int=0 (10 ед.)
        # window_months=6 → "1": 30/6=5.0, "2": 20/6≈3.333, "студия": 10/6≈1.667
        assert m.velocity_by_room is not None
        assert set(m.velocity_by_room.keys()) == {"1", "2", "студия"}
        assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
        assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
        assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)

    def test_elasticity_reuse_invoked_with_district(self) -> None:
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}) as elast:
            compute_market_metrics(db, district="Автовокзал", window_months=6)
        assert elast.called
        _args, kwargs = elast.call_args
        assert kwargs["district_name"] == "Автовокзал"
        # elasticity-окно минимум 24 мес даже при коротком window_months
        assert kwargs["elasticity_window_months"] == 24

    def test_no_district_skips_elasticity(self) -> None:
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST) as elast:
            m = compute_market_metrics(db, obj_ids=[1, 2])
        assert not elast.called
        assert m.price_sensitivity is None
        assert m.price_sensitivity_source is None


class TestDistrictResolution:
    """Step 2: админ-имя ЕКБ резолвится в МИКРО-набор, который реально идёт в SQL-фильтр.

    Баг, который это ловит: раньше админ-имя («Кировский») фильтровало
    objective_lots.district = 'Кировский' → 0 строк (микро-вокабуляр не содержит
    админ-имён) → пустой прогноз. Теперь резолвер разворачивает его в чистые микро,
    и SQL фильтрует ol.district = ANY([микро…]).
    """

    def test_admin_resolves_to_micros_in_sql_params(self, _patch_resolver: MagicMock) -> None:
        # Резолвер разворачивает 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
        _patch_resolver.side_effect = lambda _db, d: (
            ["Втузгородок", "ЖБИ"] if d == "Кировский" else None
        )
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            m = compute_market_metrics(db, district="Кировский")

        # Резолвер вызван с админ-именем. #1211: теперь зовётся ДВАЖДЫ — раз для
        # stock/sales фильтра (compute_market_metrics), раз для _price_sensitivity
        # → _elasticity_coef. Оба вызова — с одним и тем же admin-именем.
        assert _patch_resolver.called
        for call in _patch_resolver.call_args_list:
            assert call.args[1] == "Кировский"

        # КРИТИЧНО: в SQL-параметры попали МИКРО, а НЕ админ-имя (иначе 0 строк).
        for call_idx in (0, 1):  # stock + sales-window
            p = _executed_params(db, call_idx)
            assert p["has_district"] is True
            assert p["districts"] == ["Втузгородок", "ЖБИ"]
            assert "Кировский" not in p["districts"]
            assert "district" not in p  # старого скалярного параметра больше нет

        # district на результате всё равно ЭХОИТ входное админ-имя (для отображения).
        assert m.district == "Кировский"

    def test_resolver_none_drops_district_filter_ekb_wide(self, _patch_resolver: MagicMock) -> None:
        # Админ без чистых алиасов / 'не определён' → резолвер None → EKB-wide:
        # has_district=False, district-ветка фильтра отключена (пустой массив-плейсхолдер).
        _patch_resolver.side_effect = lambda _db, _d: None
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            compute_market_metrics(db, district="не определён")
        p = _executed_params(db, 0)
        assert p["has_district"] is False
        assert p["districts"] == []

    def test_does_not_filter_by_admin_name_directly(self, _patch_resolver: MagicMock) -> None:
        # Anti-vacuous guard: даже если кто-то «починит» резолвер обратно на pass-through
        # админ-имени, фильтр обязан содержать РЕЗОЛВНУТЫЙ набор. Здесь резолвер отдаёт
        # микро, и мы проверяем, что admin-имя НЕ просочилось в :districts ни в одном
        # из двух запросов.
        _patch_resolver.side_effect = lambda _db, d: (
            ["Уралмаш", "Эльмаш"] if d == "Орджоникидзевский" else None
        )
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            compute_market_metrics(db, district="Орджоникидзевский")
        for call_idx in (0, 1):
            p = _executed_params(db, call_idx)
            assert p["districts"] == ["Уралмаш", "Эльмаш"]
            assert "Орджоникидзевский" not in p["districts"]


class TestPriceSensitivityDistrictResolution:
    """#1211 P2 silent-correctness: _price_sensitivity резолвит admin → МИКРО, а НЕ
    передаёт сырое admin-имя в _elasticity_coef.

    Корень бага: ``objective_corpus_room_month.district`` — МИКРО-вокабуляр
    (Втузгородок, ЖБИ, …); фильтр по admin-имени ('Кировский') даёт 0 точек →
    регрессия всегда FALLBACK_ELASTICITY (silent-correctness — §9.2 district-level
    эластичность никогда не считалась в /analyze-пути; только 'Академический'
    случайно совпадал в обоих вокабулярах).
    """

    def test_admin_resolves_to_micros_in_elasticity_call(self, _patch_resolver: MagicMock) -> None:
        # Резолвер: 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
        _patch_resolver.side_effect = lambda _db, d: (
            ["Втузгородок", "ЖБИ"] if d == "Кировский" else None
        )
        db = MagicMock()
        with patch(_ELAST, return_value={"elasticity": -1.2, "source": "regression"}) as elast:
            value, source = _price_sensitivity(db, district="Кировский", window_months=6)

        # резолвер вызван именно с admin-именем
        _patch_resolver.assert_called_once()
        assert _patch_resolver.call_args.args[1] == "Кировский"

        # КРИТИЧНО: в _elasticity_coef ушёл список МИКРО, а НЕ сырое admin-имя.
        _args, kwargs = elast.call_args
        assert kwargs["districts"] == ["Втузгородок", "ЖБИ"]
        assert kwargs["district_name"] == "Кировский"  # сохранён для logging
        # elasticity-окно минимум 24 мес даже при коротком window_months
        assert kwargs["elasticity_window_months"] == 24

        # результат _elasticity_coef переброшен наружу
        assert value == -1.2
        assert source == "regression"

    def test_resolver_none_passes_empty_list_ekb_wide(self, _patch_resolver: MagicMock) -> None:
        # Резолвер None ('не определён' / нет чистых алиасов) → передаём [] →
        # _elasticity_coef уходит в EKB-wide ветку (без district-фильтра).
        _patch_resolver.side_effect = lambda _db, _d: None
        db = MagicMock()
        with patch(_ELAST, return_value={"elasticity": -1.5, "source": "regression"}) as elast:
            _price_sensitivity(db, district="не определён", window_months=6)

        _args, kwargs = elast.call_args
        assert kwargs["districts"] == []

    def test_none_district_skips_resolver_and_elasticity(self, _patch_resolver: MagicMock) -> None:
        # district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни
        # резолвер, ни _elasticity_coef.
        db = MagicMock()
        with patch(_ELAST) as elast:
            value, source = _price_sensitivity(db, district=None, window_months=6)
        assert value is None
        assert source is None
        assert not _patch_resolver.called
        assert not elast.called

    def test_post_resolution_regression_returns_non_fallback(
        self, _patch_resolver: MagicMock
    ) -> None:
        # Симулируем «после резолва n>=30 точек, регрессия валидна»: _elasticity_coef
        # возвращает source='regression' с конкретным slope — _price_sensitivity не
        # подменяет результат и не схлопывается в fallback.
        _patch_resolver.side_effect = lambda _db, d: (
            ["Эльмаш"] if d == "Орджоникидзевский" else None
        )
        db = MagicMock()
        with patch(
            _ELAST, return_value={"elasticity": -0.87, "r2": 0.42, "n": 45, "source": "regression"}
        ):
            value, source = _price_sensitivity(db, district="Орджоникидзевский", window_months=6)
        # источник 'regression' (не 'fallback'), значит admin→micro резолв реально дал
        # точки регрессии — это и есть фикс бага #1211.
        assert source == "regression"
        assert value == -0.87

    def test_elasticity_exception_returns_none_pair_graceful(
        self, _patch_resolver: MagicMock
    ) -> None:
        # Багу резолва ортогональна graceful-обёртка: если _elasticity_coef падает,
        # _price_sensitivity ловит и возвращает (None, None), а не крашит pipeline.
        _patch_resolver.side_effect = lambda _db, d: ["Втузгородок"]
        db = MagicMock()
        with patch(_ELAST, side_effect=RuntimeError("db down")):
            value, source = _price_sensitivity(db, district="Кировский", window_months=6)
        assert value is None
        assert source is None


class TestVelocityByRoom:
    """#1593: MarketMetrics.velocity_by_room — per-bucket ед./мес."""

    def test_velocity_by_room_computed_from_sold_by_room(self) -> None:
        # sold_by_room: "1" → 30, "2" → 20, "студия" → 10 ед. за 6 мес.
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            m = compute_market_metrics(db, district="Автовокзал", window_months=6)
        assert m.velocity_by_room is not None
        assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
        assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
        assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)

    def test_velocity_by_room_none_when_no_sample(self) -> None:
        # n_lots=0 → выборка пуста → velocity_by_room None (зеркало unit_velocity).
        empty_stock = {
            "n_lots": 0,
            "n_sold": 0,
            "n_available": 0,
            "obj_count": 0,
            "n_long_unsold": 0,
        }
        db = _mock_db(empty_stock, [])
        with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
            m = compute_market_metrics(db, district="ПустойРайон")
        assert m.velocity_by_room is None

    def test_velocity_by_room_none_when_no_by_room_data(self) -> None:
        # Только grand-total строка (нет разбивки по комнатности) → sold_by_room пустой
        # → velocity_by_room None.
        sales_total_only = [
            {"units_sold_window": 5, "area_sold_window": 200.0, "rooms_int": None, "is_total": 1}
        ]
        db = _mock_db(_FULL_STOCK, sales_total_only)
        with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
            m = compute_market_metrics(db, district="Тихий")
        assert m.velocity_by_room is None


class TestComputeMarketMetricsThinData:
    """Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""

    def test_empty_stock_all_none(self) -> None:
        empty_stock = {
            "n_lots": 0,
            "n_sold": 0,
            "n_available": 0,
            "obj_count": 0,
            "n_long_unsold": 0,
        }
        db = _mock_db(empty_stock, [])
        with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
            m = compute_market_metrics(db, district="ПустойРайон")
        assert m.n_lots == 0
        assert m.absorption_rate is None
        assert m.months_of_supply is None
        assert m.sell_through_pct is None
        assert m.unit_velocity is None
        assert m.area_velocity is None
        assert m.liquidity_index is None
        assert m.overstock_index is None
        assert m.demand_concentration is None
        assert m.velocity_by_room is None
        assert m.confidence == "low"

    def test_stock_none_row_no_crash(self) -> None:
        # .first() вернул None — не падаем, всё 0/None.
        db = _mock_db(None, [])
        with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
            m = compute_market_metrics(db, district="Х")
        assert m.n_lots == 0
        assert m.unit_velocity is None
        assert m.confidence == "low"

    def test_available_stock_but_no_sales(self) -> None:
        # Есть сток, но никто не продал за окно → velocity 0, MoS None, absorption 0.
        stock = {
            "n_lots": 50,
            "n_sold": 0,
            "n_available": 50,
            "obj_count": 1,
            "n_long_unsold": 5,
        }
        # только grand-total строка с 0 продаж
        sales = [
            {"units_sold_window": 0, "area_sold_window": 0.0, "rooms_int": None, "is_total": 1}
        ]
        db = _mock_db(stock, sales)
        with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
            m = compute_market_metrics(db, district="Тихий")
        assert m.unit_velocity == 0.0
        assert m.absorption_rate == 0.0  # 0 продаж при наличии стока = 0/мес (валидно)
        assert m.months_of_supply is None  # темп 0 → распродажа неизмерима
        assert m.sell_through_pct == 0.0  # 0 / 50 = 0%
        assert m.liquidity_index is None  # нет проданных по бакетам
        assert m.demand_concentration is None
        # overstock считается (есть доступные): 5 / 50 = 0.1
        assert m.overstock_index == 0.1
        assert m.confidence == "low"  # n_sold=0 → low

    def test_stock_query_exception_graceful(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("db down")
        with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
            m = compute_market_metrics(db, district="Сбой")
        # exception проглочен (logged), вернулся валидный пустой результат
        assert m.n_lots == 0
        assert m.confidence == "low"
        assert m.as_dict()["unit_velocity"] is None


class TestSalesWindowSource:
    """#949 REOPENED: продажи-в-окне считаются по objective_lots.contract_date,
    а НЕ по objective_lots_history-снапшотам.

    Корень бага: history глубиной ~17 дней → у каждого сейчас-проданного лота есть
    sold-снапшот в окне → «продажи в окне» схлопывались в весь кумулятивный сток
    (Автовокзал 6mo: ~33 245 ед. вместо реальных ~2 308), завышая absorption /
    velocity / months_of_supply / liquidity / demand_concentration. Фикс: считать по
    contract_date в окне — он реальная дата сделки и заполнен у 100% проданных лотов.
    """

    def test_sales_window_queries_contract_date_not_history(self) -> None:
        db = _mock_db(_FULL_STOCK, _FULL_SALES)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            compute_market_metrics(db, district="Автовокзал", window_months=6)
        sales_sql = _executed_sql(db, 1)
        # Окно теперь по contract_date из objective_lots — без зависимости от history.
        assert "objective_lots_history" not in sales_sql
        # #1959: дедуп до последнего снапшота физлота в CTE latest, окно по
        # contract_date применяется к дедуплицированному набору (bare-колонка,
        # без ol.-префикса в outer WHERE).
        assert "DISTINCT ON (" in sales_sql
        assert "contract_date IS NOT NULL" in sales_sql
        assert "contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)" in sales_sql
        assert "FROM objective_lots ol" in sales_sql
        # Контракт окна сохранён: ROLLUP по комнатности + interval-bind через CAST.
        assert "GROUP BY ROLLUP (rooms_int)" in sales_sql
        assert ":window_interval::" not in sales_sql

    def test_lots_sold_outside_window_not_counted(self) -> None:
        """Регрессия бага: лоты, проданные ДАВНО (contract_date вне окна), но всё ещё
        присутствующие как проданный сток, НЕ попадают в продажи-в-окне.

        Раньше (history-подход) такой лот считался «проданным в окне» из-за свежего
        снапшота → раздувал absorption. Теперь WHERE по contract_date их отсекает на
        стороне БД, поэтому в окно попадают только реально-свежие сделки.

        Сток: 41 091 проданных кумулятивно (как на проде), но в окне — только 2 308
        реальных новых сделок по contract_date. absorption обязан считаться от 2 308,
        а НЕ от кумулятивного стока → реалистичный (~доли в месяц), не 235%/мес.
        """
        # Кумулятивный сток (cumulative — корректен для n_sold / sell_through / confidence).
        stock = {
            "n_lots": 50_000,
            "n_sold": 41_091,
            "n_available": 8_909,
            "obj_count": 40,
            "n_long_unsold": 1_000,
        }
        # Окно (contract_date-фильтр на стороне БД) отдаёт ТОЛЬКО реально-свежие сделки.
        # Давно-проданные лоты (contract_date вне окна) сюда не попадают вовсе.
        real_window_units = 2_308
        sales = [
            {
                "units_sold_window": real_window_units,
                "area_sold_window": 110_000.0,
                "rooms_int": None,
                "is_total": 1,
            },
            {
                "units_sold_window": 1_000,
                "area_sold_window": 40_000.0,
                "rooms_int": 1,
                "is_total": 0,
            },
            {
                "units_sold_window": 800,
                "area_sold_window": 44_000.0,
                "rooms_int": 2,
                "is_total": 0,
            },
            {
                "units_sold_window": 508,
                "area_sold_window": 26_000.0,
                "rooms_int": 3,
                "is_total": 0,
            },
        ]
        db = _mock_db(stock, sales)
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            m = compute_market_metrics(db, district="Автовокзал", window_months=6)

        # velocity = 2 308 / 6, НЕ 41 091 / 6.
        assert m.unit_velocity == real_window_units / 6
        # absorption = (2 308 / 6) / 8 909 ≈ 0.043/мес — реалистично (доли единицы),
        # а НЕ ~2.35 (235%/мес), как давал баг от кумулятивного стока.
        assert m.absorption_rate is not None
        assert m.absorption_rate < 0.1
        assert round(m.absorption_rate, 4) == round((real_window_units / 6) / 8_909, 4)
        # months_of_supply = 8 909 / (2 308 / 6) ≈ 23 мес — конечное, осмысленное.
        assert m.months_of_supply is not None
        assert 20.0 < m.months_of_supply < 25.0
        # Кумулятив остаётся кумулятивным (не окно): n_sold / sell_through нетронуты.
        assert m.n_sold == 41_091
        assert m.sell_through_pct is not None
        assert round(m.sell_through_pct, 1) == round(41_091 / 50_000 * 100, 1)


# ── compute_offer_price_trend (#2178): динамика цен предложения ────────────────
# objective_lots_history-медианы. Единственный execute → .mappings().all().
# Медиана считается в SQL (PERCENTILE_CONT) — mock подаёт УЖЕ посчитанные строки
# per-snapshot, тест проверяет форму SQL (дедуп/фильтры/CAST) и Python-обвязку
# (delta_pct, lots_latest, complexes_covered, порядок, graceful-on-empty).


def _mock_offer_db(rows: list[dict]) -> MagicMock:
    """Сессия с одним execute (offer-trend) → .mappings().all() = rows."""
    db = MagicMock()
    result = MagicMock()
    result.mappings.return_value.all.return_value = rows
    db.execute.return_value = result
    return db


class TestOfferPriceTrendSql:
    """Форма SQL: psycopg v3 CAST, дедуп задвоенного снапшота, фильтры, radius-путь."""

    def test_sql_uses_cast_not_double_colon(self) -> None:
        db = _mock_offer_db([])
        compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        sql = _executed_sql(db, 0)
        assert "CAST(:center_lon AS float)" in sql
        assert "CAST(:center_lat AS float)" in sql
        assert "CAST(:radius_m AS float)" in sql
        assert "CAST(:premise_kind AS text)" in sql
        assert "CAST(:price_min AS numeric)" in sql
        assert "CAST(:price_max AS numeric)" in sql
        for bind in ("center_lon", "center_lat", "radius_m", "price_min", "price_max"):
            assert f":{bind}::" not in sql

    def test_sql_dedups_doubled_snapshot(self) -> None:
        # Дедуп задвоенного дня: DISTINCT ON (лот, день) ORDER BY fetched_at DESC.
        db = _mock_offer_db([])
        compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        sql = _executed_sql(db, 0)
        assert "DISTINCT ON (h.objective_lot_id, h.snapshot_date)" in sql
        assert "h.fetched_at DESC" in sql

    def test_sql_filters_active_and_price_sanity(self) -> None:
        db = _mock_offer_db([])
        compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        sql = _executed_sql(db, 0)
        # «в продаже»: не продан флагом и не 'продан' статусом.
        assert "h.is_sold IS NOT TRUE" in sql
        assert "LOWER(COALESCE(h.status, '')) <> 'продан'" in sql
        # Вменяемость цены — bind-границы (10000..1000000).
        assert "price_per_m2_rub BETWEEN CAST(:price_min AS numeric)" in sql
        # Радиус-путь через маппинг (НЕ complex_id-FK).
        assert "objective_complex_mapping" in sql
        assert "domrf_kn_objects" in sql
        assert "ST_DWithin" in sql
        assert "PERCENTILE_CONT(0.5)" in sql

    def test_params_center_radius_price(self) -> None:
        db = _mock_offer_db([])
        compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        p = _executed_params(db, 0)
        assert p["center_lat"] == 56.83
        assert p["center_lon"] == 60.6
        assert p["radius_m"] == 3000.0
        assert p["premise_kind"] == "квартира"
        assert p["price_min"] == 10000
        assert p["price_max"] == 1000000


class TestOfferPriceTrendCompute:
    """Python-обвязка: медианы по снапшотам, delta_pct, lots_latest, covered."""

    def _rows(self) -> list[dict]:
        # 3 снапшота, медиана растёт 100k → 110k; covered=5 (константа scalar-subq).
        return [
            {
                "snapshot_date": _dt.date(2026, 5, 17),
                "median_price_m2": 100_000.0,
                "lots_count": 120,
                "complexes_covered": 5,
            },
            {
                "snapshot_date": _dt.date(2026, 6, 3),
                "median_price_m2": 105_000.0,
                "lots_count": 118,
                "complexes_covered": 5,
            },
            {
                "snapshot_date": _dt.date(2026, 6, 30),
                "median_price_m2": 110_000.0,
                "lots_count": 130,
                "complexes_covered": 5,
            },
        ]

    def test_points_shape_and_order(self) -> None:
        db = _mock_offer_db(self._rows())
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        assert isinstance(res, OfferPriceTrend)
        assert [pt["date"] for pt in res.points] == ["2026-05-17", "2026-06-03", "2026-06-30"]
        assert [pt["median_price_m2"] for pt in res.points] == [100_000, 105_000, 110_000]
        assert [pt["lots_count"] for pt in res.points] == [120, 118, 130]

    def test_delta_pct_last_vs_first(self) -> None:
        db = _mock_offer_db(self._rows())
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        # (110000 − 100000) / 100000 × 100 = 10.0
        assert res.delta_pct == 10.0

    def test_lots_latest_and_covered(self) -> None:
        db = _mock_offer_db(self._rows())
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        assert res.lots_latest == 130
        assert res.complexes_covered == 5
        assert res.has_data is True

    def test_single_snapshot_delta_zero(self) -> None:
        rows = [
            {
                "snapshot_date": _dt.date(2026, 6, 30),
                "median_price_m2": 90_000.0,
                "lots_count": 40,
                "complexes_covered": 2,
            }
        ]
        db = _mock_offer_db(rows)
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        # Один снапшот: first==last → delta 0.0 (не None, не crash).
        assert res.delta_pct == 0.0
        assert res.lots_latest == 40
        assert len(res.points) == 1

    def test_null_median_rows_skipped(self) -> None:
        # Снапшот, где медиана NULL (все лоты отфильтрованы) — не попадает в points,
        # но complexes_covered всё равно считывается (радиус покрыт).
        rows = [
            {
                "snapshot_date": _dt.date(2026, 5, 17),
                "median_price_m2": None,
                "lots_count": 0,
                "complexes_covered": 3,
            },
            {
                "snapshot_date": _dt.date(2026, 6, 30),
                "median_price_m2": 95_000.0,
                "lots_count": 22,
                "complexes_covered": 3,
            },
        ]
        db = _mock_offer_db(rows)
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        assert [pt["date"] for pt in res.points] == ["2026-06-30"]
        assert res.complexes_covered == 3
        assert res.delta_pct == 0.0  # один валидный снапшот

    def test_empty_complex_set_returns_empty(self) -> None:
        # Пустой набор ЖК в радиусе → rows=[] → пустой результат, НЕ crash/None-поля.
        db = _mock_offer_db([])
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        assert res.points == []
        assert res.delta_pct is None
        assert res.lots_latest is None
        assert res.complexes_covered == 0
        assert res.has_data is False

    def test_query_exception_graceful(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("db down")
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        # Ошибка SQL → пустой результат (graceful), не пробрасывается.
        assert res.points == []
        assert res.has_data is False

    def test_as_dict_rounds_delta(self) -> None:
        rows = [
            {
                "snapshot_date": _dt.date(2026, 5, 17),
                "median_price_m2": 100_000.0,
                "lots_count": 10,
                "complexes_covered": 1,
            },
            {
                "snapshot_date": _dt.date(2026, 6, 30),
                "median_price_m2": 103_333.0,
                "lots_count": 12,
                "complexes_covered": 1,
            },
        ]
        db = _mock_offer_db(rows)
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        d = res.as_dict()
        assert set(d.keys()) == {"points", "delta_pct", "lots_latest", "complexes_covered"}
        # (103333 − 100000)/100000×100 = 3.333 → round(1) = 3.3
        assert d["delta_pct"] == 3.3
        assert d["lots_latest"] == 12
        assert d["complexes_covered"] == 1


# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
#
# db shared с caller (compute_market_metrics в цепочке §22-форсайта / analyze).
# Раньше _query_stock/_query_sales_window/_price_sensitivity ловили db.execute-сбой
# в bare ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это
# отравляет db shared с caller: следующий db.execute на этой же сессии падает
# "current transaction is aborted". Фикс — db.execute (и, для _price_sensitivity,
# весь вызов _elasticity_coef) обёрнут в ``with db.begin_nested():``. MagicMock не
# эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful
# fallback при сбое, (2) что begin_nested() реально вызван, (3) что db остаётся
# usable для следующего вызова на той же mock-сессии.


class TestQueryStockSavepoint:
    def test_uses_begin_nested_savepoint(self) -> None:
        db = MagicMock()
        db.execute.return_value.mappings.return_value.first.return_value = None
        _query_stock(db, {"districts": ["X"]})
        assert db.begin_nested.call_count == 1

    def test_query_failure_degrades_to_zero_counts(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("simulated DB failure")
        out = _query_stock(db, {"districts": ["X"]})
        assert out == {
            "n_lots": 0,
            "n_sold": 0,
            "n_available": 0,
            "obj_count": 0,
            "n_long_unsold": 0,
        }

    def test_session_usable_after_failure(self) -> None:
        """db.execute сбоит один раз (stock), но db остаётся usable для следующего
        (не связанного) вызова на этой же сессии — имитирует
        _query_stock → _query_sales_window → _price_sensitivity chain."""
        db = MagicMock()
        db.execute.side_effect = RuntimeError("boom")
        out = _query_stock(db, {"districts": ["X"]})
        assert out["n_lots"] == 0

        db.execute.side_effect = None
        next_result = MagicMock()
        next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
        db.execute.return_value = next_result
        assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]


class TestQuerySalesWindowSavepoint:
    def test_uses_begin_nested_savepoint(self) -> None:
        db = MagicMock()
        db.execute.return_value.mappings.return_value.all.return_value = []
        _query_sales_window(db, {"districts": ["X"]})
        assert db.begin_nested.call_count == 1

    def test_query_failure_degrades_to_empty(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("simulated DB failure")
        units, area, by_room = _query_sales_window(db, {"districts": ["X"]})
        assert (units, area, by_room) == (0, 0.0, {})

    def test_session_usable_after_failure(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("boom")
        units, _area, _by_room = _query_sales_window(db, {"districts": ["X"]})
        assert units == 0

        db.execute.side_effect = None
        next_result = MagicMock()
        next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
        db.execute.return_value = next_result
        assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]


class TestPriceSensitivitySavepoint:
    """_price_sensitivity делает db.execute КОСВЕННО (через _elasticity_coef) —
    SAVEPOINT обёрнут вокруг всего вызова, а не голого db.execute."""

    def test_uses_begin_nested_savepoint(self, _patch_resolver: MagicMock) -> None:
        _patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
        db = MagicMock()
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            _price_sensitivity(db, district="Автовокзал", window_months=6)
        assert db.begin_nested.call_count == 1

    def test_elasticity_failure_still_degrades_gracefully(self, _patch_resolver: MagicMock) -> None:
        _patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
        db = MagicMock()
        with patch(_ELAST, side_effect=RuntimeError("simulated DB failure")):
            value, source = _price_sensitivity(db, district="Автовокзал", window_months=6)
        assert (value, source) == (None, None)
        assert db.begin_nested.call_count == 1

    def test_session_usable_after_failure(self, _patch_resolver: MagicMock) -> None:
        """Сбой elasticity-цепочки не отравляет db — следующий вызов на этой же
        сессии (например, offer-price-trend) отрабатывает нормально."""
        _patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
        db = MagicMock()
        with patch(_ELAST, side_effect=RuntimeError("boom")):
            _price_sensitivity(db, district="Автовокзал", window_months=6)

        next_result = MagicMock()
        next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
        db.execute.return_value = next_result
        assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]


class TestComputeMarketMetricsSessionUsableAfterFirstFailure:
    """End-to-end (via compute_market_metrics): stock-запрос сбоит → SAVEPOINT
    (не bare rollback) → sales-window запрос по-прежнему выполняется на той же
    сессии (не блокируется "current transaction is aborted")."""

    def test_stock_failure_does_not_poison_sales_window(self, _patch_resolver: MagicMock) -> None:
        db = MagicMock()
        sales_result = MagicMock()
        sales_result.mappings.return_value.all.return_value = _FULL_SALES
        db.execute.side_effect = [RuntimeError("boom on stock"), sales_result]
        with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
            m = compute_market_metrics(db, district="Автовокзал", window_months=6)
        assert db.begin_nested.call_count >= 1
        assert m.n_lots == 0  # stock fallback (graceful, не crash)
        assert m.confidence == "low"
        # sales-window execute РЕАЛЬНО выполнился на той же db (2-й вызов не был
        # заблокирован сбоем 1-го) — session осталась usable после stock-savepoint.
        assert db.execute.call_count >= 2


class TestOfferPriceTrendSavepoint:
    """compute_offer_price_trend: db shared с caller (POST .../analyze / §22-отчёт).
    db.execute обёрнут в begin_nested — 8с-гвард QueryCanceled не отравляет сессию."""

    def test_uses_begin_nested_savepoint(self) -> None:
        db = _mock_offer_db([])
        compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        assert db.begin_nested.call_count == 1

    def test_query_failure_degrades_and_no_bare_rollback(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("query canceled (8s guard)")
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        assert res.points == []  # документированный fallback
        assert res.has_data is False
        assert db.begin_nested.call_count == 1
        db.rollback.assert_not_called()  # никакого bare rollback на shared сессии

    def test_session_usable_after_failure(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("db down")
        res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
        assert res.points == []

        db.execute.side_effect = None
        next_result = MagicMock()
        next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
        db.execute.return_value = next_result
        assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
