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
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
149 lines
5.3 KiB
TypeScript
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("");
|
|
});
|
|
});
|