Story 28: Show remaining test TODOs in the UI
Add TodoPanel that displays unchecked acceptance criteria from current story files. Backend parses `- [ ]` lines from markdown, frontend shows them in a panel with refresh. Includes 4 Rust unit tests, 3 Vitest tests, 3 Playwright E2E tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,9 @@ vi.mock("./api/workflow", () => {
|
||||
recordTests: vi.fn(),
|
||||
ensureAcceptance: vi.fn(),
|
||||
getReviewQueue: vi.fn(),
|
||||
collectCoverage: vi.fn(),
|
||||
recordCoverage: vi.fn(),
|
||||
getStoryTodos: vi.fn().mockResolvedValue({ stories: [] }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -62,6 +62,16 @@ export interface ReviewListResponse {
|
||||
stories: ReviewStory[];
|
||||
}
|
||||
|
||||
export interface StoryTodosResponse {
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
todos: string[];
|
||||
}
|
||||
|
||||
export interface TodoListResponse {
|
||||
stories: StoryTodosResponse[];
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
|
||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||
@@ -131,4 +141,7 @@ export const workflowApi = {
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getStoryTodos(baseUrl?: string) {
|
||||
return requestJson<TodoListResponse>("/workflow/todos", {}, baseUrl);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("../api/workflow", () => {
|
||||
ensureAcceptance: vi.fn(),
|
||||
recordCoverage: vi.fn(),
|
||||
collectCoverage: vi.fn(),
|
||||
getStoryTodos: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -54,6 +55,7 @@ const mockedWorkflow = {
|
||||
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
|
||||
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
||||
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
||||
getStoryTodos: vi.mocked(workflowApi.getStoryTodos),
|
||||
};
|
||||
|
||||
describe("Chat review panel", () => {
|
||||
@@ -75,6 +77,7 @@ describe("Chat review panel", () => {
|
||||
});
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||
});
|
||||
|
||||
it("shows an empty review queue state", async () => {
|
||||
@@ -510,4 +513,57 @@ describe("Chat review panel", () => {
|
||||
|
||||
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.",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Cannot read stories")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Message, ProviderConfig, ToolCall } from "../types";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { GatePanel } from "./GatePanel";
|
||||
import { ReviewPanel } from "./ReviewPanel";
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
@@ -61,6 +62,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
|
||||
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
|
||||
const [coverageError, setCoverageError] = useState<string | null>(null);
|
||||
const [storyTodos, setStoryTodos] = useState<
|
||||
{ storyId: string; storyName: string | null; items: string[] }[]
|
||||
>([]);
|
||||
const [todoError, setTodoError] = useState<string | null>(null);
|
||||
const [isTodoLoading, setIsTodoLoading] = useState(false);
|
||||
const [lastTodoRefresh, setLastTodoRefresh] = useState<Date | null>(null);
|
||||
|
||||
const storyId = "26_establish_tdd_workflow_and_gates";
|
||||
const gateStatusColor = isGateLoading
|
||||
@@ -255,6 +262,68 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setIsTodoLoading(true);
|
||||
setTodoError(null);
|
||||
|
||||
workflowApi
|
||||
.getStoryTodos()
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
setStoryTodos(
|
||||
response.stories.map((s) => ({
|
||||
storyId: s.story_id,
|
||||
storyName: s.story_name,
|
||||
items: s.todos,
|
||||
})),
|
||||
);
|
||||
setLastTodoRefresh(new Date());
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load story TODOs.";
|
||||
setTodoError(message);
|
||||
setStoryTodos([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setIsTodoLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshTodos = async () => {
|
||||
setIsTodoLoading(true);
|
||||
setTodoError(null);
|
||||
|
||||
try {
|
||||
const response = await workflowApi.getStoryTodos();
|
||||
setStoryTodos(
|
||||
response.stories.map((s) => ({
|
||||
storyId: s.story_id,
|
||||
storyName: s.story_name,
|
||||
items: s.todos,
|
||||
})),
|
||||
);
|
||||
setLastTodoRefresh(new Date());
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load story TODOs.";
|
||||
setTodoError(message);
|
||||
setStoryTodos([]);
|
||||
} finally {
|
||||
setIsTodoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshGateState = async (targetStoryId: string = storyId) => {
|
||||
setIsGateLoading(true);
|
||||
setGateError(null);
|
||||
@@ -593,6 +662,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
onCollectCoverage={handleCollectCoverage}
|
||||
isCollectingCoverage={isCollectingCoverage}
|
||||
/>
|
||||
|
||||
<TodoPanel
|
||||
todos={storyTodos}
|
||||
isTodoLoading={isTodoLoading}
|
||||
todoError={todoError}
|
||||
lastTodoRefresh={lastTodoRefresh}
|
||||
onRefresh={refreshTodos}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ const baseProps = {
|
||||
gateStatusColor: "#aaa",
|
||||
isGateLoading: false,
|
||||
gateError: null,
|
||||
coverageError: null,
|
||||
lastGateRefresh: null,
|
||||
onRefresh: vi.fn(),
|
||||
onCollectCoverage: vi.fn(),
|
||||
isCollectingCoverage: false,
|
||||
};
|
||||
|
||||
describe("GatePanel", () => {
|
||||
@@ -21,9 +24,7 @@ describe("GatePanel", () => {
|
||||
|
||||
it("shows loading message when isGateLoading is true", () => {
|
||||
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
||||
expect(
|
||||
screen.getByText("Loading workflow gates..."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading workflow gates...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error with retry button", async () => {
|
||||
@@ -64,13 +65,12 @@ describe("GatePanel", () => {
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missingCategories: [],
|
||||
coverageReport: null,
|
||||
}}
|
||||
gateStatusLabel="Ready to accept"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/5\/5 passing, 0 failing/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/5\/5 passing, 0 failing/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows missing categories", () => {
|
||||
@@ -83,12 +83,11 @@ describe("GatePanel", () => {
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missingCategories: ["unit", "integration"],
|
||||
coverageReport: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Missing: unit, integration"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning text", () => {
|
||||
@@ -101,6 +100,7 @@ describe("GatePanel", () => {
|
||||
warning: "Multiple tests failing — fix one at a time.",
|
||||
summary: { total: 4, passed: 2, failed: 2 },
|
||||
missingCategories: [],
|
||||
coverageReport: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -119,12 +119,11 @@ describe("GatePanel", () => {
|
||||
warning: null,
|
||||
summary: { total: 2, passed: 1, failed: 1 },
|
||||
missingCategories: [],
|
||||
coverageReport: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No approved test plan."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("No approved test plan.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tests are failing.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -132,9 +131,7 @@ describe("GatePanel", () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(<GatePanel {...baseProps} onRefresh={onRefresh} />);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Refresh" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
175
frontend/src/components/TodoPanel.tsx
Normal file
175
frontend/src/components/TodoPanel.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
interface StoryTodos {
|
||||
storyId: string;
|
||||
storyName: string | null;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
interface TodoPanelProps {
|
||||
todos: StoryTodos[];
|
||||
isTodoLoading: boolean;
|
||||
todoError: string | null;
|
||||
lastTodoRefresh: Date | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const formatTimestamp = (value: Date | null): string => {
|
||||
if (!value) return "\u2014";
|
||||
return value.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export function TodoPanel({
|
||||
todos,
|
||||
isTodoLoading,
|
||||
todoError,
|
||||
lastTodoRefresh,
|
||||
onRefresh,
|
||||
}: TodoPanelProps) {
|
||||
const totalTodos = todos.reduce((sum, s) => sum + s.items.length, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #333",
|
||||
borderRadius: "10px",
|
||||
padding: "12px 16px",
|
||||
background: "#1f1f1f",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>Story TODOs</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isTodoLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: isTodoLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isTodoLoading ? "#777" : "#aaa",
|
||||
cursor: isTodoLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
gap: "2px",
|
||||
fontSize: "0.85em",
|
||||
color: totalTodos === 0 ? "#7ee787" : "#aaa",
|
||||
}}
|
||||
>
|
||||
<div>{totalTodos} remaining</div>
|
||||
<div style={{ fontSize: "0.8em", color: "#777" }}>
|
||||
Updated {formatTimestamp(lastTodoRefresh)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTodoLoading ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
Loading story TODOs...
|
||||
</div>
|
||||
) : todoError ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: "#ff7b72",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span>{todoError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isTodoLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: isTodoLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isTodoLoading ? "#777" : "#aaa",
|
||||
cursor: isTodoLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : totalTodos === 0 ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#7ee787" }}>
|
||||
All acceptance criteria complete.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{todos
|
||||
.filter((s) => s.items.length > 0)
|
||||
.map((story) => (
|
||||
<div key={story.storyId}>
|
||||
{todos.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
color: "#777",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{story.storyName ?? story.storyId}
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
style={{
|
||||
margin: "0 0 0 16px",
|
||||
padding: 0,
|
||||
fontSize: "0.85em",
|
||||
color: "#ccc",
|
||||
}}
|
||||
>
|
||||
{story.items.map((item) => (
|
||||
<li key={`todo-${story.storyId}-${item}`}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FileEntry } from "./usePathCompletion";
|
||||
import {
|
||||
getCurrentPartial,
|
||||
|
||||
Reference in New Issue
Block a user