// @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChatInput } from "./ChatInput"; // Tell React this environment uses act() for event flushing. // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; describe("ChatInput", () => { let container: HTMLDivElement; let root: ReturnType | null = null; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = null; }); afterEach(() => { if (root) { act(() => { root!.unmount(); }); root = null; } if (container.parentNode) { container.remove(); } }); function renderChatInput(props: { onSend: (content: string) => void; isSubmitting?: boolean; disabled?: boolean }) { root = createRoot(container); act(() => { root!.render(); }); return { getTextarea: () => container.querySelector("textarea")!, getSendButton: () => container.querySelector("button[aria-label='Send message']") as HTMLButtonElement | null, }; } describe("keyboard shortcuts (INPUT-07)", () => { it("calls onSend when Enter is pressed without Shift", () => { const onSend = vi.fn(); const { getTextarea } = renderChatInput({ onSend }); const textarea = getTextarea(); act(() => { // Set value Object.defineProperty(textarea, "value", { writable: true, value: "Hello world", }); const inputEvent = new Event("input", { bubbles: true }); textarea.dispatchEvent(inputEvent); }); act(() => { const keydown = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, shiftKey: false, }); textarea.dispatchEvent(keydown); }); expect(onSend).toHaveBeenCalledWith("Hello world"); }); it("inserts newline when Shift+Enter is pressed", () => { const onSend = vi.fn(); const { getTextarea } = renderChatInput({ onSend }); const textarea = getTextarea(); act(() => { Object.defineProperty(textarea, "value", { writable: true, value: "Hello", }); const inputEvent = new Event("input", { bubbles: true }); textarea.dispatchEvent(inputEvent); }); act(() => { const keydown = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, shiftKey: true, }); textarea.dispatchEvent(keydown); }); // onSend should NOT have been called expect(onSend).not.toHaveBeenCalled(); }); it("clears input when Escape is pressed", () => { const onSend = vi.fn(); const { getTextarea } = renderChatInput({ onSend }); const textarea = getTextarea(); act(() => { Object.defineProperty(textarea, "value", { writable: true, value: "Some text", }); const inputEvent = new Event("input", { bubbles: true }); textarea.dispatchEvent(inputEvent); }); act(() => { const keydown = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, }); textarea.dispatchEvent(keydown); }); expect(textarea.value).toBe(""); }); it("does not send when input is empty", () => { const onSend = vi.fn(); const { getTextarea } = renderChatInput({ onSend }); const textarea = getTextarea(); act(() => { const keydown = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, shiftKey: false, }); textarea.dispatchEvent(keydown); }); expect(onSend).not.toHaveBeenCalled(); }); }); describe("auto-resize (INPUT-01)", () => { it("textarea has max-height constraint", () => { const onSend = vi.fn(); const { getTextarea } = renderChatInput({ onSend }); const textarea = getTextarea(); // The max-h-[160px] class should be on the textarea expect(textarea.className).toContain("max-h-[160px]"); }); }); describe("submit state", () => { it("disables send button when isSubmitting is true", () => { const onSend = vi.fn(); const { getSendButton } = renderChatInput({ onSend, isSubmitting: true }); const btn = getSendButton(); expect(btn).not.toBeNull(); expect(btn!.disabled).toBe(true); }); }); describe("file upload (FILE-05)", () => { it("renders file attach button", () => { const onSend = vi.fn(); renderChatInput({ onSend }); const fileInput = container.querySelector("input[type='file']"); expect(fileInput).not.toBeNull(); }); it("calls onFilesPicked when file input changes", () => { const onSend = vi.fn(); const onFilesPicked = vi.fn(); root = createRoot(container); act(() => { root!.render(); }); const fileInput = container.querySelector("input[type='file']") as HTMLInputElement; expect(fileInput).not.toBeNull(); const mockFile = new File(["content"], "test.txt", { type: "text/plain" }); act(() => { Object.defineProperty(fileInput, "files", { value: [mockFile], writable: false, }); fileInput.dispatchEvent(new Event("change", { bubbles: true })); }); expect(onFilesPicked).toHaveBeenCalledWith([mockFile]); }); it("shows pending file chips", () => { const onSend = vi.fn(); const pendingFiles = [ { id: "temp-1", file: new File([], "image.png"), name: "image.png", mimeType: "image/png", sizeBytes: 1024, progress: 50, status: "uploading" as const, }, ]; root = createRoot(container); act(() => { root!.render(); }); const chip = container.querySelector(".bg-muted"); expect(chip).not.toBeNull(); expect(chip!.textContent).toContain("image.png"); }); }); });