WIP: Batch 3 — backfill frontend tests
- usePathCompletion: 16 tests (isFuzzyMatch, getCurrentPartial, hook behavior) - api/client.ts: 9 tests (fetch mocks for all major endpoints, error handling) - api/workflow.ts: 6 tests (record, acceptance, review queue, ensure) Frontend tests: 13 → 44 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
138
frontend/src/api/client.test.ts
Normal file
138
frontend/src/api/client.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
113
frontend/src/api/workflow.test.ts
Normal file
113
frontend/src/api/workflow.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
160
frontend/src/components/selection/usePathCompletion.test.ts
Normal file
160
frontend/src/components/selection/usePathCompletion.test.ts
Normal file
@@ -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<FileEntry[]>>();
|
||||||
|
|
||||||
|
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/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ export interface UsePathCompletionResult {
|
|||||||
closeSuggestions: () => void;
|
closeSuggestions: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFuzzyMatch(candidate: string, query: string) {
|
export function isFuzzyMatch(candidate: string, query: string) {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
const lowerCandidate = candidate.toLowerCase();
|
const lowerCandidate = candidate.toLowerCase();
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
@@ -43,7 +43,7 @@ function isFuzzyMatch(candidate: string, query: string) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentPartial(input: string) {
|
export function getCurrentPartial(input: string) {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) return "";
|
||||||
if (trimmed.endsWith("/")) return "";
|
if (trimmed.endsWith("/")) return "";
|
||||||
|
|||||||
Reference in New Issue
Block a user