Витрина лендинга: пустой пересчёт больше не считается успешным, а итог игры «Проверьте себя» говорит, что квартиры отобраны #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,7 +357,23 @@ async def _job_landing_showcase_deals(
counters = await loop.run_in_executor( counters = await loop.run_in_executor(
None, lambda: refresh_landing_showcase_deals(db, **kwargs) 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: except Exception:
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id) logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {}) ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})

View file

@ -53,6 +53,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch
import pytest 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 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. Живая БД: строка реально ложится в таблицу ──────────────────────────── # ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────

View file

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

View file

@ -6,11 +6,13 @@
* ИСЧЕЗАЕТ, а не показывает ноль, прочерк или подпись без числа. Отказ ручки * ИСЧЕЗАЕТ, а не показывает ноль, прочерк или подпись без числа. Отказ ручки
* `/stats` в проде выглядит именно так: пустой объект. * `/stats` в проде выглядит именно так: пустой объект.
*/ */
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AccuracyV3 } from "../_components/v3/AccuracyV3"; import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3"; 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 { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { HeroV3 } from "../_components/v3/HeroV3"; import { HeroV3 } from "../_components/v3/HeroV3";
import { BACKTEST } from "../landing-facts"; import { BACKTEST } from "../landing-facts";
@ -315,3 +317,57 @@ describe("лента сделок: контекст разброса", () => {
expect(note.textContent).toContain("75,7 %"); 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` витрины). * той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины).
* Обе величины меряют одно и то же на одной выборке, поэтому их и можно * Обе величины меряют одно и то же на одной выборке, поэтому их и можно
* ставить рядом. * ставить рядом.
*
* НО ВЫБОРКА ОТОБРАНА ПО ОШИБКЕ МЕРЫ (#2903). С 12.09.2026 на витрину, а
* значит и в раунды, попадают только сделки с расхождением от 5 % до +20 %
* (`select_rows` в app/tasks/landing_showcase_deals.py). «Ошибка МЕРЫ на этих
* же квартирах» без оговорки читается как точность расчёта, а это лучший
* отобранный кусок. Поэтому итог называет полосу и держит рядом медиану по
* всей сверке как подписи ленты и таблицы. Про полосу говорим, только если
* её подтверждают сами строки витрины (`bandSelected` = `allWithinBand`,
* считает родитель: раунды лишь три из этих строк).
*/ */
import { useState } from "react"; import { useState } from "react";
import { BACKTEST } from "../../landing-facts";
import styles from "../../landing-v3.module.css"; 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 { DealMapV3 } from "./DealMapV3";
import { StreetMapV3 } from "./StreetMapV3"; 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; 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 [idx, setIdx] = useState(0);
const [phase, setPhase] = useState<Phase>("playing"); const [phase, setPhase] = useState<Phase>("playing");
const [guess, setGuess] = useState(rounds[0].start); const [guess, setGuess] = useState(rounds[0].start);
@ -188,6 +205,13 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
</div> </div>
</div> </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}> <div className={styles.gameVerdict}>
Рынок сложно угадать на глаз для этого и существует расчёт Рынок сложно угадать на глаз для этого и существует расчёт
по данным. по данным.

View file

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

View file

@ -12,7 +12,7 @@ import { StepsV3 } from "./_components/v3/StepsV3";
import { StickyCtaV3 } from "./_components/v3/StickyCtaV3"; import { StickyCtaV3 } from "./_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "./_components/v3/TwoPathsV3"; import { TwoPathsV3 } from "./_components/v3/TwoPathsV3";
import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3"; 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 { serviceNode } from "./_lib/schema";
import { fetchLandingStats, fetchShowcase } from "./public-api"; import { fetchLandingStats, fetchShowcase } from "./public-api";
import styles from "./landing.module.css"; import styles from "./landing.module.css";
@ -120,7 +120,9 @@ export default async function MeraPublicPage() {
<AccuracyV3 stats={stats} showcase={showcase} /> <AccuracyV3 stats={stats} showcase={showcase} />
<SourcesV3 /> <SourcesV3 />
<WhatYouGetV3 /> <WhatYouGetV3 />
{rounds.length >= MIN_ROUNDS && <GuessGameV3 rounds={rounds} />} {rounds.length >= MIN_ROUNDS && (
<GuessGameV3 rounds={rounds} bandSelected={allWithinBand(deals)} />
)}
<CostOfErrorV3 stats={stats} /> <CostOfErrorV3 stats={stats} />
<TwoPathsV3 /> <TwoPathsV3 />
<ObjectionsV3 /> <ObjectionsV3 />