gendesign/backend/tests/services/forecasting/test_sales_series.py
Light1YT 681a922d99
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m7s
feat(forecast): resolve admin district -> micro set in §9.x market/supply/sales filters
/analyze passes the official ЕКБ admin district (ekb_districts polygon, e.g.
'Кировский'), but objective_lots/corpus_room_month store informal micro-districts
('Втузгородок','ЖБИ') -> admin name matched 0 rows -> silent empty forecast.

Add resolve_objective_districts() (site_finder/district_resolver.py) mapping an
admin name to its clean micros via ekb_district_alias (note IS NULL), with
None -> EKB-wide fallback and raw-micro pass-through. Wire into the objective_lots
district filters of market_metrics (§9.2 stock+sales), supply_layers L1 (§9.3),
and sales_series Sources A+B (crm shares the micro vocab, prod-verified),
switching the scalar filter to psycopg3-safe = ANY(CAST(:districts AS text[])).
supply_layers L2/L3 keep the admin name (domrf_kn_objects.district_name is admin vocab).

Prod: Кировский/Ленинский/Орджоникидзевский obj_count 0 -> 32/64/31.
Tests mutation-verified non-vacuous. 192 module tests pass; ruff clean. Refs #969 #949.
2026-06-05 07:03:37 +05:00

725 lines
28 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 datetime as dt
import math
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.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]
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"] == []