import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FileEntry } from "./usePathCompletion"; import { getCurrentPartial, isFuzzyMatch, usePathCompletion, } from "./usePathCompletion"; describe("isFuzzyMatch", () => { it("matches when query is empty", () => { expect(isFuzzyMatch("anything", "")).toBe(true); }); it("matches exact prefix", () => { expect(isFuzzyMatch("Documents", "Doc")).toBe(true); }); it("matches fuzzy subsequence", () => { expect(isFuzzyMatch("Documents", "dms")).toBe(true); }); it("is case insensitive", () => { expect(isFuzzyMatch("Documents", "DOCU")).toBe(true); }); it("rejects when chars not found in order", () => { expect(isFuzzyMatch("abc", "acb")).toBe(false); }); it("rejects completely unrelated", () => { expect(isFuzzyMatch("hello", "xyz")).toBe(false); }); }); describe("getCurrentPartial", () => { it("returns empty for empty input", () => { expect(getCurrentPartial("")).toBe(""); }); it("returns empty when input ends with slash", () => { expect(getCurrentPartial("/home/user/")).toBe(""); }); it("returns last segment", () => { expect(getCurrentPartial("/home/user/Doc")).toBe("Doc"); }); it("returns full input when no slash", () => { expect(getCurrentPartial("Doc")).toBe("Doc"); }); it("trims then evaluates: trailing-slash input returns empty", () => { // " /home/user/ " trims to "/home/user/" which ends with slash expect(getCurrentPartial(" /home/user/ ")).toBe(""); }); it("trims then returns last segment", () => { expect(getCurrentPartial(" /home/user/Doc ")).toBe("Doc"); }); }); describe("usePathCompletion hook", () => { const mockListDir = vi.fn<(path: string) => Promise>(); beforeEach(() => { mockListDir.mockReset(); }); it("returns empty matchList for empty input", async () => { const { result } = renderHook(() => usePathCompletion({ pathInput: "", setPathInput: vi.fn(), homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); // Allow effect + setTimeout(0) to fire await waitFor(() => { expect(mockListDir).not.toHaveBeenCalled(); }); expect(result.current.matchList).toEqual([]); }); it("fetches directory listing and returns matches", async () => { mockListDir.mockResolvedValue([ { name: "Documents", kind: "dir" }, { name: "Downloads", kind: "dir" }, { name: ".bashrc", kind: "file" }, ]); const { result } = renderHook(() => usePathCompletion({ pathInput: "/home/user/", setPathInput: vi.fn(), homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.matchList.length).toBe(2); }); expect(result.current.matchList[0].name).toBe("Documents"); expect(result.current.matchList[1].name).toBe("Downloads"); expect(result.current.matchList.every((m) => m.path.endsWith("/"))).toBe( true, ); }); it("filters by fuzzy match on partial input", async () => { mockListDir.mockResolvedValue([ { name: "Documents", kind: "dir" }, { name: "Downloads", kind: "dir" }, { name: "Desktop", kind: "dir" }, ]); const { result } = renderHook(() => usePathCompletion({ pathInput: "/home/user/Doc", setPathInput: vi.fn(), homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.matchList.length).toBe(1); }); expect(result.current.matchList[0].name).toBe("Documents"); }); it("calls setPathInput when acceptMatch is invoked", () => { const setPathInput = vi.fn(); const { result } = renderHook(() => usePathCompletion({ pathInput: "/home/", setPathInput, homeDir: "/home", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); act(() => { result.current.acceptMatch("/home/user/Documents/"); }); expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/"); }); it("uses homeDir when input has no slash (bare partial)", async () => { mockListDir.mockResolvedValue([ { name: "Documents", kind: "dir" }, { name: "Downloads", kind: "dir" }, ]); const { result } = renderHook(() => usePathCompletion({ pathInput: "Doc", setPathInput: vi.fn(), homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.matchList.length).toBe(1); }); expect(mockListDir).toHaveBeenCalledWith("/home/user"); expect(result.current.matchList[0].name).toBe("Documents"); expect(result.current.matchList[0].path).toBe("/home/user/Documents/"); }); it("returns early when input has no slash and homeDir is null", async () => { const { result } = renderHook(() => usePathCompletion({ pathInput: "Doc", setPathInput: vi.fn(), homeDir: null, listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); // Wait for debounce + effect to fire await waitFor(() => { expect(result.current.matchList).toEqual([]); }); expect(mockListDir).not.toHaveBeenCalled(); }); it("returns empty matchList when no dirs match the fuzzy filter", async () => { mockListDir.mockResolvedValue([ { name: "Documents", kind: "dir" }, { name: "Downloads", kind: "dir" }, ]); const { result } = renderHook(() => usePathCompletion({ pathInput: "/home/user/zzz", setPathInput: vi.fn(), homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(mockListDir).toHaveBeenCalled(); }); // No dirs match "zzz" fuzzy filter, so matchList stays empty expect(result.current.matchList).toEqual([]); }); it("sets completionError when listDirectoryAbsolute throws an Error", async () => { mockListDir.mockRejectedValue(new Error("Permission denied")); const { result } = renderHook(() => usePathCompletion({ pathInput: "/root/", setPathInput: vi.fn(), homeDir: null, listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.completionError).toBe("Permission denied"); }); }); it("sets generic completionError when listDirectoryAbsolute throws a non-Error", async () => { mockListDir.mockRejectedValue("some string error"); const { result } = renderHook(() => usePathCompletion({ pathInput: "/root/", setPathInput: vi.fn(), homeDir: null, listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.completionError).toBe( "Failed to compute suggestion.", ); }); }); it("clears suggestionTail when selected match path does not start with input", async () => { mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]); const { result } = renderHook(() => usePathCompletion({ pathInput: "Doc", setPathInput: vi.fn(), homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); // Wait for matches to load (path will be /home/user/Documents/) await waitFor(() => { expect(result.current.matchList.length).toBe(1); }); // The match path is "/home/user/Documents/" which does NOT start with "Doc" // so suggestionTail should be "" expect(result.current.suggestionTail).toBe(""); }); it("acceptSelectedMatch calls setPathInput with the selected match path", async () => { mockListDir.mockResolvedValue([ { name: "Documents", kind: "dir" }, { name: "Downloads", kind: "dir" }, ]); const setPathInput = vi.fn(); const { result } = renderHook(() => usePathCompletion({ pathInput: "/home/user/", setPathInput, homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.matchList.length).toBe(2); }); act(() => { result.current.acceptSelectedMatch(); }); expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/"); }); it("acceptSelectedMatch does nothing when matchList is empty", () => { const setPathInput = vi.fn(); const { result } = renderHook(() => usePathCompletion({ pathInput: "", setPathInput, homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); act(() => { result.current.acceptSelectedMatch(); }); expect(setPathInput).not.toHaveBeenCalled(); }); it("closeSuggestions clears matchList, selectedMatch, suggestionTail, and completionError", async () => { mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]); const { result } = renderHook(() => usePathCompletion({ pathInput: "/home/user/", setPathInput: vi.fn(), homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.matchList.length).toBe(1); }); act(() => { result.current.closeSuggestions(); }); expect(result.current.matchList).toEqual([]); expect(result.current.selectedMatch).toBe(0); expect(result.current.suggestionTail).toBe(""); expect(result.current.completionError).toBeNull(); }); it("uses homeDir with trailing slash as-is", async () => { mockListDir.mockResolvedValue([{ name: "Projects", kind: "dir" }]); const { result } = renderHook(() => usePathCompletion({ pathInput: "Pro", setPathInput: vi.fn(), homeDir: "/home/user/", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.matchList.length).toBe(1); }); expect(mockListDir).toHaveBeenCalledWith("/home/user"); expect(result.current.matchList[0].path).toBe("/home/user/Projects/"); }); it("handles root directory listing (dir = '/')", async () => { mockListDir.mockResolvedValue([ { name: "home", kind: "dir" }, { name: "etc", kind: "dir" }, ]); const { result } = renderHook(() => usePathCompletion({ pathInput: "/", setPathInput: vi.fn(), homeDir: null, listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.matchList.length).toBe(2); }); expect(mockListDir).toHaveBeenCalledWith("/"); expect(result.current.matchList[0].name).toBe("etc"); expect(result.current.matchList[1].name).toBe("home"); }); it("computes suggestionTail when match path starts with trimmed input", async () => { mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]); const { result } = renderHook(() => usePathCompletion({ pathInput: "/home/user/", setPathInput: vi.fn(), homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.matchList.length).toBe(1); }); // path is "/home/user/Documents/" and input is "/home/user/" // so tail should be "Documents/" expect(result.current.suggestionTail).toBe("Documents/"); }); it("setSelectedMatch updates the selected index", async () => { mockListDir.mockResolvedValue([ { name: "Documents", kind: "dir" }, { name: "Downloads", kind: "dir" }, ]); const { result } = renderHook(() => usePathCompletion({ pathInput: "/home/user/", setPathInput: vi.fn(), homeDir: "/home/user", listDirectoryAbsolute: mockListDir, debounceMs: 0, }), ); await waitFor(() => { expect(result.current.matchList.length).toBe(2); }); act(() => { result.current.setSelectedMatch(1); }); expect(result.current.selectedMatch).toBe(1); // After selecting index 1, suggestionTail should reflect "Downloads/" expect(result.current.suggestionTail).toBe("Downloads/"); }); });