gendesign/backend/tests/services/forecasting/test_sales_series.py
bot-backend 82fdabccba
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m35s
Deploy / build-worker (push) Successful in 3m1s
Deploy / deploy (push) Successful in 1m34s
fix(forecasting): SAVEPOINT-isolate §22 db.execute failures + close RELEASE-trap (#2464) (#2466)
2026-07-08 05:41:35 +00:00

806 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Unit-тесты monthly ряда продаж по сегменту (#951c, ТЗ §9.6, Y-ось регрессии).
Чистые тесты (без живой БД):
• price_bucket_of — границы band'ов (включительно слева), None/≤0 → 'unknown'.
• room_area_bucket_of — rooms→bucket, area≥80 override, unknown-кейсы.
• log_diff — Δln, [0]=None всегда, ноль/None/neg → None (не inf), длина.
• fill_month_grid — zero-fill месяцев (units=0 НАСТОЯЩИЙ, area/price=None).
• SalesSeries.as_dict / SegmentSpec.as_dict — округление + None survive.
• build_sales_series через MagicMock-сессию: правильная таблица (Source A vs B),
GROUP BY date_trunc для Source B, CAST(:x AS type) не :x::type, case-handling
класса (LOWER=LOWER), zero-fill месяцев, тиры confidence, graceful empty → low.
psycopg v3 правило проверяется явно: bind-параметры — CAST(:x AS type).
"""
from __future__ import annotations
import contextlib
import datetime as dt
import math
import os
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months
from app.services.forecasting.sales_series import (
PRICE_BUCKET_BUSINESS,
PRICE_BUCKET_COMFORT,
PRICE_BUCKET_ECONOMY,
PRICE_BUCKET_PREMIUM,
PRICE_BUCKET_UNKNOWN,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_UNKNOWN,
SalesSeries,
SegmentSpec,
_confidence,
build_sales_series,
fill_month_grid,
log_diff,
price_bucket_of,
room_area_bucket_of,
)
# Резолвер admin→micro (Step 2). Обе SQL-ветки (Source A crm + Source B objective_lots)
# теперь резолвят spec.district → набор микро. Патчим на identity raw-микро, чтобы
# db.execute не получал лишний resolver-запрос; отдельный класс TestDistrictResolution
# проверяет, что резолвнутые микро реально идут в SQL-параметры.
_RESOLVE = "app.services.forecasting.sales_series.resolve_objective_districts"
@pytest.fixture(autouse=True)
def _patch_resolver() -> Iterator[MagicMock]:
"""По умолчанию резолвер = identity raw-микро (district→[district], None→None)."""
with patch(_RESOLVE) as m:
m.side_effect = lambda _db, d: [d] if d is not None else None
yield m
# ── pure: price_bucket_of ─────────────────────────────────────────────────────
class TestPriceBucketOf:
def test_economy_below_120k(self) -> None:
assert price_bucket_of(90_000) == PRICE_BUCKET_ECONOMY
assert price_bucket_of(119_999) == PRICE_BUCKET_ECONOMY
def test_comfort_band(self) -> None:
# Граница 120k включительна слева (lo ≤ x < hi).
assert price_bucket_of(120_000) == PRICE_BUCKET_COMFORT
assert price_bucket_of(159_999) == PRICE_BUCKET_COMFORT
def test_business_band(self) -> None:
assert price_bucket_of(160_000) == PRICE_BUCKET_BUSINESS
assert price_bucket_of(219_999) == PRICE_BUCKET_BUSINESS
def test_premium_at_and_above_220k(self) -> None:
assert price_bucket_of(220_000) == PRICE_BUCKET_PREMIUM
assert price_bucket_of(500_000) == PRICE_BUCKET_PREMIUM
def test_none_is_unknown(self) -> None:
assert price_bucket_of(None) == PRICE_BUCKET_UNKNOWN
def test_zero_and_negative_unknown(self) -> None:
# Цена ≤ 0 бессмысленна → unknown (не подмешиваем в реальные band'ы).
assert price_bucket_of(0) == PRICE_BUCKET_UNKNOWN
assert price_bucket_of(-5) == PRICE_BUCKET_UNKNOWN
def test_float_input(self) -> None:
assert price_bucket_of(155_500.75) == PRICE_BUCKET_COMFORT
# ── pure: room_area_bucket_of ─────────────────────────────────────────────────
class TestRoomAreaBucketOf:
def test_studio_zero_rooms(self) -> None:
# objective: 0 = студия.
assert room_area_bucket_of(0, 25.0) == ROOM_AREA_BUCKET_STUDIO
def test_studio_negative_treated_as_studio(self) -> None:
assert room_area_bucket_of(-1, 22.0) == ROOM_AREA_BUCKET_STUDIO
def test_one_room(self) -> None:
assert room_area_bucket_of(1, 38.0) == ROOM_AREA_BUCKET_1K
def test_two_rooms(self) -> None:
assert room_area_bucket_of(2, 55.0) == ROOM_AREA_BUCKET_2K
def test_three_rooms(self) -> None:
assert room_area_bucket_of(3, 70.0) == ROOM_AREA_BUCKET_3K
def test_four_plus_rooms_large(self) -> None:
assert room_area_bucket_of(4, 95.0) == ROOM_AREA_BUCKET_LARGE
assert room_area_bucket_of(5, 120.0) == ROOM_AREA_BUCKET_LARGE
def test_area_override_pushes_small_rooms_to_large(self) -> None:
# Площадь ≥ 80 м² → '80+' независимо от комнатности (зеркало _BUCKET_PRETTY).
assert room_area_bucket_of(1, 85.0) == ROOM_AREA_BUCKET_LARGE
assert room_area_bucket_of(2, 80.0) == ROOM_AREA_BUCKET_LARGE
def test_area_just_below_threshold_keeps_room_bucket(self) -> None:
# 79.9 < 80 → решаем по комнатности.
assert room_area_bucket_of(2, 79.9) == ROOM_AREA_BUCKET_2K
def test_rooms_none_area_none_unknown(self) -> None:
assert room_area_bucket_of(None, None) == ROOM_AREA_BUCKET_UNKNOWN
def test_rooms_none_large_area_is_large(self) -> None:
# Комнат нет, но площадь ≥ 80 → большой (area override срабатывает первым).
assert room_area_bucket_of(None, 90.0) == ROOM_AREA_BUCKET_LARGE
def test_rooms_none_small_area_unknown(self) -> None:
# Без комнатности тонкий формат не определить → unknown.
assert room_area_bucket_of(None, 40.0) == ROOM_AREA_BUCKET_UNKNOWN
def test_rooms_known_area_none(self) -> None:
# Площадь неизвестна → решаем чисто по комнатности.
assert room_area_bucket_of(1, None) == ROOM_AREA_BUCKET_1K
# ── pure: log_diff ────────────────────────────────────────────────────────────
class TestLogDiff:
def test_first_element_always_none(self) -> None:
assert log_diff([10, 20, 30])[0] is None
def test_basic_log_difference(self) -> None:
out = log_diff([10, 20])
assert out[0] is None
assert out[1] is not None
assert math.isclose(out[1], math.log(20) - math.log(10))
def test_length_matches_input(self) -> None:
assert len(log_diff([1, 2, 3, 4, 5])) == 5
def test_zero_current_is_none(self) -> None:
# ln(0) = inf → помечаем None (0 продаж — валидный уровень, не Δln).
out = log_diff([10, 0, 10])
assert out[1] is None # cur=0
assert out[2] is None # prev=0
def test_none_in_series_yields_none(self) -> None:
out = log_diff([10, None, 30])
assert out[1] is None # cur=None
assert out[2] is None # prev=None
def test_negative_yields_none(self) -> None:
# ln(neg) не определён → None.
out = log_diff([10, -5])
assert out[1] is None
def test_empty(self) -> None:
assert log_diff([]) == []
def test_single_element(self) -> None:
assert log_diff([42]) == [None]
def test_no_minus_inf_anywhere(self) -> None:
# Гарантия: ни одна точка не inf/nan (главная цель zero-handling).
out = log_diff([0, 5, 0, 8, None, 3])
for v in out:
assert v is None or (math.isfinite(v))
# ── pure: fill_month_grid ─────────────────────────────────────────────────────
class TestFillMonthGrid:
def test_zero_fill_missing_months(self) -> None:
grid = _month_grid(dt.date(2024, 1, 1), dt.date(2024, 3, 1))
by_month = {dt.date(2024, 2, 1): (5, 250.0, 150_000.0)}
units, area, price = fill_month_grid(by_month, grid)
# Январь и март без сделок → units=0 (НАСТОЯЩИЙ ноль), area/price=None.
assert units == [0, 5, 0]
assert area == [None, 250.0, None]
assert price == [None, 150_000.0, None]
def test_zero_is_real_not_none(self) -> None:
# Ключевое отличие: пропущенный месяц = 0 units (не None) — 0 это данные.
grid = _month_grid(dt.date(2024, 1, 1), dt.date(2024, 1, 1))
units, _area, _price = fill_month_grid({}, grid)
assert units == [0]
assert units[0] == 0 and units[0] is not None
def test_present_month_passes_through(self) -> None:
grid = [dt.date(2024, 5, 1)]
by_month = {dt.date(2024, 5, 1): (12, 600.0, 140_000.0)}
units, area, price = fill_month_grid(by_month, grid)
assert (units, area, price) == ([12], [600.0], [140_000.0])
def test_area_price_none_when_units_present_but_value_missing(self) -> None:
# Сделки есть, но area/price NULL в источнике → None сохраняется.
grid = [dt.date(2024, 5, 1)]
by_month = {dt.date(2024, 5, 1): (3, None, None)}
units, area, price = fill_month_grid(by_month, grid)
assert units == [3]
assert area == [None]
assert price == [None]
def test_keys_normalised_to_first_of_month(self) -> None:
# Ключ-середина месяца нормализуется к 1-му числу.
grid = [dt.date(2024, 5, 1)]
by_month = {dt.date(2024, 5, 17): (7, 350.0, 130_000.0)}
units, area, price = fill_month_grid(by_month, grid)
assert units == [7]
assert area == [350.0]
assert price == [130_000.0]
def test_does_not_mutate_input(self) -> None:
by_month = {dt.date(2024, 5, 1): (1, 50.0, 100_000.0)}
fill_month_grid(by_month, [dt.date(2024, 5, 1)])
assert by_month == {dt.date(2024, 5, 1): (1, 50.0, 100_000.0)}
# ── pure: _confidence ─────────────────────────────────────────────────────────
class TestConfidence:
def test_high_at_24_nonzero(self) -> None:
assert _confidence([1] * 24) == "high"
def test_medium_at_12_nonzero(self) -> None:
assert _confidence([1] * 12) == "medium"
def test_low_below_12_nonzero(self) -> None:
assert _confidence([1] * 11) == "low"
def test_zeros_do_not_count(self) -> None:
# 30 месяцев, но только 5 ненулевых → low (хвост нулей не информативен).
units = [1] * 5 + [0] * 25
assert _confidence(units) == "low"
def test_high_with_zeros_mixed(self) -> None:
# 24 ненулевых + сколько угодно нулей → high (порог по ненулевым).
units = [1] * 24 + [0] * 10
assert _confidence(units) == "high"
def test_empty_is_low(self) -> None:
assert _confidence([]) == "low"
# ── SalesSeries / SegmentSpec as_dict ─────────────────────────────────────────
class TestAsDict:
def test_sales_series_rounds_and_serialises(self) -> None:
s = SalesSeries(
months=[dt.date(2024, 1, 1), dt.date(2024, 2, 1)],
units=[5, 0],
area_m2=[250.456, None],
avg_price_per_m2=[150_123.7, None],
n_months=2,
source="objective_lots",
segment={
"obj_class": "комфорт",
"room_bucket": None,
"district": None,
"price_bucket": None,
},
confidence="low",
)
d = s.as_dict()
assert d["months"] == ["2024-01-01", "2024-02-01"]
assert d["units"] == [5, 0]
assert d["area_m2"] == [250.5, None]
assert d["avg_price_per_m2"] == [150_124, None]
assert d["n_months"] == 2
assert d["source"] == "objective_lots"
assert d["confidence"] == "low"
assert d["segment"]["obj_class"] == "комфорт"
def test_units_zero_survives_as_zero(self) -> None:
# as_dict не должен превращать 0 в None.
s = SalesSeries(
months=[dt.date(2024, 1, 1)],
units=[0],
area_m2=[None],
avg_price_per_m2=[None],
n_months=1,
source="corpus_room_month",
segment={},
confidence="low",
)
assert s.as_dict()["units"] == [0]
def test_segment_spec_as_dict_subset(self) -> None:
spec = SegmentSpec(obj_class="Комфорт", district="Автовокзал")
assert spec.as_dict() == {
"obj_class": "Комфорт",
"room_bucket": None,
"district": "Автовокзал",
"price_bucket": None,
}
# ── build_sales_series: MagicMock-сессия (форма SQL + zero-fill + graceful) ────
def _result(rows: list[dict]) -> MagicMock:
"""Результат db.execute(...).mappings().all() → rows (list of dict-like)."""
res = MagicMock()
res.mappings.return_value.all.return_value = rows
return res
def _sql_of(db: MagicMock, call_idx: int = 0) -> str:
return str(db.execute.call_args_list[call_idx].args[0])
def _params_of(db: MagicMock, call_idx: int = 0) -> dict:
return db.execute.call_args_list[call_idx].args[1]
class TestBuildSalesSeriesSourceShape:
def test_source_a_queries_corpus_table(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(obj_class="Комфорт"),
source="corpus_room_month",
months_back=3,
)
sql = _sql_of(db)
assert "objective_corpus_room_month" in sql
assert "deals_total_count" in sql
assert "deals_total_vol_m2" in sql
assert "deals_total_avg_price_thousand_rub_per_m2" in sql
def test_source_b_queries_lots_with_date_trunc_groupby(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=3,
)
sql = _sql_of(db)
assert "objective_lots" in sql
# Source B группирует по месяцу РЕГИСТРАЦИИ через date_trunc.
assert "date_trunc('month', ol.registration_date)" in sql
assert "GROUP BY month" in sql
assert "COUNT(*)" in sql
def test_source_a_uses_cast_not_double_colon(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(obj_class="Бизнес", district="Центр", room_bucket="2"),
source="corpus_room_month",
months_back=3,
)
sql = _sql_of(db)
assert "CAST(:since AS date)" in sql
assert "CAST(:cls AS text)" in sql
# district теперь резолвится в набор микро → ANY(CAST(:districts AS text[])).
assert "CAST(:districts AS text[])" in sql
assert "CAST(:room_bucket AS text)" in sql
# psycopg v3 trap: никаких :name::type.
assert "::" not in sql
def test_source_b_uses_cast_not_double_colon(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(price_bucket="комфорт"),
source="objective_lots",
months_back=3,
)
sql = _sql_of(db)
assert "CAST(:since AS date)" in sql
assert "CAST(:premise_kind AS text)" in sql
assert "CAST(:large_area AS numeric)" in sql
assert "CAST(:price_bucket AS text)" in sql
assert "::" not in sql
def test_class_case_insensitive_match_both_sources(self) -> None:
# Source A — Title-case в БД, Source B — lowercase: оба матчат LOWER=LOWER.
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(obj_class="Комфорт"),
source="corpus_room_month",
months_back=1,
)
assert "LOWER(crm.class) = LOWER(CAST(:cls AS text))" in _sql_of(db)
db2 = MagicMock()
db2.execute.return_value = _result([])
build_sales_series(
db2,
spec=SegmentSpec(obj_class="комфорт"),
source="objective_lots",
months_back=1,
)
assert "LOWER(ol.class) = LOWER(CAST(:cls AS text))" in _sql_of(db2)
def test_source_a_params_pass_spec(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(obj_class="Комфорт", district="Уралмаш", room_bucket="1"),
source="corpus_room_month",
months_back=12,
)
params = _params_of(db)
assert params["cls"] == "Комфорт"
# district резолвится в набор микро (identity → ['Уралмаш']) → ANY(:districts).
assert params["has_district"] is True
assert params["districts"] == ["Уралмаш"]
assert "district" not in params
assert params["room_bucket"] == "1"
assert isinstance(params["since"], dt.date)
def test_source_b_passes_bucket_thresholds_and_labels(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=1,
)
params = _params_of(db)
# Пороги/метки bucket'ов передаются параметрами (зеркало pure-helpers).
assert params["large_area"] == 80.0
assert params["p_economy_max"] == 120_000.0
assert params["b_studio"] == ROOM_AREA_BUCKET_STUDIO
assert params["p_premium"] == PRICE_BUCKET_PREMIUM
assert params["premise_kind"] == "квартира"
def test_source_b_custom_premise_kind(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=1,
premise_kind="нежилое",
)
assert _params_of(db)["premise_kind"] == "нежилое"
class TestBuildSalesSeriesLogic:
def test_continuous_grid_with_zero_fill(self) -> None:
today = dt.date.today()
target = _shift_months(today, -1)
db = MagicMock()
db.execute.return_value = _result(
[{"month": target, "units": 7, "area_m2": 350.0, "avg_price_per_m2": 145_000.0}]
)
out = build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=3,
)
# Непрерывная сетка 4 месяца (-3..0).
assert out.n_months == 4
assert len(out.months) == 4
assert out.months == sorted(out.months)
idx = out.months.index(target)
assert out.units[idx] == 7
assert out.area_m2[idx] == 350.0
assert out.avg_price_per_m2[idx] == 145_000.0
# Месяцы без сделок → units=0 (настоящий), area/price=None.
other = [i for i in range(out.n_months) if i != idx]
for i in other:
assert out.units[i] == 0
assert out.area_m2[i] is None
assert out.avg_price_per_m2[i] is None
def test_source_a_price_scaled_to_rub_per_m2(self) -> None:
# Source A SQL умножает тыс.₽/м² на 1000 — проверяем наличие масштаба в SQL.
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(),
source="corpus_room_month",
months_back=1,
)
assert "* 1000.0" in _sql_of(db)
def test_confidence_high_with_24_nonzero(self) -> None:
today = dt.date.today()
rows = [
{
"month": _shift_months(today, -k),
"units": 3,
"area_m2": 100.0,
"avg_price_per_m2": 130_000.0,
}
for k in range(24)
]
db = MagicMock()
db.execute.return_value = _result(rows)
out = build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=30,
)
assert out.confidence == "high"
def test_confidence_medium_with_12_nonzero(self) -> None:
today = dt.date.today()
rows = [
{
"month": _shift_months(today, -k),
"units": 2,
"area_m2": 80.0,
"avg_price_per_m2": 120_000.0,
}
for k in range(12)
]
db = MagicMock()
db.execute.return_value = _result(rows)
out = build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=20,
)
assert out.confidence == "medium"
def test_confidence_low_thin_data(self) -> None:
today = dt.date.today()
rows = [
{
"month": _shift_months(today, -k),
"units": 1,
"area_m2": 40.0,
"avg_price_per_m2": 110_000.0,
}
for k in range(3)
]
db = MagicMock()
db.execute.return_value = _result(rows)
out = build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=12,
)
assert out.confidence == "low"
def test_segment_recorded_in_result(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
spec = SegmentSpec(
obj_class="Комфорт", room_bucket="2", district="Центр", price_bucket="бизнес"
)
out = build_sales_series(
db,
spec=spec,
source="objective_lots",
months_back=1,
)
assert out.segment == spec.as_dict()
assert out.source == "objective_lots"
class TestBuildSalesSeriesGraceful:
def test_empty_data_returns_zero_filled_low(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
out = build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=2,
)
assert out.n_months == 3 # -2..0
assert out.units == [0, 0, 0]
assert out.area_m2 == [None, None, None]
assert out.avg_price_per_m2 == [None, None, None]
assert out.confidence == "low"
def test_db_exception_graceful_source_a(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("db down")
out = build_sales_series(
db,
spec=SegmentSpec(),
source="corpus_room_month",
months_back=2,
)
# Ряд по сетке всё равно, zero-filled, low (НЕ crash).
assert out.n_months == 3
assert out.units == [0, 0, 0]
assert out.confidence == "low"
def test_db_exception_graceful_source_b(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("db down")
out = build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=1,
)
assert out.n_months == 2
assert out.units == [0, 0]
assert out.confidence == "low"
def test_negative_months_back_clamps_to_single_month(self) -> None:
# months_back<0 клампится к 0 (как PR2: -max(0, months_back)) → один
# текущий месяц, валидный объект, low (НЕ crash, НЕ отрицательная сетка).
db = MagicMock()
db.execute.return_value = _result([])
out = build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=-5,
)
assert out.n_months == 1
assert out.months == [_month_start(dt.date.today())]
assert out.units == [0]
assert out.confidence == "low"
def test_month_back_zero_single_month(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
out = build_sales_series(
db,
spec=SegmentSpec(),
source="objective_lots",
months_back=0,
)
assert out.n_months == 1 # только текущий месяц
assert out.units == [0]
# ──────────────────────────────────────────────────────────────────────────────
# SAVEPOINT (#2464 cluster A finding #4, `_query_source_a`/`_query_source_b`): сбой
# db.execute НЕ должен «отравить» общую §22-сессию для ПОСЛЕДУЮЩИХ слоёв/запросов,
# переиспользующих ту же `db`-Session. Без `db.begin_nested()` сбойный `db.execute`
# оставляет транзакцию Postgres aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии
# тоже падал бы.
# ──────────────────────────────────────────────────────────────────────────────
class _FakeSalesResult:
"""Мини-результат db.execute() — покрывает `.mappings().all()`."""
def mappings(self) -> _FakeSalesResult:
return self
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) -> _FakeSalesResult:
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 sales query failure")
return _FakeSalesResult()
@contextlib.contextmanager
def begin_nested(self) -> Iterator[None]:
try:
yield
except Exception:
# ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT,
# сессия остаётся рабочей для последующих запросов.
self.poisoned = False
raise
class TestSourceQuerySavepointDoesNotPoisonSession:
"""`_query_source_a`/`_query_source_b` оборачивают db.execute в SAVEPOINT."""
def test_source_a_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = build_sales_series(db, spec=SegmentSpec(), source="corpus_room_month", months_back=1)
# сбой запроса → zero-filled сетка, low confidence (fallback, НЕ crash).
assert out.confidence == "low"
assert out.units == [0, 0]
# КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ
# сбоя проходит без 'current transaction aborted' (не poisoned).
db.execute("SELECT 1")
def test_source_b_failure_leaves_session_usable(self) -> None:
db = _PoisonableSession()
out = build_sales_series(db, spec=SegmentSpec(), source="objective_lots", months_back=1)
assert out.confidence == "low"
assert out.units == [0, 0]
db.execute("SELECT 1")
class TestDistrictResolution:
"""Step 2: spec.district (админ-имя ЕКБ) резолвится в МИКРО-набор в SQL-фильтре.
Оба источника используют МИКРО-вокабуляр: objective_corpus_room_month.district и
objective_lots.district несут одни и те же 35 informal-микро (verified на prod) →
админ-имя по ним давало 0 строк. Резолвер разворачивает его в чистые микро.
"""
def test_source_a_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None:
_patch_resolver.side_effect = lambda _db, d: (
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
)
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(district="Кировский"),
source="corpus_room_month",
months_back=1,
)
assert _patch_resolver.call_args.args[1] == "Кировский"
p = _params_of(db)
assert p["has_district"] is True
assert p["districts"] == ["Втузгородок", "ЖБИ"]
assert "Кировский" not in p["districts"]
def test_source_b_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None:
_patch_resolver.side_effect = lambda _db, d: (
["Уралмаш", "Эльмаш"] if d == "Орджоникидзевский" else None
)
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(district="Орджоникидзевский"),
source="objective_lots",
months_back=1,
)
p = _params_of(db)
assert p["has_district"] is True
assert p["districts"] == ["Уралмаш", "Эльмаш"]
assert "Орджоникидзевский" not in p["districts"]
def test_resolver_none_drops_district_filter(self, _patch_resolver: MagicMock) -> None:
_patch_resolver.side_effect = lambda _db, _d: None
db = MagicMock()
db.execute.return_value = _result([])
build_sales_series(
db,
spec=SegmentSpec(district="не определён"),
source="objective_lots",
months_back=1,
)
p = _params_of(db)
assert p["has_district"] is False
assert p["districts"] == []