import React from "react" import { render, screen, act, cleanup, waitFor, within, fireEvent } from "@/utils/test-utils" import { SearchableSelect, SearchableSelectOption } from "@/components/ui/searchable-select" import userEvent from "@testing-library/user-event" describe("SearchableSelect", () => { const mockOptions: SearchableSelectOption[] = [ { value: "option1", label: "Option 1" }, { value: "option2", label: "Option 2" }, { value: "option3", label: "Option 3", disabled: true }, ] const defaultProps = { options: mockOptions, placeholder: "Select an option", searchPlaceholder: "Search options...", emptyMessage: "No options found", onValueChange: vi.fn(), } beforeEach(() => { vi.clearAllMocks() }) afterEach(() => { cleanup() vi.useRealTimers() }) it("renders with placeholder when no value is selected", () => { render() expect(screen.getByText("Select an option")).toBeInTheDocument() }) it("renders with selected option label when value is provided", () => { render() expect(screen.getByText("Option 1")).toBeInTheDocument() }) it("opens dropdown when clicked", async () => { const user = userEvent.setup() render() const trigger = screen.getByRole("combobox") await user.click(trigger) expect(screen.getByPlaceholderText("Search options...")).toBeInTheDocument() expect(screen.getByText("Option 1")).toBeInTheDocument() expect(screen.getByText("Option 2")).toBeInTheDocument() }) it("filters options based on search input", async () => { const user = userEvent.setup() render() const trigger = screen.getByRole("combobox") await user.click(trigger) // Verify all options are initially visible await waitFor(() => { expect(screen.getByText("Option 1")).toBeInTheDocument() expect(screen.getByText("Option 2")).toBeInTheDocument() expect(screen.getByText("Option 3")).toBeInTheDocument() }) const searchInput = screen.getByPlaceholderText("Search options...") // Use fireEvent for cmdk input fireEvent.change(searchInput, { target: { value: "1" } }) // Wait for the filtering to take effect await waitFor(() => { expect(screen.getByText("Option 1")).toBeInTheDocument() expect(screen.queryByText("Option 2")).not.toBeInTheDocument() expect(screen.queryByText("Option 3")).not.toBeInTheDocument() }) }) it("calls onValueChange when an option is selected", async () => { const user = userEvent.setup() render() const trigger = screen.getByRole("combobox") await user.click(trigger) const option = screen.getByText("Option 2") await user.click(option) expect(defaultProps.onValueChange).toHaveBeenCalledWith("option2") }) it("does not call onValueChange when a disabled option is clicked", async () => { const user = userEvent.setup() render() const trigger = screen.getByRole("combobox") await user.click(trigger) const disabledOption = screen.getByText("Option 3") await user.click(disabledOption) expect(defaultProps.onValueChange).not.toHaveBeenCalled() }) it("clears search value when dropdown is closed", async () => { const user = userEvent.setup() render() const trigger = screen.getByRole("combobox") await user.click(trigger) const searchInput = screen.getByPlaceholderText("Search options...") // Use fireEvent for cmdk input fireEvent.change(searchInput, { target: { value: "test" } }) // Verify the search filters the options await waitFor(() => { expect(screen.queryByText("Option 1")).not.toBeInTheDocument() expect(screen.queryByText("Option 2")).not.toBeInTheDocument() }) // Close the dropdown by clicking outside await user.click(document.body) // Wait for dropdown to close await waitFor(() => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument() }) // Wait a bit for the timeout to clear search await new Promise((resolve) => setTimeout(resolve, 200)) // Open again to check if search was cleared await user.click(trigger) // All options should be visible again await waitFor(() => { expect(screen.getByText("Option 1")).toBeInTheDocument() expect(screen.getByText("Option 2")).toBeInTheDocument() expect(screen.getByText("Option 3")).toBeInTheDocument() }) }) it("handles component unmounting without memory leaks", async () => { vi.useFakeTimers() const { unmount, rerender } = render() // Change the value prop to trigger the effect with timeout rerender() // Immediately unmount the component before the timeout completes act(() => { unmount() }) // This test ensures that no setState calls happen after unmount // If there was a memory leak, this would throw an error expect(() => { // Wait for any pending timeouts act(() => { vi.runAllTimers() }) }).not.toThrow() }) it("cleans up timeouts on unmount", () => { vi.useFakeTimers() const clearTimeoutSpy = vi.spyOn(global, "clearTimeout") const { unmount } = render() act(() => { unmount() }) // Verify that clearTimeout was called during cleanup expect(clearTimeoutSpy).toHaveBeenCalled() clearTimeoutSpy.mockRestore() }) it("resets search value when value prop changes", async () => { const user = userEvent.setup() const { rerender } = render() // Open dropdown and type something const trigger = screen.getByRole("combobox") await user.click(trigger) const searchInput = screen.getByPlaceholderText("Search options...") fireEvent.change(searchInput, { target: { value: "2" } }) // Verify search is working - use within to scope to dropdown const dropdown = screen.getByRole("dialog") await waitFor(() => { expect(within(dropdown).queryByText("Option 1")).not.toBeInTheDocument() expect(within(dropdown).getByText("Option 2")).toBeInTheDocument() }) // Close dropdown await user.click(document.body) // Change the value prop rerender() // Wait for the component to update await waitFor(() => { expect(screen.getByRole("combobox")).toHaveTextContent("Option 2") }) // Wait for the effect to run (100ms timeout in component) await new Promise((resolve) => setTimeout(resolve, 150)) // Open dropdown again await user.click(trigger) // All options should be visible (search cleared) - use within to scope const newDropdown = screen.getByRole("dialog") await waitFor(() => { expect(within(newDropdown).getByText("Option 1")).toBeInTheDocument() expect(within(newDropdown).getByText("Option 2")).toBeInTheDocument() expect(within(newDropdown).getByText("Option 3")).toBeInTheDocument() }) }) it("handles rapid value changes without issues", async () => { const { rerender } = render() // Rapidly change values rerender() rerender() rerender() // Wait for the final value to be reflected await waitFor(() => { const trigger = screen.getByRole("combobox") expect(trigger).toHaveTextContent("Option 1") }) // Component should still be functional - open dropdown const user = userEvent.setup() const trigger = screen.getByRole("combobox") await user.click(trigger) // Should be able to search const searchInput = screen.getByPlaceholderText("Search options...") fireEvent.change(searchInput, { target: { value: "2" } }) // Check filtering works - use within to scope to dropdown const dropdown = screen.getByRole("dialog") await waitFor(() => { expect(within(dropdown).getByText("Option 2")).toBeInTheDocument() expect(within(dropdown).queryByText("Option 1")).not.toBeInTheDocument() expect(within(dropdown).queryByText("Option 3")).not.toBeInTheDocument() }) }) it("closes the dropdown when ESC key is pressed", async () => { const user = userEvent.setup() render() // Open the dropdown const trigger = screen.getByRole("combobox") await user.click(trigger) // Verify dropdown is open expect(trigger).toHaveAttribute("aria-expanded", "true") expect(screen.getByRole("listbox")).toBeInTheDocument() // Press ESC key fireEvent.keyDown(window, { key: "Escape" }) // Verify dropdown is closed await waitFor(() => { expect(trigger).toHaveAttribute("aria-expanded", "false") expect(screen.queryByRole("listbox")).not.toBeInTheDocument() }) }) it("does not close the dropdown when ESC is pressed while dropdown is closed", async () => { render() const trigger = screen.getByRole("combobox") // Ensure dropdown is closed expect(trigger).toHaveAttribute("aria-expanded", "false") // Press ESC key fireEvent.keyDown(window, { key: "Escape" }) // Verify dropdown remains closed expect(trigger).toHaveAttribute("aria-expanded", "false") expect(screen.queryByRole("listbox")).not.toBeInTheDocument() }) it("prevents default and stops propagation when ESC is pressed", async () => { const user = userEvent.setup() render() // Open the dropdown const trigger = screen.getByRole("combobox") await user.click(trigger) // Create a mock event to track preventDefault and stopPropagation const escapeEvent = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, }) const preventDefaultSpy = vi.spyOn(escapeEvent, "preventDefault") const stopPropagationSpy = vi.spyOn(escapeEvent, "stopPropagation") // Dispatch the event window.dispatchEvent(escapeEvent) // Verify preventDefault and stopPropagation were called expect(preventDefaultSpy).toHaveBeenCalled() expect(stopPropagationSpy).toHaveBeenCalled() }) })