diff --git a/mise.toml b/mise.toml index b0e2a6e..9ee6a6e 100644 --- a/mise.toml +++ b/mise.toml @@ -38,6 +38,7 @@ outputs = ['node_modules/.pnpm/lock.yaml'] alias = 'f' description = 'Format code' run = 'pnpm run format' +depends = ['install'] [tasks.lint] alias = 'l' diff --git a/src/components/chat/client.test.tsx b/src/components/chat/client.test.tsx new file mode 100644 index 0000000..09c1b23 --- /dev/null +++ b/src/components/chat/client.test.tsx @@ -0,0 +1,184 @@ +/* eslint-disable react/display-name, @typescript-eslint/no-explicit-any */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { render, act, fireEvent } from "@testing-library/react"; + +import ChatWidgetClient from "./client"; + +// Mocks +const mockToggleChat = vi.fn(); +const mockHandleInputChange = vi.fn(); +const mockHandleSubmit = vi.fn(); +const mockStop = vi.fn(); +const mockSetMessages = vi.fn(); +const mockSetInput = vi.fn(); +const mockCopyToClipboard = vi.fn(); +const mockHandleEdit = vi.fn(); +const mockHandleSuggestedQuestion = vi.fn(); + +let mockUseChatWidgetResult: any = {}; + +vi.mock("./use-chat-widget", () => ({ + useChatWidget: () => mockUseChatWidgetResult, +})); + +vi.mock("@heroui/react", async (importOriginal) => { + const actual: any = await importOriginal(); + const MockTooltip = ({ children }: any) =>
{children}
; + MockTooltip.Trigger = ({ children }: any) => <>{children}; + MockTooltip.Content = ({ children }: any) =>
{children}
; + MockTooltip.Arrow = () => null; + + return { + ...actual, + Tooltip: MockTooltip, + }; +}); + +describe("ChatWidgetClient", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseChatWidgetResult = { + isOpen: false, + toggleChat: mockToggleChat, + input: "", + handleInputChange: mockHandleInputChange, + handleSubmit: mockHandleSubmit, + messages: [], + isLoading: false, + error: null, + getErrorMessage: () => "", + stop: mockStop, + scrollRef: { current: null }, + inputRef: { current: null }, + copiedId: null, + copyToClipboard: mockCopyToClipboard, + handleEdit: mockHandleEdit, + handleSuggestedQuestion: mockHandleSuggestedQuestion, + isFilterBarVisible: false, + status: "idle", + setMessages: mockSetMessages, + setInput: mockSetInput, + }; + }); + + it("should render only toggle button when chat is closed", () => { + const { getByLabelText, queryByText } = render(); + + expect(getByLabelText("Open AI assistant")).toBeInTheDocument(); + expect(queryByText("Miro — Amr's Assistant")).not.toBeInTheDocument(); + + act(() => { + getByLabelText("Open AI assistant").click(); + }); + expect(mockToggleChat).toHaveBeenCalled(); + }); + + it("should render chat window and its contents when open", () => { + mockUseChatWidgetResult.isOpen = true; + const { + getAllByLabelText, + getByText, + getByPlaceholderText, + getByLabelText, + } = render(); + + expect(getByText("Miro — Amr's Assistant")).toBeInTheDocument(); + expect(getAllByLabelText("Close AI assistant").length).toBe(2); + expect(getByPlaceholderText("Ask a question...")).toBeInTheDocument(); + expect(getByLabelText("Ask a question to Miro")).toBeInTheDocument(); + + const textarea = getByLabelText("Ask a question to Miro"); + act(() => { + fireEvent.change(textarea, { target: { value: "Hello" } }); + }); + expect(mockHandleInputChange).toHaveBeenCalled(); + }); + + it("should render suggested questions when empty and not loading", () => { + mockUseChatWidgetResult.isOpen = true; + mockUseChatWidgetResult.messages = []; + const { getByText } = render(); + + const suggestedQuestion = getByText("What was his PhD research about?"); + expect(suggestedQuestion).toBeInTheDocument(); + + act(() => { + suggestedQuestion.click(); + }); + expect(mockHandleSuggestedQuestion).toHaveBeenCalledWith( + "What was his PhD research about?", + ); + }); + + it("should render messages list, typing indicator and stop button when loading", () => { + mockUseChatWidgetResult.isOpen = true; + mockUseChatWidgetResult.isLoading = true; + mockUseChatWidgetResult.status = "submitted"; + mockUseChatWidgetResult.messages = [ + { + id: "1", + role: "user", + content: "Tell me about Amr", + parts: [{ type: "text", text: "Tell me about Amr" }], + }, + ]; + + const { getByLabelText, getByText, queryByLabelText } = render( + , + ); + + expect(getByText("Tell me about Amr")).toBeInTheDocument(); + expect(getByLabelText("Stop generating")).toBeInTheDocument(); + expect(queryByLabelText("Send message")).not.toBeInTheDocument(); + + // Reset button is visible since messages.length > 0 + const resetBtn = getByLabelText("Reset conversation"); + expect(resetBtn).toBeInTheDocument(); + + act(() => { + resetBtn.click(); + }); + expect(mockStop).toHaveBeenCalled(); + expect(mockSetMessages).toHaveBeenCalledWith([]); + expect(mockSetInput).toHaveBeenCalledWith(""); + }); + + it("should trigger form submit on key down Enter without Shift key", () => { + mockUseChatWidgetResult.isOpen = true; + mockUseChatWidgetResult.input = "Question"; + const { getByLabelText } = render(); + + const textarea = getByLabelText("Ask a question to Miro"); + act(() => { + fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false }); + }); + + expect(mockHandleSubmit).toHaveBeenCalled(); + }); + + it("should allow shift Enter key down to add newlines without submit", () => { + mockUseChatWidgetResult.isOpen = true; + mockUseChatWidgetResult.input = "Question"; + const { getByLabelText } = render(); + + const textarea = getByLabelText("Ask a question to Miro"); + act(() => { + fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true }); + }); + + expect(mockHandleSubmit).not.toHaveBeenCalled(); + }); + + it("should render error message when error is present", () => { + mockUseChatWidgetResult.isOpen = true; + mockUseChatWidgetResult.error = new Error("Failed to generate response"); + mockUseChatWidgetResult.getErrorMessage = () => + "An error occurred. Please try again later."; + const { getByText } = render(); + + expect( + getByText("An error occurred. Please try again later."), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/chat/client.tsx b/src/components/chat/client.tsx index c9cd710..090ce0b 100644 --- a/src/components/chat/client.tsx +++ b/src/components/chat/client.tsx @@ -60,13 +60,14 @@ export default function ChatWidgetClient() { {messages.length > 0 && ( - + Reset conversation @@ -74,13 +75,22 @@ export default function ChatWidgetClient() { )} - + + + + + + Close AI assistant + + + @@ -155,6 +165,7 @@ export default function ChatWidgetClient() { value={input} onChange={handleInputChange} placeholder="Ask a question..." + aria-label="Ask a question to Miro" rows={1} className="chat-input-textarea" onKeyDown={(e) => { @@ -165,25 +176,45 @@ export default function ChatWidgetClient() { }} /> {isLoading ? ( - + + + + + + Stop generating + + + ) : ( - + + + + + + Send message + + + )} @@ -191,18 +222,26 @@ export default function ChatWidgetClient() { )} {/* Toggle Button */} - + + + + + + {isOpen ? "Close AI assistant" : "Open AI assistant"} + + + ); } diff --git a/vitest.config.ts b/vitest.config.ts index cda306c..94b179a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ "src/components/filterable-section.tsx", "src/components/featured-section-container.tsx", "src/components/header.tsx", + "src/components/chat/client.tsx", ], }, },