storkit: merge 438_story_slash_command_autocomplete_in_web_ui_text_input
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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 ");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user