diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx index 59fc6beb..08c66eda 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx @@ -415,7 +415,13 @@ export default function HistoryView({ data }: HistoryViewProps) { background: tokens.surfaceTint, }} > - Показано {data.dkpRows.length} из {data.dkpKpi.count} сделок + {/* #3320 — «из M» брало знаменатель из ДРУГОЙ выборки: строки ниже — + пары ДКП↔объявление (sales-vs-listings), а dkpKpi.count — коридор + street-deals. Расходились и числами («Показано 7 из 3»), и после + гашения пустого коридора («Показано 7 из —»). Тотала у той же + выборки в ответе нет — знаменатель убран, как в 05 РЫНОК. */} + Показано {data.dkpRows.length}{" "} + {pluralRu(data.dkpRows.length, ["сделка", "сделки", "сделок"])} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index 8ee85362..e930ec58 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -4,6 +4,7 @@ import type { CSSProperties, ReactNode } from "react"; import { safeUrl } from "@/lib/safeUrl"; import type { AggregatedEstimate } from "@/types/trade-in"; import { tokens } from "./tokens"; +import { pluralRu } from "./mappers"; import type { AdRowData, DealRowData, SourcesData } from "./mappers"; import { SourcesMap } from "./SourcesMap"; @@ -637,7 +638,10 @@ export default function SourcesView({ label="МЕДИАНА СДЕЛОК" value={data.marketDeals.kpi.median} unit={`млн ₽ · ${data.marketDeals.kpi.delta}`} - unitColor={tokens.success} + // #3320 — цвет по знаку дельты (её считает mapSources): зелёным + // здесь красился и минус, и прочерк. undefined → нейтральный + // дефолт плитки. + unitColor={data.marketDeals.kpi.deltaColor} /> - Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "} - фактических сделок + {/* #3320 — «из M» здесь брало M из ДРУГОЙ выборки (KPI выше — + корридор ДКП по улице, а строки таблицы — actual_deals оценки + по дому/району): выходило «Показано 10 из 3». Тотала у той же + выборки в ответе нет, поэтому знаменатель убран, а не починен + — разводить подпись на две выборки читается ещё хуже. */} + Показано {data.dealRows.length}{" "} + {pluralRu(data.dealRows.length, [ + "фактическая сделка", + "фактические сделки", + "фактических сделок", + ])} ) : ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx new file mode 100644 index 00000000..7399241e --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx @@ -0,0 +1,184 @@ +// #3320 — числа посчитаны честно, показаны нечестно. +// +// Бэкенд по схеме НЕ УМЕЕТ отдать null в street-deals: «сделок на улице нет» +// приезжает как count=0 и нулевые цены (trade_in.py:2258, менять там нечего — +// это контракт). Витрина такой ноль сохраняла через `??` и рисовала его как +// данные: «05 РЫНОК» показывал медиану сделок «0,00 млн ₽ · −100% к цене +// объявления» зелёным и диапазон «0,00 – 0,00». +// +// Тут держатся три места, где посчитанное честно показывалось нечестно: +// пустая выборка по улице, футер таблицы сделок (N и M брались из РАЗНЫХ +// выборок) и полоса диапазона в deals-only ветке (подписана «В ОБЪЯВЛЕНИЯХ» +// при нуле объявлений). + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import HistoryView from "../HistoryView"; +import SourcesView from "../SourcesView"; +import { mapHistory, mapResultPanel, mapSources } from "../mappers"; +import { tokens } from "../tokens"; +import { FIXTURE_ESTIMATE, FIXTURE_SALES } from "@/app/ui-preview/estimate/fixture"; +import type { + AggregatedEstimate, + AnalogLot, + StreetDealsResponse, +} from "@/types/trade-in"; + +/** Ответ street-deals «сделок нет» ровно в том виде, в каком его шлёт бэкенд. */ +const EMPTY_STREET_DEALS: StreetDealsResponse = { + street: "Космонавтов", + period_from: "2025-09-02", + period_to: "2026-09-02", + count: 0, + median_price_rub: 0, + median_price_per_m2: 0, + range_low_rub: 0, + range_high_rub: 0, + deals: [], +}; + +function deal(over: Partial): AnalogLot { + return { + address: "ул. Космонавтов, 12", + area_m2: 55, + rooms: 2, + floor: 5, + total_floors: 9, + price_rub: 8_000_000, + price_per_m2: 145_454, + listing_date: "2026-02-14", + days_on_market: null, + photo_url: null, + source: "rosreestr", + source_url: null, + distance_m: null, + tier: "T1_per_street", + date_precision: "quarter", + lat: null, + lon: null, + ...over, + }; +} + +function estimateWith(deals: AnalogLot[]): AggregatedEstimate { + return { ...FIXTURE_ESTIMATE, actual_deals: deals }; +} + +describe("05 РЫНОК — пустая выборка сделок не рисуется данными", () => { + it("count=0 с нулевыми ценами → KPI сделок в состоянии «нет данных»", () => { + const data = mapSources(estimateWith([]), EMPTY_STREET_DEALS); + + expect(data.marketDeals.kpi).toEqual({ + count: "—", + median: "—", + delta: "—", + range: "—", + }); + + // Цвет подписи тоже гаснет: зелёным красилась даже дельта, которой нет. + expect(data.marketDeals.kpi.deltaColor).toBeUndefined(); + + const { container } = render(); + + // Ни «−100% к цене объявления», ни «0,00 – 0,00» на экране быть не может. + expect(container.textContent).not.toContain("−100"); + expect(container.textContent).not.toMatch(/(? { + // Второй причинный узел жалобы «−100% зелёным»: цвет был захардкожен. + // Сделки по фикстуре (8,00 млн) ниже медианы объявлений (9,85 млн). + const below = mapSources(estimateWith([deal({ price_rub: 8_000_000 })]), null); + expect(below.marketDeals.kpi.delta).toContain("−"); + expect(below.marketDeals.kpi.deltaColor).toBe(tokens.danger); + + const above = mapSources( + estimateWith([deal({ price_rub: 12_000_000 })]), + null, + ); + expect(above.marketDeals.kpi.delta).toContain("+"); + expect(above.marketDeals.kpi.deltaColor).toBe(tokens.success); + }); + + it("count=0, но у оценки есть свои сделки → числа из НИХ, а не прочерки", () => { + // Схлопывание пустой оболочки не должно гасить реальную выборку: маппер + // обязан упасть на actual_deals, чьи строки видно в таблице ниже KPI. + const data = mapSources( + estimateWith([deal({ price_rub: 8_000_000 })]), + EMPTY_STREET_DEALS, + ); + + expect(data.marketDeals.kpi.count).toBe("1"); + expect(data.marketDeals.kpi.median).toBe("8,00"); + }); +}); + +describe("05 РЫНОК — футер таблицы сделок считает одну выборку", () => { + it("N и M не смешивают street-deals и actual_deals", () => { + // Расходящиеся числа: в таблице 3 сделки оценки, коридор по улице знает 1 — + // прежний футер писал «Показано 3 из 1 фактических сделок». + const streetDeals: StreetDealsResponse = { + ...EMPTY_STREET_DEALS, + count: 1, + median_price_rub: 12_000_000, + median_price_per_m2: 200_000, + range_low_rub: 12_000_000, + range_high_rub: 12_000_000, + deals: [deal({ price_rub: 12_000_000 })], + }; + const estimate = estimateWith([ + deal({ address: "ул. Космонавтов, 12" }), + deal({ address: "ул. Космонавтов, 14" }), + deal({ address: "ул. Космонавтов, 16" }), + ]); + + render(); + + // («Показано N из M объявлений» над таблицей аналогов — законный футер + // СВОЕЙ выборки, поэтому проверяется именно подпись под сделками.) + const footer = screen.getByText(/Показано \d+ .*сделк/); + expect(footer.textContent).toBe("Показано 3 фактические сделки"); + expect(footer.textContent).not.toMatch(/\sиз\s/); + }); + + it("04 ПРОДАЖИ — футер ДКП-таблицы тоже считает свою выборку", () => { + // Строки тут — пары ДКП↔объявление, а прежний знаменатель брался из + // коридора street-deals: 2 пары против count=7, а при пустом коридоре + // (после гашения нулей) выходило «Показано 2 из —». + const data = mapHistory( + null, + { ...EMPTY_STREET_DEALS, count: 7, median_price_rub: 12_000_000 }, + { ...FIXTURE_SALES, pairs: FIXTURE_SALES.pairs.slice(0, 2) }, + ); + + render(); + + expect(screen.getByText(/Показано 2 сделки/)).toBeTruthy(); + expect(screen.queryByText(/Показано \d+ из/)).toBeNull(); + }); +}); + +describe("02 РЕЗУЛЬТАТ — полоса диапазона называет свою выборку", () => { + it("deals-only (n_analogs=0) → полоса подписана сделками, как и карточка 1", () => { + const dealsOnly: AggregatedEstimate = { + ...FIXTURE_ESTIMATE, + analogs: [], + n_analogs: 0, + insufficient_data: false, + }; + + const { cards, ranges } = mapResultPanel(dealsOnly); + + expect(ranges.ads.label.join(" ")).not.toContain("ОБЪЯВЛЕНИЯХ"); + expect(ranges.ads.label.join(" ")).toContain("ПО СДЕЛКАМ РОСРЕЕСТРА"); + // Формулировка та же, что #2629 дал карточке над этой полосой. + expect(cards[0].title.join(" ")).toContain("ПО СДЕЛКАМ РОСРЕЕСТРА"); + }); + + it("объявления есть → полоса остаётся про объявления", () => { + expect(mapResultPanel(FIXTURE_ESTIMATE).ranges.ads.label.join(" ")).toContain( + "ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", + ); + }); +}); 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 72e45266..3d1167b3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -613,6 +613,28 @@ function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] return [best?.listing_date ?? null, best?.date_precision ?? null]; } +/** + * #3320 — «нет сделок на улице» приезжает с бэкенда НУЛЯМИ, а не null: схема + * StreetDealsResponse не допускает null в count/median/range (trade_in.py:2258 + * отдаёт count=0 и нулевые цены). Дальше по витрине `??` такой ноль СОХРАНЯЕТ — + * и пустая выборка рисуется как данные: «0,00 млн ₽ · −100 % к цене + * объявления» зелёным и «0,00 – 0,00» в диапазоне. + * + * Схлопываем пустую оболочку в null на границе мапперов — ниже null уже везде + * означает «нет данных» (прочерк / EmptyTableNote), отдельного состояния + * заводить не нужно. + */ +function usableStreetDeals( + sd: StreetDealsResponse | null | undefined, +): StreetDealsResponse | null { + if (sd == null || sd.count <= 0) return null; + // count > 0 при нулевой медиане — тоже оболочка без цен (медиана считается по + // тем же строкам, что и count: ноль тут значит «денег в выборке нет»). + return Number.isFinite(sd.median_price_rub) && sd.median_price_rub > 0 + ? sd + : null; +} + /** * Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with * real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's @@ -1083,9 +1105,11 @@ export function marketPositionPill( export function mapResultPanel( e: AggregatedEstimate, - streetDeals?: StreetDealsResponse | null, + streetDealsRaw?: StreetDealsResponse | null, analytics?: HouseAnalyticsResponse | null, ): ResultPanelData { + // #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули. + const streetDeals = usableStreetDeals(streetDealsRaw); const dealTier = resolveDealTier(e, streetDeals); // H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of @@ -1240,7 +1264,14 @@ export function mapResultPanel( }; const adsBar: RangeBar = { - label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"], + // #3320 — полоса строится из median/range самой оценки, а в deals-only + // ветке (n_analogs === 0) эти числа посчитаны по ДКП, не по объявлениям: + // подпись «В ОБЪЯВЛЕНИЯХ» обещала бы выборку, которой нет. Формулировка — + // та же, что #2629 дал карточке 1 над этой полосой («РЕКОМЕНДОВАННАЯ ЦЕНА + // ПО СДЕЛКАМ РОСРЕЕСТРА»), чтобы подпись и число читались одной парой. + label: dealsOnlyPrice + ? ["РЕКОМЕНДОВАННЫЙ ДИАПАЗОН", "ПО СДЕЛКАМ РОСРЕЕСТРА"] + : ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"], median: `${fmtMln(e.median_price_rub)} млн ₽`, lo: `${fmtMln(e.range_low_rub)} млн`, hi: `${fmtMln(e.range_high_rub)} млн`, @@ -1270,8 +1301,10 @@ export function mapResultPanel( export function mapSummary( e: AggregatedEstimate, analytics?: HouseAnalyticsResponse | null, - streetDeals?: StreetDealsResponse | null, + streetDealsRaw?: StreetDealsResponse | null, ): Summary { + // #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули. + const streetDeals = usableStreetDeals(streetDealsRaw); const kpi = analytics?.kpi; const houseSold = kpi?.sold_count ?? null; @@ -1600,16 +1633,16 @@ export function mapHistory( .filter(Boolean) .join(" "); + // #3320 — числа только из НЕПУСТОЙ выборки (нулевая оболочка → прочерки). + // Название улицы выше берётся из сырого ответа: оно известно и без сделок. + const sd = usableStreetDeals(streetDeals); const dkpKpi = { - count: streetDeals?.count != null ? String(streetDeals.count) : "—", + count: sd != null ? String(sd.count) : "—", median: - streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2) - ? numRu(streetDeals.median_price_per_m2) - : "—", - range: - streetDeals != null - ? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub) + sd != null && Number.isFinite(sd.median_price_per_m2) + ? numRu(sd.median_price_per_m2) : "—", + range: sd != null ? mlnRangeSp(sd.range_low_rub, sd.range_high_rub) : "—", }; const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => { @@ -2132,9 +2165,12 @@ function buildAdFilters(e: AggregatedEstimate | null): string[] { */ export function mapSources( estimate: AggregatedEstimate | null, - streetDeals: StreetDealsResponse | null, + streetDealsRaw: StreetDealsResponse | null, ): SourcesData { const e = estimate; + // #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули: иначе + // KPI сделок читается как «медиана 0,00 млн ₽ · −100 % к цене объявления». + const streetDeals = usableStreetDeals(streetDealsRaw); // Fix #8 — flag rows whose ₽/м² is an outlier vs. the pool (never silently // fold them into the median/spread math). @@ -2204,6 +2240,15 @@ export function mapSources( // §M8 — единая формулировка дельт: всё считается «относительно цен // объявлений» (deltaPct = медиана сделок / медиана объявлений − 1). delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—", + // #3320 — знак → цвет, тот же признак, что у пар ДКП↔объявление в 04 + // (deltaColor у DkpRowData): продали дороже объявления — success, + // дешевле — danger, числа нет — нейтральный цвет плитки. + deltaColor: + deltaPct == null + ? undefined + : deltaPct < 0 + ? tokens.danger + : tokens.success, range: mlnRangeSp(dealLo, dealHi), }, }; 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 2155e18b..61e4b8af 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -227,7 +227,19 @@ export interface MarketAds { } export interface MarketDeals { - kpi: { count: string; median: string; delta: string; range: string }; + kpi: { + count: string; + median: string; + delta: string; + range: string; + /** + * #3320 — цвет подписи дельты по её знаку (как deltaColor у DkpRowData): + * сделки выше объявлений — success, ниже — danger, «—» — undefined + * (плитка красит нейтральным по умолчанию). Раньше цвет был захардкожен + * зелёным и красил им же и минус, и прочерк. + */ + deltaColor?: string; + }; } // ---- OVERLAY: АНАЛИТИКА ДОМА ----------------------------------------------