тикер: разброс показанных строк под лентой — она больше не опровергает заголовок #3278

Merged
bot-backend merged 2 commits from fix/mera-ticker-context into main 2026-08-30 10:22:52 +00:00
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

@ -1399,9 +1399,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;