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;
|
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 "";
|
||||||
|
|||||||
@@ -450,3 +450,108 @@ fn parse_test_status(value: &str) -> Result<TestStatus, String> {
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_test_status_pass() {
|
||||||
|
assert_eq!(parse_test_status("pass").unwrap(), TestStatus::Pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_test_status_fail() {
|
||||||
|
assert_eq!(parse_test_status("fail").unwrap(), TestStatus::Fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_test_status_invalid() {
|
||||||
|
let result = parse_test_status("unknown");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Invalid test status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_test_case_converts_pass() {
|
||||||
|
let payload = TestCasePayload {
|
||||||
|
name: "my_test".to_string(),
|
||||||
|
status: "pass".to_string(),
|
||||||
|
details: Some("all good".to_string()),
|
||||||
|
};
|
||||||
|
let result = to_test_case(payload).unwrap();
|
||||||
|
assert_eq!(result.name, "my_test");
|
||||||
|
assert_eq!(result.status, TestStatus::Pass);
|
||||||
|
assert_eq!(result.details, Some("all good".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_test_case_rejects_invalid_status() {
|
||||||
|
let payload = TestCasePayload {
|
||||||
|
name: "bad".to_string(),
|
||||||
|
status: "maybe".to_string(),
|
||||||
|
details: None,
|
||||||
|
};
|
||||||
|
assert!(to_test_case(payload).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_review_story_all_passing() {
|
||||||
|
let results = StoryTestResults {
|
||||||
|
unit: vec![TestCaseResult {
|
||||||
|
name: "u1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}],
|
||||||
|
integration: vec![TestCaseResult {
|
||||||
|
name: "i1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let review = to_review_story("story-29", &results);
|
||||||
|
assert!(review.can_accept);
|
||||||
|
assert!(review.reasons.is_empty());
|
||||||
|
assert!(review.missing_categories.is_empty());
|
||||||
|
assert_eq!(review.summary.total, 2);
|
||||||
|
assert_eq!(review.summary.passed, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_review_story_missing_integration() {
|
||||||
|
let results = StoryTestResults {
|
||||||
|
unit: vec![TestCaseResult {
|
||||||
|
name: "u1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}],
|
||||||
|
integration: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let review = to_review_story("story-29", &results);
|
||||||
|
assert!(!review.can_accept);
|
||||||
|
assert!(review.missing_categories.contains(&"integration".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_review_story_with_failures() {
|
||||||
|
let results = StoryTestResults {
|
||||||
|
unit: vec![TestCaseResult {
|
||||||
|
name: "u1".to_string(),
|
||||||
|
status: TestStatus::Fail,
|
||||||
|
details: None,
|
||||||
|
}],
|
||||||
|
integration: vec![TestCaseResult {
|
||||||
|
name: "i1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let review = to_review_story("story-29", &results);
|
||||||
|
assert!(!review.can_accept);
|
||||||
|
assert_eq!(review.summary.failed, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -696,8 +696,208 @@ pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::store::JsonFileStore;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn make_store(dir: &tempfile::TempDir) -> JsonFileStore {
|
||||||
|
JsonFileStore::new(dir.path().join("test_store.json")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_state_with_root(path: PathBuf) -> SessionState {
|
||||||
|
let state = SessionState::default();
|
||||||
|
{
|
||||||
|
let mut root = state.project_root.lock().unwrap();
|
||||||
|
*root = Some(path);
|
||||||
|
}
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolve_path_impl ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_path_joins_relative_to_root() {
|
||||||
|
let root = PathBuf::from("/projects/myapp");
|
||||||
|
let result = resolve_path_impl(root, "src/main.rs").unwrap();
|
||||||
|
assert_eq!(result, PathBuf::from("/projects/myapp/src/main.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_path_rejects_traversal() {
|
||||||
|
let root = PathBuf::from("/projects/myapp");
|
||||||
|
let result = resolve_path_impl(root, "../etc/passwd");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("traversal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- is_story_kit_path ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_story_kit_path_matches_root_and_children() {
|
||||||
|
assert!(is_story_kit_path(".story_kit"));
|
||||||
|
assert!(is_story_kit_path(".story_kit/stories/current/26.md"));
|
||||||
|
assert!(!is_story_kit_path("src/main.rs"));
|
||||||
|
assert!(!is_story_kit_path(".story_kit_other"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- open/close/get project ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn open_project_sets_root_and_persists() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let project_dir = dir.path().join("myproject");
|
||||||
|
fs::create_dir_all(&project_dir).unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
let state = SessionState::default();
|
||||||
|
|
||||||
|
let result = open_project(
|
||||||
|
project_dir.to_string_lossy().to_string(),
|
||||||
|
&state,
|
||||||
|
&store,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let root = state.get_project_root().unwrap();
|
||||||
|
assert_eq!(root, project_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn close_project_clears_root() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let project_dir = dir.path().join("myproject");
|
||||||
|
fs::create_dir_all(&project_dir).unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
let state = make_state_with_root(project_dir);
|
||||||
|
|
||||||
|
close_project(&state, &store).unwrap();
|
||||||
|
|
||||||
|
let root = state.project_root.lock().unwrap();
|
||||||
|
assert!(root.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_current_project_returns_none_when_no_project() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
let state = SessionState::default();
|
||||||
|
|
||||||
|
let result = get_current_project(&state, &store).unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_current_project_returns_active_root() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
let state = make_state_with_root(dir.path().to_path_buf());
|
||||||
|
|
||||||
|
let result = get_current_project(&state, &store).unwrap();
|
||||||
|
assert!(result.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- known projects ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_projects_empty_by_default() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
let projects = get_known_projects(&store).unwrap();
|
||||||
|
assert!(projects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn open_project_adds_to_known_projects() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let project_dir = dir.path().join("proj1");
|
||||||
|
fs::create_dir_all(&project_dir).unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
let state = SessionState::default();
|
||||||
|
|
||||||
|
open_project(
|
||||||
|
project_dir.to_string_lossy().to_string(),
|
||||||
|
&state,
|
||||||
|
&store,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let projects = get_known_projects(&store).unwrap();
|
||||||
|
assert_eq!(projects.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_known_project_removes_it() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
|
||||||
|
store.set(KEY_KNOWN_PROJECTS, json!(["/a", "/b", "/c"]));
|
||||||
|
forget_known_project("/b".to_string(), &store).unwrap();
|
||||||
|
|
||||||
|
let projects = get_known_projects(&store).unwrap();
|
||||||
|
assert_eq!(projects, vec!["/a", "/c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_unknown_project_is_noop() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
|
||||||
|
store.set(KEY_KNOWN_PROJECTS, json!(["/a"]));
|
||||||
|
forget_known_project("/nonexistent".to_string(), &store).unwrap();
|
||||||
|
|
||||||
|
let projects = get_known_projects(&store).unwrap();
|
||||||
|
assert_eq!(projects, vec!["/a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- model preference ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_preference_none_by_default() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
assert!(get_model_preference(&store).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_and_get_model_preference() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
set_model_preference("claude-3-sonnet".to_string(), &store).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
get_model_preference(&store).unwrap(),
|
||||||
|
Some("claude-3-sonnet".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- file operations ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_file_impl_reads_content() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file = dir.path().join("test.txt");
|
||||||
|
fs::write(&file, "hello world").unwrap();
|
||||||
|
|
||||||
|
let content = read_file_impl(file).await.unwrap();
|
||||||
|
assert_eq!(content, "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_file_impl_errors_on_missing() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let result = read_file_impl(dir.path().join("missing.txt")).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_file_impl_creates_and_writes() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file = dir.path().join("sub").join("output.txt");
|
||||||
|
|
||||||
|
write_file_impl(file.clone(), "content".to_string()).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fs::read_to_string(&file).unwrap(), "content");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn write_file_requires_approved_test_plan() {
|
async fn write_file_requires_approved_test_plan() {
|
||||||
let dir = tempdir().expect("tempdir");
|
let dir = tempdir().expect("tempdir");
|
||||||
@@ -715,4 +915,74 @@ mod tests {
|
|||||||
"expected write to be blocked when test plan is not approved"
|
"expected write to be blocked when test plan is not approved"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- list directory ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_directory_impl_returns_sorted_entries() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::create_dir(dir.path().join("zdir")).unwrap();
|
||||||
|
fs::create_dir(dir.path().join("adir")).unwrap();
|
||||||
|
fs::write(dir.path().join("file.txt"), "").unwrap();
|
||||||
|
|
||||||
|
let entries = list_directory_impl(dir.path().to_path_buf()).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(entries[0].name, "adir");
|
||||||
|
assert_eq!(entries[0].kind, "dir");
|
||||||
|
assert_eq!(entries[1].name, "zdir");
|
||||||
|
assert_eq!(entries[1].kind, "dir");
|
||||||
|
assert_eq!(entries[2].name, "file.txt");
|
||||||
|
assert_eq!(entries[2].kind, "file");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- validate_project_path ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn validate_project_path_rejects_missing() {
|
||||||
|
let result = validate_project_path(PathBuf::from("/nonexistent/path")).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn validate_project_path_rejects_file() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file = dir.path().join("not_a_dir.txt");
|
||||||
|
fs::write(&file, "").unwrap();
|
||||||
|
|
||||||
|
let result = validate_project_path(file).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn validate_project_path_accepts_directory() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let result = validate_project_path(dir.path().to_path_buf()).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scaffold ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scaffold_story_kit_creates_structure() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
scaffold_story_kit(dir.path()).unwrap();
|
||||||
|
|
||||||
|
assert!(dir.path().join(".story_kit/README.md").exists());
|
||||||
|
assert!(dir.path().join(".story_kit/specs/README.md").exists());
|
||||||
|
assert!(dir.path().join(".story_kit/specs/00_CONTEXT.md").exists());
|
||||||
|
assert!(dir.path().join(".story_kit/specs/tech/STACK.md").exists());
|
||||||
|
assert!(dir.path().join(".story_kit/stories/archive").is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scaffold_story_kit_does_not_overwrite_existing() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let readme = dir.path().join(".story_kit/README.md");
|
||||||
|
fs::create_dir_all(readme.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&readme, "custom content").unwrap();
|
||||||
|
|
||||||
|
scaffold_story_kit(dir.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,3 +63,101 @@ pub async fn search_files_impl(query: String, root: PathBuf) -> Result<Vec<Searc
|
|||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup_project(files: &[(&str, &str)]) -> TempDir {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
for (path, content) in files {
|
||||||
|
let full = dir.path().join(path);
|
||||||
|
if let Some(parent) = full.parent() {
|
||||||
|
fs::create_dir_all(parent).unwrap();
|
||||||
|
}
|
||||||
|
fs::write(full, content).unwrap();
|
||||||
|
}
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn finds_files_matching_query() {
|
||||||
|
let dir = setup_project(&[
|
||||||
|
("hello.txt", "hello world"),
|
||||||
|
("goodbye.txt", "goodbye world"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let results = search_files_impl("hello".to_string(), dir.path().to_path_buf())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].path, "hello.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn returns_empty_for_no_matches() {
|
||||||
|
let dir = setup_project(&[("file.txt", "some content")]);
|
||||||
|
|
||||||
|
let results = search_files_impl("nonexistent".to_string(), dir.path().to_path_buf())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(results.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn searches_nested_directories() {
|
||||||
|
let dir = setup_project(&[
|
||||||
|
("top.txt", "needle"),
|
||||||
|
("sub/deep.txt", "needle in haystack"),
|
||||||
|
("sub/other.txt", "no match here"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let results = search_files_impl("needle".to_string(), dir.path().to_path_buf())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect();
|
||||||
|
assert!(paths.contains(&"top.txt"));
|
||||||
|
assert!(paths.contains(&"sub/deep.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn skips_directories_only_matches_files() {
|
||||||
|
let dir = setup_project(&[("sub/file.txt", "content")]);
|
||||||
|
|
||||||
|
let results = search_files_impl("content".to_string(), dir.path().to_path_buf())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].path, "sub/file.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn respects_gitignore() {
|
||||||
|
let dir = setup_project(&[
|
||||||
|
(".gitignore", "ignored/\n"),
|
||||||
|
("kept.txt", "search term"),
|
||||||
|
("ignored/hidden.txt", "search term"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Initialize a git repo so .gitignore is respected
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["init"])
|
||||||
|
.current_dir(dir.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let results = search_files_impl("search term".to_string(), dir.path().to_path_buf())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].path, "kept.txt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,4 +106,64 @@ mod tests {
|
|||||||
"expected shell execution to be blocked when test plan is not approved"
|
"expected shell execution to be blocked when test plan is not approved"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exec_shell_impl_rejects_disallowed_command() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let result = exec_shell_impl(
|
||||||
|
"curl".to_string(),
|
||||||
|
vec!["https://example.com".to_string()],
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("not in the allowlist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exec_shell_impl_runs_allowed_command() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let result = exec_shell_impl(
|
||||||
|
"ls".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
assert_eq!(output.exit_code, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exec_shell_impl_captures_stdout() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("hello.txt"), "").unwrap();
|
||||||
|
|
||||||
|
let result = exec_shell_impl(
|
||||||
|
"ls".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.stdout.contains("hello.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exec_shell_impl_returns_nonzero_exit_code() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let result = exec_shell_impl(
|
||||||
|
"ls".to_string(),
|
||||||
|
vec!["nonexistent_file_xyz".to_string()],
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_ne!(result.exit_code, 0);
|
||||||
|
assert!(!result.stderr.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,3 +80,104 @@ impl StoreOps for JsonFileStore {
|
|||||||
fs::write(&self.path, content).map_err(|e| format!("Failed to write store: {e}"))
|
fs::write(&self.path, content).map_err(|e| format!("Failed to write store: {e}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn store_in(dir: &TempDir, name: &str) -> JsonFileStore {
|
||||||
|
let path = dir.path().join(name);
|
||||||
|
JsonFileStore::new(path).expect("store creation should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_from_missing_file_creates_empty_store() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store = store_in(&dir, "missing.json");
|
||||||
|
assert!(store.get("anything").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_from_empty_file_creates_empty_store() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("empty.json");
|
||||||
|
fs::write(&path, "").unwrap();
|
||||||
|
let store = JsonFileStore::new(path).expect("should handle empty file");
|
||||||
|
assert!(store.get("anything").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_from_corrupt_file_returns_error() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("corrupt.json");
|
||||||
|
fs::write(&path, "not valid json {{{").unwrap();
|
||||||
|
let result = JsonFileStore::new(path);
|
||||||
|
match result {
|
||||||
|
Err(e) => assert!(e.contains("Failed to parse store"), "unexpected error: {e}"),
|
||||||
|
Ok(_) => panic!("expected error for corrupt file"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_set_delete_roundtrip() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store = store_in(&dir, "data.json");
|
||||||
|
|
||||||
|
assert!(store.get("key").is_none());
|
||||||
|
|
||||||
|
store.set("key", json!("value"));
|
||||||
|
assert_eq!(store.get("key"), Some(json!("value")));
|
||||||
|
|
||||||
|
store.set("key", json!(42));
|
||||||
|
assert_eq!(store.get("key"), Some(json!(42)));
|
||||||
|
|
||||||
|
store.delete("key");
|
||||||
|
assert!(store.get("key").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_persists_and_reload_restores() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("persist.json");
|
||||||
|
|
||||||
|
{
|
||||||
|
let store = JsonFileStore::new(path.clone()).unwrap();
|
||||||
|
store.set("name", json!("story-kit"));
|
||||||
|
store.set("version", json!(1));
|
||||||
|
store.save().expect("save should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = JsonFileStore::new(path).unwrap();
|
||||||
|
assert_eq!(store.get("name"), Some(json!("story-kit")));
|
||||||
|
assert_eq!(store.get("version"), Some(json!(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_creates_parent_directories() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("nested").join("deep").join("store.json");
|
||||||
|
let store = JsonFileStore::new(path.clone()).unwrap();
|
||||||
|
store.set("key", json!("value"));
|
||||||
|
store.save().expect("save should create parent dirs");
|
||||||
|
assert!(path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_nonexistent_key_is_noop() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store = store_in(&dir, "data.json");
|
||||||
|
store.delete("nonexistent");
|
||||||
|
assert!(store.get("nonexistent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_path_works_like_new() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("via_from.json");
|
||||||
|
let store = JsonFileStore::from_path(&path).unwrap();
|
||||||
|
store.set("test", json!(true));
|
||||||
|
assert_eq!(store.get("test"), Some(json!(true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -503,4 +503,145 @@ mod tests {
|
|||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_when_all_tests_pass() {
|
||||||
|
let results = StoryTestResults {
|
||||||
|
unit: vec![TestCaseResult {
|
||||||
|
name: "unit-1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}],
|
||||||
|
integration: vec![TestCaseResult {
|
||||||
|
name: "integration-1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let decision = evaluate_acceptance(&results);
|
||||||
|
assert!(decision.can_accept);
|
||||||
|
assert!(decision.reasons.is_empty());
|
||||||
|
assert!(decision.warning.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_when_no_results_recorded() {
|
||||||
|
let results = StoryTestResults::default();
|
||||||
|
let decision = evaluate_acceptance(&results);
|
||||||
|
assert!(!decision.can_accept);
|
||||||
|
assert!(decision.reasons.iter().any(|r| r.contains("No test results")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_with_single_failure_no_warning() {
|
||||||
|
let results = StoryTestResults {
|
||||||
|
unit: vec![
|
||||||
|
TestCaseResult {
|
||||||
|
name: "unit-1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
},
|
||||||
|
TestCaseResult {
|
||||||
|
name: "unit-2".to_string(),
|
||||||
|
status: TestStatus::Fail,
|
||||||
|
details: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
integration: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let decision = evaluate_acceptance(&results);
|
||||||
|
assert!(!decision.can_accept);
|
||||||
|
assert!(decision.reasons.iter().any(|r| r.contains("failing")));
|
||||||
|
assert!(decision.warning.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_results_counts_correctly() {
|
||||||
|
let results = StoryTestResults {
|
||||||
|
unit: vec![
|
||||||
|
TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None },
|
||||||
|
TestCaseResult { name: "u2".to_string(), status: TestStatus::Fail, details: None },
|
||||||
|
],
|
||||||
|
integration: vec![
|
||||||
|
TestCaseResult { name: "i1".to_string(), status: TestStatus::Pass, details: None },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let summary = summarize_results(&results);
|
||||||
|
assert_eq!(summary.total, 3);
|
||||||
|
assert_eq!(summary.passed, 2);
|
||||||
|
assert_eq!(summary.failed, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_start_implementation_requires_approved_plan() {
|
||||||
|
let approved = StoryMetadata {
|
||||||
|
name: Some("Test".to_string()),
|
||||||
|
test_plan: Some(TestPlanStatus::Approved),
|
||||||
|
};
|
||||||
|
assert!(can_start_implementation(&approved).is_ok());
|
||||||
|
|
||||||
|
let waiting = StoryMetadata {
|
||||||
|
name: Some("Test".to_string()),
|
||||||
|
test_plan: Some(TestPlanStatus::WaitingForApproval),
|
||||||
|
};
|
||||||
|
assert!(can_start_implementation(&waiting).is_err());
|
||||||
|
|
||||||
|
let unknown = StoryMetadata {
|
||||||
|
name: Some("Test".to_string()),
|
||||||
|
test_plan: Some(TestPlanStatus::Unknown("draft".to_string())),
|
||||||
|
};
|
||||||
|
assert!(can_start_implementation(&unknown).is_err());
|
||||||
|
|
||||||
|
let missing = StoryMetadata {
|
||||||
|
name: Some("Test".to_string()),
|
||||||
|
test_plan: None,
|
||||||
|
};
|
||||||
|
assert!(can_start_implementation(&missing).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_valid_results_stores_them() {
|
||||||
|
let mut state = WorkflowState::default();
|
||||||
|
let unit = vec![TestCaseResult {
|
||||||
|
name: "unit-1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}];
|
||||||
|
let integration = vec![TestCaseResult {
|
||||||
|
name: "int-1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let result = state.record_test_results_validated(
|
||||||
|
"story-29".to_string(),
|
||||||
|
unit,
|
||||||
|
integration,
|
||||||
|
);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(state.results.contains_key("story-29"));
|
||||||
|
assert_eq!(state.results["story-29"].unit.len(), 1);
|
||||||
|
assert_eq!(state.results["story-29"].integration.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_story_metadata_returns_false_when_unchanged() {
|
||||||
|
let mut state = WorkflowState::default();
|
||||||
|
let meta = StoryMetadata {
|
||||||
|
name: Some("Test".to_string()),
|
||||||
|
test_plan: Some(TestPlanStatus::Approved),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(state.refresh_story_metadata("s1".to_string(), meta.clone()));
|
||||||
|
assert!(!state.refresh_story_metadata("s1".to_string(), meta.clone()));
|
||||||
|
|
||||||
|
let updated = StoryMetadata {
|
||||||
|
name: Some("Updated".to_string()),
|
||||||
|
test_plan: Some(TestPlanStatus::Approved),
|
||||||
|
};
|
||||||
|
assert!(state.refresh_story_metadata("s1".to_string(), updated));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user