From 414601c1d27f9cbcf585339d3a6a582efc76f8a4 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 18:20:41 +0500 Subject: [PATCH] =?UTF-8?q?test(analytics):=20vitest=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20ObjectSaleChart=20union-months=20alignment=20(#1246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit union axis sorted, per-month alignment с nulls, регрессия-guard (хвост позднего типа не клипается), happy-path unchanged, empty-state. 5 кейсов. --- .../__tests__/ObjectSaleChart.test.tsx | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 frontend/src/components/analytics/__tests__/ObjectSaleChart.test.tsx diff --git a/frontend/src/components/analytics/__tests__/ObjectSaleChart.test.tsx b/frontend/src/components/analytics/__tests__/ObjectSaleChart.test.tsx new file mode 100644 index 00000000..32674e2f --- /dev/null +++ b/frontend/src/components/analytics/__tests__/ObjectSaleChart.test.tsx @@ -0,0 +1,143 @@ +import { render } from "@testing-library/react"; +import { vi } from "vitest"; + +import type { ObjectSaleGraphPoint } from "@/types/analytics"; + +// Capture the ECharts `option` passed to ChartShell instead of mounting the +// (SSR-disabled, heavy) ReactECharts dynamic import. +const lastOption: { current: Record | null } = { current: null }; +vi.mock("../ChartShell", () => ({ + ChartShell: ({ option }: { option: Record }) => { + lastOption.current = option; + return null; + }, +})); + +const useObjectSaleGraph = vi.fn(); +vi.mock("@/lib/analytics-api", () => ({ + useObjectSaleGraph: (objId: number | string) => useObjectSaleGraph(objId), +})); + +import { ObjectSaleChart } from "../ObjectSaleChart"; + +interface Series { + name: string; + type: string; + data: (number | null)[]; +} + +function renderWith(points: ObjectSaleGraphPoint[]) { + useObjectSaleGraph.mockReturnValue({ data: points, isLoading: false }); + lastOption.current = null; + render(); + expect(lastOption.current).not.toBeNull(); + const opt = lastOption.current as unknown as { + xAxis: { data: string[] }; + series: Series[]; + }; + const seriesByName = Object.fromEntries( + opt.series.map((s) => [s.name, s.data]), + ); + return { months: opt.xAxis.data, seriesByName }; +} + +function pt( + type: string, + report_month: string, + realised: number, + price_avg: number | null, +): ObjectSaleGraphPoint { + return { + type, + report_month, + realised, + contracted: realised, + area_sq: null, + price_avg, + }; +} + +describe("ObjectSaleChart", () => { + it("builds X axis from the union of all types' months, sorted", () => { + // apartments: May–Jul; parking starts later (Jun–Aug) — divergent sets. + const { months } = renderWith([ + pt("apartments", "2025-05-01", 5, 100000), + pt("apartments", "2025-06-01", 6, 110000), + pt("apartments", "2025-07-01", 7, 120000), + pt("parking", "2025-06-01", 1, 50000), + pt("parking", "2025-07-01", 2, 51000), + pt("parking", "2025-08-01", 3, 52000), + ]); + expect(months).toEqual([ + "2025-05", + "2025-06", + "2025-07", + "2025-08", + ]); + }); + + it("aligns each series to the union axis by month, null where absent", () => { + const { seriesByName } = renderWith([ + pt("apartments", "2025-05-01", 5, 100000), + pt("apartments", "2025-06-01", 6, 110000), + pt("apartments", "2025-07-01", 7, 120000), + pt("parking", "2025-06-01", 1, 50000), + pt("parking", "2025-07-01", 2, 51000), + pt("parking", "2025-08-01", 3, 52000), + ]); + // apartments has no Aug → null in the last slot; parking has no May → null first. + expect(seriesByName["apartments · реализовано"]).toEqual([5, 6, 7, null]); + expect(seriesByName["parking · реализовано"]).toEqual([null, 1, 2, 3]); + // Price line aligned identically (rounded), null where the type has no month. + expect(seriesByName["apartments · цена ₽/м²"]).toEqual([ + 100000, + 110000, + 120000, + null, + ]); + expect(seriesByName["parking · цена ₽/м²"]).toEqual([ + null, + 50000, + 51000, + 52000, + ]); + }); + + it("does not silently drop a later type's extra months (regression)", () => { + // First type (apartments, sorts first) has FEWER months than parking — + // the old positional code would have clipped parking's extra tail. + const { months, seriesByName } = renderWith([ + pt("apartments", "2025-05-01", 5, 100000), + pt("parking", "2025-05-01", 1, 50000), + pt("parking", "2025-06-01", 2, 51000), + pt("parking", "2025-07-01", 3, 52000), + ]); + expect(months).toEqual(["2025-05", "2025-06", "2025-07"]); + expect(seriesByName["parking · реализовано"]).toEqual([1, 2, 3]); + expect(seriesByName["apartments · реализовано"]).toEqual([5, null, null]); + }); + + it("keeps happy-path (identical months) unchanged", () => { + // Current prod reality: every type shares the same month grid. + const { months, seriesByName } = renderWith([ + pt("apartments", "2025-05-01", 5, 100000), + pt("apartments", "2025-06-01", 6, 110000), + pt("parking", "2025-05-01", 1, 50000), + pt("parking", "2025-06-01", 2, 51000), + ]); + expect(months).toEqual(["2025-05", "2025-06"]); + expect(seriesByName["apartments · реализовано"]).toEqual([5, 6]); + expect(seriesByName["parking · реализовано"]).toEqual([1, 2]); + expect(seriesByName["apartments · цена ₽/м²"]).toEqual([100000, 110000]); + }); + + it("shows empty-state title when there are no points", () => { + useObjectSaleGraph.mockReturnValue({ data: [], isLoading: false }); + lastOption.current = null; + render(); + const opt = lastOption.current as unknown as { + title?: { text: string }; + }; + expect(opt.title?.text).toBe("Нет данных по продажам"); + }); +});