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(),
recordCoverage: vi.fn(),
collectCoverage: vi.fn(),
getStoryTodos: vi.fn(),
getUpcomingStories: 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),
getStoryTodos: vi.mocked(workflowApi.getStoryTodos),
getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories),
};
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);
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
});
it("shows an empty review queue state", async () => {
render();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
});
it("shows coverage below threshold in gate panel (AC3)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: false,
reasons: ["Coverage below threshold (55.0% < 80.0%)."],
warning: null,
summary: { total: 3, passed: 3, failed: 0 },
missing_categories: [],
coverage_report: {
current_percent: 55.0,
threshold_percent: 80.0,
baseline_percent: null,
},
});
render();
expect(await screen.findByText("Blocked")).toBeInTheDocument();
expect(await screen.findByText(/Coverage: 55\.0%/)).toBeInTheDocument();
expect(await screen.findByText(/threshold: 80\.0%/)).toBeInTheDocument();
expect(
await screen.findByText("Coverage below threshold (55.0% < 80.0%)."),
).toBeInTheDocument();
});
it("shows coverage regression in review panel (AC4)", async () => {
const story: ReviewStory = {
story_id: "27_protect_tests_and_coverage",
can_accept: false,
reasons: ["Coverage regression: 90.0% → 82.0% (threshold: 80.0%)."],
warning: null,
summary: { total: 4, passed: 4, failed: 0 },
missing_categories: [],
coverage_report: {
current_percent: 82.0,
threshold_percent: 80.0,
baseline_percent: 90.0,
},
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
render();
expect(
await screen.findByText(
"Coverage regression: 90.0% → 82.0% (threshold: 80.0%).",
),
).toBeInTheDocument();
expect(await screen.findByText(/Coverage: 82\.0%/)).toBeInTheDocument();
});
it("shows green coverage when above threshold (AC3)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: true,
reasons: [],
warning: null,
summary: { total: 5, passed: 5, failed: 0 },
missing_categories: [],
coverage_report: {
current_percent: 92.0,
threshold_percent: 80.0,
baseline_percent: 90.0,
},
});
render();
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument();
});
it("fetches upcoming stories on mount and renders panel", async () => {
mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({
stories: [
{
story_id: "31_view_upcoming",
name: "View Upcoming Stories",
error: null,
},
{ story_id: "32_worktree", name: null, error: null },
],
});
render();
expect(await screen.findByText("Upcoming Stories")).toBeInTheDocument();
// Both AgentPanel and ReviewPanel display story names, so multiple elements are expected
const storyNameElements = await screen.findAllByText(
"View Upcoming Stories",
);
expect(storyNameElements.length).toBeGreaterThan(0);
const worktreeElements = await screen.findAllByText("32_worktree");
expect(worktreeElements.length).toBeGreaterThan(0);
});
it("collect coverage button triggers collection and refreshes gate", async () => {
const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage);
mockedCollectCoverage.mockResolvedValueOnce({
current_percent: 85.0,
threshold_percent: 80.0,
baseline_percent: null,
});
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: 5, passed: 5, failed: 0 },
missing_categories: [],
coverage_report: {
current_percent: 85.0,
threshold_percent: 80.0,
baseline_percent: null,
},
});
render();
const collectButton = await screen.findByRole("button", {
name: "Collect Coverage",
});
await userEvent.click(collectButton);
await waitFor(() => {
expect(mockedCollectCoverage).toHaveBeenCalledWith({
story_id: "26_establish_tdd_workflow_and_gates",
});
});
expect(await screen.findByText(/Coverage: 85\.0%/)).toBeInTheDocument();
});
it("shows story TODOs when unchecked criteria exist", async () => {
mockedWorkflow.getStoryTodos.mockResolvedValueOnce({
stories: [
{
story_id: "28_ui_show_test_todos",
story_name: "Show Remaining Test TODOs in the UI",
todos: [
"The UI lists unchecked acceptance criteria.",
"Each TODO is displayed as its full text.",
],
error: null,
},
],
});
render();
expect(
await screen.findByText("The UI lists unchecked acceptance criteria."),
).toBeInTheDocument();
expect(
await screen.findByText("Each TODO is displayed as its full text."),
).toBeInTheDocument();
expect(await screen.findByText("2 remaining")).toBeInTheDocument();
});
it("shows completion message when all criteria are checked", async () => {
mockedWorkflow.getStoryTodos.mockResolvedValueOnce({
stories: [
{
story_id: "28_ui_show_test_todos",
story_name: "Show Remaining Test TODOs in the UI",
todos: [],
error: null,
},
],
});
render();
expect(
await screen.findByText("All acceptance criteria complete."),
).toBeInTheDocument();
});
it("shows TODO error when endpoint fails", async () => {
mockedWorkflow.getStoryTodos.mockRejectedValueOnce(
new Error("Cannot read stories"),
);
render();
expect(await screen.findByText("Cannot read stories")).toBeInTheDocument();
});
it("does not fetch Anthropic models when no API key exists", async () => {
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
mockedApi.getAnthropicModels.mockClear();
render();
await waitFor(() => {
expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled();
});
expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled();
});
});