diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
index 2fdbbac7..4a3c7aca 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
@@ -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();
+ const note = screen.getByText(/Медиана расхождения показанных строк/u);
+ expect(note.textContent).toContain("11,5 %");
+ expect(note.textContent).toContain("75,7 %");
+ });
+
+ it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
+ render();
+ const note = screen.getByText(/Медиана расхождения показанных строк/u);
+ expect(note.textContent).toContain("7,8 %");
+ expect(note.textContent).not.toContain("75,7 %");
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
index 7f56ef50..4e0a957b 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
@@ -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 (
-
-
- ПРОГНОЗ → ФАКТ
-
-
- {items.map((deal, i) => (
-
- {dealTitle(deal)}
-
- ПРОГНОЗ
- {rub(deal.predicted_rub)}
-
- →
-
- ДКП
- {rub(deal.fact_rub)}
-
- {errPct(deal.err_pct)}
- {deal.deal_quarter}
-
- ))}
+
+
+
+ ПРОГНОЗ → ФАКТ
+
+
+ {items.map((deal, i) => (
+
+ {dealTitle(deal)}
+
+ ПРОГНОЗ
+ {rub(deal.predicted_rub)}
+
+ →
+
+ ДКП
+ {rub(deal.fact_rub)}
+
+ {errPct(deal.err_pct)}
+ {deal.deal_quarter}
+
+ ))}
+
+ {spread && (
+
+ {`Медиана расхождения показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
+
+ )}
);
}
diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
index c3705e01..5baa8c3f 100644
--- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
+++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
@@ -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;