МЕРА: лента над первым экраном получает контекст разброса
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 19s
CI Trade-In / backend-tests (pull_request) Has been skipped
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
CI Trade-In / frontend-checks (pull_request) Successful in 1m45s

Над лентой стояло «медианное расхождение с ценой ДКП — 14,5 % по 327
сделкам», а в самой ленте 5 строк из 20 расходились больше чем на 30 %
(75,7 · 63,6 · 47,4 · 41,4 · 38,5). Первое, что видел посетитель страницы
про точность, — крупный промах без единой цифры контекста.

Промахи не прячутся: строки витрины отобраны по ПОЛНОТЕ и СВЕЖЕСТИ
(_sort_key в app/tasks/landing_showcase_deals.py), отбор по величине
ошибки был дефектом и снят. Лечится контекстом — под лентой печатается
тот же разброс ПОКАЗАННЫХ строк, что уже стоит под таблицей сверок:
медиана модуля и худшая, обе из shownSpread() (deal-view.ts), второго
расчёта не заводится. Вывода вида «зато обычно точно» в подписи нет: он
протух бы на первом пересчёте витрины, а два числа рядом — нет.

Разметка: бегущая часть выделена в .tickerStrip — position/overflow
нужны только ей, иначе абсолютный бейдж растянулся бы и на подпись.

Проверка (landing-v3-render.test.tsx): те же строки с худшей и без неё
дают РАЗНЫЕ числа в подписи — она сосчитана по показанному, а не вписана.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-30 14:54:52 +05:00
parent 56e4de0b35
commit 7d6caed5fb
3 changed files with 85 additions and 23 deletions

View file

@ -10,6 +10,7 @@ import { 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 { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { HeroV3 } from "../_components/v3/HeroV3";
import type { LandingStat, ShowcaseResponse } from "../public-api";
@ -136,3 +137,31 @@ describe("витрина лэндинга v3 без данных", () => {
expect(screen.queryByText(/По всем расчётам МЕРЫ/)).toBeNull();
});
});
/**
* Лента висит НАД первым экраном, и её строки отобраны по полноте и свежести,
* а не по величине ошибки, крупный промах в ней штатен. Проверяется, что
* рядом с промахом стоит его контекст и что оба числа сосчитаны ПО ПОКАЗАННЫМ
* строкам: подпись с вписанными руками величинами тут же разошлась бы с лентой.
*/
describe("лента сделок: контекст разброса", () => {
const tickerDeal = (err_pct: number) => ({
...SHOWCASE.deals[0],
err_pct,
predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)),
});
it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => {
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(75.7)]} />);
const note = screen.getByText(/Медиана расхождения показанных строк/u);
expect(note.textContent).toContain("11,5 %");
expect(note.textContent).toContain("75,7 %");
});
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />);
const note = screen.getByText(/Медиана расхождения показанных строк/u);
expect(note.textContent).toContain("7,8 %");
expect(note.textContent).not.toContain("75,7 %");
});
});

View file

@ -12,6 +12,15 @@
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
* объект описан тем, что есть комнаты, площадь, район, этаж.
*
* ПОД лентой подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же
* функция, что и под таблицей сверок): медиана модуля и худшая. Без неё первое,
* что видит посетитель страницы про точность, промах в семьдесят процентов
* без единой цифры контекста, опровергающий её же заголовок. Прятать промахи
* нельзя: строки отобраны по ПОЛНОТЕ и СВЕЖЕСТИ (`_sort_key` в
* app/tasks/landing_showcase_deals.py), поэтому лечится контекстом, а не
* отбором. Вывода («зато обычно точно») в подписи нет намеренно: он протух бы
* на первом же пересчёте витрины, а два числа рядом не протухают.
*
* Список дублируется дважды подряд стандартный приём бесшовного CSS-marquee
* (анимация уводит ровно на 50%).
*
@ -24,34 +33,45 @@
import type { ShowcaseDeal } from "../../public-api";
import styles from "../../landing-v3.module.css";
import { dealTitle, errPct, rub } from "./deal-view";
import { absPct, dealTitle, errPct, rub, shownSpread } from "./deal-view";
export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
const items = [...deals, ...deals];
// Разброс считается по `deals`, а не по `items`: `items` — та же двадцатка,
// склеенная сама с собой ради бесшовного marquee. Медиана и худшая на ней
// совпали бы, а `n` — нет, и подпись начала бы врать при первой же правке.
const spread = shownSpread(deals);
return (
<div className={styles.tickerRoot}>
<div className={styles.tickerBadge}>
<span className={styles.tickerDot} aria-hidden="true" />
<span className={styles.tickerBadgeText}>ПРОГНОЗ ФАКТ</span>
</div>
<div className={styles.tickerTrack} aria-hidden="true">
{items.map((deal, i) => (
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
</span>
<span className={styles.tickerArrow}></span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelAccent}>ДКП</span>
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
</span>
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
</div>
))}
<div className={styles.tickerStrip}>
<div className={styles.tickerBadge}>
<span className={styles.tickerDot} aria-hidden="true" />
<span className={styles.tickerBadgeText}>ПРОГНОЗ ФАКТ</span>
</div>
<div className={styles.tickerTrack} aria-hidden="true">
{items.map((deal, i) => (
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
</span>
<span className={styles.tickerArrow}></span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelAccent}>ДКП</span>
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
</span>
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
</div>
))}
</div>
</div>
{spread && (
<p className={styles.tickerNote}>
{`Медиана расхождения показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
</p>
)}
</div>
);
}

View file

@ -1397,9 +1397,22 @@
/* --- Лента «ПРОГНОЗ → ФАКТ» ---------------------------------------------- */
.tickerRoot {
background: var(--b2c-surface-dark);
}
/* Бегущая часть отделена от подписи: `overflow: hidden` и точка отсчёта для
абсолютного бейджа нужны ТОЛЬКО ей, иначе бейдж растянулся бы на всю высоту
вместе с подписью. */
.tickerStrip {
position: relative;
overflow: hidden;
background: var(--b2c-surface-dark);
}
.tickerNote {
margin: 0;
padding: 0 clamp(20px, 5vw, 56px) 10px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--b2c-muted);
}
.tickerBadge {
position: absolute;