Files
storkit/frontend/src/components/Chat.test.tsx

394 lines
12 KiB
TypeScript
Raw Normal View History

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();
});
});