"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276). Здесь защищаются ДВА разных свойства, и путать их нельзя. 1. ПОЛОСА. С 2026-09-12 витрина показывает только расхождения −5 %..+20 % включительно — решение владельца продукта. Это отбор показательных строк, и проверяется он ПО ЗНАЧЕНИЮ, на реальных строках прода: +75,7 % и −27,9 % на витрину не попадают, +9,9 % попадает. 2. ВНУТРИ ПОЛОСЫ отбора по величине ошибки по-прежнему нет. Иначе витрина показывала бы лучший хвост уже самой полосы, а числа при этом остались бы красивыми — на глаз в проде такое не ловится. Поэтому проверка двусторонняя: самая точная строка, у которой не хватает данных, обязана проиграть менее точной, но полной. Отбраковка («данных нет») — третье свойство, и она живёт в `build_row`: строка вне полосы остаётся кандидатом и попадает в счётчик `eligible`, её снимает отбор, а не отбраковка. Ровно поэтому подпись под витриной может честно сказать, сколько строк прогон собрал. """ 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 ( BAND_MAX_ERR_PCT, BAND_MIN_ERR_PCT, REJECTION_RULE, 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, ) # ── Полоса −5 %..+20 %: проверка ПО ЗНАЧЕНИЮ, на реальных строках прода ─────── def test_band_drops_rows_outside_it_and_keeps_rows_inside() -> None: """Три строки, которые сегодня лежат на витрине прода (прогон 30.08.2026). id 41 — расхождение +75,71 %, id 43 — −27,87 %, id 44 — +9,85 %. Первые две на витрину попадать больше не должны, третья должна. Ломать так: снять фильтр в `select_rows` (вернуть `sorted(rows, key=_sort_key)`) — тест покраснеет ПО ЗНАЧЕНИЮ, показав [44, 41, 43] вместо [44], то есть ровно те два промаха, которых владелец на витрине видеть не хочет. """ far_over = _row(41, err_pct=75.71) far_under = _row(43, err_pct=-27.87) inside = _row(44, err_pct=9.85) chosen = select_rows([far_over, far_under, inside], limit=20) assert [r.deal_id for r in chosen] == [44], ( "на витрину прошла строка вне полосы −5 %..+20 %: подпись обещает " "полосу, а показывает не её" ) def test_band_edges_are_inclusive_and_near_misses_are_not() -> None: """Границы полосы включительные, а на волос за ними — уже нет. Проверяется ПО ЗНАЧЕНИЮ у самой границы: `<` вместо `<=` в `in_band` выбросит ровно строки 1 и 2 и покраснит тест. """ rows = [ _row(1, err_pct=BAND_MIN_ERR_PCT), _row(2, err_pct=BAND_MAX_ERR_PCT), _row(3, err_pct=BAND_MIN_ERR_PCT - 0.01), _row(4, err_pct=BAND_MAX_ERR_PCT + 0.01), ] assert sorted(r.deal_id for r in select_rows(rows, limit=20)) == [1, 2] def test_short_band_shows_what_there_is_and_does_not_top_up() -> None: """В полосу попало меньше лимита — показываем сколько есть. Добор ближайшими по ошибке был бы тем же отбором по величине ошибки, просто с другой стороны. Ломать так: добавить в `select_rows` «добить до limit остальными» — тест покраснеет тремя строками вместо одной. """ rows = [_row(1, err_pct=3.0), _row(2, err_pct=44.0), _row(3, err_pct=-60.0)] assert [r.deal_id for r in select_rows(rows, limit=20)] == [1] def test_rejection_rule_names_the_band_that_is_actually_applied() -> None: """Подпись витрины называет ТУ полосу, которую применяет фильтр. Текст едет на фронт и там читается как обещание. Вписанный руками «−5 %» в тексте и `>= -5.0` в коде — две независимые величины; здесь проверяется, что в тексте стоят именно границы фильтра. Ломать так: подвинуть `BAND_MAX_ERR_PCT` на 30, не трогая текст, — тест покраснеет на «+30 %», которого в подписи нет. """ assert f"{BAND_MIN_ERR_PCT:+.0f} %" in REJECTION_RULE assert f"{BAND_MAX_ERR_PCT:+.0f} %" in REJECTION_RULE assert "не вся сверка" in REJECTION_RULE, "подпись не говорит, что это отбор" # Снятая формулировка не должна вернуться: с фильтром она ложь. assert "на отбор и отбраковку не влияет" not in REJECTION_RULE # ── Внутри полосы: порядок задают полнота и свежесть, не ошибка ─────────────── 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=19.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=18.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=19.0) 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: """Промах ЛЮБОГО размера остаётся кандидатом и попадает в счётчик. Отбраковка и отбор — разные шаги, и величина ошибки причиной ОТБРАКОВКИ не является: вне полосы строка не показывается, но входит в `eligible`, и подпись «показано N из M собранных» остаётся правдой. Отбракуй её здесь — и отбор перестал бы быть виден в счётчиках вообще. Ломать так: вернуть в `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_counted_not_dropped() -> None: """Занижение ДКП ради налога выглядит как промах — и остаётся кандидатом. Отбраковывать такие строки нельзя: «отклонение больше 40% — это почти всегда дефект ДКП» было догадкой, а санитарный диапазон ₽/м² уже применён к выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). На витрину такая строка не выйдет — её снимет полоса, — но в `eligible` она войдёт, и счётчик под таблицей останется честным. """ # Факт 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, "остальные величины строки не пострадали"