import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { Agent, ChatSession } from "../../api"; import * as apiModule from "../../api"; import { useAgents } from "../../hooks/useAgents"; import { QuickChatFAB } from "../QuickChatFAB"; vi.mock("../../api", () => ({ fetchChatSessions: vi.fn(), createChatSession: vi.fn(), fetchChatMessages: vi.fn(), streamChatResponse: vi.fn(), })); vi.mock("../../hooks/useAgents", () => ({ useAgents: vi.fn(), })); const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions); const mockCreateChatSession = vi.mocked(apiModule.createChatSession); const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages); const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse); const mockUseAgents = vi.mocked(useAgents); const mockAgents: Agent[] = [ { id: "agent-001", name: "Agent One", role: "executor", state: "active", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, { id: "agent-002", name: "Agent Two", role: "reviewer", state: "terminated", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, ]; const mockSession: ChatSession = { id: "session-001", agentId: "agent-001", status: "active", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; function mockAgentsHook(agents: Agent[], isLoading = false) { mockUseAgents.mockReturnValue({ agents, activeAgents: agents.filter((agent) => agent.state === "active" || agent.state === "running"), stats: null, isLoading, loadAgents: vi.fn(), loadStats: vi.fn(), }); } function createMockStreamResponse() { const handlers: { onThinking?: (data: string) => void; onText?: (data: string) => void; onDone?: (data: { messageId: string }) => void; onError?: (data: string) => void; onConnectionStateChange?: (state: string) => void; } = {}; const mockStream = { close: vi.fn(), isConnected: vi.fn(() => true), // Allow setting handlers setHandlers: (h: typeof handlers) => { Object.assign(handlers, h); }, }; // Mock streamChatResponse to capture handlers and return mock stream mockStreamChatResponse.mockImplementation((sessionId, content, textHandlers) => { // Store handlers for test to invoke mockStream.setHandlers(textHandlers as typeof handlers); // Simulate async response setTimeout(() => { // Simulate streaming text textHandlers.onConnectionStateChange?.("connected"); textHandlers.onText?.("Thinking..."); textHandlers.onText?.("Here's my response."); textHandlers.onDone?.({ messageId: `msg-${Date.now()}` }); }, 10); return { close: mockStream.close, isConnected: mockStream.isConnected, }; }); return mockStream; } describe("QuickChatFAB", () => { const addToast = vi.fn(); beforeEach(() => { vi.clearAllMocks(); mockAgentsHook(mockAgents); mockFetchChatSessions.mockResolvedValue({ sessions: [] }); mockCreateChatSession.mockResolvedValue({ session: mockSession }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); createMockStreamResponse(); }); it("renders nothing when no agents exist", () => { mockAgentsHook([]); render(); expect(screen.queryByTestId("quick-chat-fab")).toBeNull(); }); it("renders FAB button when agents exist", () => { render(); expect(screen.getByTestId("quick-chat-fab")).toBeDefined(); }); it("opens chat panel when FAB is clicked", async () => { render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); await waitFor(() => { expect(screen.getByTestId("quick-chat-panel")).toBeDefined(); }); }); it("closes panel via close button and Escape key", async () => { render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); await waitFor(() => { expect(screen.getByTestId("quick-chat-panel")).toBeDefined(); }); fireEvent.click(screen.getByTestId("quick-chat-close")); await waitFor(() => { expect(screen.queryByTestId("quick-chat-panel")).toBeNull(); }); fireEvent.click(screen.getByTestId("quick-chat-fab")); await waitFor(() => { expect(screen.getByTestId("quick-chat-panel")).toBeDefined(); }); fireEvent.keyDown(document, { key: "Escape" }); await waitFor(() => { expect(screen.queryByTestId("quick-chat-panel")).toBeNull(); }); }); it("shows available agents in selector", async () => { render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); const select = await screen.findByTestId("quick-chat-agent-select"); expect(select).toBeDefined(); expect(screen.getByRole("option", { name: "Agent One (executor)" })).toBeDefined(); expect(screen.getByRole("option", { name: "Agent Two (reviewer)" })).toBeDefined(); }); it("sending a message calls streamChatResponse API with expected params", async () => { render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); // Wait for session initialization await waitFor(() => { expect(mockFetchChatSessions).toHaveBeenCalled(); }); const input = await screen.findByTestId("quick-chat-input"); fireEvent.change(input, { target: { value: "Ship it" } }); fireEvent.click(screen.getByTestId("quick-chat-send")); // Wait for streamChatResponse to be called await waitFor(() => { expect(mockStreamChatResponse).toHaveBeenCalledWith( "session-001", "Ship it", expect.objectContaining({ onThinking: expect.any(Function), onText: expect.any(Function), onDone: expect.any(Function), onError: expect.any(Function), }), "proj-123", ); }); // Input should be cleared await waitFor(() => { expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe(""); }); }); it("streaming state shows streaming message and disables input", async () => { render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); // Wait for session initialization await waitFor(() => { expect(mockFetchChatSessions).toHaveBeenCalled(); }); const input = await screen.findByTestId("quick-chat-input"); fireEvent.change(input, { target: { value: "Hello" } }); fireEvent.click(screen.getByTestId("quick-chat-send")); // Input should be cleared and disabled during streaming await waitFor(() => { expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe(""); }); expect(screen.getByTestId("quick-chat-input")).toBeDisabled(); }); it("after streaming completes, assistant message is shown", async () => { render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); // Wait for session initialization await waitFor(() => { expect(mockFetchChatSessions).toHaveBeenCalled(); }); const input = await screen.findByTestId("quick-chat-input"); fireEvent.change(input, { target: { value: "Hello" } }); fireEvent.click(screen.getByTestId("quick-chat-send")); // Wait for streaming to complete and message to appear await waitFor(() => { expect(screen.getByTestId("quick-chat-panel")).toBeDefined(); }); // After streaming completes, input should be re-enabled await waitFor(() => { expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled(); }); }); it("preserves user message after assistant reply completes", async () => { render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); // Wait for session initialization await waitFor(() => { expect(mockFetchChatSessions).toHaveBeenCalled(); }); const input = await screen.findByTestId("quick-chat-input"); fireEvent.change(input, { target: { value: "Hello" } }); fireEvent.click(screen.getByTestId("quick-chat-send")); // Wait for streaming to complete await waitFor(() => { expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled(); }); // Check that user's "Hello" message is preserved expect(screen.getByText("Hello")).toBeDefined(); // Check that assistant response is shown (mock concatenates thinking + text) // The mock sends "Thinking..." then "Here's my response." which concatenates expect(screen.getByText(/Here's my response/)).toBeDefined(); }); it("switching agents creates a new session for the selected agent", async () => { // First session exists mockFetchChatSessions.mockResolvedValueOnce({ sessions: [mockSession] }); render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); // Wait for initial session to be created await waitFor(() => { expect(mockFetchChatSessions).toHaveBeenCalled(); }); // Switch to agent-002 fireEvent.change(screen.getByTestId("quick-chat-agent-select"), { target: { value: "agent-002" }, }); // Should create a new session for agent-002 await waitFor(() => { expect(mockCreateChatSession).toHaveBeenCalledWith({ agentId: "agent-002" }, "proj-123"); }); }); it("shows placeholder text when conversation is empty", async () => { mockFetchChatMessages.mockResolvedValue({ messages: [] }); render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); await waitFor(() => { expect(screen.getByText("No messages yet. Start the conversation!")).toBeDefined(); }); }); it("closes panel when clicking outside", async () => { render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); await waitFor(() => { expect(screen.getByTestId("quick-chat-panel")).toBeDefined(); }); fireEvent.mouseDown(document.body); await waitFor(() => { expect(screen.queryByTestId("quick-chat-panel")).toBeNull(); }); }); it("hides FAB button when showFAB is false", () => { render(); expect(screen.queryByTestId("quick-chat-fab")).toBeNull(); }); it("still opens panel programmatically when showFAB is false with controlled open prop", async () => { render(); expect(screen.queryByTestId("quick-chat-fab")).toBeNull(); expect(screen.getByTestId("quick-chat-panel")).toBeDefined(); }); it("controlled open prop opens panel without clicking FAB", () => { render(); expect(screen.getByTestId("quick-chat-panel")).toBeDefined(); }); it("controlled open prop defaults to closed when not set", () => { render(); expect(screen.queryByTestId("quick-chat-panel")).toBeNull(); }); it("onOpenChange callback is called when panel is opened via FAB (controlled mode)", async () => { const onOpenChange = vi.fn(); render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); await waitFor(() => { expect(onOpenChange).toHaveBeenCalledWith(true); }); }); it("onOpenChange callback is called when panel is closed via FAB", async () => { const onOpenChange = vi.fn(); render(); // Panel should be open initially expect(screen.getByTestId("quick-chat-panel")).toBeDefined(); fireEvent.click(screen.getByTestId("quick-chat-fab")); await waitFor(() => { expect(onOpenChange).toHaveBeenCalledWith(false); }); }); it("error handling shows toast on stream error", async () => { // Mock streamChatResponse to trigger error mockStreamChatResponse.mockImplementationOnce((sessionId, content, textHandlers) => { setTimeout(() => { textHandlers.onError?.("Stream connection failed"); }, 10); return { close: vi.fn(), isConnected: vi.fn(() => false), }; }); render(); fireEvent.click(screen.getByTestId("quick-chat-fab")); // Wait for session initialization await waitFor(() => { expect(mockFetchChatSessions).toHaveBeenCalled(); }); const input = await screen.findByTestId("quick-chat-input"); fireEvent.change(input, { target: { value: "Hello" } }); fireEvent.click(screen.getByTestId("quick-chat-send")); // Wait for error toast await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Failed to send message", "error"); }); }); // Drag-related tests describe("draggable behavior", () => { const localStorageMock = { store: {} as Record, getItem: vi.fn((key: string) => localStorageMock.store[key] ?? null), setItem: vi.fn((key: string, value: string) => { localStorageMock.store[key] = value; }), removeItem: vi.fn((key: string) => { delete localStorageMock.store[key]; }), clear: vi.fn(() => { localStorageMock.store = {}; }), }; beforeEach(() => { vi.stubGlobal("localStorage", localStorageMock); localStorageMock.store = {}; localStorageMock.getItem.mockClear(); localStorageMock.setItem.mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("FAB can be dragged to a new position", async () => { render(); const fab = screen.getByTestId("quick-chat-fab"); // Simulate drag: pointerdown -> pointermove -> pointerup const fabRect = { left: window.innerWidth - 72, top: window.innerHeight - 72, width: 48, height: 48 }; vi.spyOn(fab, "getBoundingClientRect").mockReturnValue(fabRect as DOMRect); // Start drag fireEvent.pointerDown(fab, { clientX: fabRect.left + 24, clientY: fabRect.top + 24, button: 0, pointerId: 1, }); // Verify data-dragging attribute is set expect(fab.getAttribute("data-dragging")).toBe("true"); // Move pointer (drag 50px to the left, which means increasing right offset) fireEvent.pointerMove(fab, { clientX: fabRect.left + 24 - 50, clientY: fabRect.top + 24, pointerId: 1, }); // End drag fireEvent.pointerUp(fab, { clientX: fabRect.left + 24 - 50, clientY: fabRect.top + 24, button: 0, pointerId: 1, }); // Verify localStorage was called with the new position expect(localStorageMock.setItem).toHaveBeenCalledWith( "fusion-quick-chat-position-proj-123", expect.stringContaining('"x":'), ); }); it("small movement (< 5px) is treated as click not drag", async () => { const onOpenChange = vi.fn(); render(); const fab = screen.getByTestId("quick-chat-fab"); // Simulate click: pointerDown + small pointerMove (< 5px) + pointerUp + click // Since movement is < 5px, this should be treated as a click, not a drag fireEvent.pointerDown(fab, { clientX: 100, clientY: 100, button: 0, pointerId: 1, }); // Small movement (less than 5px threshold) fireEvent.pointerMove(fab, { clientX: 102, clientY: 100, pointerId: 1, }); fireEvent.pointerUp(fab, { clientX: 102, clientY: 100, button: 0, pointerId: 1, }); // jsdom doesn't fire click automatically after pointerup with movement < threshold, // so we simulate the click event that would fire in a real browser fireEvent.click(fab); // Should have toggled panel (treated as click) expect(onOpenChange).toHaveBeenCalledWith(true); // Should NOT have saved position to localStorage (was a click, not a drag) expect(localStorageMock.setItem).not.toHaveBeenCalled(); }); it("FAB position is loaded from localStorage on mount", async () => { // Pre-populate localStorage with saved position localStorageMock.store["fusion-quick-chat-position-proj-123"] = JSON.stringify({ x: 100, y: 200 }); render(); const fab = screen.getByTestId("quick-chat-fab"); // Verify the FAB has the saved position expect(fab.style.right).toBe("100px"); expect(fab.style.bottom).toBe("200px"); // Verify localStorage was read expect(localStorageMock.getItem).toHaveBeenCalledWith("fusion-quick-chat-position-proj-123"); }); it("FAB position is clamped to viewport boundaries", async () => { render(); const fab = screen.getByTestId("quick-chat-fab"); // Simulate drag to extreme position (off viewport) fireEvent.pointerDown(fab, { clientX: 100, clientY: 100, button: 0, pointerId: 1, }); // Try to drag way off screen (negative coordinates) fireEvent.pointerMove(fab, { clientX: -1000, clientY: -1000, pointerId: 1, }); fireEvent.pointerUp(fab, { clientX: -1000, clientY: -1000, button: 0, pointerId: 1, }); // Position should be clamped to at least 48px from edges const savedPosition = JSON.parse(localStorageMock.setItem.mock.calls[0]?.[1] || "{}"); expect(savedPosition.x).toBeGreaterThanOrEqual(48); expect(savedPosition.y).toBeGreaterThanOrEqual(48); }); it("touch events work for dragging", async () => { render(); const fab = screen.getByTestId("quick-chat-fab"); // Simulate touch drag fireEvent.pointerDown(fab, { clientX: 100, clientY: 100, button: 0, pointerId: 1, pointerType: "touch", }); // Verify data-dragging attribute is set expect(fab.getAttribute("data-dragging")).toBe("true"); // Move touch fireEvent.pointerMove(fab, { clientX: 50, clientY: 100, pointerId: 1, pointerType: "touch", }); // End touch fireEvent.pointerUp(fab, { clientX: 50, clientY: 100, button: 0, pointerId: 1, pointerType: "touch", }); // Position should have been saved expect(localStorageMock.setItem).toHaveBeenCalled(); }); it("panel position anchors relative to FAB position", async () => { render(); const panel = screen.getByTestId("quick-chat-panel"); const fab = screen.getByTestId("quick-chat-fab"); // Initial positions (FAB at x=24, y=24+footer, panel at x=24, y=84+footer = FAB.y + 60) const fabBottom = parseFloat(fab.style.bottom); const panelBottom = parseFloat(panel.style.bottom); expect(panelBottom - fabBottom).toBe(60); }); }); });