645 lines
24 KiB
Python
645 lines
24 KiB
Python
"""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 unittest.mock import MagicMock
|
||
|
||
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,
|
||
)
|
||
|
||
# ── 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
|
||
assert "CAST(:district 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"] == "Комфорт"
|
||
assert params["district"] == "Уралмаш"
|
||
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]
|