diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 8487dc1f..82697804 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -316,19 +316,57 @@ class ShowcaseDeal(BaseModel): note: str +class ShowcaseStats(BaseModel): + """Итог прогона, который дал показанные строки. Подпись под витриной. + + Без этих чисел «20 отличных строк» неотличимо от «столько и было»: + посетитель не может отличить выборку из работы оценщика от её лучшего + хвоста. `eligible` минус `written` — сколько годных строк не поместилось + в витрину; `rejection_rule` — по какому правилу отсеяно остальное, + записанное ТЕМ прогоном, который эти строки посчитал. + """ + + considered: int + priced: int + no_prediction: int + incomplete: int + eligible: int + written: int + with_district: int + rejection_rule: str + + class ShowcaseResponse(BaseModel): - """Витрина целиком. `computed_at` — когда считали, `deals` — что показываем.""" + """Витрина целиком: когда считали, что показываем и из чего это отобрано. + + `stats` = None только до первого пересчёта — тогда и `deals` пуст. + """ computed_at: str | None deals: list[ShowcaseDeal] + stats: ShowcaseStats | None = None +# Последний прогон — единственная точка отсчёта: и `computed_at`, и счётчики, и +# набор строк берутся ИЗ НЕГО. Брать строки по своему max(computed_at) значило +# бы, что прогон, не давший ни одной строки, показывает вчерашние строки под +# сегодняшними счётчиками. +_SHOWCASE_RUN_SQL = text( + """ + SELECT computed_at, considered, priced, no_prediction, incomplete, + eligible, written, with_district, rejection_rule + FROM landing_showcase_runs + ORDER BY computed_at DESC, id DESC + LIMIT 1 + """ +) + _SHOWCASE_SQL = text( """ SELECT district, rooms, area_m2, floor, total_floors, deal_quarter, - predicted_rub, fact_rub, err_pct, n_analogs, note, computed_at + predicted_rub, fact_rub, err_pct, n_analogs, note FROM landing_showcase_deals - WHERE computed_at = (SELECT max(computed_at) FROM landing_showcase_deals) + WHERE computed_at = CAST(:computed_at AS timestamptz) ORDER BY id """ ) @@ -348,12 +386,28 @@ def public_showcase( Пустой список — штатный ответ, а не ошибка: до первого пересчёта показывать нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк. + + Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько + годных строк не поместилось и по какому правилу отсеяно остальное. Числа + считает пересчёт; без них витрина не имеет права подписаться честно. """ _enforce(_showcase_limiter, request, "showcase") - rows = db.execute(_SHOWCASE_SQL).mappings().all() - computed_at = rows[0]["computed_at"].isoformat() if rows else None + run = db.execute(_SHOWCASE_RUN_SQL).mappings().first() + if run is None: + return ShowcaseResponse(computed_at=None, deals=[], stats=None) + rows = db.execute(_SHOWCASE_SQL, {"computed_at": run["computed_at"]}).mappings().all() return ShowcaseResponse( - computed_at=computed_at, + computed_at=run["computed_at"].isoformat(), + stats=ShowcaseStats( + considered=int(run["considered"]), + priced=int(run["priced"]), + no_prediction=int(run["no_prediction"]), + incomplete=int(run["incomplete"]), + eligible=int(run["eligible"]), + written=int(run["written"]), + with_district=int(run["with_district"]), + rejection_rule=run["rejection_rule"], + ), deals=[ ShowcaseDeal( district=r["district"], diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index 42a7e3ba..5f8a064a 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -20,6 +20,16 @@ код формально работает, а продукт врёт. Проверяется тестом `test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`. +ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ — И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь +жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ +до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в +ключе и отбор по ошибке в фильтре — одно и то же действие, и второе даже +злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение +больше 40% — это почти всегда занижение ДКП ради налога» не держится: см. +следующий раздел, грубые занижения вырезаны выше по потоку и по свойству +самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет +квартала, нет площади) — «число некрасивое» причиной не является. + Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность), у строки заполнено. Свежесть — порядок квартала сделки. @@ -33,10 +43,24 @@ объявлениям, а сделка — прошлая. Между ними дрейф рынка, который в ошибку входит целиком. Это записано в `note` КАЖДОЙ строки, а не только здесь: поле note едет на фронт вместе с числами, а докстринг — нет. - * ЦЕНА ДКП БЫВАЕТ ЗАНИЖЕНА (налоговая оптимизация, сделки между своими). - Отсюда отбраковка выбросов ниже — и счётчики отбракованного в логе, чтобы - «мы показываем 20 отличных строк» нельзя было прочитать как «столько и - было». + * ЦЕНА ДКП БЫВАЕТ ЗАНИЖЕНА (налоговая оптимизация, сделки между своими), и + такая строка выглядит как чудовищный промах оценщика. Санитарный диапазон + ₽/м² применяется ОДИН раз и ВЫШЕ ПО ПОТОКУ — в `_load_sample`, по свойству + самой сделки, а не по ошибке прогноза: для ЕКБ это глобальные + `PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в + `deal_city_price_bands`, там же и комментарий об этом). Значит грубые + занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а + всё, что после этого дало большую ошибку, — работа оценщика, и витрина + обязана её показать. Своей копии диапазона здесь нет намеренно: прежние + `MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а + `MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх + отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине. + Неработающая проверка читается как работающая, поэтому её нет. + * СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных + строк» неотличимо от «столько и было», пока рядом не написано, сколько + сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог + прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся + ручкой `/api/public/mera/showcase` вместе со строками. ЗАПУСК (прод, read-mostly: один DELETE+INSERT в свою таблицу):: @@ -60,24 +84,25 @@ from sqlalchemy.orm import Session logger = logging.getLogger(__name__) -# ── Пороги отбраковки выбросов ─────────────────────────────────────────────── +# ── Правило отбраковки: одна формулировка, она же едет на фронт ────────────── # -# ДКП-цена — это то, что стороны заявили в Росреестр. Занижение до 1 млн ради -# налога встречается, и такая строка на витрине выглядит как чудовищный промах -# оценщика, хотя оценщик прав, а врёт документ. Отбрасываем обе стороны хвоста -# по отклонению и заведомо невозможный ₽/м². -MAX_ABS_ERR_PCT = 40.0 -MIN_FACT_PPM2 = 30_000.0 -MAX_FACT_PPM2 = 1_200_000.0 - -# Замер по ЕКБ (n=398): MAPE 14.24%, покрытие коридором 88.22%. Порог 40% -# отсекает хвост, а не «неудобные» строки: при MAPE 14% строка с отклонением -# больше 40% — это почти всегда дефект цены в ДКП, а не дефект прогноза. +# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный +# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут +# значило бы завести проверку, которая в проде не срабатывает никогда. +REJECTION_RULE = ( + "Строка не попадает на витрину, только если данных нет: оценщик не дал " + "ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или " + "площадь. Величина отклонения на отбор и отбраковку не влияет — иначе " + "витрина показывала бы лучший хвост, а не работу оценщика. Санитарный " + "диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён " + "к выборке до расчёта, по цене самой сделки." +) NOTE = ( "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " - "Факт — цена ДКП, заявленная в Росреестр." + "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " + "строка выглядит как промах оценщика, хотя врёт документ." ) _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} @@ -144,26 +169,23 @@ def build_row( fact_ppm2: float, n_analogs: int, ) -> ShowcaseRow | None: - """Кандидат → строка витрины, либо None если строка отбракована. + """Кандидат → строка витрины, либо None если считать не из чего. - Причины отказа (все — «данных нет / данным нельзя верить», не «число - некрасивое»): спайн не дал ожидаемой цены продажи; квартал неизвестен; - ₽/м² факта вне здравого диапазона; |отклонение| больше порога. + Причины отказа ИСЧЕРПЫВАЮЩИЕ и все — «данных нет»: спайн не дал ожидаемой + цены продажи; квартал сделки неизвестен; нет площади или цены сделки + (делить не на что). Величина отклонения причиной НЕ является ни при каких + значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля. """ - if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0: + if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0: return None quarter = quarter_label(deal_date) if quarter is None: return None - if not (MIN_FACT_PPM2 <= fact_ppm2 <= MAX_FACT_PPM2): - return None fact_rub = fact_ppm2 * area_m2 # Знак ошибки — как в бэктесте: (прогноз − факт) / факт. Плюс = МЕРА # назвала дороже, чем ушло по ДКП. err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub - if abs(err_pct) > MAX_ABS_ERR_PCT: - return None return ShowcaseRow( deal_id=deal_id, @@ -213,6 +235,22 @@ def _fetch_districts(db: Session, deal_ids: list[int]) -> dict[int, str]: _DELETE_SQL = text("DELETE FROM landing_showcase_deals") +_DELETE_RUNS_SQL = text("DELETE FROM landing_showcase_runs") +# Тот же `now()`, что у DEFAULT в строках витрины: в Postgres now() — время +# НАЧАЛА транзакции, а батч и его итог пишутся одной транзакцией. Ручка по +# этому computed_at и связывает счётчики со строками. +_INSERT_RUN_SQL = text( + """ + INSERT INTO landing_showcase_runs + (considered, priced, no_prediction, incomplete, eligible, written, + with_district, rejection_rule) + VALUES + (CAST(:considered AS integer), CAST(:priced AS integer), + CAST(:no_prediction AS integer), CAST(:incomplete AS integer), + CAST(:eligible AS integer), CAST(:written AS integer), + CAST(:with_district AS integer), CAST(:rejection_rule AS text)) + """ +) _INSERT_SQL = text( """ INSERT INTO landing_showcase_deals @@ -238,9 +276,18 @@ def refresh_landing_showcase_deals( ) -> dict[str, int]: """Прогнать бэктест по ЕКБ и перезаписать витрину. Возвращает счётчики. - Счётчики (`considered` / `priced` / `rejected_outlier` / `written`) — - не отладочный шум: без них «на витрине 20 отличных строк» неотличимо от - «столько и было». Отбракованное печатается в лог всегда. + Счётчики — не отладочный шум: без них «на витрине 20 отличных строк» + неотличимо от «столько и было». Поэтому они не только пишутся в лог, но и + сохраняются в `landing_showcase_runs` и уезжают на фронт вместе со + строками. Значения: + + considered сколько ДКП-сделок взято в работу + priced из них оценщик дал ожидаемую цену продажи + no_prediction не дал (мало аналогов / спайн упал) + incomplete цена есть, но нет квартала/площади — строку не собрать + eligible годных строк ВСЕГО (никакого отсева по ошибке нет) + written из них показано (обрезано по `limit`) + with_district у скольких показанных удалось определить район """ # Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми # его зависимостями, а web-процессу это на импорте приложения не нужно. @@ -258,7 +305,7 @@ def refresh_landing_showcase_deals( candidates: list[ShowcaseRow] = [] n_priced = 0 - n_rejected = 0 + n_incomplete = 0 for deal in deals: capture: list[dict[str, Any]] = [] try: @@ -283,13 +330,14 @@ def refresh_landing_showcase_deals( n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, ) if row is None: - n_rejected += 1 + n_incomplete += 1 continue candidates.append(row) chosen = select_rows(candidates, limit) db.execute(_DELETE_SQL) + db.execute(_DELETE_RUNS_SQL) for row in chosen: db.execute( _INSERT_SQL, @@ -307,21 +355,26 @@ def refresh_landing_showcase_deals( "note": NOTE, }, ) - db.commit() - counters = { "considered": len(deals), "priced": n_priced, - "rejected_outlier": n_rejected, - "with_district": sum(1 for r in chosen if r.district is not None), + "no_prediction": len(deals) - n_priced, + "incomplete": n_incomplete, + "eligible": len(candidates), "written": len(chosen), + "with_district": sum(1 for r in chosen if r.district is not None), } + db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE}) + db.commit() + logger.info( - "витрина обновлена: рассмотрено=%d оценено=%d отбраковано_выбросов=%d " - "записано=%d с_районом=%d", + "витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d " + "годных=%d записано=%d с_районом=%d", counters["considered"], counters["priced"], - counters["rejected_outlier"], + counters["no_prediction"], + counters["incomplete"], + counters["eligible"], counters["written"], counters["with_district"], ) diff --git a/tradein-mvp/backend/data/sql/277_landing_showcase_runs.sql b/tradein-mvp/backend/data/sql/277_landing_showcase_runs.sql new file mode 100644 index 00000000..f0aa708c --- /dev/null +++ b/tradein-mvp/backend/data/sql/277_landing_showcase_runs.sql @@ -0,0 +1,38 @@ +-- 277: итог пересчёта витрины лэндинга — счётчики рядом со строками. +-- +-- ЗАЧЕМ ОТДЕЛЬНАЯ ТАБЛИЦА, А НЕ КОЛОНКИ В landing_showcase_deals. Счётчики — +-- факт ПРОГОНА, а не строки: на 20 строк они были бы продублированы 20 раз, а +-- в самом важном случае — когда показывать оказалось нечего — исчезли бы +-- вместе со строками. «Рассмотрено 200, показывать нечего» обязано доезжать до +-- фронта ровно так же, как «рассмотрено 200, показано 20». +-- +-- ЗАЧЕМ ВООБЩЕ. Витрина показывает 20 сделок «МЕРА сказала X — продали за Y». +-- Без чисел рядом эти 20 строк читаются как «столько и было»: посетитель не +-- может отличить выборку из работы оценщика от её лучшего хвоста. Поэтому +-- ручка /api/public/mera/showcase отдаёт вместе со строками, сколько сделок +-- рассмотрено, сколько годных строк не поместилось и по какому правилу +-- отбраковано остальное. +-- +-- rejection_rule — ТЕКСТ, А НЕ КОД ПРАВИЛА. Правило живёт в +-- app/tasks/landing_showcase_deals.REJECTION_RULE и пишется сюда тем прогоном, +-- который эти числа и посчитал: подпись под витриной обязана описывать ТОТ +-- отбор, что дал эти строки, а не тот, что задеплоен сегодня. +-- +-- computed_at совпадает со строками батча: задача пишет строки и этот итог +-- ОДНОЙ транзакцией, а now() в Postgres — время начала транзакции. + +CREATE TABLE IF NOT EXISTS landing_showcase_runs ( + id bigserial PRIMARY KEY, + computed_at timestamptz NOT NULL DEFAULT now(), + considered integer NOT NULL, + priced integer NOT NULL, + no_prediction integer NOT NULL, + incomplete integer NOT NULL, + eligible integer NOT NULL, + written integer NOT NULL, + with_district integer NOT NULL, + rejection_rule text NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_landing_showcase_runs_computed_at + ON landing_showcase_runs (computed_at DESC); diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py index 6411e02a..7b5a51ba 100644 --- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -16,7 +16,6 @@ from datetime import date os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.tasks.landing_showcase_deals import ( - MAX_ABS_ERR_PCT, ShowcaseRow, build_row, quarter_label, @@ -79,7 +78,7 @@ def test_selection_is_deterministic_on_full_ties() -> None: assert [r.deal_id for r in select_rows(rows, limit=3)] == [9, 8, 7] -# ── Отбраковка выбросов ────────────────────────────────────────────────────── +# ── Отбраковка: только «данных нет», никогда «число некрасивое» ────────────── def _build(**over: object) -> ShowcaseRow | None: @@ -107,23 +106,58 @@ def test_plain_row_survives_and_carries_signed_error() -> None: assert row.deal_quarter == "II квартал 2026" -def test_underdeclared_dkp_is_rejected() -> None: - """Занижение ради налога выглядит как чудовищный промах оценщика.""" +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%. - assert _build(fact_ppm2=40_000.0) is None + row = _build(fact_ppm2=40_000.0) + assert row is not None + assert row.err_pct == 150.0 -def test_error_just_under_threshold_survives() -> None: - """Порог отсекает хвост, а не «неудобные» строки — граница проверена с двух сторон.""" - inside = _build(predicted_rub=5_000_000.0 * (1 + (MAX_ABS_ERR_PCT - 1) / 100)) - outside = _build(predicted_rub=5_000_000.0 * (1 + (MAX_ABS_ERR_PCT + 1) / 100)) - assert inside is not None - assert outside is None +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_impossible_ppm2_is_rejected_on_both_ends() -> None: - assert _build(fact_ppm2=20_000.0, predicted_rub=1_000_000.0) is None - assert _build(fact_ppm2=2_000_000.0, predicted_rub=100_000_000.0) is 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: diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 91b49d83..7444687d 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -158,29 +158,47 @@ def test_anonymous_gets_suggest(client: TestClient) -> None: assert resp.json() == {"items": []} +_SHOWCASE_ROW = { + "district": None, + "rooms": 2, + "area_m2": 54.0, + "floor": 5, + "total_floors": None, + "deal_quarter": "II квартал 2026", + "predicted_rub": 6_100_000, + "fact_rub": 5_900_000, + "err_pct": 3.39, + "n_analogs": 41, + "note": "не point-in-time", +} +_SHOWCASE_RUN = { + "computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), + "considered": 200, + "priced": 173, + "no_prediction": 27, + "incomplete": 4, + "eligible": 169, + "written": 20, + "with_district": 18, + "rejection_rule": "данных нет: нет прогноза / квартала / площади", +} + + +def _showcase_db(run: dict | None = _SHOWCASE_RUN, rows: list | None = None) -> MagicMock: + db = MagicMock() + chain = db.execute.return_value.mappings.return_value + chain.first.return_value = run + chain.all.return_value = [_SHOWCASE_ROW] if rows is None else rows + return db + + def test_anonymous_gets_showcase(client: TestClient) -> None: """Витрина открыта анониму и отдаёт то, что лежит в таблице. Пустое поле района проходит НАСКВОЗЬ как null: витрина не имеет права подставить правдоподобный район там, где его не удалось определить. """ - row = { - "district": None, - "rooms": 2, - "area_m2": 54.0, - "floor": 5, - "total_floors": None, - "deal_quarter": "II квартал 2026", - "predicted_rub": 6_100_000, - "fact_rub": 5_900_000, - "err_pct": 3.39, - "n_analogs": 41, - "note": "не point-in-time", - "computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), - } - db = MagicMock() - db.execute.return_value.mappings.return_value.all.return_value = [row] - client.app.dependency_overrides[get_db] = lambda: db + client.app.dependency_overrides[get_db] = lambda: _showcase_db() resp = client.get(f"{PREFIX}/showcase") assert resp.status_code == 200, resp.text @@ -192,9 +210,47 @@ def test_anonymous_gets_showcase(client: TestClient) -> None: assert "address" not in body["deals"][0] +def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was( + client: TestClient, +) -> None: + """Счётчики прогона доезжают до фронта, а не остаются в логе бэкенда. + + Без них «20 отличных строк» неотличимо от «столько и было»: посетитель не + может отличить выборку из работы оценщика от её лучшего хвоста. Здесь + показано 20 из 169 годных — и оба числа обязаны быть в ответе, вместе с + правилом, по которому отсеяно остальное. + + Ломать так: убрать `stats` из `ShowcaseResponse` (или перестать его + заполнять) — тест покраснеет на отсутствующем ключе, а не на форме. + """ + client.app.dependency_overrides[get_db] = lambda: _showcase_db() + + body = client.get(f"{PREFIX}/showcase").json() + stats = body["stats"] + assert stats is not None, "витрина отдаёт строки без счётчиков — подписаться нечем" + assert stats["considered"] == 200 + assert stats["eligible"] == 169 + assert stats["written"] == 20 + assert stats["eligible"] - stats["written"] == 149, "не видно, сколько годных не влезло" + assert stats["no_prediction"] == 27 + assert stats["rejection_rule"], "правило отбраковки не подписано" + + +def test_showcase_without_a_run_shows_nothing_and_says_so(client: TestClient) -> None: + """Пересчёта не было — ни строк, ни счётчиков, и это штатный ответ. + + Обратная сторона предыдущего теста: `stats` не выдумывается там, где + прогона не было. Заодно это гейт на связку «строки берутся ИЗ прогона»: + строки в таблице есть, но прогона нет — показывать их не из чего. + """ + client.app.dependency_overrides[get_db] = lambda: _showcase_db(run=None) + + body = client.get(f"{PREFIX}/showcase").json() + assert body == {"computed_at": None, "deals": [], "stats": None} + + def test_showcase_rate_limited_per_ip(client: TestClient) -> None: - db = MagicMock() - db.execute.return_value.mappings.return_value.all.return_value = [] + db = _showcase_db(rows=[]) client.app.dependency_overrides[get_db] = lambda: db codes = [ client.get(f"{PREFIX}/showcase").status_code for _ in range(public_mera._SHOWCASE_LIMIT + 1)