тикер: разброс показанных строк под лентой — она больше не опровергает заголовок #3278
3 changed files with 85 additions and 23 deletions
|
|
@ -10,6 +10,7 @@ import { 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 { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
|
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
|
||||||
import { HeroV3 } from "../_components/v3/HeroV3";
|
import { HeroV3 } from "../_components/v3/HeroV3";
|
||||||
import type { LandingStat, ShowcaseResponse } from "../public-api";
|
import type { LandingStat, ShowcaseResponse } from "../public-api";
|
||||||
|
|
@ -136,3 +137,31 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
expect(screen.queryByText(/По всем расчётам МЕРЫ/)).toBeNull();
|
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 %");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@
|
||||||
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
|
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
|
||||||
* объект описан тем, что есть — комнаты, площадь, район, этаж.
|
* объект описан тем, что есть — комнаты, площадь, район, этаж.
|
||||||
*
|
*
|
||||||
|
* ПОД лентой — подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же
|
||||||
|
* функция, что и под таблицей сверок): медиана модуля и худшая. Без неё первое,
|
||||||
|
* что видит посетитель страницы про точность, — промах в семьдесят процентов
|
||||||
|
* без единой цифры контекста, опровергающий её же заголовок. Прятать промахи
|
||||||
|
* нельзя: строки отобраны по ПОЛНОТЕ и СВЕЖЕСТИ (`_sort_key` в
|
||||||
|
* app/tasks/landing_showcase_deals.py), поэтому лечится контекстом, а не
|
||||||
|
* отбором. Вывода («зато обычно точно») в подписи нет намеренно: он протух бы
|
||||||
|
* на первом же пересчёте витрины, а два числа рядом не протухают.
|
||||||
|
*
|
||||||
* Список дублируется дважды подряд — стандартный приём бесшовного CSS-marquee
|
* Список дублируется дважды подряд — стандартный приём бесшовного CSS-marquee
|
||||||
* (анимация уводит ровно на −50%).
|
* (анимация уводит ровно на −50%).
|
||||||
*
|
*
|
||||||
|
|
@ -24,34 +33,45 @@
|
||||||
import type { ShowcaseDeal } from "../../public-api";
|
import type { ShowcaseDeal } from "../../public-api";
|
||||||
import styles from "../../landing-v3.module.css";
|
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[] }) {
|
export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
|
||||||
const items = [...deals, ...deals];
|
const items = [...deals, ...deals];
|
||||||
|
// Разброс считается по `deals`, а не по `items`: `items` — та же двадцатка,
|
||||||
|
// склеенная сама с собой ради бесшовного marquee. Медиана и худшая на ней
|
||||||
|
// совпали бы, а `n` — нет, и подпись начала бы врать при первой же правке.
|
||||||
|
const spread = shownSpread(deals);
|
||||||
return (
|
return (
|
||||||
<div className={styles.tickerRoot}>
|
<div className={styles.tickerRoot}>
|
||||||
<div className={styles.tickerBadge}>
|
<div className={styles.tickerStrip}>
|
||||||
<span className={styles.tickerDot} aria-hidden="true" />
|
<div className={styles.tickerBadge}>
|
||||||
<span className={styles.tickerBadgeText}>ПРОГНОЗ → ФАКТ</span>
|
<span className={styles.tickerDot} aria-hidden="true" />
|
||||||
</div>
|
<span className={styles.tickerBadgeText}>ПРОГНОЗ → ФАКТ</span>
|
||||||
<div className={styles.tickerTrack} aria-hidden="true">
|
</div>
|
||||||
{items.map((deal, i) => (
|
<div className={styles.tickerTrack} aria-hidden="true">
|
||||||
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
|
{items.map((deal, i) => (
|
||||||
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
|
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
|
||||||
<span className={styles.tickerPair}>
|
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
|
||||||
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
|
<span className={styles.tickerPair}>
|
||||||
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
|
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
|
||||||
</span>
|
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
|
||||||
<span className={styles.tickerArrow}>→</span>
|
</span>
|
||||||
<span className={styles.tickerPair}>
|
<span className={styles.tickerArrow}>→</span>
|
||||||
<span className={styles.tickerLabelAccent}>ДКП</span>
|
<span className={styles.tickerPair}>
|
||||||
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
|
<span className={styles.tickerLabelAccent}>ДКП</span>
|
||||||
</span>
|
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
|
||||||
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
|
</span>
|
||||||
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
|
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
|
||||||
</div>
|
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{spread && (
|
||||||
|
<p className={styles.tickerNote}>
|
||||||
|
{`Медиана расхождения показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1399,9 +1399,22 @@
|
||||||
|
|
||||||
/* --- Лента «ПРОГНОЗ → ФАКТ» ---------------------------------------------- */
|
/* --- Лента «ПРОГНОЗ → ФАКТ» ---------------------------------------------- */
|
||||||
.tickerRoot {
|
.tickerRoot {
|
||||||
|
background: var(--b2c-surface-dark);
|
||||||
|
}
|
||||||
|
/* Бегущая часть отделена от подписи: `overflow: hidden` и точка отсчёта для
|
||||||
|
абсолютного бейджа нужны ТОЛЬКО ей, иначе бейдж растянулся бы на всю высоту
|
||||||
|
вместе с подписью. */
|
||||||
|
.tickerStrip {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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 {
|
.tickerBadge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue