Витрина лендинга: пустой пересчёт больше не считается успешным, а итог игры «Проверьте себя» говорит, что квартиры отобраны #3550

Merged
bot-backend merged 2 commits from fix/landing-showcase-and-game into main 2026-09-17 09:21:09 +00:00
7 changed files with 193 additions and 8 deletions

View file

@ -357,6 +357,22 @@ async def _job_landing_showcase_deals(
counters = await loop.run_in_executor(
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
)
# #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)

View file

@ -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. Живая БД: строка реально ложится в таблицу ────────────────────────────

View file

@ -216,12 +216,12 @@ describe("карточка игры выбирает карту по налич
});
it("схема есть — показана улица", () => {
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(SCHEME), 0)]} />);
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(SCHEME), 0)]} bandSelected />);
expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН");
});
it("схемы нет — остаётся район, а не пустое место", () => {
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(null), 0)]} />);
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(null), 0)]} bandSelected />);
expect(container.textContent).toContain("ЛЕНИНСКИЙ");
expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН");
});

View file

@ -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(<GuessGameV3 rounds={rounds} bandSelected />);
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(<GuessGameV3 rounds={rounds} bandSelected={false} />);
const note = finish();
expect(note.textContent).not.toMatch(/полос/u);
expect(note.textContent).toContain(BACKTEST.priceError.text);
});
});

View file

@ -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<Phase>("playing");
const [guess, setGuess] = useState(rounds[0].start);
@ -188,6 +205,13 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
</div>
</div>
</div>
<p className={styles.gameFinalNote}>
{`${
bandSelected
? `Квартиры для игры взяты из отобранной полосы, где МЕРА разошлась с ценой ДКП от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)}: это не вся сверка. `
: ""
}Медиана расхождения МЕРЫ по всей сверке ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок).`}
</p>
<div className={styles.gameVerdict}>
Рынок сложно угадать на глаз для этого и существует расчёт
по данным.

View file

@ -1920,6 +1920,11 @@
.gameFinal {
text-align: center;
}
/* Оговорка итога игры (#2903): отбор раундов и медиана всей сверки. */
.gameFinalNote {
composes: accFootnote;
margin: 0 0 18px;
}
.gameFinalGrid {
display: grid;
grid-template-columns: 1fr 1fr;

View file

@ -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() {
<AccuracyV3 stats={stats} showcase={showcase} />
<SourcesV3 />
<WhatYouGetV3 />
{rounds.length >= MIN_ROUNDS && <GuessGameV3 rounds={rounds} />}
{rounds.length >= MIN_ROUNDS && (
<GuessGameV3 rounds={rounds} bandSelected={allWithinBand(deals)} />
)}
<CostOfErrorV3 stats={stats} />
<TwoPathsV3 />
<ObjectionsV3 />