"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276). Главное, что здесь защищается, — НЕ формат строки, а свойство отбора: витрина показывает выборку из работы оценщика, а не её лучший хвост. Отбор по малой ошибке дал бы формально работающий код и врущий продукт, и заметить это на глаз в проде нельзя — числа будут красивые. Поэтому проверка двусторонняя: самая точная строка, у которой не хватает данных, обязана проиграть менее точной, но полной. """ from __future__ import annotations import os from datetime import date os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.tasks.landing_showcase_deals import ( ShowcaseRow, build_row, quarter_label, select_rows, ) def _row( deal_id: int, *, district: str | None = "Кировский", floor: int | None = 5, total_floors: int | None = 9, deal_date: date = date(2026, 1, 1), err_pct: float = 10.0, ) -> ShowcaseRow: return ShowcaseRow( deal_id=deal_id, district=district, rooms=2, area_m2=54.0, floor=floor, total_floors=total_floors, deal_date=deal_date, deal_quarter="I квартал 2026", predicted_rub=6_000_000, fact_rub=5_500_000, err_pct=err_pct, n_analogs=40, ) def test_selection_ignores_error_magnitude() -> None: """Точнейшая строка с дырами в данных НЕ должна оказаться впереди полной. Ломать так: добавить в `_sort_key` слагаемое `abs(row.err_pct)` — тест покраснеет с id 1 на первом месте вместо id 2. """ almost_perfect_but_thin = _row(1, district=None, floor=None, err_pct=0.1) complete_but_worse = _row(2, err_pct=27.0) chosen = select_rows([almost_perfect_but_thin, complete_but_worse], limit=1) assert [r.deal_id for r in chosen] == [2], ( "отбор поехал за величиной ошибки — витрина перестала быть выборкой " "и стала рекламой лучшего хвоста" ) def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None: older = _row(1, deal_date=date(2025, 1, 1), err_pct=1.0) fresher = _row(2, deal_date=date(2026, 4, 1), err_pct=35.0) assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2] def test_selection_is_deterministic_on_full_ties() -> None: """Полные совпадения ключа разводятся id — иначе витрина «мерцает».""" rows = [_row(7), _row(9), _row(8)] assert [r.deal_id for r in select_rows(rows, limit=3)] == [9, 8, 7] # ── Отбраковка: только «данных нет», никогда «число некрасивое» ────────────── def _build(**over: object) -> ShowcaseRow | None: kwargs: dict[str, object] = { "deal_id": 1, "district": "Кировский", "rooms": 2, "area_m2": 50.0, "floor": 5, "total_floors": 9, "deal_date": date(2026, 4, 1), "predicted_rub": 5_000_000.0, "fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0% "n_analogs": 30, } kwargs.update(over) return build_row(**kwargs) # type: ignore[arg-type] def test_plain_row_survives_and_carries_signed_error() -> None: row = _build(predicted_rub=5_500_000.0) assert row is not None assert row.fact_rub == 5_000_000 assert row.err_pct == 10.0, "знак и база ошибки: (прогноз − факт) / факт" assert row.deal_quarter == "II квартал 2026" def test_no_error_magnitude_is_ever_rejected() -> None: """Промах оценщика ЛЮБОГО размера остаётся на витрине. Это второй половина запрета «не отбирать по ошибке»: фильтр по величине ошибки — тот же отбор, просто ступенькой раньше, и он тем злее, что не оставляет строку даже в кандидатах. Ломать так: вернуть в `build_row` любой порог вида `if abs(err_pct) > X: return None` — тест покраснеет на первом же отклонении больше X с этим отклонением в сообщении. """ fact_rub = 5_000_000.0 # 100 000 ₽/м² × 50 м² for err_pct in (-95.0, -60.0, -41.0, -5.0, 0.0, 5.0, 41.0, 150.0, 900.0): row = _build(predicted_rub=fact_rub * (1 + err_pct / 100)) assert row is not None, ( f"строка с отклонением {err_pct:+.0f}% выброшена: витрина снова " "показывает лучший хвост, а не работу оценщика" ) assert row.err_pct == round(err_pct, 2) def test_underdeclared_dkp_is_shown_not_hidden() -> None: """Занижение ДКП ради налога выглядит как промах — и всё равно показывается. Прятать такие строки нельзя: «отклонение больше 40% — это почти всегда дефект ДКП» было догадкой, а санитарный диапазон ₽/м² уже применён к выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). Всё, что прошло его и дало большую ошибку, — работа оценщика. Честность за счёт строки в `note`, а не за счёт отсева. """ # Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%. row = _build(fact_ppm2=40_000.0) assert row is not None assert row.err_pct == 150.0 def test_ppm2_band_is_not_duplicated_here() -> None: """Своей копии ₽/м²-диапазона в `build_row` нет — она была мёртвой. `MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр выборки, а `MAX_FACT_PPM2 = 1.2M` был недостижим при её потолке 600k: из трёх отбраковок срабатывала ровно одна — по ошибке. Ломать так: вернуть любую из границ — покраснеет соответствующая половина. """ assert _build(fact_ppm2=20_000.0, predicted_rub=1_000_000.0) is not None assert _build(fact_ppm2=2_000_000.0, predicted_rub=100_000_000.0) is not None def test_missing_fact_price_is_rejected() -> None: """Нулевая цена сделки — это «данных нет», а не «число некрасивое»: делить не на что.""" assert _build(fact_ppm2=0.0) is None assert _build(area_m2=0.0) is None def test_no_expected_sold_price_is_not_invented() -> None: """Спайн не дал ожидаемой цены продажи — строки нет. Подставлять нечего.""" assert _build(predicted_rub=None) is None def test_unknown_quarter_is_not_invented() -> None: assert _build(deal_date=None) is None assert quarter_label(None) is None assert quarter_label(date(2026, 7, 1)) == "III квартал 2026"