import { act, 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 type { Message } from "../types"; import { Chat } from "./Chat"; // Module-level store for the WebSocket handlers captured during connect(). // Tests in the "message rendering" suite use this to simulate incoming messages. type WsHandlers = { onToken: (content: string) => void; onUpdate: (history: Message[]) => void; onSessionId: (sessionId: string) => void; onError: (message: string) => void; }; let capturedWsHandlers: WsHandlers | null = null; 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(handlers: WsHandlers) { capturedWsHandlers = handlers; } 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(); }); }); describe("Chat message rendering — unified tool call UI", () => { beforeEach(() => { capturedWsHandlers = null; mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]); mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true); mockedApi.getAnthropicModels.mockResolvedValue([]); mockedApi.getModelPreference.mockResolvedValue(null); mockedApi.setModelPreference.mockResolvedValue(true); mockedApi.cancelChat.mockResolvedValue(true); mockedWorkflow.getAcceptance.mockResolvedValue({ can_accept: true, reasons: [], warning: null, summary: { total: 0, passed: 0, failed: 0 }, missing_categories: [], }); mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] }); mockedWorkflow.ensureAcceptance.mockResolvedValue(true); mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] }); mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] }); }); it("renders tool call badge for assistant message with tool_calls (AC3)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const messages: Message[] = [ { role: "user", content: "Read src/main.rs" }, { role: "assistant", content: "I'll read that file.", tool_calls: [ { id: "toolu_abc", type: "function", function: { name: "Read", arguments: '{"file_path":"src/main.rs"}', }, }, ], }, ]; act(() => { capturedWsHandlers?.onUpdate(messages); }); expect(await screen.findByText("I'll read that file.")).toBeInTheDocument(); // Tool call badge should appear showing the function name and first arg expect(await screen.findByText("Read(src/main.rs)")).toBeInTheDocument(); }); it("renders collapsible tool output for tool role messages (AC3)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const messages: Message[] = [ { role: "user", content: "Check the file" }, { role: "assistant", content: "", tool_calls: [ { id: "toolu_1", type: "function", function: { name: "Read", arguments: '{"file_path":"foo.rs"}' }, }, ], }, { role: "tool", content: 'fn main() { println!("hello"); }', tool_call_id: "toolu_1", }, { role: "assistant", content: "The file contains a main function." }, ]; act(() => { capturedWsHandlers?.onUpdate(messages); }); // Tool output section should be collapsible expect(await screen.findByText(/Tool Output/)).toBeInTheDocument(); expect( await screen.findByText("The file contains a main function."), ).toBeInTheDocument(); }); it("renders plain assistant message without tool call badges (AC5)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const messages: Message[] = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there! How can I help?" }, ]; act(() => { capturedWsHandlers?.onUpdate(messages); }); expect( await screen.findByText("Hi there! How can I help?"), ).toBeInTheDocument(); // No tool call badges should appear expect(screen.queryByText(/Tool Output/)).toBeNull(); }); it("renders multiple tool calls in a single assistant turn (AC3)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const messages: Message[] = [ { role: "user", content: "Do some work" }, { role: "assistant", content: "I'll do multiple things.", tool_calls: [ { id: "id1", type: "function", function: { name: "Bash", arguments: '{"command":"cargo test"}' }, }, { id: "id2", type: "function", function: { name: "Read", arguments: '{"file_path":"Cargo.toml"}' }, }, ], }, ]; act(() => { capturedWsHandlers?.onUpdate(messages); }); expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument(); expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument(); }); }); describe("Chat two-column layout", () => { beforeEach(() => { capturedWsHandlers = null; mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]); mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true); mockedApi.getAnthropicModels.mockResolvedValue([]); mockedApi.getModelPreference.mockResolvedValue(null); mockedApi.setModelPreference.mockResolvedValue(true); mockedApi.cancelChat.mockResolvedValue(true); mockedWorkflow.getAcceptance.mockResolvedValue({ can_accept: true, reasons: [], warning: null, summary: { total: 0, passed: 0, failed: 0 }, missing_categories: [], }); mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] }); mockedWorkflow.ensureAcceptance.mockResolvedValue(true); mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] }); mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] }); }); it("renders left and right column containers (AC1, AC2)", async () => { render(); expect(await screen.findByTestId("chat-content-area")).toBeInTheDocument(); expect(await screen.findByTestId("chat-left-column")).toBeInTheDocument(); expect(await screen.findByTestId("chat-right-column")).toBeInTheDocument(); }); it("renders chat input inside the left column (AC2, AC5)", async () => { render(); const leftColumn = await screen.findByTestId("chat-left-column"); const input = screen.getByPlaceholderText("Send a message..."); expect(leftColumn).toContainElement(input); }); it("renders panels inside the right column (AC2)", async () => { mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] }); render(); const rightColumn = await screen.findByTestId("chat-right-column"); const reviewPanel = await screen.findByText("Stories Awaiting Review"); expect(rightColumn).toContainElement(reviewPanel); }); it("uses row flex-direction on wide screens (AC3)", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200, }); window.dispatchEvent(new Event("resize")); render(); const contentArea = await screen.findByTestId("chat-content-area"); expect(contentArea).toHaveStyle({ flexDirection: "row" }); }); it("uses column flex-direction on narrow screens (AC4)", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 600, }); window.dispatchEvent(new Event("resize")); render(); const contentArea = await screen.findByTestId("chat-content-area"); expect(contentArea).toHaveStyle({ flexDirection: "column" }); // Restore wide width for subsequent tests Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024, }); }); });