From f56d9e04e0ddfaf7b8946907002a9612b01049f6 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 14:05:57 +0000 Subject: [PATCH] =?UTF-8?q?WIP:=20Batch=204=20=E2=80=94=20App,=20GatePanel?= =?UTF-8?q?,=20ReviewPanel=20frontend=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.test.tsx | 158 +++++++++++++++++++ frontend/src/components/GatePanel.test.tsx | 145 +++++++++++++++++ frontend/src/components/ReviewPanel.test.tsx | 157 ++++++++++++++++++ 3 files changed, 460 insertions(+) create mode 100644 frontend/src/App.test.tsx create mode 100644 frontend/src/components/GatePanel.test.tsx create mode 100644 frontend/src/components/ReviewPanel.test.tsx diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..a949750 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -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(); + } + + 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(); + }); + }); +}); diff --git a/frontend/src/components/GatePanel.test.tsx b/frontend/src/components/GatePanel.test.tsx new file mode 100644 index 0000000..fa53004 --- /dev/null +++ b/frontend/src/components/GatePanel.test.tsx @@ -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(); + expect(screen.getByText("No workflow data yet.")).toBeInTheDocument(); + }); + + it("shows loading message when isGateLoading is true", () => { + render(); + expect( + screen.getByText("Loading workflow gates..."), + ).toBeInTheDocument(); + }); + + it("shows error with retry button", async () => { + const onRefresh = vi.fn(); + render( + , + ); + + 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( + , + ); + expect(screen.getByText("Blocked")).toBeInTheDocument(); + }); + + it("shows test summary when gateState is provided", () => { + render( + , + ); + expect( + screen.getByText(/5\/5 passing, 0 failing/), + ).toBeInTheDocument(); + }); + + it("shows missing categories", () => { + render( + , + ); + expect( + screen.getByText("Missing: unit, integration"), + ).toBeInTheDocument(); + }); + + it("shows warning text", () => { + render( + , + ); + expect( + screen.getByText("Multiple tests failing — fix one at a time."), + ).toBeInTheDocument(); + }); + + it("shows reasons as list items", () => { + render( + , + ); + 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(); + + await userEvent.click( + screen.getByRole("button", { name: "Refresh" }), + ); + expect(onRefresh).toHaveBeenCalledOnce(); + }); + + it("disables Refresh button when loading", () => { + render(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/ReviewPanel.test.tsx b/frontend/src/components/ReviewPanel.test.tsx new file mode 100644 index 0000000..00e00bb --- /dev/null +++ b/frontend/src/components/ReviewPanel.test.tsx @@ -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(); + expect( + screen.getByText("No stories waiting for review."), + ).toBeInTheDocument(); + }); + + it("shows loading state", () => { + render(); + expect(screen.getByText("Loading review queue...")).toBeInTheDocument(); + }); + + it("shows error with retry button", async () => { + const onRefresh = vi.fn(); + render( + , + ); + + 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(); + + 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(); + + 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(); + expect(screen.getByText("Failing 2")).toBeInTheDocument(); + }); + + it("shows warning badge", () => { + render(); + expect(screen.getByText("Warning")).toBeInTheDocument(); + }); + + it("shows test summary per story", () => { + render(); + expect(screen.getByText(/5\/5 passing,\s*0 failing/)).toBeInTheDocument(); + }); + + it("shows missing categories", () => { + const missingStory: ReviewStory = { + ...blockedStory, + missing_categories: ["unit", "integration"], + }; + render(); + expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument(); + }); + + it("calls onProceed when Proceed is clicked", async () => { + const onProceed = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Proceed" })); + expect(onProceed).toHaveBeenCalledWith("29_backfill_tests"); + }); + + it("shows queue counts in header", () => { + render( + , + ); + expect(screen.getByText(/1 ready \/ 2 total/)).toBeInTheDocument(); + }); + + it("shows proceedError message", () => { + render( + , + ); + expect( + screen.getByText("Acceptance blocked: tests failing"), + ).toBeInTheDocument(); + }); + + it("shows proceedSuccess message", () => { + render( + , + ); + expect(screen.getByText("Story accepted successfully")).toBeInTheDocument(); + }); + + it("shows reasons as list items", () => { + render(); + expect(screen.getByText("2 tests are failing.")).toBeInTheDocument(); + }); +});