import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { UseChatResult } from "@/hooks/use-chat";
import type { Conversation } from "@/lib/chat/types";
const useChatMock = vi.hoisted(() => vi.fn());
const useModelsMock = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/use-chat", () => ({
useChat: useChatMock,
}));
vi.mock("@/hooks/use-models", () => ({
useModels: useModelsMock,
}));
import { ChatApp } from "@/components/chat/chat-app";
const identity = {
id: "00000000-0000-4000-8000-000000000042",
externalUserId: "42",
login: "alice",
fullName: "Alice Smith",
position: "Engineer",
email: "alice@example.com",
firstName: "Alice",
lastName: "Smith",
middleName: "Jane",
roles: ["admin", "employee"],
lastModelId: "chatgpt" as const,
};
const conversations: Conversation[] = [
{
id: "conversation-1",
title: "План запуска",
modelId: "chatgpt",
updatedAt: "2026-07-26T10:00:00.000Z",
messages: [
{
id: "message-1",
role: "user",
content: "Составь план",
createdAt: "2026-07-26T09:59:00.000Z",
status: "complete",
},
{
id: "message-2",
role: "assistant",
content: "Шаг 1\nШаг 2",
createdAt: "2026-07-26T10:00:00.000Z",
status: "complete",
},
],
},
{
id: "conversation-2",
title: "Анализ отчёта",
modelId: "deepseek",
updatedAt: "2026-07-25T10:00:00.000Z",
messages: [],
},
];
function chatResult(overrides: Partial<UseChatResult> = {}): UseChatResult {
return {
conversations: [],
activeConversation: null,
activeModelId: "chatgpt",
isGenerating: false,
canRetryLast: false,
quota: {
status: "ready",
snapshot: {
object: "aegida.quota",
model: "chatgpt",
availableTokens: 42_000,
unlimited: false,
exhausted: false,
resetsAt: null,
asOf: "2026-08-04T12:00:00Z",
},
},
selectConversation: vi.fn(),
newConversation: vi.fn(),
deleteConversation: vi.fn(),
setModel: vi.fn(),
sendMessage: vi.fn().mockResolvedValue(undefined),
stopGenerating: vi.fn(),
retryLast: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe("ChatApp", () => {
beforeEach(() => {
useChatMock.mockReturnValue(chatResult());
useModelsMock.mockReturnValue({
status: "ready",
effectiveModelId: "chatgpt",
models: [
{ id: "auto", name: "Aegida Auto", description: "Auto" },
{ id: "chatgpt", name: "ChatGPT", description: "ChatGPT" },
{ id: "deepseek", name: "DeepSeek", description: "DeepSeek" },
{ id: "qwen", name: "Qwen", description: "Qwen" },
],
});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("renders the new-chat empty state with useful prompt suggestions", () => {
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
expect(
screen.getByRole("heading", { name: "Чем я могу помочь?" }),
).toBeInTheDocument();
expect(screen.getByText("Здравствуйте, Alice!")).toBeInTheDocument();
expect(screen.getByText("Alice Smith")).toBeInTheDocument();
expect(screen.getByText("Engineer")).toBeInTheDocument();
expect(screen.getByText("admin, employee")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /план|резюме|идеи/i })).toHaveLength(
3,
);
});
it("selects a model for later messages", async () => {
const user = userEvent.setup();
const result = chatResult();
useChatMock.mockReturnValue(result);
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Выбрать модель" }));
await user.click(screen.getByRole("menuitem", { name: /Qwen/ }));
expect(result.setModel).toHaveBeenCalledWith("qwen");
});
it("formats finite quota and omits a nullable reset", () => {
render(<ChatApp user={identity} onLogout={vi.fn()} />);
expect(screen.getByText(/42.000 токенов/)).toBeInTheDocument();
expect(screen.queryByText(/Сброс:/)).not.toBeInTheDocument();
});
it("shows unlimited quota", () => {
useChatMock.mockReturnValue(chatResult({
quota: {
status: "ready",
snapshot: {
object: "aegida.quota",
model: "auto",
availableTokens: null,
unlimited: true,
exhausted: false,
resetsAt: null,
asOf: "2026-08-04T12:00:00Z",
},
},
}));
render(<ChatApp user={identity} onLogout={vi.fn()} />);
expect(screen.getByText("Без лимита")).toBeInTheDocument();
});
it("keeps send enabled when quota is unavailable", async () => {
const user = userEvent.setup();
const result = chatResult({ quota: { status: "unavailable" } });
useChatMock.mockReturnValue(result);
render(<ChatApp user={identity} onLogout={vi.fn()} />);
expect(screen.getByText("Баланс недоступен")).toBeInTheDocument();
await user.type(screen.getByLabelText("Сообщение"), "Продолжить");
const send = screen.getByRole("button", { name: "Отправить сообщение" });
expect(send).toBeEnabled();
await user.click(send);
expect(result.sendMessage).toHaveBeenCalledWith("Продолжить");
});
it("keeps send enabled while the selected-model balance is loading", async () => {
const user = userEvent.setup();
const result = chatResult({ quota: { status: "loading" } });
useChatMock.mockReturnValue(result);
render(<ChatApp user={identity} onLogout={vi.fn()} />);
expect(screen.getByText("Баланс…")).toBeInTheDocument();
await user.type(screen.getByLabelText("Сообщение"), "Продолжить");
const send = screen.getByRole("button", { name: "Отправить сообщение" });
expect(send).toBeEnabled();
await user.click(send);
expect(result.sendMessage).toHaveBeenCalledWith("Продолжить");
});
it("disables send only for an explicitly exhausted snapshot", async () => {
const user = userEvent.setup();
useChatMock.mockReturnValue(chatResult({
quota: {
status: "ready",
snapshot: {
object: "aegida.quota",
model: "chatgpt",
availableTokens: 0,
unlimited: false,
exhausted: true,
resetsAt: null,
asOf: "2026-08-04T12:00:00Z",
},
},
}));
render(<ChatApp user={identity} onLogout={vi.fn()} />);
await user.type(screen.getByLabelText("Сообщение"), "Нельзя отправить");
expect(screen.getByRole("button", { name: "Отправить сообщение" })).toBeDisabled();
expect(screen.getByText("Лимит токенов исчерпан. Отправка недоступна.")).toBeInTheDocument();
});
it("renders only models supplied by the dynamic catalog", async () => {
const user = userEvent.setup();
const result = chatResult({ activeModelId: "auto" });
useChatMock.mockReturnValue(result);
useModelsMock.mockReturnValue({
status: "ready",
effectiveModelId: "auto",
models: [
{ id: "auto", name: "Aegida Auto", description: "Auto" },
{ id: "claude-sonnet.4", name: "Claude Sonnet 4", description: "Claude" },
],
});
render(<ChatApp user={identity} onLogout={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Выбрать модель" }));
expect(screen.queryByRole("menuitem", { name: /Qwen/ })).not.toBeInTheDocument();
await user.click(screen.getByRole("menuitem", { name: /Claude Sonnet 4/ }));
expect(result.setModel).toHaveBeenCalledWith("claude-sonnet.4");
});
it("selects auto after a persisted model disappears from the catalog", async () => {
const result = chatResult({ activeModelId: "removed-model" });
useChatMock.mockReturnValue(result);
useModelsMock.mockReturnValue({
status: "ready",
effectiveModelId: "auto",
models: [{ id: "auto", name: "Aegida Auto", description: "Auto" }],
});
render(<ChatApp user={{ ...identity, lastModelId: "removed-model" }} onLogout={vi.fn()} />);
await act(async () => undefined);
expect(result.setModel).toHaveBeenCalledWith("auto");
});
it("dismisses the model menu with Escape and an outside click", async () => {
const user = userEvent.setup();
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
const trigger = screen.getByRole("button", { name: "Выбрать модель" });
await user.click(trigger);
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu", { name: "Доступные модели" })).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
await user.click(trigger);
await user.click(screen.getByRole("heading", { name: "Чем я могу помочь?" }));
expect(screen.queryByRole("menu", { name: "Доступные модели" })).not.toBeInTheDocument();
});
it("sends with Enter, preserves Shift+Enter, and disables an empty send", async () => {
const user = userEvent.setup();
const result = chatResult();
useChatMock.mockReturnValue(result);
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
const textarea = screen.getByLabelText("Сообщение");
expect(screen.getByRole("button", { name: "Отправить сообщение" })).toBeDisabled();
await user.type(textarea, "Первая строка{shift>}{enter}{/shift}Вторая строка");
expect(textarea).toHaveValue("Первая строка\nВторая строка");
await user.type(textarea, "{enter}");
expect(result.sendMessage).toHaveBeenCalledWith(
"Первая строка\nВторая строка",
);
expect(textarea).toHaveValue("");
});
it("resets the composer height after sending", async () => {
const user = userEvent.setup();
useChatMock.mockReturnValue(chatResult());
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
const textarea = screen.getByLabelText("Сообщение");
Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 120 });
fireEvent.change(textarea, { target: { value: "Длинное сообщение" } });
expect(textarea).toHaveStyle({ height: "120px" });
await user.click(screen.getByRole("button", { name: "Отправить сообщение" }));
expect(textarea).toHaveStyle({ height: "auto" });
});
it("passes complete uploaded attachment metadata to the optimistic chat turn", async () => {
const user = userEvent.setup();
const result = chatResult();
useChatMock.mockReturnValue(result);
const attachment = {
id: "00000000-0000-4000-8000-000000000077",
name: "brief.txt",
contentType: "text/plain",
size: 5,
};
vi.stubGlobal("localStorage", {
getItem: vi.fn().mockReturnValue("test-token"),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: vi.fn(),
length: 1,
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({ attachment }, { status: 201 }),
),
);
render(<ChatApp user={identity} onLogout={vi.fn()} />);
await user.upload(
screen.getByLabelText("Прикрепить файлы", { selector: "input" }),
new File(["brief"], "brief.txt", { type: "text/plain" }),
);
await screen.findByText("brief.txt");
await user.type(screen.getByLabelText("Сообщение"), "Смотри");
await user.click(screen.getByRole("button", { name: "Отправить сообщение" }));
expect(result.sendMessage).toHaveBeenCalledWith("Смотри", [attachment]);
});
it("keeps an over-limit draft and does not submit it", async () => {
const user = userEvent.setup();
const result = chatResult();
useChatMock.mockReturnValue(result);
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
const textarea = screen.getByLabelText("Сообщение");
const content = "x".repeat(32_001);
await user.click(textarea);
await user.paste(content);
expect(textarea).toHaveValue(content);
expect(screen.getByRole("alert")).toHaveTextContent("32 000");
expect(screen.getByRole("button", { name: "Отправить сообщение" })).toBeDisabled();
await user.type(textarea, "{enter}");
expect(result.sendMessage).not.toHaveBeenCalled();
expect(textarea).toHaveValue(content);
});
it("shows a stop action while text is streaming", async () => {
const user = userEvent.setup();
const result = chatResult({ isGenerating: true });
useChatMock.mockReturnValue(result);
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
await user.click(
screen.getByRole("button", { name: "Остановить генерацию" }),
);
expect(result.stopGenerating).toHaveBeenCalledOnce();
});
it("copies assistant text and retries an errored response", async () => {
const user = userEvent.setup();
const writeText = vi.spyOn(navigator.clipboard, "writeText");
const errored = {
...conversations[0],
messages: [
...conversations[0].messages.slice(0, 1),
{
...conversations[0].messages[1],
content: "Не удалось получить ответ",
status: "error" as const,
},
],
};
const result = chatResult({
conversations: [errored],
activeConversation: errored,
canRetryLast: true,
});
useChatMock.mockReturnValue(result);
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Копировать ответ" }));
await user.click(screen.getByRole("button", { name: "Повторить запрос" }));
expect(writeText).toHaveBeenCalledWith("Не удалось получить ответ");
expect(result.retryLast).toHaveBeenCalledOnce();
});
it("does not offer retry on a historical error that cannot be retried", () => {
const conversation = {
...conversations[0],
messages: [
{
...conversations[0].messages[1],
id: "historical-error",
status: "error" as const,
},
...conversations[0].messages,
],
};
useChatMock.mockReturnValue(
chatResult({ conversations: [conversation], activeConversation: conversation }),
);
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { name: "Повторить запрос" }),
).not.toBeInTheDocument();
});
it("offers retry when the final stopped response has no content", async () => {
const user = userEvent.setup();
const stopped = {
...conversations[0],
messages: [
conversations[0].messages[0],
{
...conversations[0].messages[1],
content: "",
status: "stopped" as const,
},
],
};
const result = chatResult({
conversations: [stopped],
activeConversation: stopped,
canRetryLast: true,
});
useChatMock.mockReturnValue(result);
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Повторить запрос" }));
expect(result.retryLast).toHaveBeenCalledOnce();
expect(
screen.queryByRole("button", { name: "Копировать ответ" }),
).not.toBeInTheDocument();
});
it("selects and deletes conversations from the sidebar", async () => {
const user = userEvent.setup();
const result = chatResult({
conversations,
activeConversation: conversations[0],
});
useChatMock.mockReturnValue(result);
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
const sidebar = screen.getByRole("navigation", { name: "История чатов" });
await user.click(within(sidebar).getByRole("button", { name: "Анализ отчёта" }));
await user.click(
within(sidebar).getByRole("button", { name: "Удалить чат План запуска" }),
);
expect(result.selectConversation).toHaveBeenCalledWith("conversation-2");
expect(result.deleteConversation).toHaveBeenCalledWith("conversation-1");
});
it("opens and closes the mobile history drawer", async () => {
const user = userEvent.setup();
useChatMock.mockReturnValue(chatResult({ conversations }));
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
const opener = screen.getByRole("button", { name: "Открыть меню" });
await user.click(opener);
const drawer = screen.getByRole("dialog", { name: "История чатов" });
expect(drawer).toBeInTheDocument();
expect(within(drawer).getByRole("button", { name: "Закрыть меню" })).toHaveFocus();
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "История чатов" })).not.toBeInTheDocument();
expect(opener).toHaveFocus();
await user.click(opener);
const reopenedDrawer = screen.getByRole("dialog", { name: "История чатов" });
await user.click(
within(reopenedDrawer).getByRole("button", { name: "Закрыть меню" }),
);
expect(screen.queryByRole("dialog", { name: "История чатов" })).not.toBeInTheDocument();
});
it("wraps Tab focus within the inert mobile drawer", async () => {
const user = userEvent.setup();
const { container } = render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Открыть меню" }));
const drawer = screen.getByRole("dialog", { name: "История чатов" });
const close = within(drawer).getByRole("button", { name: "Закрыть меню" });
const logout = within(drawer).getByRole("button", { name: "Выйти" });
expect(container.querySelectorAll("[inert]")).toHaveLength(2);
expect(close).toHaveFocus();
await user.tab({ shift: true });
expect(logout).toHaveFocus();
await user.tab();
expect(close).toHaveFocus();
});
it("keeps the latest streamed message in view", () => {
const scrollIntoView = vi.fn();
Element.prototype.scrollIntoView = scrollIntoView;
useChatMock.mockReturnValue(
chatResult({ conversations, activeConversation: conversations[0], isGenerating: true }),
);
render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "end" });
});
it("removes mobile modal state when the viewport becomes desktop", async () => {
const user = userEvent.setup();
let onBreakpointChange: ((event: MediaQueryListEvent) => void) | undefined;
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: false,
media: "(min-width: 768px)",
onchange: null,
addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => {
onBreakpointChange = listener;
},
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
const { container } = render(
<ChatApp
user={identity}
onLogout={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Открыть меню" }));
expect(screen.getByRole("dialog", { name: "История чатов" })).toBeInTheDocument();
expect(container.querySelectorAll("[inert]")).toHaveLength(2);
act(() => onBreakpointChange?.({ matches: true } as MediaQueryListEvent));
expect(screen.queryByRole("dialog", { name: "История чатов" })).not.toBeInTheDocument();
expect(container.querySelectorAll("[inert]")).toHaveLength(0);
});
it("logs out from the authenticated sidebar", async () => {
const user = userEvent.setup();
const onLogout = vi.fn();
render(
<ChatApp
user={identity}
onLogout={onLogout}
/>,
);
await user.click(screen.getByRole("button", { name: "Выйти" }));
expect(onLogout).toHaveBeenCalledOnce();
});
});