"""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 ( _COMPETITORS_IN_RADIUS_SQL, _INLINE_VELOCITY_SQL, _OBJECTIVE_DEVELOPERS_SQL, _OBJECTIVE_LOTS_PROJECT_NAMES_SQL, _OBJECTIVE_PROJECT_NAMES_SQL, _PARCEL_CENTROID_SQL, _SUPPLY_BATCH_SQL, _SUPPLY_ONLY_LOTS_SQL, _TIME_WINDOW_PARAMS, MAX_BUCKET_SHARE_PCT, NAMELESS_CLUSTER_M, _cap_and_redistribute, _fetch_supply_only_layouts, get_best_layouts, group_radius_objects, match_groups_to_projects, normalize_complex_name, ) _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, comm_name: str | None = None, dev_name: str | None = None, latitude: float | None = None, longitude: float | None = None, ) -> dict[str, object]: """Строка из _COMPETITORS_IN_RADIUS_SQL. Issue #2177: SQL теперь возвращает comm_name/dev_name/гео для группировки в комплексы; get_best_layouts вызывает dict(r), поэтому возвращаем реальный dict (SQLAlchemy RowMapping тоже dict-конвертируем). По умолчанию каждый obj_id получает уникальное имя (`obj-`) → каждый = своя группа, как раньше по obj_id (существующие coverage-инварианты остаются в силе). """ return { "obj_id": obj_id, "comm_name": comm_name if comm_name is not None else f"obj-{obj_id}", "dev_name": dev_name if dev_name is not None else f"dev-{obj_id}", "latitude": latitude, "longitude": longitude, } 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, project_names: list[str] | None = None, lots_project_names: list[str] | None = None, window_start: dt.date | None = None, window_end: dt.date | None = None, ) -> MagicMock: """Строка из _INLINE_VELOCITY_SQL. deals_window — реальные сделки за честное окно (не 24 мес). project_names — matched_project_names (#2177 шаг 2b): project_name'ы с velocity в этом bucket'е (объединение mapping ∪ core-match). None → [] (только obj_id-источник). """ oids = obj_ids if obj_ids is not None else [1] pnames = project_names if project_names is not None else [] 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), "matched_project_names": pnames, "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 _supply_only_row( project_name: str, room_bucket: str = "1", area_bin: str = "25-40", lots_count: int = 12, median_price_m2: float | None = 145000.0, median_area_m2: float | None = 38.5, ) -> MagicMock: """Строка из _SUPPLY_ONLY_LOTS_SQL (#2177 шаг 3, .mappings().all()).""" r = MagicMock() r.__getitem__ = lambda self, k: { "project_name": project_name, "room_bucket": room_bucket, "area_bin": area_bin, "lots_count": lots_count, "median_price_m2": median_price_m2, "median_area_m2": median_area_m2, }[k] return r def _proj_name_rows(names: list[str]) -> list[tuple[str]]: """Строки из _OBJECTIVE_PROJECT_NAMES_SQL (.all() → row[0] = project_name).""" return [(n,) for n in names] def _dev_rows(devs_by_project: dict[str, list[str]]) -> list[tuple[str, list[str]]]: """Строки из _OBJECTIVE_DEVELOPERS_SQL (.all() → row[0]=project_name, row[1]=devs).""" return [(pname, devs) for pname, devs in devs_by_project.items()] 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, project_names: list[str] | None = None, lots_project_names: list[str] | None = None, developers_by_project: dict[str, list[str]] | None = None, supply_only_rows: list[MagicMock] | None = None, ) -> MagicMock: """Сконструировать mock Session с диспатчем по SQL-стейтменту. Порядок вызовов теперь НЕ позиционный (шаг 2b добавил objective-projects/ developers перед velocity) — роутим по идентичности SQL-констант, чтобы тесты были устойчивы к порядку. db.execute(): • _PARCEL_CENTROID_SQL → .mappings().first() • _COMPETITORS_IN_RADIUS_SQL → .mappings().all() • _OBJECTIVE_PROJECT_NAMES_SQL → .all() (core-attribution, #2177 шаг 2b) • _OBJECTIVE_LOTS_PROJECT_NAMES_SQL → .all() (пул supply-only, шаг 3) • _OBJECTIVE_DEVELOPERS_SQL → .all() (dev-confirm) • _INLINE_VELOCITY_SQL → .mappings().all() • _SUPPLY_BATCH_SQL → .mappings().all() • _SUPPLY_ONLY_LOTS_SQL → .mappings().all() (#2177 шаг 3) supply_only_rows фильтруются по bind :names (сматченные-без-velocity проекты), чтобы тест на «группа С velocity → НЕ дублируется» проверял реальный контракт: сервис НЕ должен передавать velocity-проект в этот SQL. """ db = MagicMock() r_centroid = MagicMock() r_centroid.mappings.return_value.first.return_value = coord r_comp = MagicMock() r_comp.mappings.return_value.all.return_value = id_rows or [] r_proj = MagicMock() r_proj.all.return_value = _proj_name_rows(project_names or []) # lots-имена (#2177 шаг 3 фикс пула): дефолт пустой — corpus-only поведение. r_lots_proj = MagicMock() r_lots_proj.all.return_value = _proj_name_rows(lots_project_names or []) r_dev = MagicMock() r_dev.all.return_value = _dev_rows(developers_by_project or {}) r_vel = MagicMock() r_vel.mappings.return_value.all.return_value = vel_rows or [] r_supply = MagicMock() r_supply.mappings.return_value.all.return_value = supply_rows or [] all_supply_only = supply_only_rows or [] dispatch = { _PARCEL_CENTROID_SQL: r_centroid, _COMPETITORS_IN_RADIUS_SQL: r_comp, _OBJECTIVE_PROJECT_NAMES_SQL: r_proj, _OBJECTIVE_LOTS_PROJECT_NAMES_SQL: r_lots_proj, _OBJECTIVE_DEVELOPERS_SQL: r_dev, _INLINE_VELOCITY_SQL: r_vel, _SUPPLY_BATCH_SQL: r_supply, } def _execute(stmt, params=None, *_args, **_kwargs): if stmt is _SUPPLY_ONLY_LOTS_SQL: names = set((params or {}).get("names") or []) filtered = [r for r in all_supply_only if r["project_name"] in names] res = MagicMock() res.mappings.return_value.all.return_value = filtered return res return dispatch[stmt] db.execute.side_effect = _execute 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 # ── Issue #2177: группировка obj_id в комплексы ────────────────────────────── # Реальный прод-кейс: радиус 1 км от 66:41:0204016:10 = 12 obj_id, 5 комплексов. # Координаты сконструированы под дистанционные ограничения из issue: # • Симфония — 3 точки в ~200 м (name-группа, дистанция не важна); # • Эфес безымянные — 3 точки в ~6 м друг от друга (< 300 м → один кластер), # ~600 м от именованного «7 ключей» (> 300 м → отдельная группа). _SYM1 = (56.850449, 60.600821) _SYM2 = (56.850449, 60.604107) _SYM3 = (56.852246, 60.600821) _KLYUCHI = (56.85, 60.593429) _EFES1 = (56.85, 60.583572) _EFES2 = (56.85, 60.583671) _EFES3 = (56.850045, 60.583621) def _prod_radius_rows() -> list[dict[str, object]]: """12 obj_id прод-кейса (радиус 1 км от 66:41:0204016:10).""" return [ # Симфония (Желдорипотека) — 3 obj_id, одинаковое comm_name+dev_name _obj_id_row(3346, "ЖК «Симфония»", "Желдорипотека", *_SYM1), _obj_id_row(43063, "ЖК «Симфония»", "Желдорипотека", *_SYM2), _obj_id_row(43064, "ЖК «Симфония»", "Желдорипотека", *_SYM3), # Квартет (Эталон) — 2 obj_id, повтор «Квартет» ×3 в строке _obj_id_row(33955, "Квартет Квартет Квартет", "Эталон", 56.851, 60.602), _obj_id_row(21801, "Квартет Квартет Квартет", "Эталон", 56.8512, 60.6022), # Квартал Депо (Брусника) — 2 obj_id, «квартал» без кавычек = часть имени _obj_id_row(63122, "Квартал Депо", "Брусника", 56.8505, 60.5985), _obj_id_row(63282, "Квартал Депо", "Брусника", 56.8506, 60.5986), # Траектория (Брусника) — 1 obj_id _obj_id_row(68027, "ЖК «Траектория»", "Брусника", 56.8495, 60.6015), # 7 ключей (Эфес) — именованная 2 очередь _obj_id_row(15731, "2 очередь жилого комплекса «7 ключей»", "Эфес", *_KLYUCHI), # 3 безымянных Эфеса — гео-кучка ~6 м, ~600 м от «7 ключей» _obj_id_row(55320, "", "Эфес", *_EFES1), _obj_id_row(55321, "", "Эфес", *_EFES2), _obj_id_row(55322, "", "Эфес", *_EFES3), ] def test_group_radius_objects_prod_case_groups() -> None: """Прод-кейс: 12 obj_id → 5 именованных комплексов + 1 безымянный кластер (#2177). Issue-заголовок «5 реальных комплексов» перечисляет ИМЕНОВАННЫЕ проекты (Симфония, Квартет, Квартал Депо, Траектория, 7 ключей). 3 безымянных Эфеса в ~600 м от «7 ключей» issue ЯВНО требует держать ОТДЕЛЬНОЙ группой («честно неизвестный проект», НЕ приклеивать дальше 300 м). Итог — 6 групп; ключевой инвариант: «7 ключей» и безымянные Эфесы НЕ слиты. """ groups = group_radius_objects(_prod_radius_rows()) # состав групп: множество frozenset(obj_ids) должно совпасть got = {frozenset(g.obj_ids) for g in groups} expected = { frozenset({3346, 43063, 43064}), # Симфония (snapshot-дубли схлопнуты) frozenset({33955, 21801}), # Квартет (повтор имени схлопнут) frozenset({63122, 63282}), # Квартал Депо («квартал» сохранён в ядре) frozenset({68027}), # Траектория frozenset({15731}), # 7 ключей (именованная 2 очередь) frozenset({55320, 55321, 55322}), # безымянные Эфесы — свой кластер } assert len(groups) == 6, f"ожидалось 6 групп, получено {len(groups)}: {got}" # load-bearing инвариант: 7 ключей и безымянные Эфесы разделены >300 м assert frozenset({15731}) in got, "7 ключей должна быть отдельной группой" assert ( frozenset({55320, 55321, 55322}) in got ), "3 безымянных Эфеса — один кластер, отдельный от 7 ключей" assert got == expected def test_group_radius_objects_symphony_collapses_snapshot_dupes() -> None: """Симфония: 3 obj_id с одинаковым comm_name+dev_name → одна группа.""" rows = [ _obj_id_row(3346, "ЖК «Симфония»", "Желдорипотека", *_SYM1), _obj_id_row(43063, "ЖК «Симфония»", "Желдорипотека", *_SYM2), _obj_id_row(43064, "ЖК «Симфония»", "Желдорипотека", *_SYM3), ] groups = group_radius_objects(rows) assert len(groups) == 1 assert sorted(groups[0].obj_ids) == [3346, 43063, 43064] def test_group_nameless_efes_two_clusters_do_not_merge() -> None: """Два разнесённых (>300 м) безымянных кластера одного застройщика НЕ сливаются.""" # кластер A ~6 м; кластер B в ~600 м от A; оба Эфес, comm_name пустой rows = [ _obj_id_row(55320, "", "Эфес", *_EFES1), _obj_id_row(55321, "", "Эфес", *_EFES2), _obj_id_row(55322, "", "Эфес", *_EFES3), # второй кластер — рядом с «7 ключей» (600 м от первого) _obj_id_row(90001, "", "Эфес", 56.85, 60.593429), _obj_id_row(90002, "", "Эфес", 56.850009, 60.5934), ] groups = group_radius_objects(rows) assert len(groups) == 2, f"ожидалось 2 кластера, получено {len(groups)}" clusters = {frozenset(g.obj_ids) for g in groups} assert frozenset({55320, 55321, 55322}) in clusters assert frozenset({90001, 90002}) in clusters def test_group_nameless_different_dev_never_merge() -> None: """Безымянные точки разных застройщиков в 1 м — всё равно разные группы.""" rows = [ _obj_id_row(1, "", "Эфес", 56.85, 60.60), _obj_id_row(2, "", "Брусника", 56.850001, 60.60), ] groups = group_radius_objects(rows) assert len(groups) == 2 def test_group_nameless_without_coords_each_own_group() -> None: """Безымянный obj_id без координат — своя группа (не с чем кластеризовать).""" rows = [ _obj_id_row(1, "", "Эфес", None, None), _obj_id_row(2, "", "Эфес", None, None), ] groups = group_radius_objects(rows) assert len(groups) == 2 # ── Нормализация имён ───────────────────────────────────────────────────────── @pytest.mark.parametrize( "raw, expected", [ # ёлочки: ядро из «N очередь жилого комплекса «X»» ("2 очередь жилого комплекса «7 ключей»", "7 ключей"), ("1-я очередь ЖК «Символ»", "символ"), # повторы ядра ("Квартет Квартет Квартет", "квартет"), ("Квартет", "квартет"), # generic-префиксы ("ЖК «Траектория»", "траектория"), ("ЖК Траектория", "траектория"), ("Жилой комплекс Символ", "символ"), ("микрорайон Академический", "академический"), # «квартал» без кавычек — часть имени, НЕ префикс ("Квартал Депо", "квартал депо"), ("квартал депо", "квартал депо"), # пустое / None ("", ""), (None, ""), (" ", ""), # регистр + пробелы (" Симфония ", "симфония"), ("ЖК «Символ»", "символ"), ], ) def test_normalize_complex_name(raw: str | None, expected: str) -> None: assert normalize_complex_name(raw) == expected def test_normalize_kvartal_depo_stays_intact() -> None: """«Квартал Депо» не должен потерять слово «квартал» (regression guard).""" assert normalize_complex_name("Квартал Депо") == "квартал депо" # два obj_id «Квартал Депо» → одна группа (ядро совпадает) rows = [ _obj_id_row(63122, "Квартал Депо", "Брусника", 56.8505, 60.5985), _obj_id_row(63282, "Квартал Депо", "Брусника", 56.8506, 60.5986), ] groups = group_radius_objects(rows) assert len(groups) == 1 assert sorted(groups[0].obj_ids) == [63122, 63282] # ── Coverage по группам ─────────────────────────────────────────────────────── def test_coverage_counts_groups_not_raw_obj_ids() -> None: """Прод-кейс coverage: velocity у 2 групп → «2 из 6 комплексов», raw=12. Из 6 групп velocity-данные (obj_ids_with_data) есть у Симфонии (obj 3346) и Квартета (obj 33955). Группа считается покрытой, если ХОТЯ БЫ ОДИН её obj_id в velocity-выборке. Знаменатель = группы (6), НЕ сырые obj_id (12). """ id_rows = _prod_radius_rows() # velocity только у Симфонии (через obj 3346) и Квартета (через obj 33955) vel_rows = [ _vel_row("2", deals_window=48.0, obj_ids=[3346]), _vel_row("1", deals_window=24.0, obj_ids=[33955]), ] 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) dq = resp.data_quality assert dq.raw_objects_total == 12, "сырой счёт obj_id должен быть 12" assert dq.objects_total_in_radius == 6, "знаменатель = комплексы (6), не obj_id" assert dq.objects_with_velocity_data == 2, "покрыты Симфония + Квартет" # 2/6 = 33.3% (не 2/12 = 16.7%) assert dq.velocity_coverage_pct == pytest.approx(33.3, rel=1e-2) def test_coverage_group_covered_by_any_member_obj_id() -> None: """Группа «с данными», если velocity есть хотя бы у одного её obj_id. Симфония = {3346, 43063, 43064}; velocity только у 43064 (не у 3346) → группа всё равно покрыта. """ id_rows = [ _obj_id_row(3346, "ЖК «Симфония»", "Желдорипотека", *_SYM1), _obj_id_row(43063, "ЖК «Симфония»", "Желдорипотека", *_SYM2), _obj_id_row(43064, "ЖК «Симфония»", "Желдорипотека", *_SYM3), ] vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[43064])] 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) dq = resp.data_quality assert dq.objects_total_in_radius == 1 assert dq.objects_with_velocity_data == 1 assert dq.raw_objects_total == 3 assert dq.velocity_coverage_pct == pytest.approx(100.0, rel=1e-3) def test_nameless_cluster_constant_reasonable() -> None: """NAMELESS_CLUSTER_M экспортируется и в разумных пределах (guard).""" assert 100.0 <= NAMELESS_CLUSTER_M <= 500.0 # ── Issue #2177 шаг 2b: core-attribution velocity по ядру комплекса ────────── def test_match_groups_to_projects_unique_core() -> None: """Группа с уникальным ядром матчится на project_name (dev-сигнал совпадает).""" rows = [_obj_id_row(15731, "2 очередь жилого комплекса «7 ключей»", "Эфес", *_KLYUCHI)] groups = group_radius_objects(rows) matched = match_groups_to_projects( groups, project_names=["7 Ключей", "Другой ЖК"], developers_by_project={"7 Ключей": ["СЗ Эфес"]}, ) assert list(matched.values()) == ["7 Ключей"] def test_match_groups_ambiguous_core_not_matched() -> None: """>1 objective-проекта с тем же ядром → группа НЕ матчится (ambiguous, честно).""" rows = [_obj_id_row(1, "ЖК «Символ»", "Девелопер", 56.85, 60.60)] groups = group_radius_objects(rows) matched = match_groups_to_projects( groups, # два разных project_name делят ядро «символ» project_names=["Символ", "ЖК Символ"], developers_by_project={}, ) assert matched == {} def test_match_groups_dev_conflict_not_matched() -> None: """Одноимённое ядро, но застройщик группы не подтверждает проект → НЕ матчим.""" rows = [_obj_id_row(1, "ЖК «Символ»", "Брусника", 56.85, 60.60)] groups = group_radius_objects(rows) matched = match_groups_to_projects( groups, project_names=["Символ"], # у проекта другой застройщик — dev-confirm провалится (сигнал есть с обеих сторон) developers_by_project={"Символ": ["СЗ Эфес"]}, ) assert matched == {} def test_merged_project_name_pool_priority_and_ambiguity() -> None: """Пул имён: corpus приоритетен по ядру; lots добавляет только НОВЫЕ ядра. Прод-кейс 2026-07-03: «ЖК Основа» (лоты без сделок) не попадал в кандидаты → supply-only всегда пуст. При этом lots-вариант ядра corpus-имени НЕ должен создавать ambiguous-пару (сломал бы velocity-матч), а два разных lots-проекта с одним ядром должны остаться оба (честный ambiguous для матчера). """ from app.services.site_finder.best_layouts import _merged_project_name_pool pool = _merged_project_name_pool( corpus_names=["7 Ключей", "Аксиома"], lots_names=[ "ЖК 7 ключей", # ядро занято corpus → отбрасывается (тот же проект) "Основа", # новое ядро → добавляется (supply-only кандидат) "Меридиан", # два разных lots-проекта с одним ядром — "Бутик-квартал Меридиан (2)", # (разные строки, ядро может совпасть) ], ) assert "7 Ключей" in pool and "Аксиома" in pool assert "ЖК 7 ключей" not in pool # corpus-ядро приоритетно assert "Основа" in pool def test_match_groups_no_dev_signal_matches_on_core() -> None: """Нет dev-сигнала (у проекта пустые developers) → матч по уникальному ядру достаточен.""" rows = [_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60)] groups = group_radius_objects(rows) matched = match_groups_to_projects( groups, project_names=["Траектория"], developers_by_project={}, # developers неизвестны → dev-confirm пропускается ) assert list(matched.values()) == ["Траектория"] def test_seven_keys_core_match_covered_and_deals_in_aggregates() -> None: """Реальный кейс #2177: 2-я очередь «7 Ключей» в радиусе матчится по ЯДРУ на project '7 Ключей' → группа ПОКРЫТА, её сделки входят в агрегаты планировок. Радиус содержит ТОЛЬКО obj 15731 (2-я очередь), которого нет в mapping. Раньше (mapping-only) группа была непокрыта и 162 корпус-месяца сделок не входили. Теперь velocity-строка приходит с matched_project_names=['7 Ключей'] → coverage по project_name + deals реально в top_layouts. """ id_rows = [_obj_id_row(15731, "2 очередь жилого комплекса «7 ключей»", "Эфес", *_KLYUCHI)] # velocity-строка: НЕТ mapping-obj_id (competitor_obj_ids=[]), покрытие по имени. vel_rows = [ _vel_row( "2", deals_window=162.0, obj_ids=[], project_names=["7 Ключей"], ) ] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, project_names=["7 Ключей", "Симфония"], developers_by_project={"7 Ключей": ["СЗ Эфес"]}, ) req = _request(time_window="last_quarter") resp = get_best_layouts(db, CAD_NUM, req) dq = resp.data_quality # одна группа в радиусе, покрыта по ядру (не через obj_id mapping) assert dq.objects_total_in_radius == 1 assert dq.objects_with_velocity_data == 1 assert dq.groups_matched_by_core == 1, "покрытие должно идти по core-attribution" assert dq.velocity_coverage_pct == pytest.approx(100.0, rel=1e-3) # сделки «7 Ключей» реально вошли в расчёт планировок assert len(resp.top_layouts) == 1 assert resp.top_layouts[0].room_bucket == "2" assert resp.top_layouts[0].total_sold_in_window == 162 def test_matched_project_names_passed_to_velocity_bind() -> None: """Сматченные по ядру project_name'ы передаются bind-параметром в velocity SQL.""" from unittest.mock import ANY id_rows = [_obj_id_row(15731, "ЖК «Траектория»", "Брусника", *_KLYUCHI)] vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, project_names=["Траектория"], developers_by_project={}, ) req = _request(time_window="last_quarter") get_best_layouts(db, CAD_NUM, req) # найти вызов velocity SQL и проверить bind matched_project_names vel_calls = [ c for c in db.execute.call_args_list if c.args and c.args[0] is _INLINE_VELOCITY_SQL ] assert len(vel_calls) == 1 params = vel_calls[0].args[1] if len(vel_calls[0].args) > 1 else vel_calls[0].kwargs assert params["matched_project_names"] == ["Траектория"] assert params["competitor_obj_ids"] == [15731] # sanity: ANY использован только чтобы не падать на прочих полях assert params.get("window_interval", ANY) is not None # ── Issue #2177 шаг 3: fallback-блок «по предложению» (supply-only) ─────────── def test_supply_only_group_without_velocity_appears_in_block() -> None: """Группа сматчена по ядру, БЕЗ velocity, но с лотами → в supply_only_layouts + счётчик. Радиус: две группы. «Траектория» имеет velocity (сделки в окне) → top_layouts. «Символ» сматчен по ядру, но velocity нет (не в vel_rows) → лоты в продаже из objective_lots попадают в supply_only_layouts, счётчик = 1. """ id_rows = [ _obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60), _obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601), ] # velocity ТОЛЬКО у Траектории (обе группы сматчены по ядру ниже). vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] supply_only_rows = [ _supply_only_row("Символ", room_bucket="1", area_bin="25-40", lots_count=18), _supply_only_row( "Символ", room_bucket="studio", area_bin="<25", lots_count=7, median_area_m2=22.0 ), ] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, project_names=["Траектория", "Символ"], developers_by_project={}, supply_only_rows=supply_only_rows, ) req = _request(time_window="last_quarter") resp = get_best_layouts(db, CAD_NUM, req) # supply-блок содержит ТОЛЬКО «Символ» (без velocity), 2 строки assert len(resp.supply_only_layouts) == 2 assert {r.project_name for r in resp.supply_only_layouts} == {"Символ"} first = resp.supply_only_layouts[0] assert first.lots_count == 18 assert first.median_price_m2 == 145000 assert first.source == "objective_lots" # счётчик групп в supply-блоке assert resp.data_quality.groups_with_supply_only == 1 def test_supply_only_velocity_group_not_duplicated() -> None: """Группа С velocity НЕ попадает в supply-блок (её темп продаж уже в top_layouts). Контракт: сервис НЕ передаёт velocity-проект в _SUPPLY_ONLY_LOTS_SQL (mock фильтрует по :names). Даже если бы для «Траектории» существовали строки лотов, их не должно быть в ответе — проект исключён как velocity-covered. """ id_rows = [_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60)] vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] # Ловушка: строки лотов для velocity-проекта существуют в БД… supply_only_rows = [_supply_only_row("Траектория", lots_count=99)] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, project_names=["Траектория"], developers_by_project={}, supply_only_rows=supply_only_rows, ) req = _request(time_window="last_quarter") resp = get_best_layouts(db, CAD_NUM, req) # velocity-группа в top_layouts, НЕ в supply-блоке assert len(resp.top_layouts) == 1 assert resp.supply_only_layouts == [] assert resp.data_quality.groups_with_supply_only == 0 # контракт: velocity-проект НЕ уходит в supply-only SQL. Когда supply-only # набор пуст, сервис короткозамыкает БЕЗ запроса (эффективность) — но если # запрос всё же сделан, :names НЕ должен содержать velocity-проект. supply_calls = [ c for c in db.execute.call_args_list if c.args and c.args[0] is _SUPPLY_ONLY_LOTS_SQL ] for call in supply_calls: params = call.args[1] if len(call.args) > 1 else call.kwargs assert "Траектория" not in ( params.get("names") or [] ), "velocity-проект не должен уходить в supply-only запрос" def test_supply_only_empty_snapshot_yields_empty_block() -> None: """Пустой снапшот лотов (нет строк) → supply_only_layouts пуст, счётчик 0. «Символ» сматчен по ядру и без velocity, но в objective_lots для него лотов нет (пустой снапшот) → блок пустой, счётчик 0 (аддитивная семантика, без ошибок). """ id_rows = [_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601)] vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, project_names=["Траектория", "Символ"], developers_by_project={}, supply_only_rows=[], # пустой снапшот ) req = _request(time_window="last_quarter") resp = get_best_layouts(db, CAD_NUM, req) assert resp.supply_only_layouts == [] assert resp.data_quality.groups_with_supply_only == 0 def test_supply_only_median_price_none_preserved() -> None: """Лот без валидной цены (median_price_m2 NULL) → count есть, цена None (не 0).""" id_rows = [_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601)] vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] supply_only_rows = [ _supply_only_row("Символ", lots_count=5, median_price_m2=None, median_area_m2=None), ] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, project_names=["Траектория", "Символ"], developers_by_project={}, supply_only_rows=supply_only_rows, ) req = _request(time_window="last_quarter") resp = get_best_layouts(db, CAD_NUM, req) assert len(resp.supply_only_layouts) == 1 row = resp.supply_only_layouts[0] assert row.lots_count == 5 assert row.median_price_m2 is None assert row.median_area_m2 is None def test_supply_only_sql_uses_price_sanity_and_on_sale_filter() -> None: """Regression guard: SQL фильтрует «в продаже» + price-sanity + дедуп физлота. Инварианты контракта (#2177 шаг 3): • is_sold IS NOT TRUE + status<>'продан' — фильтр «в продаже»; • price BETWEEN price_min/price_max — вменяемость цены; • DISTINCT ON (physflat) — дедуп ре-листинг-инфляции objective_lots; • room_bucket в вокабуляре velocity {studio,4+}, НЕ velocity (без деления на мес). """ sql_text = str(_SUPPLY_ONLY_LOTS_SQL.text) assert "is_sold IS NOT TRUE" in sql_text assert "<> 'продан'" in sql_text assert "price_min" in sql_text and "price_max" in sql_text assert "DISTINCT ON (" in sql_text, "нет физлот-дедупа → ре-листинг-инфляция" assert "'studio'" in sql_text and "'4+'" in sql_text # psycopg3: CAST(:x AS type), НЕ :x::type assert "::text[]" not in sql_text.replace("CAST(:names AS text[])", "") # ── SAVEPOINT regression (#2464 cluster A) ──────────────────────────────────── # # Step 5 (_SUPPLY_BATCH_SQL) db shared с caller (GET .../best-layouts, # Depends(get_db); sync get_best_layouts мостится через run_in_threadpool). Раньше # db.execute-сбой ловился в bare ``except Exception`` без SAVEPOINT/rollback — на # реальном Postgres это отравляет db shared с caller: следующий db.execute на этой # же сессии (напр. Step 9b _fetch_supply_only_layouts) падает "current transaction # is aborted". Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не # эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful # supply=0 fallback при сбое, (2) что begin_nested() реально вызван вокруг именно # этого db.execute, (3) что db остаётся usable для последующих вызовов на этой же # mock-сессии (get_best_layouts не крашит, ранжирование по-прежнему строится). def test_supply_query_failure_degrades_to_zero_supply() -> None: """Сбой Step 5 (_SUPPLY_BATCH_SQL) → supply_units_in_radius=0 для всех строк, остальной pipeline (velocity/ranking) не ломается.""" id_rows = [_obj_id_row(1)] vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])] db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) real_dispatch = db.execute.side_effect def _execute_raise_on_supply(stmt, params=None, *args, **kwargs): if stmt is _SUPPLY_BATCH_SQL: raise RuntimeError("simulated DB failure") return real_dispatch(stmt, params, *args, **kwargs) db.execute.side_effect = _execute_raise_on_supply 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].supply_units_in_radius == 0 assert resp.top_layouts[0].sold_pct_of_supply is None assert resp.top_layouts[0].is_oversold is False # velocity per-month всё ещё честно посчитан (сбой изолирован в supply-блоке). assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3) def test_supply_query_uses_begin_nested_savepoint() -> None: """Фикс: Step 5 запрос обёрнут в db.begin_nested(), не голый db.execute.""" id_rows = [_obj_id_row(1)] vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])] supply_rows = [_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") get_best_layouts(db, CAD_NUM, req) assert db.begin_nested.call_count >= 1 def test_supply_query_failure_session_usable_for_supply_only_step() -> None: """db остаётся usable после Step 5-сбоя: Step 9b (_fetch_supply_only_layouts, отдельный db.execute на той же сессии) по-прежнему выполняется без исключения. Сетап зеркалит test_supply_only_group_without_velocity_appears_in_block: две группы, «Траектория» с velocity → top_layouts, «Символ» без velocity → должна дойти до Step 9b (_fetch_supply_only_layouts) на той же db, что и Step 5. """ id_rows = [ _obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60), _obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601), ] vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])] supply_only_rows = [_supply_only_row("Символ", room_bucket="1", area_bin="25-40")] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, project_names=["Траектория", "Символ"], developers_by_project={}, supply_only_rows=supply_only_rows, ) real_dispatch = db.execute.side_effect def _execute_raise_on_supply(stmt, params=None, *args, **kwargs): if stmt is _SUPPLY_BATCH_SQL: raise RuntimeError("simulated DB failure") return real_dispatch(stmt, params, *args, **kwargs) db.execute.side_effect = _execute_raise_on_supply req = _request(time_window="last_quarter") # Не должно бросить InFailedSqlTransaction-подобное исключение — Step 9b # выполняется на той же db без проблем (в mock — просто control-flow guard). resp = get_best_layouts(db, CAD_NUM, req) assert resp.top_layouts[0].supply_units_in_radius == 0 # Step 9b реально выполнился на этой же db — supply-only блок не пуст. assert len(resp.supply_only_layouts) == 1 assert resp.supply_only_layouts[0].project_name == "Символ" # ── SAVEPOINT regression: ex-bare-rollback sites (#2464 cluster A) ────────────── # # ДВА места в best_layouts.py раньше делали ГОЛЫЙ db.rollback() на SHARED сессии в # except после сбоя db.execute — это ХУЖЕ простого swallow: bare rollback на общей # сессии откатывает ВСЮ outer-транзакцию /analyze-запроса (всю несохранённую работу # ранее в этом же запросе) и orphan'ит outer SessionTransaction (velocity.py:170). # Фикс — savepoint: db.execute обёрнут в ``with db.begin_nested():``, а bare # db.rollback() УДАЛЁН (auto-rollback savepoint'а откатывает только сбойный statement, # не весь запрос). Тесты проверяют: (a) документированный fallback сохранён, (b) # begin_nested() использован И db.rollback() НЕ вызван (весь смысл — outer tx выживает), # (c) follow-up db.execute на той же сессии проходит. class TestFetchSupplyOnlyLayoutsSavepoint: """Ex-rollback site #1: _fetch_supply_only_layouts (Step 9b).""" def test_uses_begin_nested_savepoint(self) -> None: db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [] _fetch_supply_only_layouts(db, ["ЖК Резерв"]) assert db.begin_nested.call_count == 1 def test_query_failure_degrades_to_empty_and_no_bare_rollback(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("simulated DB failure") out = _fetch_supply_only_layouts(db, ["ЖК Резерв"]) assert out == [] # документированный fallback assert db.begin_nested.call_count == 1 # savepoint использован db.rollback.assert_not_called() # bare rollback удалён — outer tx выживает def test_empty_project_names_skips_db_entirely(self) -> None: # Пустой вход → SQL не выполняется, savepoint не открывается (штатный ранний []). db = MagicMock() assert _fetch_supply_only_layouts(db, []) == [] db.execute.assert_not_called() db.begin_nested.assert_not_called() def test_session_usable_after_failure(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("boom") assert _fetch_supply_only_layouts(db, ["ЖК Резерв"]) == [] db.execute.side_effect = None next_result = MagicMock() next_result.mappings.return_value.all.return_value = [{"marker": "ok"}] db.execute.return_value = next_result assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}] def test_core_attribution_failure_uses_savepoint_no_bare_rollback() -> None: """Ex-rollback site #2: Step 2b core-attribution db.execute сбоит → SAVEPOINT (не bare rollback) → get_best_layouts деградирует в mapping-only. Раньше bare db.rollback() здесь откатил бы ВСЮ outer tx /analyze-запроса. Теперь begin_nested откатывает только сбойный savepoint, а velocity через obj_id (mapping) по-прежнему считается на той же сессии → top_layouts не пуст, outer tx выживает. """ id_rows = [_obj_id_row(1)] vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])] supply_rows = [_supply_row("2", "40-60", 80)] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows, project_names=["Символ"], developers_by_project={}, ) real_dispatch = db.execute.side_effect def _raise_on_project_names(stmt, params=None, *args, **kwargs): # Первый из трёх Step 2b запросов — сбой на нём отравил бы сессию без savepoint. if stmt is _OBJECTIVE_PROJECT_NAMES_SQL: raise RuntimeError("simulated DB failure") return real_dispatch(stmt, params, *args, **kwargs) db.execute.side_effect = _raise_on_project_names req = _request(time_window="last_quarter") resp = get_best_layouts(db, CAD_NUM, req) # savepoint использован, bare rollback НЕ вызван (outer tx /analyze выживает). assert db.begin_nested.call_count >= 1 db.rollback.assert_not_called() # fallback на mapping-only: velocity через obj_id всё равно посчитан на той же # сессии (Step 3 velocity execute не заблокирован aborted-транзакцией). assert len(resp.top_layouts) == 1 assert resp.top_layouts[0].room_bucket == "2" assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3) # core-match не дал имён (сбой) → matched-only supply-блок пуст, но ответ валиден. assert resp.data_quality.groups_matched_by_core == 0