import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { ChatView } from "../ChatView"; import * as useChatModule from "../../hooks/useChat"; import * as useChatRoomsModule from "../../hooks/useChatRooms"; import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat"; import type { UseChatRoomsResult } from "../../hooks/useChatRooms"; import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard"; vi.mock("../../hooks/useChat"); vi.mock("../../hooks/useChatRooms"); vi.mock("../../api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchAgents: vi.fn().mockResolvedValue([ { id: "agent-1", name: "Alpha", role: "executor", state: "idle", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} }, ]), }; }); const mockUseChat = vi.mocked(useChatModule.useChat); const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms); const activeSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", }; const defaultChatState: UseChatReturn = { sessions: [activeSession], activeSession, sessionsLoading: false, messages: [], messagesLoading: false, isStreaming: false, streamingText: "", streamingThinking: "", streamingToolCalls: [], selectSession: vi.fn(), createSession: vi.fn(), archiveSession: vi.fn(), deleteSession: vi.fn(), sendMessage: vi.fn(), stopStreaming: vi.fn(), pendingMessage: "", clearPendingMessage: vi.fn(), loadMoreMessages: vi.fn(), hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), filteredSessions: [activeSession], refreshSessions: vi.fn(), agentsMap: new Map(), }; const roomA = { id: "room-a", name: "Room A", slug: "room-a", description: null, projectId: "proj-123", createdBy: "agent-1", status: "active" as const, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", }; const defaultRoomsState: UseChatRoomsResult = { rooms: [roomA], roomsLoading: false, roomsError: null, activeRoom: roomA, activeRoomMembers: [], messages: [{ id: "rmsg-1", roomId: "room-a", role: "user", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: null, mentions: [] }], messagesLoading: false, selectRoom: vi.fn(), createRoom: vi.fn(), deleteRoom: vi.fn(), sendRoomMessage: vi.fn(), refreshRooms: vi.fn(), }; function setup(chatOverrides: Partial = {}, roomsOverrides: Partial = {}) { mockUseChat.mockReturnValue({ ...defaultChatState, ...chatOverrides }); mockUseChatRooms.mockReturnValue({ ...defaultRoomsState, ...roomsOverrides }); } function mockMobileViewport() { if (!window.matchMedia) { Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true }); } Object.defineProperty(window, "innerWidth", { value: 375, configurable: true }); return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ matches: query === "(max-width: 768px)", media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })); } function mockMobileVisualViewport({ innerHeight, vvHeight }: { innerHeight: number; vvHeight: number }) { const resizeListeners = new Set<() => void>(); const scrollListeners = new Set<() => void>(); const mockVV = { height: vvHeight, offsetTop: 0, addEventListener: vi.fn((event: string, cb: () => void) => { if (event === "resize") resizeListeners.add(cb); if (event === "scroll") scrollListeners.add(cb); }), removeEventListener: vi.fn((event: string, cb: () => void) => { if (event === "resize") resizeListeners.delete(cb); if (event === "scroll") scrollListeners.delete(cb); }), }; Object.defineProperty(window, "innerHeight", { value: innerHeight, configurable: true, writable: true }); Object.defineProperty(window, "visualViewport", { value: mockVV, configurable: true, writable: true }); return { mockVV, listeners: { resize: resizeListeners, scroll: scrollListeners } }; } describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { beforeEach(() => { _resetInitialViewportHeight(); vi.clearAllMocks(); if (!window.matchMedia) { Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true }); } vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })); localStorage.setItem("fusion:chat-scope", "rooms"); setup(); }); it("renders Direct/Rooms toggle and allows room selection without message leakage", async () => { const selectRoom = vi.fn(); const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" }; setup({}, { rooms: [roomA, roomB], selectRoom }); render(); expect(screen.getByTestId("chat-sidebar-scope-direct")).toBeInTheDocument(); expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument(); expect(screen.getByText("Room hello")).toBeInTheDocument(); await userEvent.click(screen.getByTestId("chat-room-item-room-b")); expect(selectRoom).toHaveBeenCalledWith("room-b"); }); it("creates room via modal and sends room message on Enter", async () => { const createRoom = vi.fn().mockResolvedValue({ ...roomA, id: "room-new", name: "Room New", slug: "room-new" }); const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { createRoom, sendRoomMessage }); render(); await userEvent.click(screen.getByTestId("chat-create-room-btn")); await userEvent.type(screen.getByLabelText("Room name"), "room-new"); await userEvent.click(await screen.findByRole("button", { name: /Alpha/i })); const modal = screen.getByRole("dialog", { name: "Create room" }); await userEvent.click(within(modal).getByRole("button", { name: "Create room" })); await waitFor(() => { expect(createRoom).toHaveBeenCalledWith({ name: "room-new", memberAgentIds: ["agent-1"] }); }); const textarea = screen.getByTestId("chat-input"); await userEvent.type(textarea, "Hello room{enter}"); await waitFor(() => { expect(sendRoomMessage).toHaveBeenCalledWith("Hello room"); }); await waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe(""); }); }); it("keeps room composer text and toasts once when room send fails", async () => { const addToast = vi.fn(); const sendRoomMessage = vi.fn().mockRejectedValue(new Error("Room backend failed")); setup({}, { sendRoomMessage, activeRoom: roomA }); render(); const textarea = screen.getByTestId("chat-input"); await userEvent.type(textarea, "Will retry{enter}"); await waitFor(() => { expect(sendRoomMessage).toHaveBeenCalledWith("Will retry"); }); await waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe("Will retry"); }); expect(addToast).toHaveBeenCalledTimes(1); expect(addToast).toHaveBeenCalledWith("Room backend failed", "error"); }); it("supports delete-room confirm/cancel and rerenders messages from hook state", async () => { const deleteRoom = vi.fn().mockResolvedValue(undefined); const rerenderedRooms = { ...defaultRoomsState, messages: [{ id: "rmsg-2", roomId: "room-a", role: "assistant", content: "Updated room reply", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-2", mentions: [] }], deleteRoom, }; mockUseChat.mockReturnValue(defaultChatState); mockUseChatRooms .mockReturnValueOnce({ ...defaultRoomsState, deleteRoom }) .mockReturnValue(rerenderedRooms); const { rerender } = render(); await userEvent.click(screen.getByTestId("chat-room-delete-room-a")); await userEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(deleteRoom).not.toHaveBeenCalled(); await userEvent.click(screen.getByTestId("chat-room-delete-room-a")); await userEvent.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { expect(deleteRoom).toHaveBeenCalledWith("room-a"); }); rerender(); expect(screen.getByText("Updated room reply")).toBeInTheDocument(); }); it("shows mobile back button in room thread view", () => { const mediaSpy = mockMobileViewport(); setup(); render(); expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); mediaSpy.mockRestore(); }); it("keeps room composer touch-focus behavior in parity with direct chat on mobile", async () => { const mediaSpy = mockMobileViewport(); setup( { activeSession, messages: [{ id: "msg-1", sessionId: activeSession.id, role: "assistant", content: "Direct hello", createdAt: "2026-04-08T00:00:00.000Z" }], }, { activeRoom: roomA, messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }], }, ); render(); const roomInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; const roomFocusSpy = vi.spyOn(roomInput, "focus"); await act(async () => { fireEvent.touchStart(roomInput); }); expect(roomFocusSpy).toHaveBeenCalledWith({ preventScroll: true }); await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct")); const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; const directFocusSpy = vi.spyOn(directInput, "focus"); await act(async () => { fireEvent.touchStart(directInput); }); expect(directFocusSpy).toHaveBeenCalledWith({ preventScroll: true }); mediaSpy.mockRestore(); }); it("applies keyboard-active thread layout in room mode on mobile and preserves direct-chat parity", async () => { const mediaSpy = mockMobileViewport(); const { listeners, mockVV } = mockMobileVisualViewport({ innerHeight: 800, vvHeight: 800 }); const originalVisualViewport = window.visualViewport; const originalInnerHeight = window.innerHeight; try { setup( { activeSession: activeSession, messages: [{ id: "msg-1", sessionId: activeSession.id, role: "assistant", content: "Direct hello", createdAt: "2026-04-08T00:00:00.000Z" }], }, { activeRoom: roomA, messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }], }, ); render(); const input = screen.getByTestId("chat-input") as HTMLTextAreaElement; await act(async () => { input.focus(); }); act(() => { document.dispatchEvent(new Event("focusin")); }); Object.defineProperty(window, "innerHeight", { value: 560, configurable: true, writable: true }); Object.defineProperty(mockVV, "height", { value: 560, configurable: true, writable: true }); act(() => { for (const cb of listeners.resize) cb(); }); const roomThread = document.querySelector(".chat-thread") as HTMLDivElement; await waitFor(() => { expect(roomThread.classList.contains("chat-thread--keyboard-active")).toBe(true); expect(roomThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px"); }); await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct")); const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; await act(async () => { directInput.focus(); }); act(() => { document.dispatchEvent(new Event("focusin")); }); const directThread = document.querySelector(".chat-thread") as HTMLDivElement; await waitFor(() => { expect(directThread.classList.contains("chat-thread--keyboard-active")).toBe(true); expect(directThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px"); }); } finally { Object.defineProperty(window, "visualViewport", { value: originalVisualViewport, configurable: true, writable: true }); Object.defineProperty(window, "innerHeight", { value: originalInnerHeight, configurable: true, writable: true }); mediaSpy.mockRestore(); } }); it("keeps direct mode behavior unchanged when rooms are enabled", async () => { localStorage.setItem("fusion:chat-scope", "direct"); const addToast = vi.fn(); const sendMessage = vi.fn(); const sendRoomMessage = vi.fn().mockRejectedValue(new Error("Room backend failed")); setup({ sendMessage }, { sendRoomMessage, activeRoom: roomA }); render(); const textarea = screen.getByTestId("chat-input"); await userEvent.type(textarea, "Direct hello{enter}"); expect(sendMessage).toHaveBeenCalledWith("Direct hello", []); expect(sendRoomMessage).not.toHaveBeenCalled(); expect(addToast).not.toHaveBeenCalled(); }); });