"""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"] == []