Коммит555cce44обещал в сообщении одно — подписать студию вместо «0-к», — а сделал ещё и другое: удалил миграцию 280_landing_showcase_deals_coords.sql и откатил правки mera.py, landing_showcase_deals.py и двух тестов из коммитаcd2a7671. Заметил не я: об этом написал агент, которому потом достался номер 281 и который увидел дыру на 280. ПРИЧИНА. `git commit` фиксирует ИНДЕКС ЦЕЛИКОМ, а не то, что было добавлено последним `git add`. В индексе главного рабочего дерева лежали staged-удаления, оставшиеся от параллельных агентов (они работают в своих worktree, но индекс основного дерева переживает переключения веток). Я добавил два файла, а закоммитил вместе с ними чужие удаления. Что восстановлено: миграция 280 целиком, поля lat/lon в ShowcaseDeal, перенос координат в пересчёте витрины, оба теста. Обе величины нужны и не заменяют друг друга: схема улицы (281) закрывает 92% сделок, координата (280) — карту района для остальных 8%. Конфликты сведены вручную: механическое «оставить обе стороны» задвоило список колонок в INSERT и в SELECT, что тесты бы пропустили, а прод — нет. Проверено: 5085 passed, 35 skipped; миграции 275-281 без дыры; список колонок INSERT сверен со списком значений программно (15 и 15, порядок совпадает).
204 lines
9.9 KiB
Python
204 lines
9.9 KiB
Python
"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 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,
|
||
"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_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"
|
||
|
||
|
||
# ── Координата: центроид улицы, но обязана доехать и обязана не перепутаться ──
|
||
|
||
|
||
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, "остальные величины строки не пострадали"
|