394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
|
|
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";
|
||
|
|
import type { ReviewStory } from "../api/workflow";
|
||
|
|
import { workflowApi } from "../api/workflow";
|
||
|
|
import { Chat } from "./Chat";
|
||
|
|
|
||
|
|
vi.mock("../api/client", () => {
|
||
|
|
const api = {
|
||
|
|
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(),
|
||
|
|
getReviewQueue: vi.fn(),
|
||
|
|
getReviewQueueAll: vi.fn(),
|
||
|
|
ensureAcceptance: vi.fn(),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
const mockedApi = {
|
||
|
|
getOllamaModels: vi.mocked(api.getOllamaModels),
|
||
|
|
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
|
||
|
|
getAnthropicModels: vi.mocked(api.getAnthropicModels),
|
||
|
|
getModelPreference: vi.mocked(api.getModelPreference),
|
||
|
|
setModelPreference: vi.mocked(api.setModelPreference),
|
||
|
|
cancelChat: vi.mocked(api.cancelChat),
|
||
|
|
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||
|
|
};
|
||
|
|
|
||
|
|
const mockedWorkflow = {
|
||
|
|
getAcceptance: vi.mocked(workflowApi.getAcceptance),
|
||
|
|
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
|
||
|
|
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
||
|
|
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
||
|
|
};
|
||
|
|
|
||
|
|
describe("Chat review panel", () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||
|
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||
|
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||
|
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
||
|
|
mockedApi.setModelPreference.mockResolvedValue(true);
|
||
|
|
mockedApi.cancelChat.mockResolvedValue(true);
|
||
|
|
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||
|
|
|
||
|
|
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||
|
|
can_accept: false,
|
||
|
|
reasons: ["No test results recorded for the story."],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 0, passed: 0, failed: 0 },
|
||
|
|
missing_categories: ["unit", "integration"],
|
||
|
|
});
|
||
|
|
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||
|
|
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows an empty review queue state", async () => {
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
await screen.findByText("Stories Awaiting Review"),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
expect(await screen.findByText("0 ready / 0 total")).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText("No stories waiting for review."),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
|
||
|
|
const updatedLabels = await screen.findAllByText(/Updated/i);
|
||
|
|
expect(updatedLabels.length).toBeGreaterThanOrEqual(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("renders review stories and proceeds", async () => {
|
||
|
|
const story: ReviewStory = {
|
||
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||
|
|
can_accept: true,
|
||
|
|
reasons: [],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 3, passed: 3, failed: 0 },
|
||
|
|
missing_categories: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
mockedWorkflow.getReviewQueueAll
|
||
|
|
.mockResolvedValueOnce({ stories: [story] })
|
||
|
|
.mockResolvedValueOnce({ stories: [] });
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
|
||
|
|
|
||
|
|
const proceedButton = screen.getByRole("button", { name: "Proceed" });
|
||
|
|
await userEvent.click(proceedButton);
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
|
||
|
|
story_id: story.story_id,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(
|
||
|
|
await screen.findByText("No stories waiting for review."),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows a review error when the queue fails to load", async () => {
|
||
|
|
mockedWorkflow.getReviewQueueAll.mockRejectedValueOnce(
|
||
|
|
new Error("Review queue failed"),
|
||
|
|
);
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
expect(await screen.findByText(/Review queue failed/i)).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText(/Use Refresh to try again\./i),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByRole("button", { name: "Retry" }),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("refreshes the review queue when clicking refresh", async () => {
|
||
|
|
mockedWorkflow.getReviewQueueAll
|
||
|
|
.mockResolvedValueOnce({ stories: [] })
|
||
|
|
.mockResolvedValueOnce({ stories: [] });
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
const refreshButtons = await screen.findAllByRole("button", {
|
||
|
|
name: "Refresh",
|
||
|
|
});
|
||
|
|
const refreshButton = refreshButtons[0];
|
||
|
|
|
||
|
|
await userEvent.click(refreshButton);
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockedWorkflow.getReviewQueueAll).toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("disables proceed when a story is blocked", async () => {
|
||
|
|
const story: ReviewStory = {
|
||
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||
|
|
can_accept: false,
|
||
|
|
reasons: ["Missing unit tests"],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 1, passed: 0, failed: 1 },
|
||
|
|
missing_categories: ["unit"],
|
||
|
|
};
|
||
|
|
|
||
|
|
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||
|
|
stories: [story],
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
|
||
|
|
|
||
|
|
const blockedButton = screen.getByRole("button", { name: "Blocked" });
|
||
|
|
expect(blockedButton).toBeDisabled();
|
||
|
|
|
||
|
|
expect(await screen.findByText("Missing: unit")).toBeInTheDocument();
|
||
|
|
expect(await screen.findByText("Missing unit tests")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows gate panel blocked status with reasons (AC1/AC3)", async () => {
|
||
|
|
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||
|
|
can_accept: false,
|
||
|
|
reasons: ["No approved test plan for the story."],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 0, passed: 0, failed: 0 },
|
||
|
|
missing_categories: ["unit", "integration"],
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText("No approved test plan for the story."),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText("Missing: unit, integration"),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText(/0\/0 passing, 0 failing/),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows gate panel ready status when all tests pass (AC1/AC3)", async () => {
|
||
|
|
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||
|
|
can_accept: true,
|
||
|
|
reasons: [],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 5, passed: 5, failed: 0 },
|
||
|
|
missing_categories: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText(/5\/5 passing, 0 failing/),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows failing badge and count in review panel (AC4/AC5)", async () => {
|
||
|
|
const story: ReviewStory = {
|
||
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||
|
|
can_accept: false,
|
||
|
|
reasons: ["3 tests are failing."],
|
||
|
|
warning: "Multiple tests failing — fix one at a time.",
|
||
|
|
summary: { total: 5, passed: 2, failed: 3 },
|
||
|
|
missing_categories: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||
|
|
stories: [story],
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
expect(await screen.findByText("Failing 3")).toBeInTheDocument();
|
||
|
|
expect(await screen.findByText("Warning")).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText("Multiple tests failing — fix one at a time."),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
expect(await screen.findByText("3 tests are failing.")).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText(/2\/5 passing, 3 failing/),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
|
||
|
|
const blockedButton = screen.getByRole("button", { name: "Blocked" });
|
||
|
|
expect(blockedButton).toBeDisabled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows gate warning when multiple tests fail (AC5)", async () => {
|
||
|
|
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||
|
|
can_accept: false,
|
||
|
|
reasons: ["2 tests are failing."],
|
||
|
|
warning: "Multiple tests failing — fix one at a time.",
|
||
|
|
summary: { total: 4, passed: 2, failed: 2 },
|
||
|
|
missing_categories: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText("Multiple tests failing — fix one at a time."),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
expect(
|
||
|
|
await screen.findByText(/2\/4 passing, 2 failing/),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
expect(await screen.findByText("2 tests are failing.")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not call ensureAcceptance when clicking a blocked proceed button (AC4)", async () => {
|
||
|
|
const story: ReviewStory = {
|
||
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||
|
|
can_accept: false,
|
||
|
|
reasons: ["Tests are failing."],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 3, passed: 1, failed: 2 },
|
||
|
|
missing_categories: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||
|
|
stories: [story],
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
const blockedButton = await screen.findByRole("button", {
|
||
|
|
name: "Blocked",
|
||
|
|
});
|
||
|
|
expect(blockedButton).toBeDisabled();
|
||
|
|
|
||
|
|
// Clear any prior calls then attempt click on disabled button
|
||
|
|
mockedWorkflow.ensureAcceptance.mockClear();
|
||
|
|
await userEvent.click(blockedButton);
|
||
|
|
|
||
|
|
expect(mockedWorkflow.ensureAcceptance).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows proceed error when ensureAcceptance fails", async () => {
|
||
|
|
const story: ReviewStory = {
|
||
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||
|
|
can_accept: true,
|
||
|
|
reasons: [],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 3, passed: 3, failed: 0 },
|
||
|
|
missing_categories: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||
|
|
stories: [story],
|
||
|
|
});
|
||
|
|
mockedWorkflow.ensureAcceptance.mockRejectedValueOnce(
|
||
|
|
new Error("Acceptance blocked: tests still failing"),
|
||
|
|
);
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
const proceedButton = await screen.findByRole("button", {
|
||
|
|
name: "Proceed",
|
||
|
|
});
|
||
|
|
await userEvent.click(proceedButton);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
await screen.findByText("Acceptance blocked: tests still failing"),
|
||
|
|
).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows gate error when acceptance endpoint fails", async () => {
|
||
|
|
mockedWorkflow.getAcceptance.mockRejectedValueOnce(
|
||
|
|
new Error("Server unreachable"),
|
||
|
|
);
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
expect(await screen.findByText("Server unreachable")).toBeInTheDocument();
|
||
|
|
|
||
|
|
const retryButtons = await screen.findAllByRole("button", {
|
||
|
|
name: "Retry",
|
||
|
|
});
|
||
|
|
expect(retryButtons.length).toBeGreaterThanOrEqual(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("refreshes gate status after proceeding on the current story", async () => {
|
||
|
|
const story: ReviewStory = {
|
||
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||
|
|
can_accept: true,
|
||
|
|
reasons: [],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 2, passed: 2, failed: 0 },
|
||
|
|
missing_categories: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
mockedWorkflow.getAcceptance
|
||
|
|
.mockResolvedValueOnce({
|
||
|
|
can_accept: false,
|
||
|
|
reasons: ["No test results recorded for the story."],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 0, passed: 0, failed: 0 },
|
||
|
|
missing_categories: ["unit", "integration"],
|
||
|
|
})
|
||
|
|
.mockResolvedValueOnce({
|
||
|
|
can_accept: true,
|
||
|
|
reasons: [],
|
||
|
|
warning: null,
|
||
|
|
summary: { total: 2, passed: 2, failed: 0 },
|
||
|
|
missing_categories: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
mockedWorkflow.getReviewQueueAll
|
||
|
|
.mockResolvedValueOnce({ stories: [story] })
|
||
|
|
.mockResolvedValueOnce({ stories: [] });
|
||
|
|
|
||
|
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||
|
|
|
||
|
|
const proceedButton = await screen.findByRole("button", {
|
||
|
|
name: "Proceed",
|
||
|
|
});
|
||
|
|
await userEvent.click(proceedButton);
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
|
||
|
|
story_id: story.story_id,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|