@@ -59,6 +66,7 @@ export function IMVBenchmark({ benchmark, isLoading }: Props) {
{formatRub(recommended_price)}
+ {note &&
{note}
}
Диапазон
diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx
new file mode 100644
index 00000000..ed0fa74e
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx
@@ -0,0 +1,113 @@
+/**
+ * #3339 — число, исключённое из расчёта, обязано быть подписано.
+ *
+ * Бэкенд (#3336, thin_market) при market_count ниже порога не пускает IMV в
+ * headline: величина остаётся справочной. На витрине она всё равно стоит рядом
+ * с итоговой ценой — без пометки читается как участвующая в расчёте.
+ *
+ * Проверяется ЗНАЧЕНИЕ подписи (реальный N из market_count) и оба безопасных
+ * состояния: флаг false и полное отсутствие поля (оценка со старого бэкенда).
+ */
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { HeroSummary, thinMarketNote } from "../HeroSummary";
+import { IMVBenchmark } from "../IMVBenchmark";
+import {
+ FIXTURE_ESTIMATE,
+ FIXTURE_IMV,
+ FIXTURE_INPUT,
+} from "@/app/ui-preview/estimate/fixture";
+import type { AggregatedEstimate, AvitoImvSummary } from "@/types/trade-in";
+
+// Бренд и блок прозрачности ходят в сеть (TanStack Query) — к подписи отношения
+// не имеют, гасим, чтобы рендер hero был оффлайновым.
+vi.mock("@/lib/useBrand", () => ({
+ useActiveBrandSlug: () => null,
+ useBrand: () => ({ data: null }),
+}));
+vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null }));
+
+const IMV: AvitoImvSummary = {
+ recommended_price: 10_200_000,
+ lower_price: 9_400_000,
+ higher_price: 11_000_000,
+ market_count: 3,
+};
+
+function estimateWithImv(imv: AvitoImvSummary): AggregatedEstimate {
+ return { ...FIXTURE_ESTIMATE, avito_imv: imv };
+}
+
+function renderHero(imv: AvitoImvSummary) {
+ return render(
+
{}}
+ />,
+ );
+}
+
+describe("thin_market — подпись у числа оценочной модели площадки", () => {
+ it("thin_market=true, market_count=3 → подпись называет реальные 3 объявления", () => {
+ expect(thinMarketNote({ ...IMV, thin_market: true })).toBe(
+ "рынок из 3 объявлений — в расчёт не входит",
+ );
+
+ const { container } = renderHero({ ...IMV, thin_market: true });
+
+ // Число IMV осталось на экране — гасить его нечестно, оно посчитано.
+ expect(container.textContent).toContain("10,20 млн");
+ // Но рядом с ним стоит N из market_count, а не общая формулировка.
+ expect(screen.getAllByText(/рынок из 3 объявлений — в расчёт не входит/).length)
+ .toBeGreaterThan(0);
+ });
+
+ it("thin_market=false → подписи нет (IMV участвует в расчёте)", () => {
+ expect(thinMarketNote({ ...IMV, thin_market: false })).toBeNull();
+
+ const { container } = renderHero({ ...IMV, thin_market: false });
+ expect(container.textContent).toContain("10,20 млн");
+ expect(container.textContent).not.toContain("в расчёт не входит");
+ });
+
+ it("поля нет вовсе (оценка со старого бэкенда) → подписи нет", () => {
+ expect(thinMarketNote(IMV)).toBeNull();
+ expect(thinMarketNote(null)).toBeNull();
+
+ const { container } = renderHero(IMV);
+ expect(container.textContent).not.toContain("в расчёт не входит");
+ });
+
+ it("единственное объявление — счётная форма не ломается", () => {
+ expect(thinMarketNote({ ...IMV, market_count: 1, thin_market: true })).toBe(
+ "рынок из 1 объявления — в расчёт не входит",
+ );
+ });
+});
+
+/**
+ * То же число печатается второй раз в отдельной карточке под hero. Подпись в
+ * hero её не покрывает: у карточки свой источник данных (imv-benchmark endpoint),
+ * и без проброса note рядом с итогом остаётся неподписанная копия величины.
+ */
+describe("IMVBenchmark — подпись доезжает до второго места с тем же числом", () => {
+ const NOTE = "рынок из 3 объявлений — в расчёт не входит";
+
+ it("note передан → подпись стоит под числом", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.textContent).toContain(NOTE);
+ });
+
+ it("note не передан (рынок не тонкий) → подписи нет", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.textContent).not.toContain("в расчёт не входит");
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css
index 7cb46b9e..302bb7d2 100644
--- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css
+++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css
@@ -846,6 +846,7 @@
.bench-chip__sub {
font-size: 10px;
color: var(--muted);
+ max-width: 240px;
}
/* ── empty-state headline (без данных) ── */
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts
index 518adb4a..6bcbd92c 100644
--- a/tradein-mvp/frontend/src/types/trade-in.ts
+++ b/tradein-mvp/frontend/src/types/trade-in.ts
@@ -147,6 +147,10 @@ export interface AvitoImvSummary {
lower_price: number | null; // нижняя граница IMV-коридора, ₽
higher_price: number | null; // верхняя граница IMV-коридора, ₽
market_count: number | null; // объём рынка, на котором построена оценка
+ // #3339 / backend #audit-5b: market_count < avito_imv_thin_market_threshold —
+ // IMV построен на малой выборке и в headline НЕ участвует (display-only).
+ // optional: оценки, посчитанные до деплоя гейта, поля не содержат.
+ thin_market?: boolean;
}
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──