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"; vi.mock("../ProviderIcon", () => ({ ProviderIcon: ({ provider }: { provider: string }) => , })); const MOCK_MODELS = [ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, ]; describe("CustomModelDropdown", () => { beforeEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; }); it("renders the open dropdown in a portal attached to document.body", async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(
, ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); expect(portal).toBeInTheDocument(); expect(portal).toHaveClass("model-combobox-dropdown--portal"); expect(document.body).toContainElement(portal); const hostSurface = screen.getByTestId("host-surface"); expect(hostSurface).not.toContainElement(portal); }); it("keeps the portaled list interactive for selecting a model and clearing back to default", async () => { const user = userEvent.setup(); const onChange = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); await user.click(within(portal).getByText("Claude Sonnet 4.5")); expect(onChange).toHaveBeenCalledWith("anthropic/claude-sonnet-4-5"); onChange.mockClear(); await user.click(screen.getByRole("button", { name: "Executor Model" })); const reopenedPortal = await screen.findByTestId("model-combobox-portal"); await user.click(within(reopenedPortal).getByText("Use default")); expect(onChange).toHaveBeenCalledWith(""); }); it("closes the portaled dropdown when clicking outside the trigger and menu", async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(
, ); await user.click(screen.getByRole("button", { name: "Executor Model" })); expect(await screen.findByTestId("model-combobox-portal")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Outside surface" })); await waitFor(() => { expect(screen.queryByTestId("model-combobox-portal")).not.toBeInTheDocument(); }); }); describe("Model Favorites", () => { it("shows favorited models as pinned rows at the top before provider groups", async () => { const user = userEvent.setup(); const onChange = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); // The favorited model should appear first (after "Use default" at index 0) const options = within(portal).getAllByRole("option"); // Index 0 is "Use default", index 1 should be the favorited model expect(options[1]).toHaveTextContent("Claude Sonnet 4.5"); // GPT-4o should appear under its provider group, after the favorited model section expect(options[options.length - 1]).toHaveTextContent("GPT-4o"); }); it("shows star buttons on model options when onToggleModelFavorite is provided", async () => { const user = userEvent.setup(); const onChange = vi.fn(); const onToggleModelFavorite = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); // Star buttons should exist const addButtons = within(portal).queryAllByRole("button", { name: /Add.*to favorites/ }); // At least 2 models should have Add buttons when no favorites expect(addButtons.length).toBeGreaterThanOrEqual(2); }); it("calls onToggleModelFavorite when star button is clicked", async () => { const user = userEvent.setup(); const onChange = vi.fn(); const onToggleModelFavorite = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); // Click the star button for Claude Sonnet const starButton = within(portal).getByRole("button", { name: "Add Claude Sonnet 4.5 to favorites" }); await user.click(starButton); expect(onToggleModelFavorite).toHaveBeenCalledWith("anthropic/claude-sonnet-4-5"); }); it("shows star buttons when onToggleModelFavorite is provided", async () => { const user = userEvent.setup(); const onChange = vi.fn(); const onToggleModelFavorite = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); // Star buttons should exist const buttons = within(portal).queryAllByRole("button"); expect(buttons.length).toBeGreaterThanOrEqual(3); // At least: clear, Remove, Add }); it("shows favorited models in the correct order when multiple are favorited", async () => { const user = userEvent.setup(); const onChange = vi.fn(); const modelsWithThree = [ ...MOCK_MODELS, { provider: "google", id: "gemini-pro", name: "Gemini Pro", reasoning: false, contextWindow: 100000 }, ]; render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); // Get options after "Use default" (index 0) const options = within(portal).getAllByRole("option"); // First favorited model (gemini-pro) should be at index 1 expect(options[1]).toHaveTextContent("Gemini Pro"); // Second favorited model (claude-sonnet) should be at index 2 expect(options[2]).toHaveTextContent("Claude Sonnet 4.5"); }); it("shows no pinned section when favoriteModels is empty", async () => { const user = userEvent.setup(); const onChange = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); // When no favorites, the divider should not exist // First model should appear under its provider group const options = within(portal).getAllByRole("option"); expect(options.length).toBeGreaterThanOrEqual(2); }); it("filters favorited models correctly when search is active", async () => { const user = userEvent.setup(); const onChange = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); // Type in search box to filter const searchInput = within(portal).getByPlaceholderText("Filter models…"); await user.type(searchInput, "claude"); // The favorited model that matches should still appear (appears in pinned section) // GPT-4o should not appear since it doesn't match "claude" expect(within(portal).queryByText("GPT-4o")).not.toBeInTheDocument(); }); }); describe("Smart Dropdown Positioning", () => { // Helper to mock getBoundingClientRect on Element.prototype const setupBoundingRectMock = (rectValues: DOMRect) => { const originalGetBCR = Element.prototype.getBoundingClientRect; Element.prototype.getBoundingClientRect = vi.fn(() => rectValues as DOMRect); return () => { Element.prototype.getBoundingClientRect = originalGetBCR; }; }; it("opens downward when space below the trigger is sufficient", async () => { const user = userEvent.setup(); const onChange = vi.fn(); // Trigger at top of viewport (top: 100px, bottom: 140px), plenty of space below // Space below: 800 - 140 = 660px (sufficient, more than 320px) const restore = setupBoundingRectMock({ top: 100, left: 50, bottom: 140, width: 300, height: 40, right: 350, x: 50, y: 100, } as DOMRect); // Spy on window.innerHeight const originalInnerHeight = window.innerHeight; Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: 800, }); try { render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); const top = parseFloat(portal.style.top); // Should position downward: rect.bottom + 4 = 140 + 4 = 144 expect(top).toBe(144); } finally { restore(); Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: originalInnerHeight, }); } }); it("opens upward when space below is insufficient but space above is sufficient", async () => { const user = userEvent.setup(); const onChange = vi.fn(); // Trigger near bottom of viewport (bottom: 750px in 800px viewport) // Space below: 800 - 750 = 50px (insufficient, less than 320px) // Space above: 750px (sufficient) const restore = setupBoundingRectMock({ top: 710, left: 50, bottom: 750, width: 300, height: 40, right: 350, x: 50, y: 710, } as DOMRect); const originalInnerHeight = window.innerHeight; Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: 800, }); try { render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); const top = parseFloat(portal.style.top); // Should position upward: rect.top - estimatedHeight - 4 = 710 - 320 - 4 = 386 expect(top).toBe(386); } finally { restore(); Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: originalInnerHeight, }); } }); it("opens downward when both directions have room (prefers downward)", async () => { const user = userEvent.setup(); const onChange = vi.fn(); // Trigger in middle of viewport // Space below: 600px (sufficient) // Space above: 200px (also sufficient but less than below) const restore = setupBoundingRectMock({ top: 200, left: 50, bottom: 240, width: 300, height: 40, right: 350, x: 50, y: 200, } as DOMRect); const originalInnerHeight = window.innerHeight; Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: 800, }); try { render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); const top = parseFloat(portal.style.top); // Should position downward since there's enough space below // rect.bottom + 4 = 240 + 4 = 244 expect(top).toBe(244); } finally { restore(); Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: originalInnerHeight, }); } }); it("opens downward when there is space below even if above is also constrained", async () => { const user = userEvent.setup(); const onChange = vi.fn(); // Trigger at very top of viewport (top: 10px, bottom: 50px) // Space below: 800 - 50 = 750px (sufficient) // Space above: 10px (insufficient for upward) // This test ensures downward is used when there's sufficient space below const restore = setupBoundingRectMock({ top: 10, left: 50, bottom: 50, width: 300, height: 40, right: 350, x: 50, y: 10, } as DOMRect); const originalInnerHeight = window.innerHeight; Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: 800, }); try { render( , ); await user.click(screen.getByRole("button", { name: "Executor Model" })); const portal = await screen.findByTestId("model-combobox-portal"); const top = parseFloat(portal.style.top); // Should position downward since there's sufficient space below (750 >= 320) // rect.bottom + 4 = 50 + 4 = 54 expect(top).toBe(54); } finally { restore(); Object.defineProperty(window, "innerHeight", { writable: true, configurable: true, value: originalInnerHeight, }); } }); }); });