Merge story-29: Backfill tests for maximum coverage
Adds 57 Rust tests and 60 frontend tests across 4 batches: - Batch 1: store, search, workflow - Batch 2: fs, shell, http/workflow - Batch 3: usePathCompletion, api/client, api/workflow - Batch 4: App, GatePanel, ReviewPanel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
158
frontend/src/App.test.tsx
Normal file
158
frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "./api/client";
|
||||
|
||||
vi.mock("./api/client", () => {
|
||||
const api = {
|
||||
getCurrentProject: vi.fn(),
|
||||
getKnownProjects: vi.fn(),
|
||||
getHomeDirectory: vi.fn(),
|
||||
openProject: vi.fn(),
|
||||
closeProject: vi.fn(),
|
||||
forgetKnownProject: vi.fn(),
|
||||
listDirectoryAbsolute: vi.fn(),
|
||||
getOllamaModels: vi.fn(),
|
||||
getAnthropicApiKeyExists: vi.fn(),
|
||||
getAnthropicModels: vi.fn(),
|
||||
getModelPreference: vi.fn(),
|
||||
setModelPreference: vi.fn(),
|
||||
cancelChat: vi.fn(),
|
||||
setAnthropicApiKey: vi.fn(),
|
||||
};
|
||||
class ChatWebSocket {
|
||||
connect() {}
|
||||
close() {}
|
||||
sendChat() {}
|
||||
cancel() {}
|
||||
}
|
||||
return { api, ChatWebSocket };
|
||||
});
|
||||
|
||||
vi.mock("./api/workflow", () => {
|
||||
return {
|
||||
workflowApi: {
|
||||
getAcceptance: vi.fn().mockResolvedValue({
|
||||
can_accept: false,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: [],
|
||||
}),
|
||||
getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }),
|
||||
recordTests: vi.fn(),
|
||||
ensureAcceptance: vi.fn(),
|
||||
getReviewQueue: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockedApi = vi.mocked(api);
|
||||
|
||||
describe("App", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mockedApi.getKnownProjects.mockResolvedValue([]);
|
||||
mockedApi.getHomeDirectory.mockResolvedValue("/home/user");
|
||||
mockedApi.listDirectoryAbsolute.mockResolvedValue([]);
|
||||
mockedApi.getOllamaModels.mockResolvedValue([]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
async function renderApp() {
|
||||
const { default: App } = await import("./App");
|
||||
return render(<App />);
|
||||
}
|
||||
|
||||
it("renders the selection screen when no project is open", async () => {
|
||||
await renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("populates path input with home directory", async () => {
|
||||
mockedApi.getHomeDirectory.mockResolvedValue("/Users/dave");
|
||||
|
||||
await renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByPlaceholderText(
|
||||
/\/path\/to\/project/i,
|
||||
) as HTMLInputElement;
|
||||
expect(input.value).toBe("/Users/dave/");
|
||||
});
|
||||
});
|
||||
|
||||
it("opens project and shows chat view", async () => {
|
||||
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
|
||||
|
||||
await renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
/\/path\/to\/project/i,
|
||||
) as HTMLInputElement;
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "/home/user/myproject");
|
||||
|
||||
const openButton = screen.getByRole("button", { name: /open project/i });
|
||||
await userEvent.click(openButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.openProject).toHaveBeenCalledWith(
|
||||
"/home/user/myproject",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when openProject fails", async () => {
|
||||
mockedApi.openProject.mockRejectedValue(new Error("Path does not exist"));
|
||||
|
||||
await renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
/\/path\/to\/project/i,
|
||||
) as HTMLInputElement;
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "/bad/path");
|
||||
|
||||
const openButton = screen.getByRole("button", { name: /open project/i });
|
||||
await userEvent.click(openButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Path does not exist/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows known projects list", async () => {
|
||||
mockedApi.getKnownProjects.mockResolvedValue([
|
||||
"/home/user/project1",
|
||||
"/home/user/project2",
|
||||
]);
|
||||
|
||||
await renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("/home/user/project1")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("/home/user/project2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
145
frontend/src/components/GatePanel.test.tsx
Normal file
145
frontend/src/components/GatePanel.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GatePanel } from "./GatePanel";
|
||||
|
||||
const baseProps = {
|
||||
gateState: null,
|
||||
gateStatusLabel: "Unknown",
|
||||
gateStatusColor: "#aaa",
|
||||
isGateLoading: false,
|
||||
gateError: null,
|
||||
lastGateRefresh: null,
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
|
||||
describe("GatePanel", () => {
|
||||
it("shows 'no workflow data' when gateState is null", () => {
|
||||
render(<GatePanel {...baseProps} />);
|
||||
expect(screen.getByText("No workflow data yet.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading message when isGateLoading is true", () => {
|
||||
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
||||
expect(
|
||||
screen.getByText("Loading workflow gates..."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error with retry button", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateError="Connection failed"
|
||||
onRefresh={onRefresh}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Connection failed")).toBeInTheDocument();
|
||||
|
||||
const retryButton = screen.getByRole("button", { name: "Retry" });
|
||||
await userEvent.click(retryButton);
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows gate status label and color", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateStatusLabel="Blocked"
|
||||
gateStatusColor="#ff7b72"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Blocked")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows test summary when gateState is provided", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateState={{
|
||||
canAccept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missingCategories: [],
|
||||
}}
|
||||
gateStatusLabel="Ready to accept"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/5\/5 passing, 0 failing/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows missing categories", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateState={{
|
||||
canAccept: false,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missingCategories: ["unit", "integration"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Missing: unit, integration"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning text", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateState={{
|
||||
canAccept: false,
|
||||
reasons: [],
|
||||
warning: "Multiple tests failing — fix one at a time.",
|
||||
summary: { total: 4, passed: 2, failed: 2 },
|
||||
missingCategories: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Multiple tests failing — fix one at a time."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows reasons as list items", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateState={{
|
||||
canAccept: false,
|
||||
reasons: ["No approved test plan.", "Tests are failing."],
|
||||
warning: null,
|
||||
summary: { total: 2, passed: 1, failed: 1 },
|
||||
missingCategories: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No approved test plan."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Tests are failing.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh button is clicked", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(<GatePanel {...baseProps} onRefresh={onRefresh} />);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Refresh" }),
|
||||
);
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables Refresh button when loading", () => {
|
||||
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
157
frontend/src/components/ReviewPanel.test.tsx
Normal file
157
frontend/src/components/ReviewPanel.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReviewStory } from "../api/workflow";
|
||||
import { ReviewPanel } from "./ReviewPanel";
|
||||
|
||||
const readyStory: ReviewStory = {
|
||||
story_id: "29_backfill_tests",
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
const blockedStory: ReviewStory = {
|
||||
story_id: "26_tdd_gates",
|
||||
can_accept: false,
|
||||
reasons: ["2 tests are failing."],
|
||||
warning: "Multiple tests failing — fix one at a time.",
|
||||
summary: { total: 5, passed: 3, failed: 2 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
reviewQueue: [] as ReviewStory[],
|
||||
isReviewLoading: false,
|
||||
reviewError: null,
|
||||
proceedingStoryId: null,
|
||||
storyId: "",
|
||||
isGateLoading: false,
|
||||
proceedError: null,
|
||||
proceedSuccess: null,
|
||||
lastReviewRefresh: null,
|
||||
onRefresh: vi.fn(),
|
||||
onProceed: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
describe("ReviewPanel", () => {
|
||||
it("shows empty state when no stories", () => {
|
||||
render(<ReviewPanel {...baseProps} />);
|
||||
expect(
|
||||
screen.getByText("No stories waiting for review."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading state", () => {
|
||||
render(<ReviewPanel {...baseProps} isReviewLoading={true} />);
|
||||
expect(screen.getByText("Loading review queue...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error with retry button", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(
|
||||
<ReviewPanel
|
||||
{...baseProps}
|
||||
reviewError="Network error"
|
||||
onRefresh={onRefresh}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Network error.*Use Refresh to try again\./),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders ready story with Proceed button", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[readyStory]} />);
|
||||
|
||||
expect(screen.getByText("29_backfill_tests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ready")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Proceed" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("renders blocked story with disabled button", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||
|
||||
expect(screen.getByText("26_tdd_gates")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Blocked")).toHaveLength(2);
|
||||
expect(screen.getByRole("button", { name: "Blocked" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows failing badge with count", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||
expect(screen.getByText("Failing 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning badge", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||
expect(screen.getByText("Warning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows test summary per story", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[readyStory]} />);
|
||||
expect(screen.getByText(/5\/5 passing,\s*0 failing/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows missing categories", () => {
|
||||
const missingStory: ReviewStory = {
|
||||
...blockedStory,
|
||||
missing_categories: ["unit", "integration"],
|
||||
};
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[missingStory]} />);
|
||||
expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onProceed when Proceed is clicked", async () => {
|
||||
const onProceed = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<ReviewPanel
|
||||
{...baseProps}
|
||||
reviewQueue={[readyStory]}
|
||||
onProceed={onProceed}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Proceed" }));
|
||||
expect(onProceed).toHaveBeenCalledWith("29_backfill_tests");
|
||||
});
|
||||
|
||||
it("shows queue counts in header", () => {
|
||||
render(
|
||||
<ReviewPanel {...baseProps} reviewQueue={[readyStory, blockedStory]} />,
|
||||
);
|
||||
expect(screen.getByText(/1 ready \/ 2 total/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows proceedError message", () => {
|
||||
render(
|
||||
<ReviewPanel
|
||||
{...baseProps}
|
||||
proceedError="Acceptance blocked: tests failing"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Acceptance blocked: tests failing"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows proceedSuccess message", () => {
|
||||
render(
|
||||
<ReviewPanel
|
||||
{...baseProps}
|
||||
proceedSuccess="Story accepted successfully"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Story accepted successfully")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows reasons as list items", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||
expect(screen.getByText("2 tests are failing.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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 "";
|
||||
|
||||
Reference in New Issue
Block a user