Витрина лендинга: пустой пересчёт больше не считается успешным, а итог игры «Проверьте себя» говорит, что квартиры отобраны #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(
|
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", {})
|
||||||
|
|
|
||||||
|
|
@ -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. Живая БД: строка реально ложится в таблицу ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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("ДОМ НЕ ИЗВЕСТЕН");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
Рынок сложно угадать на глаз — для этого и существует расчёт
|
Рынок сложно угадать на глаз — для этого и существует расчёт
|
||||||
по данным.
|
по данным.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue