Витрина лендинга: пустой пересчёт больше не считается успешным, а итог игры «Проверьте себя» говорит, что квартиры отобраны #3550
7 changed files with 193 additions and 8 deletions
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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. Живая БД: строка реально ложится в таблицу ────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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("ДОМ НЕ ИЗВЕСТЕН");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
Рынок сложно угадать на глаз — для этого и существует расчёт
|
||||
по данным.
|
||||
|
|
|
|||
|
|
@ -1920,6 +1920,11 @@
|
|||
.gameFinal {
|
||||
text-align: center;
|
||||
}
|
||||
/* Оговорка итога игры (#2903): отбор раундов и медиана всей сверки. */
|
||||
.gameFinalNote {
|
||||
composes: accFootnote;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.gameFinalGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue