241 lines
7.0 KiB
TypeScript
241 lines
7.0 KiB
TypeScript
|
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
|
import { ChatInput } from "./ChatInput";
|
||
|
|
|
||
|
|
vi.mock("../api/client", () => ({
|
||
|
|
api: {
|
||
|
|
listProjectFiles: vi.fn().mockResolvedValue([]),
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
|
||
|
|
const defaultProps = {
|
||
|
|
loading: false,
|
||
|
|
queuedMessages: [],
|
||
|
|
onSubmit: vi.fn(),
|
||
|
|
onCancel: vi.fn(),
|
||
|
|
onRemoveQueuedMessage: vi.fn(),
|
||
|
|
};
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Slash command picker overlay (Story 438 AC1)", () => {
|
||
|
|
it("shows slash command picker when / is typed at position 0", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not show slash command picker for plain text", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "hello" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(
|
||
|
|
screen.queryByTestId("slash-command-picker"),
|
||
|
|
).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not show slash command picker when / is not at position 0", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "hello /world" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(
|
||
|
|
screen.queryByTestId("slash-command-picker"),
|
||
|
|
).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Slash command list (Story 438 AC2)", () => {
|
||
|
|
it("lists slash commands with name and description", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||
|
|
// First command should be /help
|
||
|
|
expect(screen.getByTestId("slash-command-item-0")).toBeInTheDocument();
|
||
|
|
expect(screen.getByTestId("slash-command-item-0")).toHaveTextContent(
|
||
|
|
"/help",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Slash command fuzzy filter (Story 438 AC3)", () => {
|
||
|
|
it("filters commands when typing after /", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/hel" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||
|
|
// /help should match "hel"
|
||
|
|
expect(screen.getByTestId("slash-command-item-0")).toHaveTextContent(
|
||
|
|
"/help",
|
||
|
|
);
|
||
|
|
// /rebuild should not be visible (no match for "hel")
|
||
|
|
const items = screen.queryAllByTestId(/^slash-command-item-/);
|
||
|
|
const texts = items.map((el) => el.textContent ?? "");
|
||
|
|
expect(texts.some((t) => t.includes("/rebuild"))).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows no picker when query matches nothing", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/zzzzz" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(
|
||
|
|
screen.queryByTestId("slash-command-picker"),
|
||
|
|
).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Slash command keyboard navigation (Story 438 AC4)", () => {
|
||
|
|
it("ArrowDown navigates to next item", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
const item0 = screen.getByTestId("slash-command-item-0");
|
||
|
|
expect(item0).toHaveStyle({ background: "#2d4a6e" });
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||
|
|
});
|
||
|
|
|
||
|
|
const item1 = screen.getByTestId("slash-command-item-1");
|
||
|
|
expect(item1).toHaveStyle({ background: "#2d4a6e" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("ArrowUp stays at 0 when already at top", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||
|
|
});
|
||
|
|
|
||
|
|
const item0 = screen.getByTestId("slash-command-item-0");
|
||
|
|
expect(item0).toHaveStyle({ background: "#2d4a6e" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("Enter selects the highlighted command and inserts it", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/hel" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(
|
||
|
|
screen.queryByTestId("slash-command-picker"),
|
||
|
|
).not.toBeInTheDocument();
|
||
|
|
expect((textarea as HTMLTextAreaElement).value).toBe("/help ");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("Tab selects the highlighted command and inserts it", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/hel" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.keyDown(textarea, { key: "Tab" });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(
|
||
|
|
screen.queryByTestId("slash-command-picker"),
|
||
|
|
).not.toBeInTheDocument();
|
||
|
|
expect((textarea as HTMLTextAreaElement).value).toBe("/help ");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("Escape dismisses the picker", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(
|
||
|
|
screen.queryByTestId("slash-command-picker"),
|
||
|
|
).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Slash command selection inserts with trailing space (Story 438 AC5)", () => {
|
||
|
|
it("clicking a command inserts /<command> with trailing space", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.click(screen.getByTestId("slash-command-item-0"));
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(
|
||
|
|
screen.queryByTestId("slash-command-picker"),
|
||
|
|
).not.toBeInTheDocument();
|
||
|
|
const val = (textarea as HTMLTextAreaElement).value;
|
||
|
|
expect(val).toMatch(/^\/\w+ $/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("selection inserts only the base command (no argument placeholders)", async () => {
|
||
|
|
render(<ChatInput {...defaultProps} />);
|
||
|
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.change(textarea, { target: { value: "/ass" } });
|
||
|
|
});
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect((textarea as HTMLTextAreaElement).value).toBe("/assign ");
|
||
|
|
});
|
||
|
|
});
|