"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 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, has_street: bool = True, ) -> 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, has_street=has_street, ) 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_prefers_row_with_street_scheme() -> None: """Строка без улицы не может быть самой полной: ей нечем нарисовать карту. Так дефект и выглядел в проде: первой строкой витрины и первым раундом игры «Проверьте себя» стояла единственная из двадцати сделка БЕЗ улицы, и вместо схемы улиц у неё рисовался полигон РАЙОНА. Всё остальное здесь одинаково, а id подобраны так, что без учёта улицы выиграл бы разрыв ключа по id. Ломать так: убрать `row.has_street` из `completeness` — тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8]. Вторая сторона проверки — строка без улицы ОСТАЁТСЯ в витрине: она не первая, но и не выброшена. Прятать промахи по-прежнему нельзя. """ no_street = _row(9, has_street=False, err_pct=75.7) with_street = _row(8, err_pct=3.0) chosen = select_rows([no_street, with_street], limit=2) assert [r.deal_id for r in chosen] == [8, 9], ( "строка, которой нечем нарисовать карту, оказалась полнее строки со " "схемой улиц — витрина открывается полигоном района" ) 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_rub": 5_000_000.0, # цена ДКП из договора, ошибка 0% "n_analogs": 30, "lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи, "lon": 60.6055, # поэтому перестановка ловится по значению. } 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_fact_is_the_contract_price_not_the_reconstruction() -> None: """«Цена ДКП» — это `deals.price_rub`, а не `price_per_m2 × area_m2`. Числа взяты с прода (сделка 5777343): в договоре 4 800 000 ₽, а произведение даёт 4 799 995 — `price_per_m2` в базе integer. Витрина показывала произведение под подписью «Цена ДКП». Ломать так: вернуть в `build_row` реконструкцию (`fact_ppm2 * area_m2`, то есть 185 328 × 25.9) — тест покраснеет ПО ЗНАЧЕНИЮ: 4 799 995 вместо 4 800 000, и вместе с ним поедет err_pct. """ contract_rub = 4_800_000.0 reconstruction = 185_328 * 25.9 # 4 799 995.2 — то, что показывалось раньше assert round(reconstruction) != contract_rub row = _build(area_m2=25.9, fact_rub=contract_rub, predicted_rub=contract_rub) assert row is not None assert row.fact_rub == 4_800_000, "на витрину уехала реконструкция, а не цена договора" assert row.err_pct == 0.0, "отклонение считается от той же величины, что показана" 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_rub=2_000_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_rub=1_000_000.0, predicted_rub=1_000_000.0) is not None assert _build(fact_rub=100_000_000.0, predicted_rub=100_000_000.0) is not None def test_missing_fact_price_is_rejected() -> None: """Нет цены ДКП — строки нет. Реконструкция вместо неё запрещена. Подставить `price_per_m2 * area_m2` в строку без `price_rub` значило бы вернуть тот самый дефект в одну строку из двадцати, где его уже не найти. На проде price_rub заполнен у 33 555 из 33 555 сделок выборки, так что это защита, а не рабочий путь. """ assert _build(fact_rub=None) is None assert _build(fact_rub=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" # ── Координата: центроид улицы, но обязана доехать и обязана не перепутаться ── def test_coords_are_carried_through_and_not_swapped() -> None: """Широта остаётся широтой. Обе величины float, тип перестановку не поймает. Ломать так: поменять в `build_row` местами `lat=lat` и `lon=lon` — тест покраснеет по ЗНАЧЕНИЮ (56.8386 против 60.6055), а не по форме. """ row = _build() assert row is not None assert row.lat == 56.8386, "широта уехала не в ту колонку" assert row.lon == 60.6055, "долгота уехала не в ту колонку" def test_row_without_coords_stays_on_showcase() -> None: """Нет точки — строка всё равно на витрине, с lat=lon=None. Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не связанному с качеством оценки: та же порча витрины, что и отбор по величине ошибки, просто по другому полю. Карта переживёт строку без точки. Ломать так: добавить в `build_row` `if lat is None or lon is None: return None` — тест покраснеет на None вместо строки. """ row = _build(lat=None, lon=None) assert row is not None, "сделка выброшена из витрины за отсутствие координаты" assert row.lat is None and row.lon is None, "координата придумана там, где её нет" assert row.fact_rub == 5_000_000, "остальные величины строки не пострадали"