gendesign/backend/tests/services/forecasting/test_sales_series.py
bot-backend c7bfc9e22a
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m20s
Deploy / build-worker (push) Successful in 2m17s
Deploy / deploy (push) Successful in 1m6s
feat(forecasting): monthly sales series builder for §9.6 (#951c) (#1008)
2026-06-03 05:52:33 +00:00

645 lines
24 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 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]