diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 834247d2..0b4bf289 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -16,7 +16,7 @@ import { HTTPError } from "@/lib/api"; import { EstimateForm } from "@/components/trade-in/EstimateForm"; import { Topbar } from "@/components/trade-in/Topbar"; import { SourcesProgress } from "@/components/trade-in/SourcesProgress"; -import { HeroSummary } from "@/components/trade-in/HeroSummary"; +import { HeroSummary, thinMarketNote } from "@/components/trade-in/HeroSummary"; import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark"; import { CianValuationCard } from "@/components/trade-in/CianValuationCard"; import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard"; @@ -276,6 +276,7 @@ export default function TradeInPage() { 0 ? imv.recommended_price : null; + // #3339: число IMV показываем, но при тонком рынке честно называем его вне расчёта. + const imvThin = thinMarketNote(imv); // ── #652: коридор реальных ДКП-сделок (advisory). ₽/м² → млн через площадь. ── const dkp = estimate.dkp_corridor; const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0; @@ -537,6 +555,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{sourcePublicLabel("avito_imv")} {formatMln(imvPrice)} ₽ + {imvThin && {imvThin}}
)} {estimate.cian_valuation?.sale_price_rub != null && ( @@ -599,6 +618,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }} > {sourcePublicLabel("avito_imv")}, оценочная модель: {formatMln(imvPrice)} ₽ + {imvThin ? ` · ${imvThin}` : ""} )} diff --git a/tradein-mvp/frontend/src/components/trade-in/IMVBenchmark.tsx b/tradein-mvp/frontend/src/components/trade-in/IMVBenchmark.tsx index fb6621fb..f02db7b5 100644 --- a/tradein-mvp/frontend/src/components/trade-in/IMVBenchmark.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/IMVBenchmark.tsx @@ -6,6 +6,13 @@ import { sourcePublicLabel } from "@/lib/source-registry"; interface Props { benchmark: IMVBenchmarkResponse | undefined; isLoading: boolean; + /** + * #3339 — подпись «тонкого рынка» от thinMarketNote(). Это же число стоит в + * hero, и там оно уже подписано; здесь оно печатается второй раз, поэтому + * подпись обязана доехать и сюда — иначе рядом с итогом остаётся неподписанная + * копия величины, исключённой из расчёта. + */ + note?: string | null; } const SRC_LABEL = sourcePublicLabel("avito_imv"); @@ -16,7 +23,7 @@ function formatRub(n: number | null | undefined): string { return new Intl.NumberFormat("ru-RU").format(n) + " ₽"; } -export function IMVBenchmark({ benchmark, isLoading }: Props) { +export function IMVBenchmark({ benchmark, isLoading, note }: Props) { if (isLoading) { return (
@@ -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, не клампит) ──