195 lines
5.5 KiB
TypeScript
195 lines
5.5 KiB
TypeScript
|
|
import {
|
||
|
|
act,
|
||
|
|
fireEvent,
|
||
|
|
render,
|
||
|
|
screen,
|
||
|
|
waitFor,
|
||
|
|
} from "@testing-library/react";
|
||
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
|
import { api } from "../api/client";
|
||
|
|
import { ChatInput } from "./ChatInput";
|
||
|
|
|
||
|
|
vi.mock("../api/client", () => ({
|
||
|
|
api: {
|
||
|
|
listProjectFiles: vi.fn(),
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
|
||
|
|
const mockedListProjectFiles = vi.mocked(api.listProjectFiles);
|
||
|
|
|
||
|
|
const defaultProps = {
|
||
|
|
loading: false,
|
||
|
|
queuedMessages: [],
|
||
|
|
onSubmit: vi.fn(),
|
||
|
|
onCancel: vi.fn(),
|
||
|
|
onRemoveQueuedMessage: vi.fn(),
|
||
|
|
};
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
mockedListProjectFiles.mockResolvedValue([
|
||
|
|
"src/main.rs",
|
||
|
|
"src/lib.rs",
|
||
|
|
"frontend/index.html",
|
||
|
|
"README.md",
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("File picker overlay (Story 269 AC1)", () => {
|
||
|
|
it("shows file picker overlay when @ is typed", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not show file picker overlay for text without @", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "hello world" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("File picker fuzzy matching (Story 269 AC2)", () => {
|
||
|
|
it("filters files by query typed after @", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "@main" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
// main.rs should be visible, README.md should not
|
||
|
|
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
|
||
|
|
expect(screen.queryByText("README.md")).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows all files when @ is typed with no query", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
// All 4 files should be visible
|
||
|
|
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
|
||
|
|
expect(screen.getByText("src/lib.rs")).toBeInTheDocument();
|
||
|
|
expect(screen.getByText("README.md")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("File picker selection (Story 269 AC3)", () => {
|
||
|
|
it("clicking a file inserts @path into the message", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId("file-picker-item-0")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.click(screen.getByTestId("file-picker-item-0"));
|
||
|
|
});
|
||
|
|
|
||
|
|
// Picker should be dismissed and the file reference inserted
|
||
|
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||
|
|
expect((textarea as HTMLTextAreaElement).value).toMatch(/^@\S+/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("Enter key selects highlighted file and inserts it into message", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "@main" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||
|
|
expect((textarea as HTMLTextAreaElement).value).toContain("@src/main.rs");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("File picker dismiss (Story 269 AC5)", () => {
|
||
|
|
it("Escape key dismisses the file picker", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Multiple @ references (Story 269 AC6)", () => {
|
||
|
|
it("typing @ after a completed reference triggers picker again", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
// First reference
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "@main" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Select file
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||
|
|
});
|
||
|
|
|
||
|
|
// Type a second @
|
||
|
|
await act(async () => {
|
||
|
|
const current = (textarea as HTMLTextAreaElement).value;
|
||
|
|
fireEvent.change(textarea, { target: { value: `${current} @` } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|