From aefbd993bb29134f53c717f237baeb71076a925c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 14:39:10 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/ui):=20=C2=AB=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D1=8D=D1=82=D0=BE=D1=82=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=C2=BB=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B3=D0=B4=D0=B0=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D1=8F=D0=BB=D0=B0=D1=81=D1=8C=20=D1=81=D0=BE=D1=81=D0=B5=D0=B4?= =?UTF-8?q?=D1=8F=D0=BC=D0=B8=20(#2583=20M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /house-analytics при <8 архивных лотах добирает до 30 соседних домов в радиусе 300 м и возвращает radius_m. Карточка истории цен всё равно писала «История цен в этом доме · только этот дом», а рядом на том же экране стояло «в радиусе 300 м». Заголовок и подпись теперь зависят от radius_m: при расширении — «дом и соседние в радиусе N м». Поправлены обе версии экрана (v2 и старый PriceHistoryChart). Co-Authored-By: Claude Opus 5 --- .../trade-in/HouseAnalyticsSection.tsx | 2 +- .../components/trade-in/PriceHistoryChart.tsx | 12 ++- .../components/trade-in/v2/AnalyticsView.tsx | 6 +- .../v2/__tests__/priceHistoryScope.test.tsx | 75 +++++++++++++++++++ .../src/components/trade-in/v2/mappers.ts | 12 ++- .../src/components/trade-in/v2/types.ts | 2 + 6 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/priceHistoryScope.test.tsx diff --git a/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx b/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx index decb019d..168f870c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx @@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) { {sellTime.data && } {data.price_history.length >= 2 && ( - + )} {data.recent_sold.length > 0 && ( diff --git a/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx b/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx index 420125c9..6bbb51d6 100644 --- a/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx @@ -13,7 +13,9 @@ import { import type { PriceHistoryYearPoint } from "@/types/trade-in"; import { sourcePublicLabel } from "@/lib/source-registry"; -type Props = { points: PriceHistoryYearPoint[] }; +// radiusM — radius_m из /house-analytics: 0 = выборка ровно по дому, >0 = дом и соседние +// в этом радиусе (M2 audit #2583). +type Props = { points: PriceHistoryYearPoint[]; radiusM: number }; const AVITO_LABEL = sourcePublicLabel("avito_imv"); const YANDEX_LABEL = sourcePublicLabel("yandex_valuation"); @@ -28,7 +30,7 @@ interface PivotRow { n_yandex?: number; } -export function PriceHistoryChart({ points }: Props) { +export function PriceHistoryChart({ points, radiusM }: Props) { const pivoted = useMemo(() => { const byYear: Record = {}; for (const p of points) { @@ -50,10 +52,12 @@ export function PriceHistoryChart({ points }: Props) {

- История цен в этом доме + {radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}

- Медиана ₽/м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов) + Медиана ₽/м² по годам ·{" "} + {radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "} + {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx index 082b1101..02602fc6 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx @@ -357,7 +357,7 @@ export default function AnalyticsView({ >
- История цен в этом доме + {data.priceHistory.title}
{historyNote} @@ -401,9 +401,9 @@ export default function AnalyticsView({ style={{ width: "100%", height: 260, marginTop: 12 }} preserveAspectRatio="none" role="img" - aria-label={`График истории цен в этом доме: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`} + aria-label={`${data.priceHistory.title}: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`} > - История цен в этом доме — медиана ₽/м² по годам + {`${data.priceHistory.title} — медиана ₽/м² по годам`} {PH_GRID_Y.map((y) => ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/priceHistoryScope.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/priceHistoryScope.test.tsx new file mode 100644 index 00000000..fab3e36a --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/priceHistoryScope.test.tsx @@ -0,0 +1,75 @@ +// M2 audit #2583 — подпись «История цен в этом доме · только этот дом» была +// безусловной, хотя /house-analytics при <8 архивных лотах расширяет выборку до +// соседних домов в радиусе 300 м (radius_m в ответе). На том же экране рядом +// стояло «в радиусе 300 м» — две взаимоисключающие подписи. + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { PriceHistoryChart } from "../../PriceHistoryChart"; +import AnalyticsView from "../AnalyticsView"; +import { mapAnalytics } from "../mappers"; +import type { + HouseAnalyticsResponse, + PriceHistoryYearPoint, +} from "@/types/trade-in"; + +const points: PriceHistoryYearPoint[] = [ + { year: 2024, source: "avito_imv", median_price_per_m2: 120_000, n_lots: 3, median_price_rub: 6_000_000 }, + { year: 2025, source: "avito_imv", median_price_per_m2: 130_000, n_lots: 4, median_price_rub: 6_500_000 }, +]; + +function analytics(radius_m: number): HouseAnalyticsResponse { + return { + house_ids: [1, 2, 3], + radius_m, + price_history: points, + recent_sold: [], + kpi: { + total_lots: 7, + sold_count: 0, + sold_rate_pct: 0, + median_exposure_days: null, + median_bargain_pct: null, + }, + }; +} + +describe("v2 история цен: подпись следует radius_m", () => { + it("выборка расширена соседями — не «только этот дом»", () => { + const { priceHistory } = mapAnalytics(analytics(300), null, null); + expect(priceHistory.title).toBe("История цен: дом и соседние"); + expect(priceHistory.note).toContain("дом и соседние в радиусе 300 м"); + expect(priceHistory.note).not.toContain("только этот дом"); + }); + + it("экран v2 печатает подпись из данных, а не константу", () => { + render(); + expect(screen.getByText("История цен: дом и соседние")).toBeTruthy(); + expect(screen.queryByText(/в этом доме/)).toBeNull(); + }); + + it("выборка ровно по дому — прежняя подпись", () => { + const { priceHistory } = mapAnalytics(analytics(0), null, null); + expect(priceHistory.title).toBe("История цен в этом доме"); + expect(priceHistory.note).toContain("только этот дом"); + }); +}); + +describe("старый экран: PriceHistoryChart", () => { + it("radiusM>0 — заголовок и подпись про соседей", () => { + // recharts ResponsiveContainer требует ResizeObserver, которого нет в jsdom. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + render(); + expect(screen.getByRole("heading").textContent).toBe("История цен: дом и соседние"); + expect(screen.queryByText(/только этот дом/)).toBeNull(); + expect(screen.getByText(/дом и соседние в радиусе 300 м/)).toBeTruthy(); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 5f33f713..c293fc51 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -1820,7 +1820,16 @@ export function phYearX(i: number, n: number): number { } function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { + // M2 audit #2583: при <8 архивных лотах /house-analytics подмешивает до 30 соседних + // домов в радиусе radius_m — «только этот дом» тогда неправда (рядом на экране + // стоит «в радиусе 300 м»). radius_m=0 — выборка ровно по дому. + const radiusM = a?.radius_m ?? 0; + const title = + radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"; + const scope = + radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"; const empty: PriceHistory = { + title, note: "Нет данных по истории цен", years: [], avito: "", @@ -1879,7 +1888,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { .join(" "); const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0); - const note = `Медиана ₽/м² по годам · только этот дом · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu( + const note = `Медиана ₽/м² по годам · ${scope} · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu( totalLots, ["лот", "лота", "лотов"], )})`; @@ -1890,6 +1899,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { }); return { + title, note, years: yearsNum.map(String), avito: series("avito_imv"), diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 61e4b8af..961c86d5 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -264,6 +264,8 @@ export interface SellTimeTier { } export interface PriceHistory { + /** Заголовок карточки: «в этом доме» только когда выборка не расширялась соседями. */ + title: string; note: string; years: string[]; /** SVG polyline points string (viewBox 0 0 900 220). */