Files
kandev/apps/web/components/model-config-selector.test.tsx
T

606 lines
19 KiB
TypeScript

import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
configOptionToModelOptions,
ModelConfigSelector,
type SelectConfigOption,
} from "@/components/model-config-selector";
afterEach(() => {
cleanup();
});
const effortSectionTestId = "config-option-section-effort";
const effortTriggerTestId = "config-option-trigger-effort";
const modelSettingsButtonName = "Model settings";
const providerModelId = "gpt-5.6-sol";
const providerModelName = "GPT-5.6-Sol";
const copilotModelId = "claude-opus-5";
const copilotModelName = "Claude Opus 5";
const optionDescription = "Controls how much reasoning the model performs.";
const effortOptionName = "Reasoning Effort";
const makeModelOptions = (count: number) =>
Array.from({ length: count }, (_, index) => ({
id: `model-${index + 1}`,
name: `Model ${index + 1}`,
}));
describe("ModelConfigSelector", () => {
it("shows the provider icon in the trigger and open model heading", () => {
render(
<ModelConfigSelector
modelOptions={[{ id: "sonnet", name: "Sonnet" }]}
currentModel="sonnet"
onModelChange={() => {}}
providerIcon={<svg data-testid="provider-glyph" />}
/>,
);
const trigger = screen.getByRole("button", { name: modelSettingsButtonName });
expect(within(trigger).getByTestId("provider-glyph")).toBeTruthy();
fireEvent.click(trigger);
const group = screen.getByRole("group", { name: "Model" });
const labelId = group.getAttribute("aria-labelledby");
expect(labelId).not.toBeNull();
const heading = document.getElementById(labelId!);
expect(heading).not.toBeNull();
expect(within(heading!).getByTestId("provider-glyph")).toBeTruthy();
expect(screen.getByRole("option", { name: "Sonnet" })).toBeTruthy();
});
it("passes custom trigger classes to the button", () => {
render(
<ModelConfigSelector
modelOptions={[{ id: "gpt-5.5", name: "GPT-5.5" }]}
currentModel="gpt-5.5"
onModelChange={() => {}}
triggerClassName="max-w-[56vw]"
/>,
);
expect(screen.getByRole("button", { name: modelSettingsButtonName }).className).toContain(
"max-w-[56vw]",
);
});
it("opens extra config options from compact sub-selector rows", () => {
const onConfigChange = vi.fn();
render(
<ModelConfigSelector
modelOptions={[{ id: "sonnet", name: "Sonnet" }]}
currentModel="sonnet"
onModelChange={() => {}}
onConfigChange={onConfigChange}
configOptions={[
{
type: "select",
id: "model",
name: "Model",
currentValue: "sonnet",
category: "model",
options: [{ value: "sonnet", name: "Sonnet" }],
},
{
type: "select",
id: "effort",
name: "Effort",
currentValue: "medium",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
},
]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
const effortTrigger = screen.getByTestId(effortTriggerTestId);
expect(effortTrigger.textContent).toContain("Effort");
expect(effortTrigger.textContent).toContain("Medium");
expect(screen.queryByTestId(effortSectionTestId)).toBeNull();
fireEvent.click(effortTrigger);
const effortSection = screen.getByTestId(effortSectionTestId);
expect(effortSection).not.toBeNull();
const backButton = screen.getByRole("button", { name: /back to model settings from effort/i });
expect(document.activeElement).toBe(backButton);
fireEvent.click(backButton);
expect(screen.queryByTestId(effortSectionTestId)).toBeNull();
expect(document.activeElement).toBe(screen.getByTestId(effortTriggerTestId));
fireEvent.click(screen.getByTestId(effortTriggerTestId));
const reopenedEffortSection = screen.getByTestId(effortSectionTestId);
fireEvent.click(within(reopenedEffortSection).getByRole("button", { name: /^High$/ }));
expect(onConfigChange).toHaveBeenCalledWith("effort", "high");
expect(screen.queryByTestId(effortSectionTestId)).toBeNull();
});
});
describe("ModelConfigSelector selected options", () => {
it("puts the current model and nested config value first", () => {
render(
<ModelConfigSelector
modelOptions={[
{ id: "model-1", name: "Model 1" },
{ id: "model-2", name: "Model 2" },
{ id: "model-3", name: "Model 3" },
]}
currentModel="model-2"
onModelChange={() => {}}
onConfigChange={() => {}}
configOptions={[
{
type: "select",
id: "effort",
name: "Effort",
currentValue: "medium",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
},
]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
expect(screen.getAllByRole("option").map((option) => option.textContent?.trim())).toEqual([
"Model 2",
"Model 1",
"Model 3",
]);
expect(screen.getByTestId("model-config-selected-row").className).toContain("bg-card");
expect(screen.getByTestId("model-config-selected-row").className).toContain(
"border-primary/50",
);
fireEvent.click(screen.getByTestId(effortTriggerTestId));
const effortSection = screen.getByTestId(effortSectionTestId);
expect(
within(effortSection)
.getAllByRole("button")
.map((button) => button.textContent?.trim()),
).toEqual(["Medium", "Low", "High"]);
expect(within(effortSection).getByRole("button", { name: "Medium" }).className).toContain(
"bg-card",
);
});
});
describe("ModelConfigSelector loading behavior", () => {
it("keeps the picker open after selecting a model without existing dependent options", () => {
const onModelChange = vi.fn();
render(
<ModelConfigSelector
modelOptions={[
{ id: "model-a", name: "Model A" },
{ id: "model-b", name: "Model B" },
]}
currentModel="model-a"
onModelChange={onModelChange}
keepOpenOnModelChange
/>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
fireEvent.click(screen.getByRole("option", { name: /Model B/ }));
expect(onModelChange).toHaveBeenCalledWith("model-b");
expect(screen.getByRole("option", { name: /Model B/ })).toBeTruthy();
});
it("replaces dependent options with a loading row until the snapshot resolves", () => {
const { rerender } = render(
<ModelConfigSelector
modelOptions={[{ id: "model-a", name: "Model A" }]}
currentModel="model-a"
onModelChange={() => {}}
configOptions={[
{
type: "select",
id: "effort",
name: "Effort",
currentValue: "medium",
options: [{ value: "medium", name: "Medium" }],
},
]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
expect(screen.getByTestId(effortTriggerTestId)).toBeTruthy();
rerender(
<ModelConfigSelector
modelOptions={[{ id: "model-b", name: "Model B" }]}
currentModel="model-b"
onModelChange={() => {}}
configOptions={[
{
type: "select",
id: "effort",
name: "Effort",
currentValue: "medium",
options: [{ value: "medium", name: "Medium" }],
},
]}
configOptionsLoading
keepOpenOnModelChange
/>,
);
const loadingRow = screen.getByTestId("model-config-options-loading");
expect(loadingRow.getAttribute("role")).toBe("status");
expect(screen.getByRole("status", { name: "Loading model options…" })).toBe(loadingRow);
expect(screen.queryByTestId(effortTriggerTestId)).toBeNull();
const selectedModelRow = screen.getByTestId("model-config-selected-row");
expect(selectedModelRow.querySelector("svg.tabler-icon-loader")).toBeTruthy();
expect(selectedModelRow.querySelector("svg.tabler-icon-check.absolute")).toBeNull();
rerender(
<ModelConfigSelector
modelOptions={[{ id: "model-b", name: "Model B" }]}
currentModel="model-b"
onModelChange={() => {}}
configOptions={[
{
type: "select",
id: "effort",
name: "Effort",
currentValue: "max",
options: [{ value: "max", name: "Max" }],
},
]}
keepOpenOnModelChange
/>,
);
expect(screen.queryByTestId("model-config-options-loading")).toBeNull();
expect(selectedModelRow.querySelector("svg.tabler-icon-loader")).toBeNull();
expect(selectedModelRow.querySelector("svg.tabler-icon-check.absolute")).toBeTruthy();
expect(screen.getByTestId(effortTriggerTestId).textContent).toContain("Max");
});
});
describe("ModelConfigSelector loading guard", () => {
it("keeps the picker open when options are loading without the explicit open flag", () => {
const onModelChange = vi.fn();
render(
<ModelConfigSelector
modelOptions={[
{ id: "model-a", name: "Model A" },
{ id: "model-b", name: "Model B" },
]}
currentModel="model-a"
onModelChange={onModelChange}
configOptionsLoading
/>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
fireEvent.click(screen.getByRole("option", { name: /Model B/ }));
expect(onModelChange).toHaveBeenCalledWith("model-b");
expect(screen.getByTestId("model-config-options-loading")).toBeTruthy();
});
});
describe("ModelConfigSelector filtering", () => {
it("deduplicates repeated model ids from provider config", () => {
const modelConfig: SelectConfigOption = {
type: "select",
id: "model",
name: "Model",
currentValue: providerModelId,
category: "model",
options: [
{
value: providerModelId,
name: providerModelName,
description: "Latest frontier agentic coding model.",
},
{
value: "gpt-5.6-terra",
name: "GPT-5.6-Terra",
description: "Balanced agentic coding model for everyday work.",
},
{
value: "gpt-5.6-terra",
name: "GPT-5.6-Terra",
description: "Balanced agentic coding model for everyday work.",
},
],
};
expect(configOptionToModelOptions(modelConfig).map((model) => model.id)).toEqual([
providerModelId,
"gpt-5.6-terra",
]);
});
it("drops a description that duplicates the model name and falls back to the id", () => {
const modelConfig: SelectConfigOption = {
type: "select",
id: "model",
name: "Model",
currentValue: copilotModelId,
category: "model",
options: [{ value: copilotModelId, name: copilotModelName, description: copilotModelName }],
};
expect(configOptionToModelOptions(modelConfig)).toEqual([
{ id: copilotModelId, name: copilotModelName, description: copilotModelId },
]);
});
it("keeps a distinct provider description", () => {
const modelConfig: SelectConfigOption = {
type: "select",
id: "model",
name: "Model",
currentValue: providerModelId,
category: "model",
options: [
{ value: providerModelId, name: providerModelName, description: "Frontier coding model." },
],
};
expect(configOptionToModelOptions(modelConfig)).toEqual([
{ id: providerModelId, name: providerModelName, description: "Frontier coding model." },
]);
});
it("hides the model filter when there are five or fewer models", () => {
render(
<ModelConfigSelector
modelOptions={makeModelOptions(5)}
currentModel="model-1"
onModelChange={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
expect(screen.queryByPlaceholderText("Filter models...")).toBeNull();
});
it("shows the model filter when there are more than five models", () => {
render(
<ModelConfigSelector
modelOptions={makeModelOptions(6)}
currentModel="model-1"
onModelChange={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
expect(screen.getByPlaceholderText("Filter models...")).not.toBeNull();
});
});
describe("ModelConfigSelector provider descriptions", () => {
it("shows provider descriptions only inside the selected option submenu", () => {
render(
<ModelConfigSelector
modelOptions={[{ id: providerModelId, name: providerModelName }]}
currentModel={providerModelId}
onModelChange={() => {}}
onConfigChange={() => {}}
configOptions={[
{
type: "select",
id: "effort",
name: effortOptionName,
description: optionDescription,
currentValue: "high",
options: [
{
value: "high",
name: "High",
description: "More thorough reasoning for complex tasks.",
},
],
},
]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
expect(screen.queryByText(optionDescription)).toBeNull();
fireEvent.click(screen.getByTestId(effortTriggerTestId));
expect(screen.getByText(optionDescription)).not.toBeNull();
expect(screen.getByText("More thorough reasoning for complex tasks.")).not.toBeNull();
});
it("shows all current option names and values on compact task trigger focus", () => {
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[{ id: providerModelId, name: providerModelName }]}
currentModel={providerModelId}
onModelChange={() => {}}
triggerSummary="changed"
configBaseline={{ effort: "high", fast_mode: "off" }}
configOptions={[
{
type: "select",
id: "effort",
name: effortOptionName,
description: optionDescription,
currentValue: "low",
options: [
{
value: "low",
name: "Low",
description: "Faster responses with less reasoning.",
},
],
},
{
type: "select",
id: "fast_mode",
name: "Fast Mode",
currentValue: "off",
options: [{ value: "off", name: "Off" }],
},
]}
/>
</TooltipProvider>,
);
fireEvent.focus(screen.getByRole("button", { name: modelSettingsButtonName }));
const tooltip = screen.getByRole("tooltip");
expect(tooltip.textContent).toContain(`Model: ${providerModelName}`);
expect(tooltip.textContent).toContain("Reasoning Effort: Low");
expect(tooltip.textContent).toContain("Fast Mode: Off");
expect(tooltip.textContent).not.toContain(optionDescription);
});
it("does not add the task details tooltip to shared selectors", () => {
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[{ id: providerModelId, name: providerModelName }]}
currentModel={providerModelId}
onModelChange={() => {}}
configOptions={[
{
type: "select",
id: "effort",
name: effortOptionName,
currentValue: "low",
options: [{ value: "low", name: "Low" }],
},
]}
/>
</TooltipProvider>,
);
fireEvent.focus(screen.getByRole("button", { name: modelSettingsButtonName }));
expect(screen.queryByRole("tooltip")).toBeNull();
});
});
describe("ModelConfigSelector fallback suffix", () => {
it("appends the suffix to the trigger label when provided", () => {
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[{ id: "gpt-5", name: "GPT-5" }]}
currentModel="gpt-5"
onModelChange={() => {}}
currentModelSuffix=" (fallback)"
triggerSummary="changed"
/>
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: modelSettingsButtonName }).textContent).toContain(
"GPT-5 (fallback)",
);
});
it("renders a config-option model with the suffix on the trigger", () => {
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[{ id: "gpt-5", name: "GPT-5" }]}
currentModel="gpt-5"
onModelChange={() => {}}
currentModelSuffix=" (fallback)"
triggerSummary="changed"
configOptions={[
{
type: "select",
id: "model",
name: "Model",
currentValue: "gpt-5",
category: "model",
options: [{ value: "gpt-5", name: "GPT-5" }],
},
]}
/>
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: modelSettingsButtonName }).textContent).toContain(
"GPT-5 (fallback)",
);
});
});
describe("ModelConfigSelector disabled (gone) models", () => {
it("renders a disabled model greyed out with its reason and blocks selection", () => {
const onModelChange = vi.fn();
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[
{ id: "claude-sonnet", name: "Claude Sonnet" },
{
id: "claude-gone",
name: "Claude Gone",
disabled: true,
disabledReason: "no longer available",
},
]}
currentModel="claude-gone"
onModelChange={onModelChange}
/>
</TooltipProvider>,
);
fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName }));
const disabledItem = screen.getByRole("option", { name: /claude gone/i });
expect(disabledItem.className).toContain("opacity-40");
expect(disabledItem.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(disabledItem);
expect(onModelChange).not.toHaveBeenCalled();
});
it("keeps the disabled (gone) model as the current value display", () => {
const onModelChange = vi.fn();
render(
<TooltipProvider>
<ModelConfigSelector
modelOptions={[
{
id: "claude-gone",
name: "Claude Gone",
disabled: true,
disabledReason: "no longer available",
},
{ id: "gpt-5", name: "GPT-5" },
]}
currentModel="claude-gone"
onModelChange={onModelChange}
/>
</TooltipProvider>,
);
// The trigger still shows the configured (gone) model name so the user
// sees what was configured.
expect(screen.getByRole("button", { name: modelSettingsButtonName }).textContent).toContain(
"Claude Gone",
);
});
});