test(KB-627): add ModelSelectionModal tests and update QuickEntryBox tests for modal integration
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ModelSelectionModal } from "../ModelSelectionModal";
|
||||
import type { ModelInfo } from "../../api";
|
||||
|
||||
const MOCK_MODELS: ModelInfo[] = [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
reasoning: true,
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
reasoning: true,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
Brain: () => null,
|
||||
X: () => null,
|
||||
}));
|
||||
|
||||
// Mock CustomModelDropdown
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
models,
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
models: ModelInfo[];
|
||||
placeholder: string;
|
||||
}) => (
|
||||
<div data-testid={`mock-dropdown-${id}`}>
|
||||
<span data-testid={`dropdown-label-${id}`}>{label}</span>
|
||||
<span data-testid={`dropdown-value-${id}`}>{value || "empty"}</span>
|
||||
<select
|
||||
data-testid={`dropdown-select-${id}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{models.map((m) => (
|
||||
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function renderModelSelectionModal(props = {}) {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
models: MOCK_MODELS,
|
||||
executorValue: "",
|
||||
validatorValue: "",
|
||||
onExecutorChange: vi.fn(),
|
||||
onValidatorChange: vi.fn(),
|
||||
modelsLoading: false,
|
||||
modelsError: null,
|
||||
onRetry: vi.fn(),
|
||||
};
|
||||
return render(<ModelSelectionModal {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
describe("ModelSelectionModal", () => {
|
||||
it("renders null when isOpen is false", () => {
|
||||
renderModelSelectionModal({ isOpen: false });
|
||||
expect(screen.queryByTestId("model-selection-modal")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders when isOpen is true", () => {
|
||||
renderModelSelectionModal({ isOpen: true });
|
||||
expect(screen.getByTestId("model-selection-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows loading state when modelsLoading is true", () => {
|
||||
renderModelSelectionModal({ modelsLoading: true });
|
||||
expect(screen.getByText("Loading models…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error state with retry button when modelsError is set", () => {
|
||||
const onRetry = vi.fn();
|
||||
renderModelSelectionModal({ modelsError: "Failed to fetch", onRetry });
|
||||
|
||||
expect(screen.getByText("Failed to fetch")).toBeTruthy();
|
||||
|
||||
const retryButton = screen.getByTestId("model-selection-retry");
|
||||
expect(retryButton).toBeTruthy();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows empty state when no models available", () => {
|
||||
renderModelSelectionModal({ models: [] });
|
||||
expect(
|
||||
screen.getByText(/No models available. Configure authentication in Settings/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders CustomModelDropdown for executor and validator", () => {
|
||||
renderModelSelectionModal();
|
||||
|
||||
expect(screen.getByTestId("mock-dropdown-model-selection-executor")).toBeTruthy();
|
||||
expect(screen.getByTestId("mock-dropdown-model-selection-validator")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onClose when clicking close button", () => {
|
||||
const onClose = vi.fn();
|
||||
renderModelSelectionModal({ onClose });
|
||||
|
||||
const closeButton = screen.getByTestId("model-selection-close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when clicking overlay", () => {
|
||||
const onClose = vi.fn();
|
||||
renderModelSelectionModal({ onClose });
|
||||
|
||||
const overlay = screen.getByTestId("model-selection-modal");
|
||||
fireEvent.click(overlay);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when pressing Escape key", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderModelSelectionModal({ onClose });
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onExecutorChange when executor selection changes", () => {
|
||||
const onExecutorChange = vi.fn();
|
||||
renderModelSelectionModal({ onExecutorChange });
|
||||
|
||||
const executorSelect = screen.getByTestId("dropdown-select-model-selection-executor");
|
||||
fireEvent.change(executorSelect, { target: { value: "anthropic/claude-sonnet-4-5" } });
|
||||
|
||||
expect(onExecutorChange).toHaveBeenCalledWith("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("calls onValidatorChange when validator selection changes", () => {
|
||||
const onValidatorChange = vi.fn();
|
||||
renderModelSelectionModal({ onValidatorChange });
|
||||
|
||||
const validatorSelect = screen.getByTestId("dropdown-select-model-selection-validator");
|
||||
fireEvent.change(validatorSelect, { target: { value: "openai/gpt-4o" } });
|
||||
|
||||
expect(onValidatorChange).toHaveBeenCalledWith("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("displays executor badge with selected model", () => {
|
||||
renderModelSelectionModal({
|
||||
executorValue: "anthropic/claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const executorBadge = screen.getByTestId("executor-badge");
|
||||
expect(executorBadge.textContent).toBe("anthropic/claude-sonnet-4-5");
|
||||
expect(executorBadge.classList.contains("model-badge-custom")).toBe(true);
|
||||
});
|
||||
|
||||
it("displays executor badge with 'Using default' when no selection", () => {
|
||||
renderModelSelectionModal({ executorValue: "" });
|
||||
|
||||
const executorBadge = screen.getByTestId("executor-badge");
|
||||
expect(executorBadge.textContent).toBe("Using default");
|
||||
expect(executorBadge.classList.contains("model-badge-default")).toBe(true);
|
||||
});
|
||||
|
||||
it("displays validator badge with selected model", () => {
|
||||
renderModelSelectionModal({
|
||||
validatorValue: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const validatorBadge = screen.getByTestId("validator-badge");
|
||||
expect(validatorBadge.textContent).toBe("openai/gpt-4o");
|
||||
expect(validatorBadge.classList.contains("model-badge-custom")).toBe(true);
|
||||
});
|
||||
|
||||
it("displays validator badge with 'Using default' when no selection", () => {
|
||||
renderModelSelectionModal({ validatorValue: "" });
|
||||
|
||||
const validatorBadge = screen.getByTestId("validator-badge");
|
||||
expect(validatorBadge.textContent).toBe("Using default");
|
||||
expect(validatorBadge.classList.contains("model-badge-default")).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onClose when clicking Done button", () => {
|
||||
const onClose = vi.fn();
|
||||
renderModelSelectionModal({ onClose });
|
||||
|
||||
const doneButton = screen.getByTestId("model-selection-done");
|
||||
fireEvent.click(doneButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes correct props to executor dropdown", () => {
|
||||
renderModelSelectionModal({
|
||||
executorValue: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("dropdown-value-model-selection-executor").textContent).toBe(
|
||||
"openai/gpt-4o",
|
||||
);
|
||||
expect(screen.getByTestId("dropdown-label-model-selection-executor").textContent).toBe(
|
||||
"Executor Model",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes correct props to validator dropdown", () => {
|
||||
renderModelSelectionModal({
|
||||
validatorValue: "anthropic/claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("dropdown-value-model-selection-validator").textContent).toBe(
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
);
|
||||
expect(screen.getByTestId("dropdown-label-model-selection-validator").textContent).toBe(
|
||||
"Validator Model",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,63 @@ vi.mock("lucide-react", () => ({
|
||||
ListTree: () => null,
|
||||
Sparkles: () => null,
|
||||
Save: () => null,
|
||||
X: () => null,
|
||||
}));
|
||||
|
||||
// Mock ModelSelectionModal
|
||||
vi.mock("../ModelSelectionModal", () => ({
|
||||
ModelSelectionModal: ({
|
||||
isOpen,
|
||||
onClose,
|
||||
models,
|
||||
executorValue,
|
||||
validatorValue,
|
||||
onExecutorChange,
|
||||
onValidatorChange,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
onRetry,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
models: typeof MOCK_MODELS;
|
||||
executorValue: string;
|
||||
validatorValue: string;
|
||||
onExecutorChange: (value: string) => void;
|
||||
onValidatorChange: (value: string) => void;
|
||||
modelsLoading: boolean;
|
||||
modelsError: string | null;
|
||||
onRetry: () => void;
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div data-testid="model-selection-modal">
|
||||
<div data-testid="modal-props-models-count">{models.length}</div>
|
||||
<div data-testid="modal-props-executor-value">{executorValue}</div>
|
||||
<div data-testid="modal-props-validator-value">{validatorValue}</div>
|
||||
<div data-testid="modal-props-loading">{modelsLoading ? "loading" : "not-loading"}</div>
|
||||
<div data-testid="modal-props-error">{modelsError || "no-error"}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-select-executor"
|
||||
onClick={() => onExecutorChange("anthropic/claude-sonnet-4-5")}
|
||||
>
|
||||
Select Executor
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-select-validator"
|
||||
onClick={() => onValidatorChange("openai/gpt-4o")}
|
||||
>
|
||||
Select Validator
|
||||
</button>
|
||||
<button data-testid="modal-retry" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
function renderQuickEntryBox(props = {}) {
|
||||
@@ -416,7 +473,24 @@ describe("QuickEntryBox", () => {
|
||||
expect(document.querySelector(".dep-dropdown-search")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens model dropdown when clicking models button", () => {
|
||||
it("opens model modal when clicking models button", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with models" } });
|
||||
|
||||
// Modal should not be visible initially
|
||||
expect(screen.queryByTestId("model-selection-modal")).toBeNull();
|
||||
|
||||
// Click the models button
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
// Modal should now be visible
|
||||
expect(screen.getByTestId("model-selection-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("modal receives correct props (models, loading state, etc.)", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -424,8 +498,15 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.change(textarea, { target: { value: "Task with models" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
// Dropdown should be visible with model options
|
||||
expect(document.querySelector(".inline-create-model-dropdown")).toBeTruthy();
|
||||
// Modal should be open
|
||||
expect(screen.getByTestId("model-selection-modal")).toBeTruthy();
|
||||
|
||||
// Check props passed to modal
|
||||
expect(screen.getByTestId("modal-props-models-count").textContent).toBe("2");
|
||||
expect(screen.getByTestId("modal-props-executor-value").textContent).toBe("");
|
||||
expect(screen.getByTestId("modal-props-validator-value").textContent).toBe("");
|
||||
expect(screen.getByTestId("modal-props-loading").textContent).toBe("not-loading");
|
||||
expect(screen.getByTestId("modal-props-error").textContent).toBe("no-error");
|
||||
});
|
||||
|
||||
it("selects dependencies and includes them in submit payload", async () => {
|
||||
@@ -580,13 +661,14 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.change(textarea, { target: { value: "Task with model" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
// Select executor model
|
||||
const executorButton = screen.getByRole("button", { name: "Executor Model" });
|
||||
fireEvent.click(executorButton);
|
||||
// Modal should be open
|
||||
expect(screen.getByTestId("model-selection-modal")).toBeTruthy();
|
||||
|
||||
// Select the first model option
|
||||
const modelOption = screen.getByText("Claude Sonnet 4.5");
|
||||
fireEvent.click(modelOption);
|
||||
// Select executor model via mocked modal
|
||||
fireEvent.click(screen.getByTestId("modal-select-executor"));
|
||||
|
||||
// Close the modal
|
||||
fireEvent.click(screen.getByTestId("modal-close"));
|
||||
|
||||
// Submit the task
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
@@ -602,25 +684,25 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdowns on Escape and preserves input", () => {
|
||||
it("closes modal on Escape when open", async () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with dropdown" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
|
||||
fireEvent.change(textarea, { target: { value: "Task with modal" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
// Dropdown should be open
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
// Modal should be open
|
||||
expect(screen.getByTestId("model-selection-modal")).toBeTruthy();
|
||||
|
||||
// Press Escape - should close dropdown but not clear input
|
||||
// Press Escape - should close modal but not clear input
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
// Dropdown should be closed
|
||||
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
||||
// Modal should be closed
|
||||
expect(screen.queryByTestId("model-selection-modal")).toBeNull();
|
||||
|
||||
// Input should still have the value
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Task with dropdown");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Task with modal");
|
||||
});
|
||||
|
||||
it("clears all state on second Escape after dropdowns are closed", () => {
|
||||
|
||||
Reference in New Issue
Block a user