From 5356d564e63a1a76ea5599ea39d4992ca69748af Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 22:48:07 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(mera):=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D1=82=D1=8C=20IMV=20=D0=BD=D0=B0=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE=D0=BC=20=D1=80=D1=8B=D0=BD=D0=BA=D0=B5=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20=D0=B2=D0=BD=D0=B5?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд (#3336, thin_market) при market_count ниже порога исключает IMV из headline, но витрина ставила число рядом с итоговой ценой молча — читалось как расчётная величина. Подпись называет реальный N из market_count. Closes #3339 --- .../src/components/trade-in/HeroSummary.tsx | 22 ++++- .../__tests__/thinMarketBadge.test.tsx | 83 +++++++++++++++++++ tradein-mvp/frontend/src/types/trade-in.ts | 4 + 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 0a0134e6..fe1d0ae9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -10,7 +10,7 @@ * 14.08.2026 (блок 4.1): SHORT_ESTIMATE_DISCLAIMER — см. `lib/legal-copy.ts`. */ import { useState } from "react"; -import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in"; +import type { AggregatedEstimate, AvitoImvSummary, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in"; import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { sourceDot, sourcePublicLabel } from "@/lib/source-registry"; @@ -18,6 +18,22 @@ import { dealsAsOfLabel } from "@/lib/rosreestr"; import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy"; import { HeroTransparency } from "./HeroTransparency"; +/** + * #3339 — подпись «тонкого рынка» под числом оценочной модели площадки. + * + * Бэкенд (thin_market, #audit-5b) при market_count ниже порога ИСКЛЮЧАЕТ IMV из + * headline — число остаётся справочным. Показывать его рядом с итогом молча — + * то же самое, что выдать не участвующую в расчёте величину за расчётную. + * null (нет флага / старая оценка без поля) → подписи нет. + */ +export function thinMarketNote(imv: AvitoImvSummary | null | undefined): string | null { + if (imv?.thin_market !== true) return null; + const n = imv.market_count; + if (n == null) return "тонкий рынок — в расчёт не входит"; + // После «из» счётная форма родительная у всех чисел, кроме единицы. + return `рынок из ${n} ${n === 1 ? "объявления" : "объявлений"} — в расчёт не входит`; +} + interface Props { estimate: AggregatedEstimate; input: TradeInEstimateInput; @@ -175,6 +191,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals imv && typeof imv.recommended_price === "number" && imv.recommended_price > 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/__tests__/thinMarketBadge.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx new file mode 100644 index 00000000..01c5d7f6 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx @@ -0,0 +1,83 @@ +/** + * #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 { FIXTURE_ESTIMATE, 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 объявления — в расчёт не входит", + ); + }); +}); 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, не клампит) ── From d953899d929d9db1ca7812e12b07ad013d8db9c6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 23:05:12 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(mera):=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8C=20=D1=82=D0=BE=D0=BD=D0=BA=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=80=D1=8B=D0=BD=D0=BA=D0=B0=20=D0=B4=D0=BE=D0=B5=D0=B7=D0=B6?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=B4=D0=BE=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка печатает тот же recommended_price, что и hero, но берёт его из своего endpoint и подписи не имела — рядом с итогом стояла неподписанная копия величины, исключённой из расчёта (ровно то, что #3339 чинил в hero). Проп note + проброс thinMarketNote(estimate.avito_imv) со страницы; подпись тем же классом .bench-chip__sub, ему же дан max-width, чтобы длинная строка не растягивала чип. Тест падает при снятии рендера подписи (проверено: 1 failed / 5 passed). --- tradein-mvp/frontend/src/app/page.tsx | 3 +- .../src/components/trade-in/IMVBenchmark.tsx | 10 +++++- .../__tests__/thinMarketBadge.test.tsx | 32 ++++++++++++++++++- .../src/components/trade-in/trade-in.css | 1 + 4 files changed, 43 insertions(+), 3 deletions(-) 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() { @@ -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 index 01c5d7f6..ed0fa74e 100644 --- a/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/thinMarketBadge.test.tsx @@ -12,7 +12,12 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { HeroSummary, thinMarketNote } from "../HeroSummary"; -import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture"; +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) — к подписи отношения @@ -81,3 +86,28 @@ describe("thin_market — подпись у числа оценочной мод ); }); }); + +/** + * То же число печатается второй раз в отдельной карточке под 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 (без данных) ── */