From 71d60ff13e4d9f229f67447b7180020a6160e47b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:24:20 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(mera/=D0=B2=D0=B8=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0):=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=83=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=88=D0=BD=D1=8B=D0=BC=20(#3511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обработчик landing_showcase_deals безусловно ставил прогону done. У счётчиков витрины (considered/eligible/written) нет результатного ключа кита, поэтому сводка просроченных судит её только по статусу: прогон с written=0 обнулял часы свежести так же, как удачный, а страница тем временем теряла таблицу. Теперь written=0 — mark_failed с причиной и logger.error. Тест идёт путём сводки: обработчик -> freshness_rows -> stale_sources; пустой последний прогон при старом непустом даёт витрину в тревоге, непустой — нет. Co-Authored-By: Claude Opus 5 --- .../backend/app/services/product_handlers.py | 18 +++- .../tests/test_3469_showcase_schedule.py | 82 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 42630b40..7cc7d6d6 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -357,7 +357,23 @@ async def _job_landing_showcase_deals( counters = await loop.run_in_executor( None, lambda: refresh_landing_showcase_deals(db, **kwargs) ) - ctx.runs.mark_done(db, run_id, counters) + # #3511: пустая витрина — не успех. Результатного ключа кита + # (`_RESULT_COUNTER_KEYS`) у счётчиков витрины нет, сводка просроченных + # судит её по статусу, и `done` с written=0 гасил часы свежести так же, + # как удачный прогон, — а страница тем временем теряла таблицу целиком. + if not counters.get("written"): + logger.error( + "landing_showcase_deals run_id=%d: витрина пуста (written=0, " + "considered=%s, eligible=%s) — прогон неуспешен", + run_id, + counters.get("considered"), + counters.get("eligible"), + ) + ctx.runs.mark_failed( + db, run_id, "витрина пуста: прогон не записал ни одной строки (written=0)", counters + ) + else: + ctx.runs.mark_done(db, run_id, counters) except Exception: logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id) ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {}) diff --git a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py index 4394079b..79a9a5b0 100644 --- a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py +++ b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py @@ -53,6 +53,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from typing import Any +from unittest.mock import MagicMock, patch import pytest @@ -211,6 +212,87 @@ def test_showcase_counters_do_not_fake_freshness() -> None: assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False +# ── 3а. Пустой прогон витрины не гасит часы свежести (#3511) ──────────────── + + +class _RecordedRuns: + """`ctx.runs`, который кладёт финал прогона строкой `_STALE_SOURCES_SQL`. + + Тогда тест идёт ТЕМ ЖЕ путём, что и сводка на проде: обработчик ставит статус, + `freshness_rows` судит, принёс ли прогон данные, `stale_sources` — просрочку. + Проверка «вызван mark_failed» такой путь не проходит: она зелёная и тогда, + когда статус верный, а мера свежести его не видит. + """ + + def __init__(self) -> None: + self.rows: list[Any] = [] + self.finished_at = NOW + + def _row(self, status: str, counters: dict[str, Any]) -> None: + self.rows.append( + SimpleNamespace( + source=SOURCE, + interval_days=str(_seeded_interval_days()), + created_at=NOW - timedelta(days=30), + finished_at=self.finished_at, + status=status, + counters=counters, + ) + ) + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self._row("done", counters) + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self._row("failed", counters) + + +async def _run_handler(runs: _RecordedRuns, counters: dict[str, int], age_days: float) -> None: + runs.finished_at = NOW - timedelta(days=age_days) + with patch( + "app.tasks.landing_showcase_deals.refresh_landing_showcase_deals", + return_value=counters, + ): + await _job_landing_showcase_deals(MagicMock(), 1, {}, SimpleNamespace(runs=runs)) + + +_FULL = {"considered": 200, "priced": 180, "eligible": 160, "written": 20} +_EMPTY = {"considered": 200, "priced": 180, "eligible": 160, "written": 0} + + +@pytest.mark.asyncio +async def test_empty_showcase_run_does_not_reset_freshness() -> None: + """Последний непустой прогон старше 3× такта, вчерашний пустой → витрина в тревоге. + + До #3511 пустой прогон завершался `done`, и `run_brought_data` (судит по + статусу: результатного ключа кита у витрины нет) засчитывал его свежестью — + опустевший блок лэндинга молчал бы сколько угодно. + """ + runs = _RecordedRuns() + await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1) + await _run_handler(runs, _EMPTY, age_days=1) + + stale = _stale_now(runs.rows) + assert [s.source for s in stale] == [SOURCE], ( + f"пустой прогон витрины засчитан свежестью: статусы {[r.status for r in runs.rows]}" + ) + assert stale[0].age_days > _ACCEPTANCE_CYCLES * _seeded_interval_days() + + +@pytest.mark.asyncio +async def test_nonempty_showcase_run_keeps_freshness() -> None: + """Контроль: тот же сценарий с непустым последним прогоном — тревоги нет. + + Зелёный с обеих сторон правки; без него правка могла бы валить любой прогон. + """ + runs = _RecordedRuns() + await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1) + await _run_handler(runs, _FULL, age_days=1) + + assert _stale_now(runs.rows) == [] + assert [r.status for r in runs.rows] == ["done", "done"] + + # ── 4. Живая БД: строка реально ложится в таблицу ──────────────────────────── -- 2.45.3 From 4dffb2d1fbaf1d6cbce552c7200b3761fa309916 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:27:33 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(mera/=D0=BB=D0=B5=D0=BD=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3):=20=D0=B8=D1=82=D0=BE=D0=B3=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D1=8B=20=C2=AB=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8C=D1=82?= =?UTF-8?q?=D0=B5=20=D1=81=D0=B5=D0=B1=D1=8F=C2=BB=20=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=82,=20=D1=87=D1=82=D0=BE=20=D0=BA=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B8=D1=80=D1=8B=20=D0=B2=D0=B7=D1=8F=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B8=D0=B7=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=BB=D0=BE=D1=81=D1=8B?= =?UTF-8?q?=20(#2903)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раунды игры — строки витрины, а витрина с 12.09 отобрана по ошибке МЕРЫ (полоса -5..+20 %). Плитка «ошибка МЕРЫ на этих же квартирах» выходила без оговорки и читалась как точность расчёта: на проде 17.09 медиана |ошибки| двадцати строк 6,5 %. Лента и таблица «Точность» полосу называли, игра — нет. Теперь итог печатает медиану по всей сверке (landing-facts) и, если все строки витрины лежат в полосе (allWithinBand, как у ленты и таблицы), говорит, что квартиры из отобранной полосы, а не из всей сверки. Co-Authored-By: Claude Opus 5 --- .../mera-public/__tests__/deal-map.test.tsx | 4 +- .../__tests__/landing-v3-render.test.tsx | 58 ++++++++++++++++++- .../_components/v3/GuessGameV3.tsx | 28 ++++++++- .../src/app/mera-public/landing-v3.module.css | 5 ++ .../frontend/src/app/mera-public/page.tsx | 6 +- 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx index 987bb353..da8b6541 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx @@ -216,12 +216,12 @@ describe("карточка игры выбирает карту по налич }); it("схема есть — показана улица", () => { - const { container } = render(); + const { container } = render(); expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН"); }); it("схемы нет — остаётся район, а не пустое место", () => { - const { container } = render(); + const { container } = render(); expect(container.textContent).toContain("ЛЕНИНСКИЙ"); expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН"); }); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx index b1405fdf..3598d5e8 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx @@ -6,11 +6,13 @@ * ИСЧЕЗАЕТ, а не показывает ноль, прочерк или подпись без числа. Отказ ручки * `/stats` в проде выглядит именно так: пустой объект. */ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { AccuracyV3 } from "../_components/v3/AccuracyV3"; import { DealsTickerV3 } from "../_components/v3/DealsTickerV3"; +import { GuessGameV3 } from "../_components/v3/GuessGameV3"; +import { toGameRound } from "../_components/v3/deal-view"; import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3"; import { HeroV3 } from "../_components/v3/HeroV3"; import { BACKTEST } from "../landing-facts"; @@ -315,3 +317,57 @@ describe("лента сделок: контекст разброса", () => { expect(note.textContent).toContain("75,7 %"); }); }); + +/** + * ИТОГ ИГРЫ НАЗЫВАЕТ ОТБОР (#2903). + * + * Раунды — строки витрины, а витрина с 12.09.2026 отобрана по ошибке МЕРЫ + * (полоса −5 %..+20 %). Плитка «ошибка МЕРЫ на этих же квартирах» без + * оговорки читается как точность расчёта, хотя это лучший отобранный кусок: + * на проде 17.09 медиана |ошибки| двадцати строк 6,5 %, по всей сверке + * кратно больше. Проверяется по тексту итога, до которого доходим кликами. + */ +describe("игра: итог называет отбор раундов", () => { + const rounds = [0.58, -3.2, 17.9].map((err_pct, i) => + toGameRound( + { + ...SHOWCASE.deals[0], + rooms: i, + err_pct, + predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)), + }, + i, + ), + ); + + const finish = () => { + for (let i = 0; i < rounds.length; i += 1) { + fireEvent.click(screen.getByRole("button", { name: "Ответить" })); + fireEvent.click( + screen.getByRole("button", { + name: i === rounds.length - 1 ? "Показать итог" : "Следующая квартира →", + }), + ); + } + return screen.getByText(/Медиана расхождения МЕРЫ по всей сверке/u); + }; + + it("строки витрины в полосе → итог говорит, что квартиры из отобранной полосы, и печатает медиану сверки", () => { + render(); + const note = finish(); + expect(screen.getByText("ошибка МЕРЫ на этих же квартирах")).toBeTruthy(); + expect( + note.textContent, + "итог сравнивает игрока с МЕРОЙ на отобранных по её ошибке квартирах и молчит об отборе", + ).toMatch(/отобранной полосы.*от -5 % до \+20 %.*не вся сверка/u); + expect(note.textContent).toContain(BACKTEST.priceError.text); + expect(note.textContent).toContain(String(BACKTEST.priceError.sampleN)); + }); + + it("строки витрины вне полосы → про полосу молчит, медиана сверки остаётся", () => { + render(); + const note = finish(); + expect(note.textContent).not.toMatch(/полос/u); + expect(note.textContent).toContain(BACKTEST.priceError.text); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx index e59f0ffc..564663fd 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx @@ -27,12 +27,22 @@ * — той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины). * Обе величины меряют одно и то же на одной выборке, поэтому их и можно * ставить рядом. + * + * НО ВЫБОРКА ОТОБРАНА ПО ОШИБКЕ МЕРЫ (#2903). С 12.09.2026 на витрину, а + * значит и в раунды, попадают только сделки с расхождением от −5 % до +20 % + * (`select_rows` в app/tasks/landing_showcase_deals.py). «Ошибка МЕРЫ на этих + * же квартирах» без оговорки читается как точность расчёта, а это лучший + * отобранный кусок. Поэтому итог называет полосу и держит рядом медиану по + * всей сверке — как подписи ленты и таблицы. Про полосу говорим, только если + * её подтверждают сами строки витрины (`bandSelected` = `allWithinBand`, + * считает родитель: раунды — лишь три из этих строк). */ import { useState } from "react"; +import { BACKTEST } from "../../landing-facts"; import styles from "../../landing-v3.module.css"; -import type { GameRound } from "./deal-view"; +import { BAND_MAX_PCT, BAND_MIN_PCT, count, errPct, type GameRound } from "./deal-view"; import { DealMapV3 } from "./DealMapV3"; import { StreetMapV3 } from "./StreetMapV3"; @@ -49,7 +59,14 @@ function median(xs: number[]): number { return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; } -export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) { +export function GuessGameV3({ + rounds, + bandSelected, +}: { + rounds: readonly GameRound[]; + /** Все строки витрины лежат в полосе — значит раунды взяты из отобранной полосы. */ + bandSelected: boolean; +}) { const [idx, setIdx] = useState(0); const [phase, setPhase] = useState("playing"); const [guess, setGuess] = useState(rounds[0].start); @@ -188,6 +205,13 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) { +

+ {`${ + bandSelected + ? `Квартиры для игры взяты из отобранной полосы, где МЕРА разошлась с ценой ДКП от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)}: это не вся сверка. ` + : "" + }Медиана расхождения МЕРЫ по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок).`} +

Рынок сложно угадать на глаз — для этого и существует расчёт по данным. diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index fa5f4963..d3d02b07 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -1920,6 +1920,11 @@ .gameFinal { text-align: center; } +/* Оговорка итога игры (#2903): отбор раундов и медиана всей сверки. */ +.gameFinalNote { + composes: accFootnote; + margin: 0 0 18px; +} .gameFinalGrid { display: grid; grid-template-columns: 1fr 1fr; diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx index 662aba96..8bf611fc 100644 --- a/tradein-mvp/frontend/src/app/mera-public/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx @@ -12,7 +12,7 @@ import { StepsV3 } from "./_components/v3/StepsV3"; import { StickyCtaV3 } from "./_components/v3/StickyCtaV3"; import { TwoPathsV3 } from "./_components/v3/TwoPathsV3"; import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3"; -import { pickVariedDeals, toGameRound } from "./_components/v3/deal-view"; +import { allWithinBand, pickVariedDeals, toGameRound } from "./_components/v3/deal-view"; import { serviceNode } from "./_lib/schema"; import { fetchLandingStats, fetchShowcase } from "./public-api"; import styles from "./landing.module.css"; @@ -120,7 +120,9 @@ export default async function MeraPublicPage() { - {rounds.length >= MIN_ROUNDS && } + {rounds.length >= MIN_ROUNDS && ( + + )} -- 2.45.3