471 lines
25 KiB
Python
471 lines
25 KiB
Python
"""Unit-тесты §9.x → §22 оркестратора (`build_site_finder_report`, step 3a, 955-A2).
|
||
|
||
Оркестратор — ШОВ: считает §9.x-слои и раскладывает их в ЧИСТЫЙ §22-сборщик
|
||
(`assemble_report`). Тесты пинят ТРИ контракта шва, БЕЗ живой БД (db = sentinel,
|
||
§9.x-вызовы запатчены):
|
||
|
||
• МОДАЛЬНЫЙ дефолт класса (audit Q1): competitors [Комфорт, Комфорт, Бизнес] →
|
||
obj_class="Комфорт"; пустые competitors → None (ЕКБ-wide). Явный obj_class имеет
|
||
приоритет над выводом.
|
||
• ПРОВОДКА/СИГНАТУРЫ (ловушки audit): патчим §9.x на sentinel'ы, ассертим что
|
||
`assemble_report` получил КАЖДЫЙ вход в правильном слоте, а §9.x вызваны с
|
||
ПРАВИЛЬНОЙ ГЕТЕРОГЕННОЙ сигнатурой (forecasts/scenarios/special_indices ← `horizons=`;
|
||
score_card/future_supply ← `horizon_months=`; overlay ← `target_class=`). Регрессия
|
||
(не тот kwarg) → тест падает.
|
||
• GRACEFUL: один §9.x-слой бросает → отчёт всё равно собирается (его слот None),
|
||
исключение НЕ всплывает.
|
||
|
||
Детерминированно, без сети, без LLM. DATABASE_URL до импорта app-модулей (зеркало
|
||
соседних тестов — на случай side-effect'ов импорта пакета forecasting).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import os
|
||
from collections.abc import Iterator, Mapping
|
||
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.orchestrator import (
|
||
_modal_obj_class,
|
||
_normalize_district,
|
||
_primary_horizon,
|
||
_summarize_supply_layers,
|
||
build_site_finder_report,
|
||
)
|
||
from app.services.forecasting.report import SiteFinderReport
|
||
|
||
# Полностью-квалифицированный путь к каждому §9.x-символу как он ИМПОРТИРОВАН в
|
||
# orchestrator (патчим там, где используется, не там, где определён).
|
||
_MOD = "app.services.forecasting.orchestrator"
|
||
|
||
|
||
def _analyze(competitors: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||
"""Минимальный analyze-dict (только то, что читает оркестратор/сборщик)."""
|
||
return {
|
||
"cad_num": "66:41:0702048:27",
|
||
"district": {"district_name": "Ленинский"},
|
||
"competitors": competitors if competitors is not None else [],
|
||
}
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Модальный дефолт класса (audit Q1).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestModalObjClass:
|
||
def test_modal_picks_most_common(self) -> None:
|
||
comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}]
|
||
assert _modal_obj_class(comps) == "Комфорт"
|
||
|
||
def test_empty_competitors_none(self) -> None:
|
||
assert _modal_obj_class([]) is None
|
||
|
||
def test_competitors_without_class_none(self) -> None:
|
||
# есть конкуренты, но ни у одного нет класса → None (честно «не уточнён»).
|
||
assert _modal_obj_class([{"obj_id": 1}, {"comm_name": "ЖК Альфа"}]) is None
|
||
|
||
def test_legacy_class_key_supported(self) -> None:
|
||
# legacy-ключ `class` (а не `obj_class`) тоже читается.
|
||
assert _modal_obj_class([{"class": "Стандарт"}, {"class": "Стандарт"}]) == "Стандарт"
|
||
|
||
def test_obj_class_preferred_over_legacy(self) -> None:
|
||
comps = [{"obj_class": "Бизнес", "class": "Комфорт"}]
|
||
assert _modal_obj_class(comps) == "Бизнес"
|
||
|
||
def test_non_list_none(self) -> None:
|
||
assert _modal_obj_class(None) is None
|
||
assert _modal_obj_class("Комфорт") is None # type: ignore[arg-type]
|
||
|
||
|
||
class TestPrimaryHorizon:
|
||
def test_prefers_12_when_present(self) -> None:
|
||
assert _primary_horizon([6, 12, 18, 24]) == 12
|
||
|
||
def test_falls_back_to_first_when_no_12(self) -> None:
|
||
assert _primary_horizon([6, 18, 24]) == 6
|
||
|
||
def test_empty_defaults_to_12(self) -> None:
|
||
assert _primary_horizon([]) == 12
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Хелпер сводки supply-слоёв (#950 → слот supply_layers сборщика).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSummarizeSupplyLayers:
|
||
def test_none_or_empty_none(self) -> None:
|
||
assert _summarize_supply_layers(None) is None
|
||
assert _summarize_supply_layers([]) is None
|
||
|
||
def test_aggregates_units_by_layer(self) -> None:
|
||
rows = [
|
||
MagicMock(layer=1, units_estimate=1200, as_dict=lambda: {"layer": 1}),
|
||
MagicMock(layer=2, units_estimate=800, as_dict=lambda: {"layer": 2}),
|
||
MagicMock(layer=3, units_estimate=500, as_dict=lambda: {"layer": 3}),
|
||
MagicMock(layer=1, units_estimate=None, as_dict=lambda: {"layer": 1}), # None skip
|
||
]
|
||
summary = _summarize_supply_layers(rows)
|
||
assert summary is not None
|
||
assert summary["open_units"] == 1200
|
||
assert summary["hidden_units"] == 800
|
||
assert summary["future_units"] == 500
|
||
assert summary["n_rows"] == 4
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# ПРОВОДКА + СИГНАТУРНЫЕ ловушки (audit): §9.x → sentinel, ассерт слот+kwarg.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _patched_layers() -> dict[str, Any]:
|
||
"""Контекст-менеджеры-патчи всех §9.x на распознаваемые sentinel-возвраты."""
|
||
# supply_layers возвращает СПИСОК строк (оркестратор свернёт в сводку-dict).
|
||
supply_row = MagicMock(layer=1, units_estimate=10, as_dict=lambda: {"layer": 1})
|
||
return {
|
||
"compute_market_metrics": MagicMock(return_value="MM"),
|
||
"compute_all_layers": MagicMock(return_value=[supply_row]),
|
||
"compute_future_supply_pressure": MagicMock(return_value="FSP"),
|
||
"compute_demand_supply_forecast": MagicMock(return_value=["F6", "F12"]),
|
||
# #1871 P1: compute_scenarios теперь возвращает кортеж
|
||
# `(scenarios, collapsed, collapse_reason)`. Sentinel: scenarios=["SC"], не схлопнуто.
|
||
"compute_scenarios": MagicMock(return_value=(["SC"], False, None)),
|
||
"compute_score_card": MagicMock(return_value="PSC"),
|
||
"compute_special_indices": MagicMock(return_value="SI"),
|
||
"build_forecast_overlay": MagicMock(return_value="OVL"),
|
||
"get_monthly_macro": MagicMock(return_value=[]),
|
||
}
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def _patch_layers(mocks: Mapping[str, Any], assemble: Any) -> Iterator[None]:
|
||
"""Запатчить ВСЕ §9.x-символы + assemble_report (по `mocks`-словарю). Компактно.
|
||
|
||
Патчим там, где символы ИМПОРТИРОВАНЫ в orchestrator (`_MOD`), не там, где определены.
|
||
"""
|
||
with contextlib.ExitStack() as stack:
|
||
for name, mock in mocks.items():
|
||
stack.enter_context(patch(f"{_MOD}.{name}", mock))
|
||
stack.enter_context(patch(f"{_MOD}.assemble_report", side_effect=assemble))
|
||
yield
|
||
|
||
|
||
class TestWiringAndSignatures:
|
||
"""Патчим §9.x на sentinel'ы + перехватываем assemble_report → ассертим слоты+kwargs."""
|
||
|
||
def _run(
|
||
self, *, obj_class: str | None = None, competitors: list[dict[str, Any]] | None = None
|
||
) -> tuple[SiteFinderReport, dict[str, Any], dict[str, Any]]:
|
||
mocks = _patched_layers()
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
|
||
captured["analyze"] = analyze
|
||
captured.update(kwargs)
|
||
return SiteFinderReport()
|
||
|
||
with _patch_layers(mocks, _fake_assemble):
|
||
report = build_site_finder_report(
|
||
MagicMock(name="db"),
|
||
analyze=_analyze(competitors),
|
||
cad_num="66:41:0702048:27",
|
||
district="Ленинский",
|
||
obj_class=obj_class,
|
||
horizons=[6, 12, 18],
|
||
)
|
||
return report, captured, mocks
|
||
|
||
def test_each_layer_lands_in_correct_slot(self) -> None:
|
||
_report, captured, _mocks = self._run(obj_class="Комфорт")
|
||
# assemble_report вызван (captured заполнен) — каждый sentinel в СВОЁМ слоте.
|
||
assert captured["market_metrics"] == "MM"
|
||
assert captured["future_supply"] == "FSP"
|
||
assert captured["forecasts"] == ["F6", "F12"]
|
||
assert captured["scenarios"] == ["SC"]
|
||
assert captured["product_scores"] == "PSC"
|
||
assert captured["special_indices"] == "SI"
|
||
# overlay идёт именно в recommendation_overlay (НЕ product_tz/product_scores).
|
||
assert captured["recommendation_overlay"] == "OVL"
|
||
# supply_layers свёрнут в сводку-dict (не сырой список строк).
|
||
assert isinstance(captured["supply_layers"], dict)
|
||
assert captured["supply_layers"]["open_units"] == 10
|
||
# meta-проводка.
|
||
assert captured["cad_num"] == "66:41:0702048:27"
|
||
assert captured["district"] == "Ленинский"
|
||
assert captured["horizons"] == [6, 12, 18]
|
||
assert captured["advisory"] is True
|
||
assert captured["segment"] == {
|
||
"obj_class": "Комфорт",
|
||
"room_bucket": None,
|
||
"district": "Ленинский",
|
||
"price_bucket": None,
|
||
}
|
||
|
||
def test_horizons_services_get_horizons_kwarg(self) -> None:
|
||
"""forecasts/scenarios/special_indices ДОЛЖНЫ получить `horizons=` (Sequence)."""
|
||
_report, _captured, mocks = self._run(obj_class="Комфорт")
|
||
horizons_services = (
|
||
"compute_demand_supply_forecast",
|
||
"compute_scenarios",
|
||
"compute_special_indices",
|
||
)
|
||
for name in horizons_services:
|
||
_args, kwargs = mocks[name].call_args
|
||
assert kwargs.get("horizons") == [6, 12, 18], f"{name} must take horizons="
|
||
assert "horizon_months" not in kwargs, f"{name} must NOT take horizon_months"
|
||
assert kwargs.get("cad_num") == "66:41:0702048:27"
|
||
assert kwargs.get("district") == "Ленинский"
|
||
# spec прокинут как SegmentSpec.
|
||
assert kwargs["spec"].obj_class == "Комфорт"
|
||
|
||
def test_horizon_months_services_get_int_kwarg(self) -> None:
|
||
"""score_card/future_supply ДОЛЖНЫ получить `horizon_months=` (int=primary=12)."""
|
||
_report, _captured, mocks = self._run(obj_class="Комфорт")
|
||
_args, sc_kwargs = mocks["compute_score_card"].call_args
|
||
assert sc_kwargs.get("horizon_months") == 12 # 12 в сетке [6,12,18] → primary
|
||
assert "horizons" not in sc_kwargs
|
||
assert sc_kwargs["spec"].obj_class == "Комфорт"
|
||
_args, fsp_kwargs = mocks["compute_future_supply_pressure"].call_args
|
||
assert fsp_kwargs.get("horizon_months") == 12
|
||
assert "horizons" not in fsp_kwargs
|
||
|
||
def test_overlay_gets_target_class_not_spec(self) -> None:
|
||
"""overlay ДОЛЖЕН получить `target_class=` (str) + `horizon_months=`, НЕ spec."""
|
||
_report, _captured, mocks = self._run(obj_class="Бизнес")
|
||
_args, kwargs = mocks["build_forecast_overlay"].call_args
|
||
assert kwargs.get("target_class") == "Бизнес"
|
||
assert kwargs.get("horizon_months") == 12
|
||
assert "spec" not in kwargs
|
||
assert "horizons" not in kwargs
|
||
|
||
def test_market_metrics_and_supply_use_premise_kind(self) -> None:
|
||
"""§9.2/§9.3 ходят по premise_kind, без spec/horizons."""
|
||
_report, _captured, mocks = self._run(obj_class="Комфорт")
|
||
_args, mm_kwargs = mocks["compute_market_metrics"].call_args
|
||
assert mm_kwargs.get("premise_kind") == "квартира"
|
||
assert mm_kwargs.get("district") == "Ленинский"
|
||
_args, al_kwargs = mocks["compute_all_layers"].call_args
|
||
assert al_kwargs.get("premise_kind") == "квартира"
|
||
|
||
def test_modal_class_default_flows_into_spec_and_overlay(self) -> None:
|
||
"""obj_class=None + конкуренты [Комфорт×2, Бизнес] → модальный 'Комфорт' всюду."""
|
||
comps = [{"obj_class": "Комфорт"}, {"obj_class": "Комфорт"}, {"obj_class": "Бизнес"}]
|
||
_report, captured, mocks = self._run(obj_class=None, competitors=comps)
|
||
assert captured["segment"]["obj_class"] == "Комфорт"
|
||
_args, ovl_kwargs = mocks["build_forecast_overlay"].call_args
|
||
assert ovl_kwargs.get("target_class") == "Комфорт"
|
||
_args, f_kwargs = mocks["compute_demand_supply_forecast"].call_args
|
||
assert f_kwargs["spec"].obj_class == "Комфорт"
|
||
|
||
def test_empty_competitors_default_class_none(self) -> None:
|
||
"""obj_class=None + пустые конкуренты → None (ЕКБ-wide all-class)."""
|
||
_report, captured, mocks = self._run(obj_class=None, competitors=[])
|
||
assert captured["segment"]["obj_class"] is None
|
||
_args, ovl_kwargs = mocks["build_forecast_overlay"].call_args
|
||
assert ovl_kwargs.get("target_class") is None
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# GRACEFUL: один §9.x-слой бросает → отчёт всё равно собирается, слот None.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestGracefulDegradation:
|
||
def test_one_layer_raises_report_still_assembles(self) -> None:
|
||
mocks = _patched_layers()
|
||
# forecasts-слой бросает — отчёт ДОЛЖЕН собраться с forecasts=None.
|
||
mocks["compute_demand_supply_forecast"] = MagicMock(side_effect=RuntimeError("boom"))
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
|
||
captured.update(kwargs)
|
||
return SiteFinderReport()
|
||
|
||
with _patch_layers(mocks, _fake_assemble):
|
||
# НЕ должно бросить, несмотря на падение слоя.
|
||
report = build_site_finder_report(
|
||
MagicMock(name="db"),
|
||
analyze=_analyze([{"obj_class": "Комфорт"}]),
|
||
cad_num="66:41:0702048:27",
|
||
district="Ленинский",
|
||
horizons=[6, 12, 18],
|
||
)
|
||
assert isinstance(report, SiteFinderReport)
|
||
# упавший слой → None в слоте; остальные сохранены.
|
||
assert captured["forecasts"] is None
|
||
assert captured["market_metrics"] == "MM"
|
||
assert captured["scenarios"] == ["SC"]
|
||
|
||
def test_all_layers_raise_report_still_assembles(self) -> None:
|
||
"""Даже если ВСЕ §9.x-слои бросают — отчёт собирается (все слоты None)."""
|
||
boom = MagicMock(side_effect=RuntimeError("boom"))
|
||
# Каждый §9.x-символ → бросающий mock (имена зеркалят _patched_layers).
|
||
mocks = {name: boom for name in _patched_layers()}
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
|
||
captured.update(kwargs)
|
||
return SiteFinderReport()
|
||
|
||
with _patch_layers(mocks, _fake_assemble):
|
||
report = build_site_finder_report(
|
||
MagicMock(name="db"),
|
||
analyze=_analyze([{"obj_class": "Комфорт"}]),
|
||
cad_num="66:41:0702048:27",
|
||
district="Ленинский",
|
||
)
|
||
assert isinstance(report, SiteFinderReport)
|
||
for slot in (
|
||
"market_metrics",
|
||
"supply_layers",
|
||
"future_supply",
|
||
"forecasts",
|
||
"scenarios",
|
||
"product_scores",
|
||
"special_indices",
|
||
"recommendation_overlay",
|
||
):
|
||
assert captured[slot] is None, f"{slot} should degrade to None"
|
||
# сегмент всё равно проброшен (он PURE, не от §9.x).
|
||
assert captured["segment"]["obj_class"] == "Комфорт"
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Defensive district normalization (#1130 follow-up — silent-degrade guard).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestNormalizeDistrict:
|
||
def test_str_passes_through(self) -> None:
|
||
assert _normalize_district("Ленинский") == "Ленинский"
|
||
|
||
def test_none_passes_through(self) -> None:
|
||
assert _normalize_district(None) is None
|
||
|
||
def test_dict_with_district_name_extracted(self) -> None:
|
||
d = {
|
||
"district_name": "Верх-Исетский",
|
||
"dist_to_center": 0.0,
|
||
"median_price_per_m2": 139908.25,
|
||
}
|
||
assert _normalize_district(d) == "Верх-Исетский"
|
||
|
||
def test_dict_without_district_name_returns_none(self) -> None:
|
||
# Защитный fallback: dict без ожидаемого поля → None + warning.
|
||
# Не падаем (silent-degrade лучше чем TypeError в forecast_cache.wrapper).
|
||
assert _normalize_district({"dist_to_center": 0.0}) is None
|
||
|
||
def test_dict_with_empty_district_name_returns_none(self) -> None:
|
||
assert _normalize_district({"district_name": ""}) is None
|
||
|
||
def test_dict_with_non_string_district_name_returns_none(self) -> None:
|
||
# «district_name»: 42 — не строка, защищаемся.
|
||
assert _normalize_district({"district_name": 42}) is None
|
||
|
||
def test_unsupported_type_returns_none(self) -> None:
|
||
# list / int / прочие неподдерживаемые типы → None + warning.
|
||
assert _normalize_district([1, 2, 3]) is None
|
||
assert _normalize_district(42) is None
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# SAVEPOINT (#2464 cluster A finding #1, orchestrator.py `_safe_call`): один сбойный
|
||
# §9.x-слой НЕ должен «отравить» общую §22-сессию для ОСТАЛЬНЫХ слоёв. Без
|
||
# `db.begin_nested()` сбойный `db.execute` внутри слоя оставляет транзакцию Postgres
|
||
# aborted («current transaction is aborted, commands ignored until end of
|
||
# transaction block») — каждый ПОСЛЕДУЮЩИЙ слой на той же сессии тоже падал бы.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class _FakeResult:
|
||
"""Мини-результат db.execute() — покрывает `.mappings().first()`/`.all()`."""
|
||
|
||
def mappings(self) -> _FakeResult:
|
||
return self
|
||
|
||
def first(self) -> None:
|
||
return None
|
||
|
||
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()` (который откатил бы
|
||
всю внешнюю транзакцию — established anti-pattern, `backend.md`).
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self.poisoned = False
|
||
self._raised = False
|
||
self.calls = 0
|
||
|
||
def execute(self, *args: Any, **kwargs: Any) -> _FakeResult:
|
||
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 §9.x layer query failure")
|
||
return _FakeResult()
|
||
|
||
@contextlib.contextmanager
|
||
def begin_nested(self) -> Iterator[None]:
|
||
try:
|
||
yield
|
||
except Exception:
|
||
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
|
||
# сессия остаётся рабочей для следующего слоя.
|
||
self.poisoned = False
|
||
raise
|
||
|
||
|
||
class TestSafeCallSavepointDoesNotPoisonSession:
|
||
"""`_safe_call` оборачивает вызов слоя в `db.begin_nested()` (SAVEPOINT)."""
|
||
|
||
def test_layer_db_failure_leaves_session_usable_for_later_layers(self) -> None:
|
||
db = _PoisonableSession()
|
||
mocks = _patched_layers()
|
||
# market_metrics — единственный слой в этом тесте, реально трогающий `db`:
|
||
# его мок сам вызывает db.execute (через _PoisonableSession бросает один раз).
|
||
# Остальные слои остаются sentinel-моками из _patched_layers() (db не трогают).
|
||
mocks["compute_market_metrics"] = MagicMock(
|
||
side_effect=lambda *a, **kw: db.execute("SELECT 1")
|
||
)
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_assemble(analyze: dict[str, Any], **kwargs: Any) -> SiteFinderReport:
|
||
captured.update(kwargs)
|
||
return SiteFinderReport()
|
||
|
||
with _patch_layers(mocks, _fake_assemble):
|
||
report = build_site_finder_report(
|
||
db,
|
||
analyze=_analyze([{"obj_class": "Комфорт"}]),
|
||
cad_num="66:41:0702048:27",
|
||
district="Ленинский",
|
||
horizons=[6, 12, 18],
|
||
)
|
||
assert isinstance(report, SiteFinderReport)
|
||
# упавший слой → None в слоте (fallback), остальные слои — sentinel'ы, целы.
|
||
assert captured["market_metrics"] is None
|
||
assert captured["scenarios"] == ["SC"]
|
||
assert captured["forecasts"] == ["F6", "F12"]
|
||
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
|
||
# сбойного слоя проходит без 'current transaction aborted' (не poisoned).
|
||
db.execute("SELECT 1")
|