fix(mera/лендинг): итог игры «Проверьте себя» говорит, что квартиры взяты из отобранной полосы (#2903)
All checks were successful
CI Trade-In / frontend-checks (pull_request) Successful in 2m32s
CI Trade-In / backend-tests (pull_request) Successful in 7m1s
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

Раунды игры — строки витрины, а витрина с 12.09 отобрана по ошибке МЕРЫ
(полоса -5..+20 %). Плитка «ошибка МЕРЫ на этих же квартирах» выходила без
оговорки и читалась как точность расчёта: на проде 17.09 медиана |ошибки|
двадцати строк 6,5 %. Лента и таблица «Точность» полосу называли, игра — нет.

Теперь итог печатает медиану по всей сверке (landing-facts) и, если все
строки витрины лежат в полосе (allWithinBand, как у ленты и таблицы),
говорит, что квартиры из отобранной полосы, а не из всей сверки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 12:27:33 +05:00
parent 71d60ff13e
commit 4dffb2d1fb
5 changed files with 94 additions and 7 deletions

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 />