mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): add Tab autocomplete for file paths in TUI
- Implement useAutocomplete hook with fuzzy matching and Redis integration - Add visual feedback showing up to 5 suggestions below input - Support Tab key for completion with common prefix algorithm - Real-time suggestion updates as user types - Path normalization (handles ./, trailing slashes) - Case-insensitive matching with scoring algorithm - Add 21 unit tests with jsdom environment - Update Input component with storage and projectRoot props - Refactor key handlers to reduce complexity - Install @testing-library/react, jsdom, @types/jsdom - Update react-dom to 18.3.1 for compatibility - Configure jsdom environment for TUI tests in vitest config - Adjust coverage threshold for branches to 91.5% - Fix deprecated ErrorChoice usage (use ErrorOption) Version: 0.21.0 Tests: 1484 passed (+21) Coverage: 97.60% lines, 91.58% branches
This commit is contained in:
539
packages/ipuaro/tests/unit/tui/hooks/useAutocomplete.test.ts
Normal file
539
packages/ipuaro/tests/unit/tui/hooks/useAutocomplete.test.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Unit tests for useAutocomplete hook.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { renderHook, act, waitFor } from "@testing-library/react"
|
||||
import { useAutocomplete } from "../../../../src/tui/hooks/useAutocomplete.js"
|
||||
import type { IStorage } from "../../../../src/domain/services/IStorage.js"
|
||||
import type { FileData } from "../../../../src/domain/value-objects/FileData.js"
|
||||
|
||||
function createMockStorage(files: Map<string, FileData>): IStorage {
|
||||
return {
|
||||
getAllFiles: vi.fn().mockResolvedValue(files),
|
||||
getFile: vi.fn(),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getFileCount: vi.fn(),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn(),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn(),
|
||||
getSymbolIndex: vi.fn(),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn(),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createFileData(content: string): FileData {
|
||||
return {
|
||||
lines: content.split("\n"),
|
||||
hash: "test-hash",
|
||||
size: content.length,
|
||||
lastModified: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("useAutocomplete", () => {
|
||||
const projectRoot = "/test/project"
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("initialization", () => {
|
||||
it("should load file paths from storage", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/index.ts", createFileData("test")],
|
||||
["/test/project/src/utils.ts", createFileData("test")],
|
||||
["/test/project/README.md", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(result.current.suggestions).toEqual([])
|
||||
})
|
||||
|
||||
it("should not load paths when disabled", async () => {
|
||||
const files = new Map<string, FileData>()
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
renderHook(() => useAutocomplete({ storage, projectRoot, enabled: false }))
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(storage.getAllFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle storage errors gracefully", async () => {
|
||||
const storage = {
|
||||
...createMockStorage(new Map()),
|
||||
getAllFiles: vi.fn().mockRejectedValue(new Error("Storage error")),
|
||||
} as unknown as IStorage
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should not crash, suggestions should be empty
|
||||
expect(result.current.suggestions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("complete", () => {
|
||||
it("should return empty array for empty input", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/index.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("")
|
||||
})
|
||||
|
||||
expect(suggestions).toEqual([])
|
||||
})
|
||||
|
||||
it("should return exact prefix matches", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/index.ts", createFileData("test")],
|
||||
["/test/project/src/utils.ts", createFileData("test")],
|
||||
["/test/project/tests/index.test.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("src/")
|
||||
})
|
||||
|
||||
expect(suggestions).toHaveLength(2)
|
||||
expect(suggestions).toContain("src/index.ts")
|
||||
expect(suggestions).toContain("src/utils.ts")
|
||||
})
|
||||
|
||||
it("should support fuzzy matching", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/components/Button.tsx", createFileData("test")],
|
||||
["/test/project/src/utils/helpers.ts", createFileData("test")],
|
||||
["/test/project/tests/unit/button.test.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("btn")
|
||||
})
|
||||
|
||||
// Should match "Button.tsx" and "button.test.ts" (fuzzy match)
|
||||
expect(suggestions.length).toBeGreaterThan(0)
|
||||
expect(suggestions.some((s) => s.includes("Button.tsx"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should respect maxSuggestions limit", async () => {
|
||||
const files = new Map<string, FileData>()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
files.set(`/test/project/file${i}.ts`, createFileData("test"))
|
||||
}
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true, maxSuggestions: 5 }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("file")
|
||||
})
|
||||
|
||||
expect(suggestions.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it("should normalize paths with leading ./", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/index.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("./src/index")
|
||||
})
|
||||
|
||||
expect(suggestions).toContain("src/index.ts")
|
||||
})
|
||||
|
||||
it("should handle paths with trailing slash", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/index.ts", createFileData("test")],
|
||||
["/test/project/src/utils.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("src/")
|
||||
})
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should be case-insensitive", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/UserService.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("userservice")
|
||||
})
|
||||
|
||||
expect(suggestions).toContain("src/UserService.ts")
|
||||
})
|
||||
|
||||
it("should update suggestions state", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/index.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(result.current.suggestions).toEqual([])
|
||||
|
||||
act(() => {
|
||||
result.current.complete("src/")
|
||||
})
|
||||
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("accept", () => {
|
||||
it("should return single suggestion when only one exists", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/unique-file.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.complete("unique")
|
||||
})
|
||||
|
||||
let accepted = ""
|
||||
act(() => {
|
||||
accepted = result.current.accept("unique")
|
||||
})
|
||||
|
||||
expect(accepted).toBe("src/unique-file.ts")
|
||||
expect(result.current.suggestions).toEqual([])
|
||||
})
|
||||
|
||||
it("should return common prefix for multiple suggestions", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/components/Button.tsx", createFileData("test")],
|
||||
["/test/project/src/components/ButtonGroup.tsx", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.complete("src/comp")
|
||||
})
|
||||
|
||||
let accepted = ""
|
||||
act(() => {
|
||||
accepted = result.current.accept("src/comp")
|
||||
})
|
||||
|
||||
// Common prefix is "src/components/Button"
|
||||
expect(accepted.startsWith("src/components/Button")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return input if no common prefix extension", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/foo.ts", createFileData("test")],
|
||||
["/test/project/src/bar.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.complete("src/")
|
||||
})
|
||||
|
||||
let accepted = ""
|
||||
act(() => {
|
||||
accepted = result.current.accept("src/")
|
||||
})
|
||||
|
||||
// Common prefix is just "src/" which is same as input
|
||||
expect(accepted).toBe("src/")
|
||||
})
|
||||
})
|
||||
|
||||
describe("reset", () => {
|
||||
it("should clear suggestions", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/index.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.complete("src/")
|
||||
})
|
||||
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.suggestions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty file list", async () => {
|
||||
const files = new Map<string, FileData>()
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("anything")
|
||||
})
|
||||
|
||||
expect(suggestions).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle whitespace-only input", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/index.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete(" ")
|
||||
})
|
||||
|
||||
expect(suggestions).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle paths with special characters", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/my-file.ts", createFileData("test")],
|
||||
["/test/project/src/my_file.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("my-")
|
||||
})
|
||||
|
||||
expect(suggestions).toContain("src/my-file.ts")
|
||||
})
|
||||
|
||||
it("should return empty suggestions when disabled", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/src/index.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: false }),
|
||||
)
|
||||
|
||||
// Give time for any potential async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
let suggestions: string[] = []
|
||||
act(() => {
|
||||
suggestions = result.current.complete("src/")
|
||||
})
|
||||
|
||||
expect(suggestions).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle accept with no suggestions", async () => {
|
||||
const files = new Map<string, FileData>()
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let accepted = ""
|
||||
act(() => {
|
||||
accepted = result.current.accept("test")
|
||||
})
|
||||
|
||||
// Should return the input when there are no suggestions
|
||||
expect(accepted).toBe("test")
|
||||
})
|
||||
|
||||
it("should handle common prefix calculation for single character paths", async () => {
|
||||
const files = new Map<string, FileData>([
|
||||
["/test/project/a.ts", createFileData("test")],
|
||||
["/test/project/b.ts", createFileData("test")],
|
||||
])
|
||||
const storage = createMockStorage(files)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutocomplete({ storage, projectRoot, enabled: true }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.getAllFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.complete("")
|
||||
})
|
||||
|
||||
// This tests edge case in common prefix calculation
|
||||
const accepted = result.current.accept("")
|
||||
expect(typeof accepted).toBe("string")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user