gendesign/backend/tests/services/site_finder/test_best_layouts.py
Light1YT 0acd72a325
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Successful in 13m45s
fix(best-layouts): per-object latest snapshot for supply (#1956)
_SUPPLY_BATCH_SQL джойнил domrf_kn_flats по ОДНОЙ глобальной дате
(f.snapshot_date = MAX(snapshot_date) по всей таблице). Но domrf_kn_flats —
ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в свой день. На единственной
глобал-max дате присутствует обычно 1 объект → у остальных 0 квартир →
supply_units_in_radius=0 для всех строк 4.2 Планировки → frontend показывал
«Срок продажи 0 мес» и «% продано —». Регрессия от #1944 (objects-first
дедуп snapshot'ов объектов, который сам по себе корректен).

Фикс: flats_latest CTE (DISTINCT ON (obj_id) ... ORDER BY obj_id,
snapshot_date DESC, id DESC) берёт для КАЖДОГО obj_id его собственный
последний снимок и джойнится к nearby. objects-first MATERIALIZED дедуп
(#1944) сохранён → fan-out по снимкам не возвращается. Глобальный
db.scalar(MAX(snapshot_date)) + :latest_snap bind удалены.

Прод (66:41:0205010:287, r=1км, 9 объектов): supply 0 (global-max) → 2675
(per-object, 4 объекта имеют flats на разных датах 2026-05-17/05-05; ни один
не на глобал-max 2026-06-22). Данные flats частично сломаны (#1945, отдельно),
но фикс корректно двигает supply с 0 к реальным per-object числам.

Тесты: новый guard test_supply_joins_flats_per_object_latest_snapshot;
обновлены mock-фабрики (db.scalar больше не вызывается).
2026-06-27 23:24:28 +05:00

596 lines
25 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-тесты для get_best_layouts (Fix SF-01: honest time_window velocity).
Проверяет, что разные time_window → разные deals_window → разный velocity_per_month.
Mock-стратегия: патчим db.execute с side_effect, повторяя порядок вызовов
в get_best_layouts:
1. _PARCEL_CENTROID_SQL → .mappings().first()
2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
3. _INLINE_VELOCITY_SQL → .mappings().all()
4. _SUPPLY_BATCH_SQL → .mappings().all()
Fix #1956: supply берёт per-object последний snapshot внутри _SUPPLY_BATCH_SQL
(flats_latest CTE), отдельного db.scalar(MAX(snapshot_date)) больше нет.
Ключевые asserts:
- last_month (1 мес) → velocity = deals_window / 1.0
- last_quarter (3 мес) → velocity = deals_window / 3.0
- last_year (12 мес) → velocity = deals_window / 12.0
- Разный deals_window при разных time_window → разный mix.
"""
from __future__ import annotations
import datetime as dt
from unittest.mock import MagicMock
import pytest
from app.schemas.parcel import BestLayoutsRequest
from app.services.site_finder.best_layouts import (
_TIME_WINDOW_PARAMS,
MAX_BUCKET_SHARE_PCT,
_cap_and_redistribute,
get_best_layouts,
)
_TODAY = dt.date.today()
CAD_NUM = "66:41:0303161:123"
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {"center_lon": lon, "center_lat": lat}[k]
return r
def _obj_id_row(obj_id: int) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
return r
def _vel_row(
room_bucket: str = "2",
deals_window: float = 48.0,
avg_area: float = 55.0,
avg_price_rub: float | None = 120000.0,
obj_ids: list[int] | None = None,
window_start: dt.date | None = None,
window_end: dt.date | None = None,
) -> MagicMock:
"""Строка из _INLINE_VELOCITY_SQL.
deals_window — реальные сделки за честное окно (не 24 мес).
"""
oids = obj_ids if obj_ids is not None else [1]
ws = window_start or _TODAY - dt.timedelta(days=90)
we = window_end or _TODAY
r = MagicMock()
r.__getitem__ = lambda self, k: {
"room_bucket": room_bucket,
"deals_window": deals_window,
"avg_area_m2": avg_area,
"avg_price_per_m2_rub": avg_price_rub,
"competitor_obj_ids": oids,
"competitor_count": len(oids),
"window_start": ws,
"window_end": we,
}[k]
return r
def _supply_row(rb: str, ab: str, units: int) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k]
return r
def _make_db(
coord: MagicMock | None = None,
id_rows: list[MagicMock] | None = None,
vel_rows: list[MagicMock] | None = None,
supply_rows: list[MagicMock] | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Порядок db.execute() (Fix #1956: db.scalar(MAX(snapshot_date)) удалён —
per-object последний снимок считается внутри _SUPPLY_BATCH_SQL):
1. centroid → .mappings().first()
2. competitors → .mappings().all()
3. velocity → .mappings().all()
4. supply → .mappings().all()
"""
db = MagicMock()
r0 = MagicMock()
r0.mappings.return_value.first.return_value = coord
r1 = MagicMock()
r1.mappings.return_value.all.return_value = id_rows or []
r2 = MagicMock()
r2.mappings.return_value.all.return_value = vel_rows or []
r3 = MagicMock()
r3.mappings.return_value.all.return_value = supply_rows or []
db.execute.side_effect = [r0, r1, r2, r3]
return db
def _request(**kwargs) -> BestLayoutsRequest:
defaults: dict = {
"radius_km": 1.0,
"time_window": "last_quarter",
"min_velocity_per_month": 0.0,
}
defaults.update(kwargs)
return BestLayoutsRequest(**defaults)
# ── Тесты TIME_WINDOW_PARAMS ──────────────────────────────────────────────────
def test_time_window_params_keys() -> None:
"""Все три time_window определены, months_in_window > 0."""
for key in ("last_month", "last_quarter", "last_year"):
assert key in _TIME_WINDOW_PARAMS
interval_str, months = _TIME_WINDOW_PARAMS[key]
assert isinstance(interval_str, str) and len(interval_str) > 0
assert months > 0
# ── Тест SF-01: разный deals_window → разный velocity ────────────────────────
def test_last_month_velocity_divisor_1() -> None:
"""time_window=last_month: velocity = deals_window / 1.0."""
deals = 30.0
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(1)],
vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
)
req = _request(time_window="last_month")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].velocity_per_month == pytest.approx(30.0, rel=1e-3)
def test_last_quarter_velocity_divisor_3() -> None:
"""time_window=last_quarter: velocity = deals_window / 3.0."""
deals = 30.0
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(1)],
vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].velocity_per_month == pytest.approx(10.0, rel=1e-3)
def test_last_year_velocity_divisor_12() -> None:
"""time_window=last_year: velocity = deals_window / 12.0."""
deals = 60.0
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(1)],
vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
)
req = _request(time_window="last_year")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].velocity_per_month == pytest.approx(5.0, rel=1e-3)
def test_different_time_windows_produce_different_velocity() -> None:
"""Одни и те же deals_window → разная velocity_per_month для разных time_window.
Главный acceptance-тест SF-01: time_window влияет на velocity, не только на масштаб.
При одном и том же deals_window=30:
last_month → 30.0
last_quarter → 10.0
last_year → 2.5
"""
deals = 30.0
velocities: dict[str, float] = {}
for tw in ("last_month", "last_quarter", "last_year"):
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(1)],
vel_rows=[_vel_row("2", deals_window=deals, obj_ids=[1])],
)
req = _request(time_window=tw)
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1, f"No layouts for {tw}"
velocities[tw] = resp.top_layouts[0].velocity_per_month
# Все три значения различаются
vals = list(velocities.values())
assert vals[0] != vals[1] != vals[2], f"Velocities must differ: {velocities}"
# last_month > last_quarter > last_year (одинаковые deals, разный знаменатель)
assert velocities["last_month"] > velocities["last_quarter"] > velocities["last_year"]
# ── Тест: ranking по velocity и sum pct = 100 ────────────────────────────────
def test_ranking_and_pct_sum_100() -> None:
"""3 room_buckets → ranking по velocity, sum pct = 100."""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
vel_rows = [
_vel_row("studio", deals_window=9.0, avg_area=26.0, obj_ids=[1]), # 9/3=3.0
_vel_row("1", deals_window=24.0, avg_area=40.0, obj_ids=[2]), # 24/3=8.0
_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[3]), # 48/3=16.0
]
supply_rows = [
_supply_row("studio", "<25", 20),
_supply_row("1", "40-60", 60),
_supply_row("2", "40-60", 80),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
top = resp.top_layouts
assert len(top) == 3
# rank 1 = "2" (наибольший velocity 16.0)
assert top[0].room_bucket == "2"
assert top[0].rank == 1
assert top[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
# rank 2 = "1" (8.0)
assert top[1].room_bucket == "1"
assert top[1].velocity_per_month == pytest.approx(8.0, rel=1e-3)
# ранги уникальны
assert sorted(t.rank for t in top) == [1, 2, 3]
# sum pct = 100
mix = resp.recommendation_for_tz.mix
assert sum(m.pct for m in mix) == 100
# ── Тест: пустые конкуренты ───────────────────────────────────────────────────
def test_no_competitors_returns_empty_response() -> None:
"""Нет конкурентов в радиусе → пустые top_layouts + confidence=low."""
db = _make_db(coord=_coord_row(), id_rows=[], vel_rows=[])
req = _request()
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts == []
assert resp.data_quality.confidence == "low"
assert resp.recommendation_for_tz.based_on_obj_count == 0
# ── Тест: centroid не найден ──────────────────────────────────────────────────
def test_centroid_not_found_raises_value_error() -> None:
"""Геометрия участка не найдена → ValueError."""
db = _make_db(coord=None)
req = _request()
with pytest.raises(ValueError, match="не найдена"):
get_best_layouts(db, "99:99:9999999:999", req)
# ── Тест: min_velocity фильтрует строки ──────────────────────────────────────
def test_min_velocity_filters_low_rows() -> None:
"""min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts.
last_quarter (3 мес):
studio: 9 / 3 = 3.0 < 5.0 → отфильтрован
1: 24 / 3 = 8.0 > 5.0 → остаётся
"""
id_rows = [_obj_id_row(1), _obj_id_row(2)]
vel_rows = [
_vel_row("studio", deals_window=9.0, obj_ids=[1]),
_vel_row("1", deals_window=24.0, obj_ids=[2]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter", min_velocity_per_month=5.0)
resp = get_best_layouts(db, CAD_NUM, req)
top = resp.top_layouts
assert len(top) == 1
assert top[0].room_bucket == "1"
assert top[0].velocity_per_month == pytest.approx(8.0, rel=1e-3)
# ── Тест: exclude_competitor_obj_ids ─────────────────────────────────────────
def test_exclude_competitor_obj_ids() -> None:
"""exclude_competitor_obj_ids=[20] при единственном конкуренте → пустой ответ."""
id_rows = [_obj_id_row(20)]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
req = _request(exclude_competitor_obj_ids=[20])
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts == []
assert resp.data_quality.objects_total_in_radius == 1
# ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────
def test_total_sold_in_window_matches_deals_window() -> None:
"""total_sold_in_window в TopLayoutRow = deals_window (целое)."""
deals = 37.0
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(5)],
vel_rows=[_vel_row("3", deals_window=deals, obj_ids=[5])],
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].total_sold_in_window == int(deals)
# ── Тесты Fix #1229: supply / velocity bucket vocabulary match ────────────────
def test_supply_velocity_buckets_match_for_2_rooms() -> None:
"""Fix #1229: supply bucket для 2-комн матчит velocity '2' независимо от площади.
Раньше supply отдельно вычислял euro-1/euro-2 для rooms=2 + area<50 → эти
строки выпадали из знаменателя bucket '2' → sold_pct/is_oversold двушек
были завышены. После фикса вся supply rooms=2 идёт в '2' и sold_pct
рассчитывается от полного supply.
Здесь моделируем что весь supply для '2' уже агрегирован SQL-стороной в
один (rb='2', ab='40-60') ряд: 20 deals_window против 50 supply → 40% sold.
"""
deals = 20.0
id_rows = [_obj_id_row(7)]
vel_rows = [_vel_row("2", deals_window=deals, avg_area=45.0, obj_ids=[7])]
supply_rows = [_supply_row("2", "40-60", 50)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
row = resp.top_layouts[0]
assert row.room_bucket == "2"
assert row.supply_units_in_radius == 50
# 20 / 50 * 100 = 40.0 (раньше при euro-* разделении знаменатель был меньше
# → sold_pct > 40 или is_oversold=True).
assert row.sold_pct_of_supply == pytest.approx(40.0, rel=1e-3)
assert row.is_oversold is False
def test_supply_does_not_emit_euro_buckets() -> None:
"""Fix #1229: supply SQL больше НЕ содержит литералов 'euro-1' / 'euro-2'.
Regression guard: если кто-то восстанавливает SF-08 euro-биннинг в supply
без согласования с velocity-стороной (objective_corpus_room_month отдаёт
{studio,1,2,3,4+}, не euro-*), sold_pct двушек снова поедет.
"""
from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
sql_text = str(_SUPPLY_BATCH_SQL.text)
assert "'euro-1'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-1 — see #1229"
assert "'euro-2'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-2 — see #1229"
def test_supply_dedups_objects_to_latest_snapshot() -> None:
"""Regression guard против object-snapshot fan-out (supply units ~8.5x inflation).
domrf_kn_objects — snapshot-dimension (UNIQUE (obj_id, snapshot_date), ~8
снимков/obj_id). Если supply снова заджойнит flats НАПРЯМУЮ к objects без
дедупа до одного снимка на obj_id, каждый flat посчитается ~8.5x →
supply_units_in_radius завышен, sold_pct_of_supply занижен, is_oversold
недо-фаерит (все три — user-facing). Фикс: objects-first MATERIALIZED CTE
с DISTINCT ON (obj_id) по последнему снимку, flats джойнятся к нему.
"""
from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
sql_text = str(_SUPPLY_BATCH_SQL.text)
# objects дедуплицируются до одного (последнего) снимка на obj_id
assert "DISTINCT ON (o.obj_id)" in sql_text, "supply не дедупит objects → fan-out вернётся"
assert "snapshot_date DESC" in sql_text, "DISTINCT ON должен брать ПОСЛЕДНИЙ снимок"
# objects-first CTE материализован — иначе планнер инлайнит → flats-first seq scan
assert "MATERIALIZED" in sql_text, "nearby CTE должен быть MATERIALIZED (план objects-first)"
# flats джойнятся к дедуплицированному nearby, НЕ напрямую к raw objects
assert "JOIN domrf_kn_objects" not in sql_text, "прямой flats→objects join = fan-out по снимкам"
def test_supply_joins_flats_per_object_latest_snapshot() -> None:
"""Fix #1956 regression guard: supply джойнит flats по ПЕР-ОБЪЕКТНОМУ снимку.
domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series (каждый ЖК скрейпится в свой день).
Регрессия #1944 джойнила flats по ОДНОЙ глобальной дате
`f.snapshot_date = MAX(snapshot_date по всей таблице)` → на ней присутствует
обычно 1 объект → supply_units_in_radius=0 для всех строк → frontend
«Срок продажи 0 мес» / «% продано —». Фикс: flats_latest CTE с
DISTINCT ON (obj_id) ... ORDER BY obj_id, snapshot_date DESC — у каждого
объекта свой последний снимок. Этот guard ловит возврат глобал-max джойна.
"""
from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
sql_text = str(_SUPPLY_BATCH_SQL.text)
# per-object последний снимок flats
assert "flats_latest" in sql_text, "нет flats_latest CTE → supply снова на глобал-max снимке"
assert (
"DISTINCT ON (f.obj_id)" in sql_text
), "flats не дедупятся per-object → нет per-obj снимка"
# НЕ должно быть джойна по единой внешней дате-параметру (регрессия #1944)
assert ":latest_snap" not in sql_text, "глобальный :latest_snap вернулся → supply=0 регрессия"
assert (
"MAX(snapshot_date)" not in sql_text
), "глобальный MAX(snapshot_date) → supply=0 регрессия"
# ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────
@pytest.mark.parametrize(
"pct_map, expect_pathological",
[
# 1. normal: одиночный bucket > 35, free достаточно capacity
({"1k": 50, "studio": 30, "2k": 20}, False),
# 2. heavy skew (3-bucket): surplus=40, capacity=20+25=45 — помещается
({"1k": 75, "studio": 15, "2k": 10}, False),
# 3. multiple buckets > 35
({"1k": 50, "studio": 40, "2k": 10}, False),
# 4. all > 35 — pathological
({"1k": 50, "studio": 50}, True),
# 5. граничный: один bucket ровно на cap — не clamp
({"1k": 35, "studio": 35, "2k": 30}, False),
# 6. single bucket 100% — pathological (нет free)
({"1k": 100}, True),
# 7. 2-bucket heavy: surplus=55, capacity=25 — pathological (не помещается)
({"1k": 90, "studio": 10}, True),
# 8. все ≤ cap — fast-path без изменений
({"1k": 30, "studio": 35, "2k": 35}, False),
# 9. 2-bucket: 70/30 → surplus=35, capacity=5 → pathological
({"1k": 70, "studio": 30}, True),
# 10. 2-bucket: 99/1 → surplus=64, capacity=34 → pathological
({"1k": 99, "studio": 1}, True),
],
)
def test_cap_and_redistribute_invariants(
pct_map: dict[str, int],
expect_pathological: bool,
) -> None:
"""Invariant: max(pct) ≤ cap И sum(pct) == 100 (или cap_skipped=True в pathological).
Pathological — `cap_skipped=True`, max МОЖЕТ быть > cap (геометрически surplus
не вмещается в free capacity).
"""
result, cap_skipped = _cap_and_redistribute(pct_map)
assert (
cap_skipped == expect_pathological
), f"cap_skipped={cap_skipped} но ожидали {expect_pathological} для {pct_map}"
assert (
sum(result.values()) == 100
), f"sum={sum(result.values())} != 100 для {pct_map}{result}"
if not expect_pathological:
assert (
max(result.values()) <= MAX_BUCKET_SHARE_PCT
), f"max={max(result.values())} > cap={MAX_BUCKET_SHARE_PCT} для {pct_map}{result}"
@pytest.mark.parametrize(
"deals, expect_pathological, label",
[
# 3-bucket с достаточной capacity — surplus помещается, cap соблюдён
({"1k": 75, "studio": 15, "2k": 10}, False, "{1k:75, studio:15, 2k:10}"),
({"1k": 80, "studio": 12, "2k": 8}, False, "{1k:80, studio:12, 2k:8}"),
({"1k": 60, "studio": 30, "2k": 10}, False, "{1k:60, studio:30, 2k:10}"),
({"a": 50, "b": 30, "c": 20}, False, "{50, 30, 20}"),
# 2-bucket — surplus геометрически не помещается, cap_skipped=True
({"1k": 90, "studio": 10}, True, "{1k:90, studio:10}"),
({"1k": 70, "studio": 30}, True, "{1k:70, studio:30}"),
({"1k": 99, "studio": 1}, True, "{1k:99, studio:1}"),
],
)
def test_cap_reproduced_failing_cases(
deals: dict[str, int], expect_pathological: bool, label: str
) -> None:
"""Review round-2 reproduced cases: 2-bucket — pathological, 3-bucket — fit cap."""
result, cap_skipped = _cap_and_redistribute(deals)
assert (
cap_skipped == expect_pathological
), f"cap_skipped={cap_skipped} ожидали {expect_pathological} для {label}"
assert sum(result.values()) == 100, f"sum != 100 для {label}{result}"
if not expect_pathological:
assert (
max(result.values()) <= MAX_BUCKET_SHARE_PCT
), f"max={max(result.values())} > {MAX_BUCKET_SHARE_PCT} для {label}{result}"
def test_cap_iteration_count_bounded() -> None:
"""Round 2 regression: алгоритм завершается за ≤ len(pct_map)+1 итераций.
Round 1 bag: на 2-bucket {1k:70, studio:30} цикл осциллировал бесконечно.
Round 2 fix: capacity-aware redistribute + hard `for _ in range(N+1)` guard.
Этот тест гарантирует что вызов не зависает (pytest-timeout не нужен).
"""
import time
pathological_cases = [
{"1k": 70, "studio": 30},
{"1k": 99, "studio": 1},
{"1k": 90, "studio": 10},
{"1k": 50, "studio": 50},
]
for case in pathological_cases:
start = time.perf_counter()
result, cap_skipped = _cap_and_redistribute(case)
elapsed_ms = (time.perf_counter() - start) * 1000
assert elapsed_ms < 100, f"Завис ({elapsed_ms:.0f}ms) на {case}"
assert sum(result.values()) == 100, f"sum != 100 для {case}"
# 2-bucket с одним > cap всегда pathological (surplus > free capacity)
if case != {"1k": 50, "studio": 50}:
assert cap_skipped, f"Ожидали cap_skipped=True для {case}"
def test_cap_and_redistribute_no_dominant_unchanged() -> None:
"""Если все bucket'ы ≤ cap — результат идентичен входу (fast-path)."""
pct_map = {"studio": 20, "1": 35, "2": 30, "3": 15}
result, cap_skipped = _cap_and_redistribute(pct_map)
assert not cap_skipped
assert result == pct_map
def test_cap_and_redistribute_empty() -> None:
"""Пустой dict → возвращается как есть."""
result, cap_skipped = _cap_and_redistribute({})
assert result == {}
assert not cap_skipped
def test_cap_skipped_flag_propagates_to_recommendation() -> None:
"""Pathological case → cap_skipped=True в recommendation_for_tz ответа."""
# 2 bucket'а по 50% — pathological
id_rows = [_obj_id_row(1), _obj_id_row(2)]
vel_rows = [
_vel_row("studio", deals_window=50.0, obj_ids=[1]),
_vel_row("1", deals_window=50.0, obj_ids=[2]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
# С deals 50/50 → normalize_pct даёт {studio:50, 1:50} — оба выше cap
assert resp.recommendation_for_tz.cap_skipped is True
def test_cap_skipped_false_for_normal_case() -> None:
"""Normal case с capping → cap_skipped=False в recommendation_for_tz."""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
vel_rows = [
_vel_row("1k", deals_window=75.0, obj_ids=[1]),
_vel_row("studio", deals_window=15.0, obj_ids=[2]),
_vel_row("2k", deals_window=10.0, obj_ids=[3]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.recommendation_for_tz.cap_skipped is False
mix = resp.recommendation_for_tz.mix
assert all(row.pct <= MAX_BUCKET_SHARE_PCT for row in mix)
assert sum(row.pct for row in mix) == 100