gendesign/frontend/src/components/site-finder/__tests__/ChatPanel.test.tsx
Light1YT 4adfef2ad0
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 45s
CI / frontend-tests (pull_request) Successful in 42s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m43s
Deploy / deploy (push) Successful in 1m4s
CI / changes (pull_request) Successful in 6s
feat(site-finder): grounded parcel-chat panel UI (#958)
Chat panel on the analysis screen consuming POST /api/v1/chat/ask:
preset-intent quick-buttons (summary/what_to_build/why_forecast/risks/
scenarios), history-aware turns (cap 10), advisory badge + grounded_in
provenance + llm_used marker. Plain-text answer rendering (no HTML
injection); loading/pending/error states with retry. TanStack useMutation
(no useEffect-for-HTTP), tokens-only, Lucide icons, TS strict. Types inline
(codegen backend unreachable in env); no new dependency.

Refs #958
2026-06-08 18:08:01 +05:00

149 lines
5.3 KiB
TypeScript

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatPanel } from "../ChatPanel";
import type { ChatAskRequest, ChatAskResponse } from "@/lib/site-finder-api";
// Mock the API client so the mutation resolves with a controlled response and we
// can assert the request payload (intent / cad_num).
const postChatAsk = vi.fn<(req: ChatAskRequest) => Promise<ChatAskResponse>>();
vi.mock("@/lib/site-finder-api", () => ({
postChatAsk: (req: ChatAskRequest) => postChatAsk(req),
}));
function renderPanel(cadNum = "66:41:0000000:1") {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ChatPanel cadNum={cadNum} />
</QueryClientProvider>,
);
}
const READY_RESPONSE: ChatAskResponse = {
answer: "Краткая сводка по участку.",
grounded_in: { run_id: 42, schema_version: "1.0", sections: ["6.1", "6.4"] },
llm_used: true,
fallback_reason: null,
advisory: true,
report_status: "ready",
};
const PENDING_RESPONSE: ChatAskResponse = {
answer: "Сначала запустите анализ участка.",
grounded_in: null,
llm_used: false,
fallback_reason: "report_pending",
advisory: false,
report_status: "pending",
};
beforeEach(() => {
postChatAsk.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("ChatPanel", () => {
it("renders the heading and the five preset-intent buttons", () => {
renderPanel();
expect(
screen.getByRole("heading", { name: "Чат по участку" }),
).toBeInTheDocument();
for (const label of [
"Сводка",
"Что строить",
"Почему такой прогноз",
"Риски",
"Сценарии",
]) {
expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
}
});
it("sends with the matching intent + cad_num when a preset is clicked", async () => {
postChatAsk.mockResolvedValue(READY_RESPONSE);
renderPanel("66:41:0000000:1");
await userEvent.click(screen.getByRole("button", { name: "Риски" }));
await waitFor(() => expect(postChatAsk).toHaveBeenCalledTimes(1));
const req = postChatAsk.mock.calls[0][0];
expect(req.intent).toBe("risks");
expect(req.cad_num).toBe("66:41:0000000:1");
expect(req.message.length).toBeGreaterThan(0);
});
it("renders the assistant answer, advisory badge and provenance line", async () => {
postChatAsk.mockResolvedValue(READY_RESPONSE);
renderPanel();
await userEvent.click(screen.getByRole("button", { name: "Сводка" }));
expect(
await screen.findByText("Краткая сводка по участку."),
).toBeInTheDocument();
expect(screen.getByText("Advisory")).toBeInTheDocument();
// grounded_in → "источник: разделы 6.1, 6.4 · run #42"
expect(screen.getByText(/источник:/)).toHaveTextContent("разделы 6.1, 6.4");
expect(screen.getByText(/источник:/)).toHaveTextContent("run #42");
});
it("renders the pending state as a neutral message (not an error)", async () => {
postChatAsk.mockResolvedValue(PENDING_RESPONSE);
renderPanel();
await userEvent.click(screen.getByRole("button", { name: "Сводка" }));
expect(
await screen.findByText("Сначала запустите анализ участка."),
).toBeInTheDocument();
expect(screen.getByText("Прогноз не рассчитан")).toBeInTheDocument();
expect(screen.getByText("Детерминированный ответ")).toBeInTheDocument();
// pending is NOT an error → no alert / retry button
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument();
});
it("shows a retry affordance on network error", async () => {
postChatAsk.mockRejectedValueOnce(new Error("network down"));
renderPanel();
await userEvent.click(screen.getByRole("button", { name: "Сводка" }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
const retry = screen.getByRole("button", { name: "Повторить" });
expect(retry).toBeInTheDocument();
// Retry re-fires the same request.
postChatAsk.mockResolvedValueOnce(READY_RESPONSE);
await userEvent.click(retry);
await waitFor(() => expect(postChatAsk).toHaveBeenCalledTimes(2));
});
it("submits the free-text input and clears it", async () => {
postChatAsk.mockResolvedValue(READY_RESPONSE);
renderPanel();
const input = screen.getByPlaceholderText("Спросите про участок…");
await userEvent.type(input, "Какова плотность застройки?");
await userEvent.click(
screen.getByRole("button", { name: "Отправить сообщение" }),
);
await waitFor(() => expect(postChatAsk).toHaveBeenCalledTimes(1));
expect(postChatAsk.mock.calls[0][0].message).toBe(
"Какова плотность застройки?",
);
expect(input).toHaveValue("");
});
});