import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomModelDropdown } from "../CustomModelDropdown";
import type { ModelInfo } from "../../api";
// Mock ProviderIcon to avoid rendering actual icons in most tests
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => ,
}));
const MOCK_MODELS: ModelInfo[] = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "anthropic", id: "claude-opus-4", name: "Claude Opus 4", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
{ provider: "ollama", id: "llama3", name: "Llama 3", reasoning: false, contextWindow: 4096 },
];
const defaultProps = {
models: MOCK_MODELS,
value: "",
onChange: vi.fn(),
label: "Test Model",
id: "test-model",
};
describe("CustomModelDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders trigger button with placeholder text", () => {
render();
expect(screen.getByLabelText("Test Model")).toBeInTheDocument();
expect(screen.getByText("Use default")).toBeInTheDocument();
});
it("renders trigger button with selected model name", () => {
render();
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
});
it("shows provider icon in trigger when model is selected", () => {
render();
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
});
it("does not show provider icon in trigger when using default", () => {
render();
expect(screen.queryByTestId(/provider-icon-/)).not.toBeInTheDocument();
});
it("opens dropdown when trigger is clicked", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
expect(screen.getByText("4 models")).toBeInTheDocument();
});
it("groups models by provider in dropdown", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
// Provider groups should be visible with icons
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
expect(screen.getByTestId("provider-icon-openai")).toBeInTheDocument();
expect(screen.getByTestId("provider-icon-ollama")).toBeInTheDocument();
});
it("displays provider names in group headers", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByText("anthropic")).toBeInTheDocument();
expect(screen.getByText("openai")).toBeInTheDocument();
expect(screen.getByText("ollama")).toBeInTheDocument();
});
it("displays model names in dropdown", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
expect(screen.getByText("Llama 3")).toBeInTheDocument();
});
it("displays model IDs next to names", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument();
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
});
it("calls onChange when a model is selected", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(screen.getByLabelText("Test Model"));
await user.click(screen.getByText("GPT-4o"));
expect(onChange).toHaveBeenCalledWith("openai/gpt-4o");
});
it("calls onChange with empty string when 'Use default' is selected", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(screen.getByLabelText("Test Model"));
// Find and click the "Use default" option (it's always first)
const defaultOptions = screen.getAllByText("Use default");
// Click the one in the dropdown list (not the trigger)
const dropdownDefault = defaultOptions.find((el) => el.classList.contains("model-combobox-option-text--default"));
if (dropdownDefault) {
await user.click(dropdownDefault);
}
expect(onChange).toHaveBeenCalledWith("");
});
it("filters models when typing in search input", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "openai");
expect(screen.getByText("1 model")).toBeInTheDocument();
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
});
it("filters models by model name", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "opus");
expect(screen.getByText("1 model")).toBeInTheDocument();
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
});
it("clear button clears filter and restores full list", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "openai");
expect(screen.getByText("1 model")).toBeInTheDocument();
await user.click(screen.getByLabelText("Clear filter"));
expect(searchInput).toHaveValue("");
expect(screen.getByText("4 models")).toBeInTheDocument();
});
it("shows empty state when filter matches nothing", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "xyz123");
expect(screen.getByText("0 models")).toBeInTheDocument();
expect(screen.getByText(/No models match/)).toBeInTheDocument();
});
it("closes dropdown when clicking outside", async () => {
const user = userEvent.setup();
render(
);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
await user.click(screen.getByTestId("outside"));
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
it("closes dropdown on Escape key", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
it("opens dropdown with arrow down key", async () => {
const user = userEvent.setup();
render();
screen.getByLabelText("Test Model").focus();
await user.keyboard("{ArrowDown}");
await waitFor(() => {
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
});
});
it("navigates with arrow keys and selects with Enter", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
screen.getByLabelText("Test Model").focus();
await user.keyboard("{ArrowDown}");
await waitFor(() => {
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
});
// Navigate down and press Enter
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
await waitFor(() => {
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
expect(onChange).toHaveBeenCalled();
});
it("is disabled when disabled prop is true", () => {
render();
expect(screen.getByLabelText("Test Model")).toBeDisabled();
});
it("does not open when disabled", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
it("has correct ARIA attributes", async () => {
const user = userEvent.setup();
render();
const trigger = screen.getByLabelText("Test Model");
// Native button element - role is implicit
expect(trigger.tagName.toLowerCase()).toBe("button");
expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
expect(trigger).toHaveAttribute("aria-expanded", "false");
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
});
it("marks selected option with aria-selected", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
// Find the selected option by looking for the selected class and aria-selected
const options = screen.getAllByRole("option");
const selectedOption = options.find((opt) => opt.getAttribute("aria-selected") === "true");
expect(selectedOption).toHaveTextContent("GPT-4o");
});
it("shows 'Use default' option at the top", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByLabelText("Test Model"));
// The "Use default" option should be visible
const defaultOptions = screen.getAllByText("Use default");
expect(defaultOptions.length).toBeGreaterThan(0);
});
it("shows fallback value text when model is not found", () => {
render();
expect(screen.getByText("unknown/unknown-model")).toBeInTheDocument();
});
});