gendesign/tradein-mvp/backend/tests/test_landing_showcase_deals.py
bot-backend e45b05845e
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
fix(mera-landing): наличие схемы улицы входит в полноту строки витрины
Первой строкой витрины и первым раундом игры «Проверьте себя» стояла
единственная из двадцати сделка БЕЗ улицы: у неё вместо схемы улиц
рисовался полигон района. Причина — `completeness()` считала район, этаж
и этажность, но не улицу, хотя именно она решает, будет ли у строки карта.

Схема улицы — такое же ВИДИМОЕ поле, как район: строка, которой нечем
нарисовать карту, полнее строки с картой быть не может. Признак берётся
из уже загружаемого индекса улиц (`StreetIndex.lookup`, поиск в памяти);
индекс поднят выше отбора, дорогие пространственные запросы остались в
`_schemes_for` и по-прежнему считаются только для показанных строк.

Отбор по ВЕЛИЧИНЕ ОШИБКИ не введён и введён быть не может: строка
с отклонением +75,7% остаётся в витрине, просто больше не открывает её.
Прежнее правило «наличие схемы на отбор не влияет» в докстринге
`_schemes_for` заменено с разбором, почему оно давало этот дефект.

Тест двусторонний: при прочих равных строка со схемой выше строки без
неё, а строка без улицы остаётся в витрине. Фальсифицирован — снятие
`row.has_street` из `completeness` даёт красное ПО ЗНАЧЕНИЮ ([9, 8]
вместо [8, 9]), не по исключению.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:22:39 +05:00

259 lines
13 KiB
Python
Raw Permalink 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.

"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 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, "остальные величины строки не пострадали"