gendesign/backend/tests/services/site_finder/test_best_layouts.py
Light1YT 7533bc333b
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Successful in 12m10s
fix(site_finder): correct best-layouts supply fan-out + objects-first perf
domrf_kn_objects is a snapshot dimension (UNIQUE (obj_id, snapshot_date), ~8
snapshots/obj_id). _SUPPLY_BATCH_SQL joined flats to ALL object-snapshot rows
(no o.snapshot_date filter), counting each flat ~8.5x → supply_units_in_radius
inflated ~8.5x, sold_pct_of_supply deflated ~8.5x, is_oversold under-fired
(all user-facing, best_layouts.py:571-611; sold_pct=deals/supply is a raw
ratio so no canceling).

Fix: dedup objects to one row per obj_id (latest-snapshot coords) via
DISTINCT ON in an objects-first MATERIALIZED CTE, then join domrf_kn_flats via
idx_kn_flats_obj. units now = one count per flat (prod cross-check at radius
1.5km: units == count(*) == count(DISTINCT f.id) == 9612 for 65 objects;
correction factor 8.56x at 1.5km, 9.13x at 1.0km). This also aligns the supply
denominator with the deals numerator (_COMPETITORS_IN_RADIUS_SQL already uses
DISTINCT ON latest snapshot).

Perf bonus: objects-first avoids the parallel seq scan of the ~376k-row flats
snapshot. radius 1.5km / snapshot 2026-05-17: 240ms/~28k buffers/6712 disk
reads -> 49ms/1554 buffers/0 disk reads (~5x).

Tests: add SQL-text fan-out guard (DISTINCT ON + MATERIALIZED, no bare
flats->objects join); update stale EXPLAIN mirror in test_phantom_columns.

USER-FACING: best-layouts supply/sold_pct/is_oversold/sell-out-months shift
~8.5x toward correct (frontend BestLayoutsBlock only; ТЗ recommendation + PDF
unchanged — they derive from sum_deals, not supply). Deep-reviewed (APPROVE).
2026-06-27 10:45:19 +05:00

571 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-тесты для 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. db.scalar() → MAX(snapshot_date) — через .return_value
5. _SUPPLY_BATCH_SQL → .mappings().all()
Ключевые 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,
latest_snap: dt.date | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Порядок db.execute():
1. centroid → .mappings().first()
2. competitors → .mappings().all()
3. velocity → .mappings().all()
4. supply → .mappings().all() (только если latest_snap is not None)
db.scalar() → latest_snap (MAX snapshot_date).
"""
db = MagicMock()
db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY
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 "FROM nearby" in sql_text
assert "JOIN domrf_kn_objects" not in sql_text, "прямой flats→objects join = fan-out по снимкам"
# ── Тесты _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