fix(tradein-v2): футер 04, падеж сделок и цвет дельты по знаку
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m31s

Ревью PR #3328:
- HistoryView: «Показано N из M сделок» брало M из street-deals, а строки —
  из sales-vs-listings; после гашения пустого коридора выходило «из —».
  Знаменатель убран, как в 05 РЫНОК;
- обе подписи склоняются через pluralRu («3 фактические сделки», «2 сделки»);
- цвет дельты медианы сделок считается по знаку (mapSources.deltaColor):
  зелёным красились и минус, и прочерк — это второй причинный узел жалобы
  «−100% зелёным»;
- подпись полосы совпала с карточкой 1 буквально («ПО СДЕЛКАМ РОСРЕЕСТРА»);
- тест: убран тавтологичный assert про EmptyTableNote (её рисует пустой
  dealRows, а не guard), добавлены кейсы на футер 04 и на цвет дельты.
This commit is contained in:
bot-backend 2026-09-02 14:50:47 +05:00
parent 08cabe8d98
commit 35db6608ee
5 changed files with 84 additions and 13 deletions

View file

@ -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, ["сделка", "сделки", "сделок"])}
</div>
</div>
</div>

View file

@ -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}
/>
<KpiCell
label="ДИАПАЗОН"
@ -669,7 +673,12 @@ export default function SourcesView({
по дому/району): выходило «Показано 10 из 3». Тотала у той же
выборки в ответе нет, поэтому знаменатель убран, а не починен
разводить подпись на две выборки читается ещё хуже. */}
Показано {data.dealRows.length} фактических сделок
Показано {data.dealRows.length}{" "}
{pluralRu(data.dealRows.length, [
"фактическая сделка",
"фактические сделки",
"фактических сделок",
])}
</div>
</>
) : (

View file

@ -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(<SourcesView data={data} />);
// Тот же пустой блок, что и при отсутствующем ответе street-deals.
expect(
screen.getByText(/Сделок по этому адресу за 12 месяцев не найдено/),
).toBeTruthy();
// Ни «100% к цене объявления», ни «0,00 0,00» на экране быть не может.
expect(container.textContent).not.toContain("100");
expect(container.textContent).not.toMatch(/(?<![\d,])0,00/);
});
it("дельта красится по знаку: минус — не зелёным", () => {
// Второй причинный узел жалобы «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(<SourcesView data={mapSources(estimate, streetDeals)} />);
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(<HistoryView data={data} />);
expect(screen.getByText(/Показано 2 сделки/)).toBeTruthy();
expect(screen.queryByText(/Показано \d+ из/)).toBeNull();
});
});

View file

@ -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),
},
};

View file

@ -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: АНАЛИТИКА ДОМА ----------------------------------------------