diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts new file mode 100644 index 0000000..81f933c --- /dev/null +++ b/frontend/src/api/client.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "./client"; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function okResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function errorResponse(status: number, text: string) { + return new Response(text, { status }); +} + +describe("api client", () => { + describe("getCurrentProject", () => { + it("sends GET to /project", async () => { + mockFetch.mockResolvedValueOnce(okResponse("/home/user/project")); + + const result = await api.getCurrentProject(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/project", + expect.objectContaining({}), + ); + expect(result).toBe("/home/user/project"); + }); + + it("returns null when no project open", async () => { + mockFetch.mockResolvedValueOnce(okResponse(null)); + + const result = await api.getCurrentProject(); + expect(result).toBeNull(); + }); + }); + + describe("openProject", () => { + it("sends POST with path", async () => { + mockFetch.mockResolvedValueOnce(okResponse("/home/user/project")); + + await api.openProject("/home/user/project"); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/project", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ path: "/home/user/project" }), + }), + ); + }); + }); + + describe("closeProject", () => { + it("sends DELETE to /project", async () => { + mockFetch.mockResolvedValueOnce(okResponse(true)); + + await api.closeProject(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/project", + expect.objectContaining({ method: "DELETE" }), + ); + }); + }); + + describe("getKnownProjects", () => { + it("returns array of project paths", async () => { + mockFetch.mockResolvedValueOnce(okResponse(["/a", "/b"])); + + const result = await api.getKnownProjects(); + expect(result).toEqual(["/a", "/b"]); + }); + }); + + describe("error handling", () => { + it("throws on non-ok response with body text", async () => { + mockFetch.mockResolvedValueOnce(errorResponse(404, "Not found")); + + await expect(api.getCurrentProject()).rejects.toThrow("Not found"); + }); + + it("throws with status code when no body", async () => { + mockFetch.mockResolvedValueOnce(errorResponse(500, "")); + + await expect(api.getCurrentProject()).rejects.toThrow( + "Request failed (500)", + ); + }); + }); + + describe("searchFiles", () => { + it("sends POST with query", async () => { + mockFetch.mockResolvedValueOnce( + okResponse([{ path: "src/main.rs", matches: 1 }]), + ); + + const result = await api.searchFiles("hello"); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/fs/search", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ query: "hello" }), + }), + ); + expect(result).toHaveLength(1); + }); + }); + + describe("execShell", () => { + it("sends POST with command and args", async () => { + mockFetch.mockResolvedValueOnce( + okResponse({ stdout: "output", stderr: "", exit_code: 0 }), + ); + + const result = await api.execShell("ls", ["-la"]); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/shell/exec", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ command: "ls", args: ["-la"] }), + }), + ); + expect(result.exit_code).toBe(0); + }); + }); +}); diff --git a/frontend/src/api/workflow.test.ts b/frontend/src/api/workflow.test.ts new file mode 100644 index 0000000..0d68f0c --- /dev/null +++ b/frontend/src/api/workflow.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { workflowApi } from "./workflow"; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function okResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function errorResponse(status: number, text: string) { + return new Response(text, { status }); +} + +describe("workflowApi", () => { + describe("recordTests", () => { + it("sends POST to /workflow/tests/record", async () => { + mockFetch.mockResolvedValueOnce(okResponse(true)); + + const payload = { + story_id: "story-29", + unit: [{ name: "t1", status: "pass" as const }], + integration: [], + }; + + await workflowApi.recordTests(payload); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/workflow/tests/record", + expect.objectContaining({ method: "POST" }), + ); + }); + }); + + describe("getAcceptance", () => { + it("sends POST and returns acceptance response", async () => { + const response = { + can_accept: true, + reasons: [], + warning: null, + summary: { total: 2, passed: 2, failed: 0 }, + missing_categories: [], + }; + mockFetch.mockResolvedValueOnce(okResponse(response)); + + const result = await workflowApi.getAcceptance({ + story_id: "story-29", + }); + + expect(result.can_accept).toBe(true); + expect(result.summary.total).toBe(2); + }); + }); + + describe("getReviewQueueAll", () => { + it("sends GET to /workflow/review/all", async () => { + mockFetch.mockResolvedValueOnce(okResponse({ stories: [] })); + + const result = await workflowApi.getReviewQueueAll(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/workflow/review/all", + expect.objectContaining({}), + ); + expect(result.stories).toEqual([]); + }); + }); + + describe("ensureAcceptance", () => { + it("returns true when acceptance passes", async () => { + mockFetch.mockResolvedValueOnce(okResponse(true)); + + const result = await workflowApi.ensureAcceptance({ + story_id: "story-29", + }); + + expect(result).toBe(true); + }); + + it("throws on error response", async () => { + mockFetch.mockResolvedValueOnce( + errorResponse(400, "Acceptance is blocked"), + ); + + await expect( + workflowApi.ensureAcceptance({ story_id: "story-29" }), + ).rejects.toThrow("Acceptance is blocked"); + }); + }); + + describe("getReviewQueue", () => { + it("sends GET to /workflow/review", async () => { + mockFetch.mockResolvedValueOnce( + okResponse({ stories: [{ story_id: "s1", can_accept: true }] }), + ); + + const result = await workflowApi.getReviewQueue(); + + expect(result.stories).toHaveLength(1); + expect(result.stories[0].story_id).toBe("s1"); + }); + }); +}); diff --git a/frontend/src/components/selection/usePathCompletion.test.ts b/frontend/src/components/selection/usePathCompletion.test.ts new file mode 100644 index 0000000..ab785f6 --- /dev/null +++ b/frontend/src/components/selection/usePathCompletion.test.ts @@ -0,0 +1,160 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { 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/"); + }); +}); diff --git a/frontend/src/components/selection/usePathCompletion.ts b/frontend/src/components/selection/usePathCompletion.ts index 83468aa..d3eca77 100644 --- a/frontend/src/components/selection/usePathCompletion.ts +++ b/frontend/src/components/selection/usePathCompletion.ts @@ -30,7 +30,7 @@ export interface UsePathCompletionResult { closeSuggestions: () => void; } -function isFuzzyMatch(candidate: string, query: string) { +export function isFuzzyMatch(candidate: string, query: string) { if (!query) return true; const lowerCandidate = candidate.toLowerCase(); const lowerQuery = query.toLowerCase(); @@ -43,7 +43,7 @@ function isFuzzyMatch(candidate: string, query: string) { return true; } -function getCurrentPartial(input: string) { +export function getCurrentPartial(input: string) { const trimmed = input.trim(); if (!trimmed) return ""; if (trimmed.endsWith("/")) return "";