МЕРА, область: коридор сделок узнаёт города развёрток, ГАР не раздаёт домам чужой регион, история цен не врёт про «только этот дом» #3575
6 changed files with 100 additions and 9 deletions
|
|
@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) {
|
|||
<HouseAnalyticsKpiRow kpi={data.kpi} />
|
||||
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
|
||||
{data.price_history.length >= 2 && (
|
||||
<PriceHistoryChart points={data.price_history} />
|
||||
<PriceHistoryChart points={data.price_history} radiusM={data.radius_m} />
|
||||
)}
|
||||
{data.recent_sold.length > 0 && (
|
||||
<RecentSoldList items={data.recent_sold} />
|
||||
|
|
|
|||
|
|
@ -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<PivotRow[]>(() => {
|
||||
const byYear: Record<number, PivotRow> = {};
|
||||
for (const p of points) {
|
||||
|
|
@ -50,10 +52,12 @@ export function PriceHistoryChart({ points }: Props) {
|
|||
<article className="card" style={{ marginTop: 12, padding: 16 }}>
|
||||
<header style={{ marginBottom: 8 }}>
|
||||
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||||
История цен в этом доме
|
||||
{radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}
|
||||
</h4>
|
||||
<small style={{ color: "var(--muted, #6b7280)" }}>
|
||||
Медиана ₽/м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
||||
Медиана ₽/м² по годам ·{" "}
|
||||
{radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "}
|
||||
{AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
||||
</small>
|
||||
</header>
|
||||
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ export default function AnalyticsView({
|
|||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||
История цен в этом доме
|
||||
{data.priceHistory.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
{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}`}
|
||||
>
|
||||
<title>История цен в этом доме — медиана ₽/м² по годам</title>
|
||||
<title>{`${data.priceHistory.title} — медиана ₽/м² по годам`}</title>
|
||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||
{PH_GRID_Y.map((y) => (
|
||||
<line key={y} x1={40} y1={y} x2={900} y2={y} />
|
||||
|
|
|
|||
|
|
@ -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(<AnalyticsView data={mapAnalytics(analytics(300), null, null)} />);
|
||||
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(<PriceHistoryChart points={points} radiusM={300} />);
|
||||
expect(screen.getByRole("heading").textContent).toBe("История цен: дом и соседние");
|
||||
expect(screen.queryByText(/только этот дом/)).toBeNull();
|
||||
expect(screen.getByText(/дом и соседние в радиусе 300 м/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue