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 3d151798..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} /> ) : ( 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 index 8832d700..7399241e 100644 --- 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 @@ -14,9 +14,11 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; +import HistoryView from "../HistoryView"; import SourcesView from "../SourcesView"; -import { mapResultPanel, mapSources } from "../mappers"; -import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +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, @@ -74,17 +76,31 @@ describe("05 РЫНОК — пустая выборка сделок не рис range: "—", }); + // Цвет подписи тоже гаснет: зелёным красилась даже дельта, которой нет. + expect(data.marketDeals.kpi.deltaColor).toBeUndefined(); + const { container } = render(); - // Тот же пустой блок, что и при отсутствующем ответе street-deals. - expect( - screen.getByText(/Сделок по этому адресу за 12 месяцев не найдено/), - ).toBeTruthy(); // Ни «−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. @@ -119,8 +135,27 @@ describe("05 РЫНОК — футер таблицы сделок считае render(); - expect(screen.getByText("Показано 3 фактических сделок")).toBeTruthy(); - expect(screen.queryByText(/Показано \d+ из \d+ фактических/)).toBeNull(); + // («Показано 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(); }); }); 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 eca517da..3d1167b3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -1270,7 +1270,7 @@ export function mapResultPanel( // та же, что #2629 дал карточке 1 над этой полосой («РЕКОМЕНДОВАННАЯ ЦЕНА // ПО СДЕЛКАМ РОСРЕЕСТРА»), чтобы подпись и число читались одной парой. label: dealsOnlyPrice - ? ["РЕКОМЕНДОВАННЫЙ ДИАПАЗОН", "ПО СДЕЛКАМ РОСРЕЕСТРА (ДКП)"] + ? ["РЕКОМЕНДОВАННЫЙ ДИАПАЗОН", "ПО СДЕЛКАМ РОСРЕЕСТРА"] : ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"], median: `${fmtMln(e.median_price_rub)} млн ₽`, lo: `${fmtMln(e.range_low_rub)} млн`, @@ -2240,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: АНАЛИТИКА ДОМА ----------------------------------------------