test(analytics): vitest для ObjectSaleChart union-months alignment (#1246)
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Successful in 1m6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 56s
CI / frontend-tests (pull_request) Successful in 58s
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m37s

union axis sorted, per-month alignment с nulls, регрессия-guard (хвост позднего
типа не клипается), happy-path unchanged, empty-state. 5 кейсов.
This commit is contained in:
Light1YT 2026-06-13 18:20:41 +05:00
parent e2c8c0e97b
commit 414601c1d2

View file

@ -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<string, unknown> | null } = { current: null };
vi.mock("../ChartShell", () => ({
ChartShell: ({ option }: { option: Record<string, unknown> }) => {
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(<ObjectSaleChart objId={1} />);
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: MayJul; parking starts later (JunAug) — 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(<ObjectSaleChart objId={1} />);
const opt = lastOption.current as unknown as {
title?: { text: string };
};
expect(opt.title?.text).toBe("Нет данных по продажам");
});
});